Merge pull request #13135 from supabase/master

Prod deploy dashboard
This commit is contained in:
Joshen Lim authored and GitHub committed 2023-03-22 15:20:24 +08:00
commit 955e930879
29 files changed
+928 -174

No files matched your search

+6
View File
@@ -136,3 +136,9 @@ Create a new entry in the [`redirects.js`](https://github.com/supabase/supabase/
## Community channels
Stuck somewhere? Have any questions? Join the [Discord Community Server](https://discord.supabase.com/) or the [Github Discussions](https://github.com/supabase/supabase/discussions). We are here to help!
## Contributors
<a href="https://github.com/supabase/supabase/graphs/contributors">
<img src="https://contributors.deno.dev/supabase/supabase?height=1200&width=1200&count=90" width="1200" height="1200" alt="contributors">
</a>
@@ -241,12 +241,57 @@ export const menuItems: NavMenu = {
{
label: 'Realtime',
items: [
{ name: 'Overview', url: '/guides/realtime', items: [] },
{ name: 'Quickstart', url: '/guides/realtime/quickstart', items: [] },
{ name: 'Broadcast', url: '/guides/realtime/broadcast', items: [] },
{ name: 'Presence', url: '/guides/realtime/presence', items: [] },
{ name: 'Postgres Changes', url: '/guides/realtime/postgres-changes', items: [] },
{ name: 'Rate Limits', url: '/guides/realtime/rate-limits', items: [] },
{
name: 'Overview',
url: '/guides/realtime',
items: [],
},
{
name: 'Quickstart',
url: '/guides/realtime/quickstart',
items: [],
},
{
name: 'Features',
url: undefined,
items: [
{ name: 'Channels', url: '/guides/realtime/channels', items: [] },
{
name: 'Extensions',
url: '/guides/realtime/extensions',
items: [
{ name: 'Broadcast', url: '/guides/realtime/broadcast', items: [] },
{ name: 'Presence', url: '/guides/realtime/presence', items: [] },
{ name: 'Postgres Changes', url: '/guides/realtime/postgres-changes', items: [] },
],
},
],
},
{
name: 'Guides',
url: undefined,
items: [
{
name: 'Subscribing to Database Changes',
url: '/guides/realtime/subscribing-to-database-changes',
items: [],
},
{
name: 'Using Realtime with Next.js',
url: '/guides/realtime/realtime-with-nextjs',
items: [],
},
],
},
{
name: 'Deep dive',
url: undefined,
items: [
{ name: 'Rate Limits', url: '/guides/realtime/rate-limits', items: [] },
{ name: 'Architecture', url: '/guides/realtime/architecture', items: [] },
{ name: 'Protocol', url: '/guides/realtime/protocol', items: [] },
],
},
],
},
{
@@ -604,16 +604,53 @@ export const realtime = {
label: 'Realtime',
url: '/guides/realtime',
items: [
{ name: 'Overview', url: '/guides/realtime', items: [] },
{ name: 'Quickstart', url: '/guides/realtime/quickstart', items: [] },
{
name: 'Channels',
name: 'Overview',
url: '/guides/realtime',
},
{
name: 'Quickstart',
url: '/guides/realtime/quickstart',
},
{
name: 'Features',
url: undefined,
items: [
{ name: 'Channels', url: '/guides/realtime/channels', items: [] },
{
name: 'Extensions',
url: '/guides/realtime/extensions',
items: [
{ name: 'Broadcast', url: '/guides/realtime/broadcast', items: [] },
{ name: 'Presence', url: '/guides/realtime/presence', items: [] },
{ name: 'Postgres Changes', url: '/guides/realtime/postgres-changes', items: [] },
],
},
],
},
{
name: 'Guides',
url: undefined,
items: [
{
name: 'Subscribing to Database Changes',
url: '/guides/realtime/subscribing-to-database-changes',
items: [],
},
{
name: 'Using Realtime with Next.js',
url: '/guides/realtime/realtime-with-nextjs',
items: [],
},
],
},
{
name: 'Deep dive',
url: undefined,
items: [
{ name: 'Broadcast', url: '/guides/realtime/broadcast', items: [] },
{ name: 'Presence', url: '/guides/realtime/presence', items: [] },
{ name: 'Postgres Changes', url: '/guides/realtime/postgres-changes', items: [] },
{ name: 'Rate Limits', url: '/guides/realtime/rate-limits', items: [] },
{ name: 'Architecture', url: '/guides/realtime/architecture', items: [] },
{ name: 'Protocol', url: '/guides/realtime/protocol', items: [] },
],
},
],
@@ -205,7 +205,7 @@ const NavigationMenu = () => {
<NavigationMenuGuideList id={'database'} active={isDatabaseActive} />
<NavigationMenuGuideList id={'auth'} active={isAuthActive} />
<NavigationMenuGuideList id={'functions'} active={isFunctionsActive} />
<NavigationMenuGuideList id={'realtime'} active={isRealtimeActive} />
<NavigationMenuGuideList id={'realtime'} active={isRealtimeActive} collapsible={false} />
<NavigationMenuGuideList id={'storage'} active={isStorageActive} />
<NavigationMenuGuideList id={'supabase_cli'} active={issupabase_cliActive} />
<NavigationMenuGuideList id={'platform'} active={isPlatformActive} />
@@ -6,8 +6,9 @@ import NavigationMenuGuideListItems from './NavigationMenuGuideListItems'
interface Props {
id: string
active: boolean
collapsible?: boolean
}
const NavigationMenuGuideList: React.FC<Props> = ({ id, active }) => {
const NavigationMenuGuideList: React.FC<Props> = ({ id, active, collapsible = true }) => {
const router = useRouter()
// get url
@@ -26,7 +27,7 @@ const NavigationMenuGuideList: React.FC<Props> = ({ id, active }) => {
return (
<Accordion.Root
collapsible
collapsible={collapsible}
key={id}
type="single"
value={firstLevelRoute}
@@ -227,6 +227,8 @@ final result = await client
### Search multiple columns
Right now there is no direct way to use JavaScript or Dart to search through multiple columns but you can do it by creating [computed columns](https://postgrest.org/en/stable/api.html#computed-virtual-columns) on the database.
To find all `books` where `description` or `title` contain the word `little`:
<Tabs
@@ -247,6 +249,38 @@ where
@@ to_tsquery('little');
```
</TabPanel>
<TabPanel id="js" label="JavaScript">
```sql
create function title_description(books) returns text as $$
select $1.title || ' ' || $1.description;
$$ language sql immutable;
```
```js
const { data, error } = await supabase
.from('books')
.select()
.textSearch('title_description', `little`)
```
</TabPanel>
<TabPanel id="dart" label="Dart">
```sql
create function title_description(books) returns text as $$
select $1.title || ' ' || $1.description;
$$ language sql immutable;
```
```dart
final result = await client
.from('books')
.select()
.textSearch('title_description', "little")
```
</TabPanel>
<TabPanel id="data" label="Data">
+3 -1
View File
@@ -9,7 +9,9 @@ Database backups are an integral part of any disaster recovery plan. Disasters c
## Frequency of Backups
When deciding how often a database should be backed up, the key business metric Recovery Point Objective (RPO) should be considered. RPO is the threshold for how much data, measured in time, a business could lose when disaster strikes. This amount is fully dependent on a business and its underlying requirements. A low RPO would mean that database backups would have to be taken at an increased cadence throughout the day. Each Supabase project has access to two forms of backups, Daily Backups and Point-in-Time Recovery. The agreed upon RPO would be a deciding factor in choosing which solution best fits a project.
When deciding how often a database should be backed up, the key business metric Recovery Point Objective (RPO) should be considered. RPO is the threshold for how much data, measured in time, a business could lose when disaster strikes. This amount is fully dependent on a business and its underlying requirements. A low RPO would mean that database backups would have to be taken at an increased cadence throughout the day. Each Supabase project has access to two forms of backups, Daily Backups and Point-in-Time Recovery (PITR). The agreed upon RPO would be a deciding factor in choosing which solution best fits a project.
Daily Backups and PITR are mutually exclusive. If your project opts into using PITR, Daily Backups will no longer be taken. They're also unnecessary, as PITR supports a superset of functionality, in terms of the granular recovery that can be performed.
<Admonition type="note">
Database backups do not include objects stored via the Storage API, as the database only includes
+21
View File
@@ -60,6 +60,12 @@ Supabase provides a logging interface specific to each product. You can use simp
[Realtime logs](https://app.supabase.com/project/_/logs/realtime-logs) show all server logs for your [Realtime API usage](../../guides/realtime).
<Admonition type="note">
Realtime connections are not logged by default. Turn on [Realtime connection logs per client](#logging-realtime-connections) with the `log_level` parameter.
</Admonition>
![Realtime Logs](/docs/img/guides/platform/logs/logs-realtime.png)
</TabPanel>
@@ -126,6 +132,21 @@ If any permission errors are encountered when executing `alter role postgres ...
</Admonition>
## Logging Realtime Connections
Realtime doesn't log new WebSocket connections or Channel joins by default. Enable connection logging per client by including an `info` `log_level` parameter when instantiating the Supabase client.
```javascript
import { createClient } from '@supabase/supabase-js'
const options = {
realtime: {
log_level: 'info',
},
}
const supabase = createClient('https://xyzcompany.supabase.co', 'public-anon-key', options)
```
## Logs Explorer
The [Logs Explorer](https://app.supabase.com/project/_/logs-explorer) exposes logs from each part of the Supabase stack as a separate table that can be queried and joined using SQL.
@@ -0,0 +1,58 @@
import Layout from '~/layouts/DefaultGuideLayout'
export const meta = {
id: 'architecture',
title: 'Realtime Architecture',
description: 'Architecture of the Supabase Realtime service',
sidebar_label: 'Architecture',
}
Realtime is a globally distributed Elixir cluster. Clients can connect to any node in the cluster via WebSockets and send messages to any other client connected to the cluster.
Realtime is written in [Elixir](https://elixir-lang.org/), which compiles to [Erlang](https://www.erlang.org/), and utilizes many tools the [Phoenix Framework](https://www.phoenixframework.org/) provides out of the box.
![Global Architecture](/docs/img/guides/realtime/realtime-arch.png)
## Elixir & Phoenix
Phoenix is fast and able to handle millions of concurrent connections.
Phoenix can handle many concurrent connections because Elixir provides lightweight processes (not OS processes) to work with.
Client-facing WebSocket servers need to handle many concurrent connections. Elixir & Phoenix let the Supabase Realtime cluster do this easily.
## Global Cluster
Presence is an in-memory key-value store backed by a CRDT. When a user is connected to the cluster the state of that user is sent to all connected Realtime nodes.
Broadcast lets you send a message from any connected client to a Channel. Any other client connected to that same Channel will receive that message.
This works globally. A client connected to a Realtime node in the United States can send a message to another client connected to a node in Singapore. Simply connect two clients to the same Realtime Channel and they'll all receive the same messages.
Broadcast is useful for getting messages to users in the same location very quickly. If a group of clients are connected to a node in Singapore, the message only needs to go to that Realtime node in Singapore and back down. If users are close to a Realtime node they'll get Broadcast messages in the time it takes to ping the cluster.
Thanks to the Realtime cluster, you (an amazing Supabase user) don't have to think about which regions your clients are connected to.
If you're using Broadcast, Presence, or streaming database changes, messages will always get to your users via the shortest path possible.
## Connecting to a Database
Realtime allows you to listen to changes from your Postgres database. When a new client connects to Realtime and initializes the `postgres_changes` Realtime Extension the cluster will connect to your Postgres database and start streaming changes from a replication slot.
Realtime knows the region your database is in, and connects to it from the closest region possible.
Every Realtime region has at least two nodes so if one node goes offline the other node should reconnect and start streaming changes again.
## Streaming the Write-Ahead Log
A Postgres logical replication slot is acquired when connecting to your database.
Realtime delivers changes by polling the replication slot and appending channel subscription IDs to each wal record.
Subscription IDs are Erlang processes representing underlying sockets on the cluster. These IDs are globally unique and messages to processes are routed automatically by the Erlang virtual machine.
After receiving results from the polling query, with subscription IDs appended, Realtime delivers records to those clients.
export const Page = ({ children }) => <Layout meta={meta} children={children} />
export default Page
@@ -0,0 +1,22 @@
import Layout from '~/layouts/DefaultGuideLayout'
export const meta = {
id: 'channels',
title: 'Realtime Channels',
description: 'WebSockets are Channels on Supabase Realtime',
sidebar_label: 'Channels',
}
You can uniquely reference a channel by its `topic` you define when you initialize your Supabase Realtime client. Everyone connected to the same Channel topic will receive the same messages.
Clients can send and receive messages bi-directionally over a Channel. The Realtime backend can also push messages to all clients connected to the same Channel.
A single client can receive change records from Postgres, Broadcast messages from other clients and Presence updates all over the same Channel.
Channels are implemented over [Phoenix Channels](https://hexdocs.pm/phoenix/channels.html) which uses [Phoenix.PubSub](https://hexdocs.pm/phoenix_pubsub/Phoenix.PubSub.html) with the default `Phoenix.PubSub.PG2` adapter.
The PG2 adapter utilizes Erlang [process groups](https://www.erlang.org/docs/18/man/pg2.html) to implement the PubSub model where a publisher can send messages to many subscribers.
export const Page = ({ children }) => <Layout meta={meta} children={children} />
export default Page
@@ -0,0 +1,27 @@
import Layout from '~/layouts/DefaultGuideLayout'
export const meta = {
id: 'extensions',
title: 'Realtime Extensions',
description:
'Extensions enable powerful Realtime features like Presence, Broadcast and database change streaming',
sidebar_label: 'Extensions',
}
Supabase Realtime provides multiplayer features so that you can build real-time applications. The server is has several extensions which are built to solve a specific task.
- [Broadcast](/docs/guides/realtime/broadcast): for sending rapid, ephemeral messages to other connected clients. A typical use-case is tracking mouse movements.
- [Presence](/docs/guides/realtime/presence): for sending user state between connected clients. A typical use-case is simply an "online" status, which will disappear when a user is disconnected.
- [Postgres Changes](/docs/guides/realtime/postgres-changes): for receiving database changes in real-time.
## Choosing between Broadcast and Presence
Defaulting to using Broadcast is a good idea, and then use Presence sparingly. The Presence extension merges all changes into a shared state for every client connected to the same "channel". If you _do_ use Presence, it's best to throttle your changes so that you are sending updates less frequently.
## Future possibilities
Realtime extensions are still under development. We have many more extensions planned. You can follow our progress by watching the [Realtime](https://github.com/supabase/realtime) repo on GitHub.
export const Page = ({ children }) => <Layout meta={meta} children={children} />
export default Page
@@ -15,7 +15,9 @@ Clients can choose to receive `INSERT`, `UPDATE`, `DELETE`, or `*` (all) changes
Postgres Changes works out of the box for tables in the `public` schema. You can listen to tables in your private schemas by granting table `SELECT` permissions to the database role found in your access token. You can run a query similar to the following:
```sql
GRANT SELECT ON "private_schema"."table" TO authenticated;
grant
select
on "private_schema"."table" to authenticated;
```
<Admonition type="caution">
@@ -29,15 +31,19 @@ You can do this in the [Replication](https://app.supabase.com/project/_/database
```sql
begin;
-- remove the supabase_realtime publication
drop publication if exists supabase_realtime;
-- re-create the supabase_realtime publication with no tables
create publication supabase_realtime;
-- remove the supabase_realtime publication
drop
publication if exists supabase_realtime;
-- re-create the supabase_realtime publication with no tables
create publication supabase_realtime;
commit;
-- add a table to the publication
alter publication supabase_realtime add table messages;
alter
publication supabase_realtime add table messages;
```
### Full `old` Record
@@ -46,9 +52,16 @@ By default, only `new` record changes are sent but if you want to receive the `o
you can set the `replica identity` of your table to `full`:
```sql
alter table messages replica identity full;
alter table
messages replica identity full;
```
<Admonition type="caution">
RLS policies are not applied to `DELETE` statements. When RLS is enabled and `replica identity` is set to `full` on a table, the `old` record contains only the primary key(s).
</Admonition>
## Schema Changes
To listen to all changes in the `public` schema:
@@ -0,0 +1,194 @@
import Layout from '~/layouts/DefaultGuideLayout'
export const meta = {
id: 'protocol',
title: 'Realtime Protocol',
description: 'Understanding Realtime Protocol',
}
The Realtime Protocol is a set of message formats used for communication over a WebSocket connection between a Realtime client and server. These messages are used to initiate a connection, update access tokens, receive system status updates, and receive real-time updates from the Postgres database.
## Connection
In the initial message, the client sends a message specifying the features they want to use (Broadcast, Presence, Postgres Changes).
```ts
{
"event": "phx_join",
"topic": string,
"payload": {
"config": {
"broadcast": {
"self": boolean
},
"presence": {
"key": string
},
"postgres_changes": [
{
"event": "*" | "INSERT" | "UPDATE" | "DELETE",
"schema": string,
"table": string,
"filter": string + '=' + "eq" | "neq" | "gt" | "gte" | "lt" | "lte" | "in" + '.' + string
}
]
}
},
"ref": string
}
```
<Admonition type="note">
The `in` filter has the format `COLUMN_NAME=in.(value1,value2,value3)`. However, other filters use the format `COLUMN_NAME=FILTER_NAME.value`.
</Admonition>
In response, the server sends the Postgres configuration with a unique ID. With this ID, the client should route incoming changes to the appropriate callback.
```ts
{
"event": "phx_reply",
"topic": string,
"payload": {
"response": {
"postgres_changes": [
{
"id": number,
"event": "*" | "INSERT" | "UPDATE" | "DELETE",
"schema": string,
"table": string,
"filter": string + '=' + "eq" | "neq" | "gt" | "gte" | "lt" | "lte" | "in" + '.' + string
}
]
},
"status": "ok" | "error"
},
"ref": string
}
```
## System messages
System message are used to inform a client about the status of the Postgres subscription. The `payload.status` indicates if the subscription successful or not.
The body of the `payload.message` can be "Subscribed to PostgreSQL" or "Subscribing to PostgreSQL failed" with subscription params.
```ts
{
"event": "system",
"topic": string,
"payload":{
"channel": string,
"extension": "postgres_changes",
"message": "Subscribed to PostgreSQL" | "Subscribing to PostgreSQL failed",
"status": "ok" | "error"
},
"ref": null,
}
```
## Heartbeat
The heartbeat message should be sent every 30 seconds to avoid a connection timeout.
```ts
{
"event": "heartbeat",
"topic": "phoenix",
"payload": {},
"ref": string
}
```
## Access Token
To update the access token, you need to send to the server a message specifying a new token in the `payload.access_token` value.
```ts
{
"event": "access_token",
"topic": string,
"payload":{
"access_token": string
},
"ref": string
}
```
## Postgres CDC message
Realtime sends a message with the following structure
```ts
{
"event": "postgres_changes",
"topic": string,
"payload": {
"data": {
"columns": Array<{name: string, type: string}>,
"commit_timestamp": string,
"errors": null | string,
"old_record": {"id": number | string},
"record": {[key: string]: boolean | number | string | null},
"type": "*" | "INSERT" | "UPDATE" | "DELETE",
"schema": string,
"table": string
},
"ids": Array<number>
},
"ref": null
}
```
## Broadcast message
Structure of the broadcast event
```ts
{
"event": "broadcast",
"topic": string,
"payload": {
"event": string,
"payload": {[key: string]: boolean | number | string | null | undefined},
"type": "broadcast"
},
"ref": null
}
```
## Presence message
The Presence events allow clients to monitor the online status of other clients in real-time.
### State Update
After joining, the server sends a `presence_state` message to a client with presence information. The payload field contains keys in UUID format, where each key represents a client and its value is a JSON object containing information about that client.
```ts
{
"event": "presence_state",
"topic": string,
"payload": {
[key: string]: {metas: Array<{phx_ref: string, name: string, t: float}>}
},
"ref": null
}
```
### Diff Update
After a change to the presence state, such as a client joining or leaving, the server sends a presence_diff message to update the client's view of the presence state. The payload field contains two keys, `joins` and `leaves`, which represent clients that have joined and left, respectively. The values associated with each key are UUIDs of the clients.
```ts
{
"event": "presence_diff",
"topic": string,
"payload": {
"joins": {metas: Array<{phx_ref: string, name: string, t: float}>},
"leaves": {metas: Array<{phx_ref: string, name: string, t: float}>}
},
"ref": null
}
```
export const Page = ({ children }) => <Layout meta={meta} children={children} />
export default Page
@@ -0,0 +1,24 @@
import Layout from '~/layouts/DefaultGuideLayout'
export const meta = {
id: 'realtime-with-nextjs',
title: 'Using Realtime with Next.js',
description: 'Client & Server Components in Next.js with Realtime Updates',
sidebar_label: 'Videos',
}
In this guide we explore the best ways to receive realtime Postgres changes with your Next.js application.
We'll show both client and serverside updates, and explore the which option is best.
<div className="video-container">
<iframe
src="https://www.youtube-nocookie.com/embed/YR-xP6PPXXA"
frameBorder="1"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
></iframe>
</div>
export const Page = ({ children }) => <Layout meta={meta} children={children} />
export default Page
@@ -0,0 +1,80 @@
import Layout from '~/layouts/DefaultGuideLayout'
export const meta = {
id: 'subscribing-to-database-changes',
title: 'Subscribing to Database Changes',
description: 'Listen to database changes in real-time from your website or application.',
sidebar_label: 'Videos',
}
Supabase allows you to subscribe to real-time changes on your database from your client application.
You can listen to database changes using the [Postgres Changes](/docs/guides/realtime/postgres-changes) extension.
## Demo
<div className="video-container">
<iframe
src="https://www.youtube-nocookie.com/embed/2rUjcmgZDwQ"
frameBorder="1"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
></iframe>
</div>
## Streaming inserts
You can use the `INSERT` event to stream all new rows.
```js
const { createClient } = require('@supabase/supabase-js')
const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_KEY)
const channel = supabase
.channel('schema-db-changes')
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
},
(payload) => console.log(payload)
)
.subscribe()
```
## Streaming updates
You can use the `UPDATE` event to stream all updated rows.
```js
const { createClient } = require('@supabase/supabase-js')
const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_KEY)
const channel = supabase
.channel('schema-db-changes')
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
},
(payload) => console.log(payload)
)
.subscribe()
```
## More resources
- Learn more about the [Postgres Changes](/docs/guides/realtime/postgres-changes) extension.
- Client Libraries:
- [JavaScript](/docs/reference/javascript/subscribe)
- [Flutter](/docs/reference/dart/stream)
- [Python](/docs/reference/python/subscribe)
- [C#](/docs/reference/csharp/subscribe)
export const Page = ({ children }) => <Layout meta={meta} children={children} />
export default Page
Binary file not shown.

After

Width:  |  Height:  |  Size: 933 KiB

+2 -1
View File
@@ -449,7 +449,8 @@ export default function IndexPage() {
<p className="mt-3 prose lg:max-w-lg">
The Pro tier has a usage quota included and a spend cap turned on by default. If you
need to go beyond the inclusive limits, simply switch off your spend cap to pay for
additional usage.
additional usage and scale seamlessly. Note that your project will run into
restrictions if you have the spend cap enabled and exhaust your quota.
</p>
</div>
<div>
+1 -1
View File
@@ -9,7 +9,7 @@ interface Props extends Omit<React.HTMLAttributes<HTMLButtonElement>, 'size'> {
disabled?: boolean
layout?: 'horizontal' | 'vertical' | 'flex'
error?: string
descriptionText?: string
descriptionText?: string | React.ReactNode
label?: string | React.ReactNode
afterLabel?: string
beforeLabel?: string
@@ -34,7 +34,7 @@ export const ForeignKeyFormatter = (props: PropsWithChildren<FormatterProps<Supa
<span className="sb-grid-foreign-key-formatter__text">
{value === null ? <NullValue /> : value}
</span>
{relationship !== undefined && targetTable !== undefined && (
{relationship !== undefined && targetTable !== undefined && value !== null && (
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger>
<Link
@@ -120,6 +120,11 @@ const PaymentSummaryPanel: FC<Props> = ({
const isChangingCustomDomains =
currentAddons.customDomains?.id !== selectedAddons.customDomains?.id
const togglingOnSpendCap =
currentPlan.supabase_prod_id === PRICING_TIER_PRODUCT_IDS.PAYG &&
selectedPlan &&
selectedPlan.metadata.supabase_prod_id === PRICING_TIER_PRODUCT_IDS.PRO
// If it's enterprise we only only changing of add-ons
const hasChangesToPlan = isEnterprise
? isChangingComputeSize || isChangingPITRDuration
@@ -316,6 +321,16 @@ const PaymentSummaryPanel: FC<Props> = ({
description="It will take up to 2 minutes for changes to take place, and your project will be unavailable during that time"
/>
)}
{togglingOnSpendCap && (
<InformationBox
hideCollapse
defaultVisibility
icon={<IconAlertCircle strokeWidth={2} />}
title="Enabling spend cap"
description="Exceeding your plan's quota will result in service restrictions. With the spend cap disabled, you'll be charged for usage beyond the quota."
/>
)}
</div>
</div>
)}
@@ -14,7 +14,8 @@ interface Props {
const PlanCard: FC<Props> = ({ plan, currentPlan, onSelectPlan }) => {
// Team tier is enabled when the flag is turned on OR the user is already on the team tier (manually assigned by us)
const teamTierEnabled = currentPlan?.supabase_prod_id === PRICING_TIER_PRODUCT_IDS.TEAM || useFlag('teamTier')
const teamTierEnabled =
currentPlan?.supabase_prod_id === PRICING_TIER_PRODUCT_IDS.TEAM || useFlag('teamTier')
const planMeta = PRICING_META[plan.id]
const isEnterprise = plan.id === 'Enterprise'
@@ -118,8 +119,29 @@ const PlanCard: FC<Props> = ({ plan, currentPlan, onSelectPlan }) => {
{!isEnterprise && (
<div className="space-y-4 !mt-20">
<div className="space-y-2">
<p className="text-sm text-scale-1100">{planMeta.additional}</p>
<p className="text-xs text-scale-1000">{planMeta.scale}</p>
{planMeta.name === 'Pro' && currentPlan?.prod_id === STRIPE_PRODUCT_IDS.PRO && (
<div>
<p className="text-sm text-scale-1100">
Need more? Turn off your spend cap to Pay As You Grow
</p>
<p className="text-xs text-scale-1000">
Additional fees apply for spend beyond the limits above
</p>{' '}
</div>
)}
{planMeta.name === 'Pro' && currentPlan?.prod_id === STRIPE_PRODUCT_IDS.PAYG && (
<div>
<p className="text-sm text-scale-1100">
Spend cap is currently disabled
</p>
<p className="text-xs text-scale-1000">
You will be charged for usage beyond the plan limits
</p>{' '}
</div>
)}
</div>
<PlanCTAButton plan={plan} currentPlan={currentPlan} onSelectPlan={onSelectPlan} />
@@ -49,8 +49,6 @@ export const PRICING_META = {
'No project pausing',
'Email support',
],
additional: 'Need more? Turn off your spend cap to Pay As You Grow ',
scale: 'Additional fees apply for usage and storage beyond the limits above.',
},
[STRIPE_PRODUCT_IDS.TEAM]: {
new: true,
@@ -1,8 +1,7 @@
import { FC, useEffect, useRef, useState } from 'react'
import { Transition } from '@headlessui/react'
import { useRouter } from 'next/router'
import * as Tooltip from '@radix-ui/react-tooltip'
import { Button, IconHelpCircle, Toggle, Modal } from 'ui'
import { IconHelpCircle, Toggle } from 'ui'
import { useStore } from 'hooks'
import { post, patch } from 'lib/common/fetch'
@@ -30,6 +29,7 @@ import {
import BackButton from 'components/ui/BackButton'
import SupportPlan from './AddOns/SupportPlan'
import HCaptcha from '@hcaptcha/react-hcaptcha'
import SpendCapModal from './SpendCapModal'
// Do not allow compute size changes for af-south-1
@@ -263,7 +263,7 @@ const ProUpgrade: FC<Props> = ({
<div className="flex items-center justify-between gap-16 px-6 py-4 border rounded border-panel-border-light border-panel-border-dark bg-panel-body-light drop-shadow-sm dark:bg-panel-body-dark">
<div>
<div className="flex items-center space-x-2">
<p>Enable spend cap</p>
<p>Spend cap</p>
<IconHelpCircle
size={16}
strokeWidth={1.5}
@@ -272,7 +272,10 @@ const ProUpgrade: FC<Props> = ({
/>
</div>
<p className="text-sm text-scale-1100">
If enabled, additional resources will not be charged on a per-usage basis
By default, Pro projects have a spend cap enabled to control costs and prevent
exceeding limits. This restricts usage upon exhausting the quota. To scale
without restrictions, disable the spend cap and be charged for overusage beyond
the quota.
</p>
</div>
<Toggle
@@ -364,103 +367,11 @@ const ProUpgrade: FC<Props> = ({
/>
{/* Spend caps helper modal */}
<Modal
hideFooter
<SpendCapModal
visible={showSpendCapHelperModal}
size="large"
header="Enabling spend cap"
onCancel={() => setShowSpendCapHelperModal(false)}
>
<div className="py-4 space-y-4">
<Modal.Content>
<div className="space-y-4">
<p className="text-sm">
A spend cap allows you to restrict your project's resource usage within the limits
of the Pro tier. Disabling the spend cap will then remove those limits and any
additional resources consumed beyond the Pro tier limits will be charged on a
per-usage basis
</p>
<p className="text-sm">
The table below shows an overview of which resources are chargeable, and how they
are charged:
</p>
{/* Maybe instead of a table, show something more interactive like a spend cap playground */}
{/* Maybe ideate this in Figma first but this is good enough for now */}
<div className="border rounded border-scale-600 bg-scale-500">
<div className="flex items-center px-4 pt-2 pb-1">
<p className="w-[50%] text-sm text-scale-1100">Item</p>
<p className="w-[25%] text-sm text-scale-1100">Limit</p>
<p className="w-[25%] text-sm text-scale-1100">Rate</p>
</div>
<div className="py-1">
<div className="flex items-center px-4 py-1">
<p className="w-[50%] text-sm">Database size</p>
<p className="w-[25%] text-sm">8GB</p>
<p className="w-[25%] text-sm">$0.125/GB</p>
</div>
<div className="flex items-center px-4 py-1">
<p className="w-[50%] text-sm">Data transfer</p>
<p className="w-[25%] text-sm">50GB</p>
<p className="w-[25%] text-sm">$0.09/GB</p>
</div>
</div>
<div className="py-1">
<div className="flex items-center px-4 py-1">
<div className="flex w-[50%] items-center space-x-2">
<p className="text-sm">Auth MAUs</p>
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger>
<IconHelpCircle
size={16}
strokeWidth={1.5}
className="transition opacity-50 cursor-pointer hover:opacity-100"
/>
</Tooltip.Trigger>
<Tooltip.Content side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'rounded bg-scale-100 py-1 px-2 leading-none shadow', // background
'border border-scale-200 ', //border
].join(' ')}
>
<span className="text-xs text-scale-1200">
Monthly Active Users: A user that has made an API request in the last
month
</span>
</div>
</Tooltip.Content>
</Tooltip.Root>
</div>
<p className="w-[25%] text-sm">100,000</p>
<p className="w-[25%] text-sm">$0.00325/user</p>
</div>
</div>
<div className="py-1">
<div className="flex items-center px-4 py-1">
<p className="w-[50%] text-sm">Storage size</p>
<p className="w-[25%] text-sm">100GB</p>
<p className="w-[25%] text-sm">$0.021/GB</p>
</div>
<div className="flex items-center px-4 py-1">
<p className="w-[50%] text-sm">Data Transfer</p>
<p className="w-[25%] text-sm">50GB</p>
<p className="w-[25%] text-sm">$0.09/GB</p>
</div>
</div>
</div>
</div>
</Modal.Content>
<Modal.Separator />
<Modal.Content>
<div className="flex items-center gap-2">
<Button block type="primary" onClick={() => setShowSpendCapHelperModal(false)}>
Understood
</Button>
</div>
</Modal.Content>
</div>
</Modal>
onHide={() => setShowSpendCapHelperModal(false)}
/>
</>
)
}
@@ -0,0 +1,149 @@
import { FC } from 'react'
import * as Tooltip from '@radix-ui/react-tooltip'
import { Button, IconHelpCircle, Modal } from 'ui'
import Link from 'next/link'
interface Props {
visible: boolean
onHide: () => void
}
const SpendCapModal: FC<Props> = ({ visible, onHide }) => {
return (
<Modal hideFooter visible={visible} size="xlarge" header="Spend cap" onCancel={() => onHide()}>
<div className="py-4 space-y-4">
<Modal.Content>
<div className="space-y-4">
<p className="text-sm">
Enabling the spend cap sets a limit on your usage to stay within your plan's quota,
which controls costs but can limit service. Disabling the spend cap removes these
limits, but any extra usage beyond the plan's limit will be charged per usage.
</p>
<p className="text-sm">
Take a look at the following table to see which resources are chargeable and how they
are charged:
</p>
{/* Maybe instead of a table, show something more interactive like a spend cap playground */}
{/* Maybe ideate this in Figma first but this is good enough for now */}
<div className="border rounded border-scale-600 bg-scale-500">
<div className="flex items-center px-4 pt-2 pb-1">
<p className="w-[50%] text-sm text-scale-1100">Item</p>
<p className="w-[25%] text-sm text-scale-1100">Limit</p>
<p className="w-[25%] text-sm text-scale-1100">Rate</p>
</div>
<div className="py-2">
<div className="flex items-center px-4 py-1">
<p className="w-[50%] text-sm">Database size</p>
<p className="w-[25%] text-sm">8GB</p>
<p className="w-[25%] text-sm">$0.125/GB</p>
</div>
<div className="flex items-center px-4 py-1">
<p className="w-[50%] text-sm">Database egress</p>
<p className="w-[25%] text-sm">50GB</p>
<p className="w-[25%] text-sm">$0.09/GB</p>
</div>
</div>
<div className="py-2">
<div className="flex items-center px-4 py-1">
<div className="flex w-[50%] items-center space-x-2">
<p className="text-sm">Auth MAUs</p>
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger>
<IconHelpCircle
size={16}
strokeWidth={1.5}
className="transition opacity-50 cursor-pointer hover:opacity-100"
/>
</Tooltip.Trigger>
<Tooltip.Content side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'rounded bg-scale-100 py-1 px-2 leading-none shadow', // background
'border border-scale-200 ', //border
].join(' ')}
>
<span className="text-xs text-scale-1200">
Monthly Active Users: A user that has made an API request in the last
month
</span>
</div>
</Tooltip.Content>
</Tooltip.Root>
</div>
<p className="w-[25%] text-sm">100,000</p>
<p className="w-[25%] text-sm">$0.00325/user</p>
</div>
</div>
<div className="py-2">
<div className="flex items-center px-4 py-1">
<p className="w-[50%] text-sm">Storage size</p>
<p className="w-[25%] text-sm">100GB</p>
<p className="w-[25%] text-sm">$0.021/GB</p>
</div>
<div className="flex items-center px-4 py-1">
<p className="w-[50%] text-sm">Storage egress</p>
<p className="w-[25%] text-sm">200GB</p>
<p className="w-[25%] text-sm">$0.09/GB</p>
</div>
<div className="flex items-center px-4 py-1">
<p className="w-[50%] text-sm">Asset transformations</p>
<p className="w-[25%] text-sm">100 origin images</p>
<p className="w-[25%] text-sm">$5 per 1000 origin images</p>
</div>
</div>
<div className="py-2">
<div className="flex items-center px-4 py-1">
<p className="w-[50%] text-sm">Realtime concurrent peak connections</p>
<p className="w-[25%] text-sm">500</p>
<p className="w-[25%] text-sm">$10/1000</p>
</div>
<div className="flex items-center px-4 py-1">
<p className="w-[50%] text-sm">Realtime messages</p>
<p className="w-[25%] text-sm">5 Million</p>
<p className="w-[25%] text-sm">$2.50/Million</p>
</div>
</div>
<div className="py-2">
<div className="flex items-center px-4 py-1">
<p className="w-[50%] text-sm">Function invocations</p>
<p className="w-[25%] text-sm">2 Million</p>
<p className="w-[25%] text-sm">$2 per Million</p>
</div>
<div className="flex items-center px-4 py-1">
<p className="w-[50%] text-sm">Function count</p>
<p className="w-[25%] text-sm">100 functions</p>
<p className="w-[25%] text-sm">$10 per 100 functions</p>
</div>
</div>
</div>
<p>
See{' '}
<Link href="https://supabase.com/pricing" passHref>
<a className="text-brand-900" target="_blank">
<span>pricing page</span>
</a>
</Link>{' '}
for a full overview.
</p>
</div>
</Modal.Content>
<Modal.Separator />
<Modal.Content>
<div className="flex items-center gap-2">
<Button block type="primary" onClick={() => onHide()}>
Understood
</Button>
</div>
</Modal.Content>
</div>
</Modal>
)
}
export default SpendCapModal
@@ -14,7 +14,7 @@ import {
IconBookOpen,
} from 'ui'
import { checkPermissions, useStore } from 'hooks'
import { checkPermissions, useFlag, useStore } from 'hooks'
import { formatBytes } from 'lib/helpers'
import { PRICING_TIER_PRODUCT_IDS, USAGE_APPROACHING_THRESHOLD } from 'lib/constants'
import SparkBar from 'components/ui/SparkBar'
@@ -24,6 +24,7 @@ import { USAGE_BASED_PRODUCTS } from 'components/interfaces/Billing/Billing.cons
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useProjectReadOnlyQuery } from 'data/config/project-read-only-query'
import { ProjectUsageResponseUsageKeys, useProjectUsageQuery } from 'data/usage/project-usage-query'
import { getResourcesExceededLimits } from 'components/ui/OveragesBanner/OveragesBanner.utils'
interface Props {
projectRef?: string
@@ -40,6 +41,8 @@ const ProjectUsage: FC<Props> = ({ projectRef }) => {
connectionString: project?.connectionString,
})
const overusageBadgeEnabled = useFlag('overusageBadge')
const canUpdateSubscription = checkPermissions(
PermissionAction.BILLING_WRITE,
'stripe.subscriptions'
@@ -51,6 +54,8 @@ const ProjectUsage: FC<Props> = ({ projectRef }) => {
subscriptionTier === PRICING_TIER_PRODUCT_IDS.TEAM ||
subscriptionTier === PRICING_TIER_PRODUCT_IDS.ENTERPRISE
const resourcesExceededLimits = getResourcesExceededLimits(usage)
const showUsageExceedMessage = subscriptionTier !== undefined && !projectHasNoLimits
const planNames = {
@@ -106,16 +111,54 @@ const ProjectUsage: FC<Props> = ({ projectRef }) => {
<Loading active={isLoading}>
{usage && (
<div>
{USAGE_BASED_PRODUCTS.map((product) => {
const isExceededUsage =
showUsageExceedMessage &&
product.features
.map((feature) => {
const featureUsage = usage[feature.key as ProjectUsageResponseUsageKeys]
return (featureUsage.usage ?? 0) / featureUsage.limit > 1
})
.some((x) => x === true)
{resourcesExceededLimits.length > 0 &&
showUsageExceedMessage &&
overusageBadgeEnabled && (
<div className="mb-10">
<InformationBox
hideCollapse
defaultVisibility
icon={<IconAlertCircle strokeWidth={2} />}
title="You are exceeding your plans quota"
description={
<div className="p-1">
{subscriptionTier === PRICING_TIER_PRODUCT_IDS.FREE ? (
<div>
<p>
Your project is currently on the Free tier - upgrade to the Pro tier for
a greatly increased quota and continue to scale.
</p>
<p className="mb-4">
See{' '}
<Link href="https://supabase.com/pricing" passHref>
<a className="text-brand-900">pricing page</a>
</Link>{' '}
for a full breakdown of available plans.
</p>
<Link href={`/project/${projectRef}/settings/billing/update`}>
<Button>Upgrade to Pro</Button>
</Link>
</div>
) : (
<div className="space-y-4">
<p>
By default, Pro projects have spend caps to control costs. When enabled,
usage is limited to the plan's quota, with restrictions when limits are
exceeded. To scale beyond Pro limits without restrictions, disable the
spend cap and pay for over-usage beyond the quota.
</p>
<Link href={`/project/${projectRef}/settings/billing/update/pro`}>
<Button>Configure Spend Cap</Button>
</Link>
</div>
)}
</div>
}
/>
</div>
)}
{USAGE_BASED_PRODUCTS.map((product) => {
return (
<div
key={product.title}
@@ -141,12 +184,6 @@ const ProjectUsage: FC<Props> = ({ projectRef }) => {
<h5 className="mb-0">{product.title}</h5>
</div>
</th>
{/* Plan Limits */}
<th className="hidden p-3 text-xs font-medium leading-4 text-left text-gray-400 lg:table-cell">
{isExceededUsage && <Badge color="red">Exceeded usage</Badge>}
</th>
{/* Usage */}
<th className="p-3 text-xs font-medium leading-4 text-left text-gray-400" />
</tr>
</thead>
{/* Line items */}
@@ -196,6 +196,11 @@ const InputField = ({
}
if (field.format === 'bool') {
const options = [
{ value: 'true', label: 'TRUE' },
{ value: 'false', label: 'FALSE' },
...(field.isNullable ? [{ value: 'null', label: 'NULL' }] : []),
]
return (
<Listbox
size="small"
@@ -210,17 +215,16 @@ const InputField = ({
else onUpdateField({ [field.name]: value })
}}
>
<Listbox.Option id="true" key="true" label="TRUE" value="true">
TRUE
</Listbox.Option>
<Listbox.Option id="false" key="false" label="FALSE" value="false">
FALSE
</Listbox.Option>
{field.isNullable && (
<Listbox.Option id="null" key="null" label="NULL" value="null">
NULL
{options.map((option) => (
<Listbox.Option
id={option.value}
key={option.value}
label={option.label}
value={option.value}
>
{option.label}
</Listbox.Option>
)}
))}
</Listbox>
)
}
@@ -3,7 +3,7 @@ import { find, isUndefined, compact, isEqual, omitBy, isNull, isString } from 'l
import { Dictionary } from 'components/grid'
import type { PostgresTable } from '@supabase/postgres-meta'
import { uuidv4, minifyJSON, tryParseJson } from 'lib/helpers'
import { minifyJSON, tryParseJson } from 'lib/helpers'
import { RowField } from './RowEditor.types'
import {
DATETIME_TYPES,
@@ -24,6 +24,10 @@ export const generateRowFields = (
const value =
isUndefined(row) && TEXT_TYPES.includes(column.format)
? null
: isUndefined(row) && column.format === 'bool' && !column.is_nullable
? 'true'
: isUndefined(row) && column.format === 'bool' && column.is_nullable
? 'null'
: isUndefined(row)
? ''
: DATETIME_TYPES.includes(column.format)
@@ -78,7 +82,7 @@ export const validateFields = (fields: RowField[]) => {
return errors
}
const parseValue = (originalValue: string, format: string) => {
const parseValue = (originalValue: any, format: string) => {
try {
if (originalValue === null || originalValue.length === 0) {
return originalValue
@@ -87,7 +91,7 @@ const parseValue = (originalValue: string, format: string) => {
} else if (typeof originalValue === 'object') {
return JSON.stringify(originalValue)
} else if (typeof originalValue === 'boolean') {
return (originalValue as any).toString()
return originalValue.toString()
} else {
return originalValue
}
@@ -168,7 +172,8 @@ export const generateRowObjectFromFields = (
rowObject[field.name] = tryParseJson(value)
}
} else if (field.format === 'bool' && value) {
rowObject[field.name] = value === 'true'
if (value === 'null') rowObject[field.name] = null
else rowObject[field.name] = value === 'true'
} else if (DATETIME_TYPES.includes(field.format)) {
// Seconds are missing if they are set to 0 in the HTML input
// Need to format that first, before passing to dayjs
@@ -2,7 +2,7 @@ import Link from 'next/link'
import { observer } from 'mobx-react-lite'
import { IS_PLATFORM, PRICING_TIER_PRODUCT_IDS } from 'lib/constants'
import { useParams, useStore } from 'hooks'
import { useFlag, useParams, useStore } from 'hooks'
import BreadcrumbsView from './BreadcrumbsView'
import OrgDropdown from './OrgDropdown'
import ProjectDropdown from './ProjectDropdown'
@@ -38,7 +38,8 @@ const LayoutHeader = ({ customHeaderComponents, breadcrumbs = [], headerBorder =
const showOverUsageBadge =
selectedProject?.subscription_tier !== undefined &&
!projectHasNoLimits &&
resourcesExceededLimits.length > 0
resourcesExceededLimits.length > 0 &&
useFlag('overusageBadge')
return (
<div
@@ -86,16 +87,15 @@ const LayoutHeader = ({ customHeaderComponents, breadcrumbs = [], headerBorder =
</div>
)}
{/* [Joshen TODO] Temporarily hidden until usage endpoint is sorted out */}
{/* {showOverUsageBadge && (
{showOverUsageBadge && (
<div className="ml-2">
<Link href={`/project/${projectRef}/settings/billing/subscription`}>
<Link href={`/project/${projectRef}/settings/billing/usage`}>
<a>
<Badge color="red">Project has exceeded usage limits </Badge>
</a>
</Link>
</div>
)} */}
)}
</>
)}
</>
+56 -8
View File
@@ -4,7 +4,7 @@ import { debounce, isUndefined, values } from 'lodash'
import { toJS } from 'mobx'
import { observer } from 'mobx-react-lite'
import generator from 'generate-password'
import { Button, Listbox, IconUsers, Input, Alert } from 'ui'
import { Button, Listbox, IconUsers, Input, Alert, IconHelpCircle, Toggle } from 'ui'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { NextPageWithLayout } from 'types'
@@ -33,6 +33,7 @@ import {
NotOrganizationOwnerWarning,
EmptyPaymentMethodWarning,
} from 'components/interfaces/Organization/NewProject'
import SpendCapModal from 'components/interfaces/Billing/SpendCapModal'
const Wizard: NextPageWithLayout = () => {
const router = useRouter()
@@ -55,6 +56,10 @@ const Wizard: NextPageWithLayout = () => {
const [passwordStrengthScore, setPasswordStrengthScore] = useState(0)
const [paymentMethods, setPaymentMethods] = useState<any[] | undefined>(undefined)
const [showSpendCapHelperModal, setShowSpendCapHelperModal] = useState(false)
const [isSpendCapEnabled, setIsSpendCapEnabled] = useState(true)
const organizations = values(toJS(app.organizations.list()))
const currentOrg = organizations.find((o: any) => o.slug === slug)
const stripeCustomerId = currentOrg?.stripe_customer_id
@@ -150,13 +155,16 @@ const Wizard: NextPageWithLayout = () => {
const onClickNext = async () => {
setNewProjectLoading(true)
const dbTier = dbPricingTierKey === 'PRO' && !isSpendCapEnabled ? 'PAYG' : dbPricingTierKey
const data: Record<string, any> = {
cloud_provider: PROVIDERS.AWS.id, // hardcoded for DB instances to be under AWS
org_id: currentOrg?.id,
name: projectName,
db_pass: dbPass,
db_region: dbRegion,
db_pricing_tier_id: (PRICING_TIER_PRODUCT_IDS as any)[dbPricingTierKey],
db_pricing_tier_id: (PRICING_TIER_PRODUCT_IDS as any)[dbTier],
kps_enabled: kpsEnabled,
}
if (postgresVersion) {
@@ -243,7 +251,7 @@ const Wizard: NextPageWithLayout = () => {
<>
<Panel.Content
className={[
'Form section-block--body has-inputs-centered space-y-4 border-t border-b',
'space-y-4 border-t border-b',
'border-panel-border-interior-light dark:border-panel-border-interior-dark',
].join(' ')}
>
@@ -274,7 +282,7 @@ const Wizard: NextPageWithLayout = () => {
<>
<Panel.Content
className={[
'Form section-block--body has-inputs-centered border-t border-b',
'border-t border-b',
'border-panel-border-interior-light dark:border-panel-border-interior-dark',
].join(' ')}
>
@@ -293,7 +301,7 @@ const Wizard: NextPageWithLayout = () => {
{showCustomVersionInput && (
<Panel.Content
className={[
'Form section-block--body has-inputs-centered border-t border-b',
'border-t border-b',
'border-panel-border-interior-light dark:border-panel-border-interior-dark',
].join(' ')}
>
@@ -316,7 +324,7 @@ const Wizard: NextPageWithLayout = () => {
</Panel.Content>
)}
<Panel.Content className="border-b Form section-block--body has-inputs-centered border-panel-border-interior-light dark:border-panel-border-interior-dark">
<Panel.Content className="border-b border-panel-border-interior-light dark:border-panel-border-interior-dark">
<Input
id="password"
copy={dbPass.length > 0}
@@ -338,7 +346,7 @@ const Wizard: NextPageWithLayout = () => {
/>
</Panel.Content>
<Panel.Content className="border-b Form section-block--body has-inputs-centered border-panel-border-interior-light dark:border-panel-border-interior-dark">
<Panel.Content className="border-b border-panel-border-interior-light dark:border-panel-border-interior-dark">
<Listbox
layout="horizontal"
label="Region"
@@ -372,7 +380,7 @@ const Wizard: NextPageWithLayout = () => {
)}
{isAdmin && (
<Panel.Content className="Form section-block--body has-inputs-centered ">
<Panel.Content>
<Listbox
label="Pricing Plan"
layout="horizontal"
@@ -421,6 +429,46 @@ const Wizard: NextPageWithLayout = () => {
)}
</Panel.Content>
)}
{!isSelectFreeTier && (
<>
<Panel.Content className="border-b border-panel-border-interior-light dark:border-panel-border-interior-dark">
<Toggle
id="project-name"
layout="horizontal"
label={
<div className="flex space-x-4">
<span>Spend Cap</span>
<IconHelpCircle
size={16}
strokeWidth={1.5}
className="transition opacity-50 cursor-pointer hover:opacity-100"
onClick={() => setShowSpendCapHelperModal(true)}
/>
</div>
}
placeholder="Project name"
checked={isSpendCapEnabled}
onChange={() => setIsSpendCapEnabled(!isSpendCapEnabled)}
descriptionText={
<div>
<p>
By default, Pro projects have spend caps to control costs. When enabled,
usage is limited to the plan's quota, with restrictions when limits are
exceeded. To scale beyond Pro limits without restrictions, disable the
spend cap and pay for over-usage beyond the quota.
</p>
</div>
}
/>
</Panel.Content>
<SpendCapModal
visible={showSpendCapHelperModal}
onHide={() => setShowSpendCapHelperModal(false)}
/>
</>
)}
</>
)}
</>