Pull master and resolve conflicts
No files matched your search
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
.vercel
|
||||
.next
|
||||
.env.local
|
||||
.env.production
|
||||
.env.dev
|
||||
.env.*
|
||||
@@ -225,6 +225,7 @@ Our approach for client libraries is modular. Each sub-library is a standalone i
|
||||
- [Portuguese (Brazilian) / Português Brasileiro](/i18n/README.pt-br.md)
|
||||
- [Romanian / Română](/i18n/README.ro.md)
|
||||
- [Russian / Pусский](/i18n/README.ru.md)
|
||||
- [Serbian / Srpski](/i18n/README.sr.md)
|
||||
- [Sinhala / සිංහල](/i18n/README.si.md)
|
||||
- [Spanish / Español](/i18n/README.es.md)
|
||||
- [Simplified Chinese / 简体中文](/i18n/README.zh-cn.md)
|
||||
|
||||
@@ -26,7 +26,7 @@ Important notes:
|
||||
|
||||
- Since the generator uses JSON API, there is no way to determine if a column is an Array. It will generate array types as `string`, even though Supabase handles this automatically and returns arrays.
|
||||
You can fix this manually in the files by changing the type, e.g. `names: string` -> `names: string[]`
|
||||
- The types won't automatically stay in sync with your database, so make sure to regenerate your types after your make changes to your database.
|
||||
- The types won't automatically stay in sync with your database, so make sure to regenerate your types after you make changes to your database.
|
||||
|
||||
After you have generated your types, you can use them in your TypeScript projects:
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ Azure OAuth consists of four broad steps:
|
||||
- Create an application under Azure Active Directory.
|
||||
- Obtain a `Application (client) ID` with “Sign In with Azure” capabilities. This will be used as the `client id`.
|
||||
- Create a `Secret ID` with “Sign In with Azure” capabilities. The value of the secret will be used as the `client secret`.
|
||||
- Whitelist the callback url of your application.
|
||||
- Add the callback url of your application to the allowlist.
|
||||
|
||||
## Steps
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
---
|
||||
id: auth-captcha
|
||||
title: 'Enable Captcha Protection'
|
||||
description: Add Captcha Protection to your Supabase project
|
||||
---
|
||||
|
||||
import Tabs from '@theme/Tabs'
|
||||
import TabItem from '@theme/TabItem'
|
||||
|
||||
# hCaptcha guide
|
||||
|
||||
Supabase provides you with the option of adding captcha to your sign-in, sign-up, and password reset forms. This keeps your website safe from bots and malicious scripts. Supabase authentication has support for [hCaptcha](https://www.hcaptcha.com/).
|
||||
|
||||
### Sign up for hCaptcha
|
||||
|
||||
Go to the [hCaptcha](https://www.hcaptcha.com/) website and sign up for an account. On the welcome page, copy the **Sitekey** and **Secret key**.
|
||||
|
||||
If you have already signed up and didn't copy this information from the welcome page, you can get the **Secret key** from the settings page.
|
||||
|
||||

|
||||
|
||||
The **Sitekey** can be found in the **Settings** of the active site you created.
|
||||
|
||||

|
||||
|
||||
In the Settings page, look for the **Sitekey** section and copy the key.
|
||||
|
||||

|
||||
|
||||
### Enable hCaptcha protection for your Supabase project
|
||||
|
||||
Navigate to the **[Authentication](https://app.supabase.com/project/_/auth/settings)** page in the Supabase Dashboard and find the **Enable hCaptcha protection** toggle under the **Security and Protection** section.
|
||||
|
||||

|
||||
|
||||
Enter your hCaptcha **Secret key** and click **Save**.
|
||||
|
||||
### Add the hCaptcha frontend component
|
||||
|
||||
The frontend requires some changes to provide the captcha on-screen for the user. This example uses React and the hCaptcha React component, but hCaptcha can be used with any JavaScript framework.
|
||||
|
||||
Install `@hcaptcha/react-hcaptcha` in your project as a dependency.
|
||||
|
||||
```bash
|
||||
npm install @hcaptcha/react-hcaptcha
|
||||
```
|
||||
|
||||
Now import the `HCaptcha` component from the `@hcaptcha/react-hcaptcha` library.
|
||||
|
||||
```javascript
|
||||
import HCaptcha from '@hcaptcha/react-hcaptcha'
|
||||
```
|
||||
|
||||
Let’s create a empty state to store our `captchaToken`
|
||||
|
||||
```jsx
|
||||
const [captchaToken, setCaptchaToken] = useState()
|
||||
```
|
||||
|
||||
Now lets add the HCaptcha component to the JSX section of our code
|
||||
|
||||
```html
|
||||
<HCaptcha />
|
||||
```
|
||||
|
||||
We will pass it the sitekey we copied from the hCaptcha website as a property along with a onVerify property which takes a callback function. This callback function will have a token as one of its properties. Let's set the token in the state using `setCaptchaToken`
|
||||
|
||||
```jsx
|
||||
<HCaptcha
|
||||
sitekey="your-sitekey"
|
||||
onVerify={(token) => { setCaptchaToken(token) }
|
||||
/>
|
||||
```
|
||||
|
||||
Now lets use the captcha token we receive in our Supabase signUp function.
|
||||
|
||||
```jsx
|
||||
await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: { captchaToken },
|
||||
})
|
||||
```
|
||||
|
||||
We will also need to reset the captcha challenge after we have made a call to the function above.
|
||||
|
||||
Create a ref to use on our HCaptcha component.
|
||||
|
||||
```jsx
|
||||
const captcha = useRef()
|
||||
```
|
||||
|
||||
Let's add a ref attribute on the `HCaptcha` component and assign the `captcha` constant to it.
|
||||
|
||||
```jsx
|
||||
<HCaptcha
|
||||
ref={captcha}
|
||||
sitekey="your-sitekey"
|
||||
onVerify={(token) => {
|
||||
setCaptchaToken(token)
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
Reset the `captcha` after the signUp function is called using the following code:
|
||||
|
||||
```jsx
|
||||
captcha.current.resetCaptcha()
|
||||
```
|
||||
|
||||
In order to test that this works locally we will need to use something like [ngrok](https://ngrok.com/) or add an entry to your hosts file. You can read more about this in the [hCaptcha docs](https://docs.hcaptcha.com/#local-development).
|
||||
|
||||
Run the application and you should now be provided with a captcha challenge.
|
||||
@@ -22,7 +22,7 @@ Keycloak OAuth consists of five broad steps:
|
||||
- Ensure that the new client has the "Client Protocol" set to "openid-connect" and the "Access Type" is set to "confidential".
|
||||
- The `Client ID` of the client created will be used as the `client id`.
|
||||
- Obtain the `Secret` from the credentials tab which will be used as the `client secret`.
|
||||
- Whitelist the callback url of your application.
|
||||
- Add the callback url of your application to your allowlist.
|
||||
|
||||
## Steps
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
id: polyscale
|
||||
title: 'PolyScale'
|
||||
description: 'The easiest way to get low-latency reads from your Supabase database for multi-region applications is by using PolyScale, a code-free global caching service.'
|
||||
---
|
||||
|
||||
[PolyScale](https://polyscale.ai) is an intelligent, serverless caching engine which allows low-latency reads from your database globally, no coding required. Supabase can be connected to PolyScale in minutes, providing you fast access to your Supabase data around the globe.
|
||||
|
||||
This guide explains how to connect Supabase to a PolyScale cache.
|
||||
|
||||
The video below illustrates how to get connected. Or you can read the steps below.
|
||||
|
||||
<div class="video-container">
|
||||
<iframe
|
||||
src="https://www.youtube.com/embed/ZQ8TG-A-CIw?rel=0"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture; modestbranding; showinfo=0"
|
||||
allowfullscreen
|
||||
width="100%"
|
||||
height="400px"
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
## Step 0: Create a PolyScale account
|
||||
|
||||
If you do not already have a PolyScale account, you can create an account [here](https://app.polyscale.ai/signup). PolyScale offers a free tier and no credit card is required.
|
||||
|
||||
## Step 1: Create your PolyScale Cache
|
||||
|
||||
### 1.1 Retrieve your Supabase Host
|
||||
|
||||
In your Supabase project, click on `Settings > Database` and scroll down to the `Connection info` section to copy your database `Host`.
|
||||
|
||||

|
||||
|
||||
### 1.2 Configure your PolyScale Cache
|
||||
|
||||
- In your PolyScale account, click on the **New Cache** button
|
||||
- Give the cache a **Name**
|
||||
- Select **PostgreSQL** for the **Type**
|
||||
- Enter the **Host** from Step 1.1 above
|
||||
- Enter `5432` for the **Port**
|
||||
- Click **Create**
|
||||
|
||||

|
||||
|
||||
Your cache is now created. PolyScale automatically checks to see that your database is accessible from all our global PoPs.
|
||||
|
||||
## Step 2: Connect to your PolyScale Cache
|
||||
|
||||
Using your PolyScale cache is simple -- instead of connecting to your Supabase database directly, you'll replace your orignal connection string with the PolyScale connection string in your application.
|
||||
|
||||
For example, if your original connection string was: `postgres://postgres:zqSPGHFAbPLvVCKw@db.rogpiubvixysbakciwqz.supabase.co:5432`
|
||||
|
||||
Your PolyScale connection string would be: `postgres://postgres:zqSPGHFAbPLvVCKw@psedge.global:5432?application_name=a645cb93-fa53-46b2-9d6c-227e357e5bfb`
|
||||
|
||||
You can read more about connecting to PolyScale [here](https://docs.polyscale.ai/connecting-to-polyscale#postgresql)
|
||||
|
||||
That's it.
|
||||
|
||||
## All done!
|
||||
|
||||
You can read more about PolyScale [here](https://www.polyscale.ai/) or check out our [documentation](https://docs.polyscale.ai/).
|
||||
@@ -4,78 +4,212 @@ title: Logging
|
||||
description: Getting started with Supabase Platform Log Browser
|
||||
---
|
||||
|
||||
The Supabase Platform provides a log explorer that allows log tracing and debugging. Currently, PostgreSQL and Cloudflare edge logs are available.
|
||||
import Tabs from '@theme/Tabs'
|
||||
import TabItem from '@theme/TabItem'
|
||||
import ThemedImage from '@theme/ThemedImage';
|
||||
import useBaseUrl from '@docusaurus/useBaseUrl';
|
||||
|
||||
The Supabase Platform includes a Logs Explorer that allows log tracing and debugging. Log retention is based on your [project's pricing plan](https://supabase.com/pricing).
|
||||
|
||||
:::note
|
||||
|
||||
The features discussed in this article are only available through the Supabase Platform and are not available on self-hosted.
|
||||
|
||||
These features are not currently available for self-hosting and local development.<br/>
|
||||
This is on the roadmap and you can follow the progress in the [Logflare repository](https://github.com/Logflare/logflare).
|
||||
:::
|
||||
|
||||
## Product Logs
|
||||
|
||||
As well as a Log Explorer, Supabase provides a logging interface specific to each product.
|
||||
Supabase provides a logging interface specific to each product. You can use simple regular expressions for keywords and patterns to search log event messages. You can also export and download the log events matching your query as a spreadsheet.
|
||||
|
||||
### API Logs
|
||||
<!--
|
||||
To update the screenshots, ensure that at least one log line is selected to display the metadata. Can use meme.town as an example.
|
||||
-->
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="api" label="API" default>
|
||||
|
||||
[API logs](https://app.supabase.com/project/_/database/api-logs) show all network requests and response for the REST and GraphQL [APIs](../../guides/api).
|
||||
|
||||

|
||||
|
||||
The API Logs can be found under `Database > API Logs`. These show all network requests for the REST and GraphQL [API](../../guides/api).
|
||||
</TabItem>
|
||||
<TabItem value="postgres" label="Postgres">
|
||||
|
||||
### Postgres Logs
|
||||
[Postgres logs](https://app.supabase.com/project/_/database/postgres-logs) show all queries and activity for your [database](../../guides/database).
|
||||
|
||||

|
||||
|
||||
The Postgres Logs can be found under `Database > Postgres Logs`. These show all queries and activity for your [Database](../../guides/database).
|
||||
</TabItem>
|
||||
<TabItem value="auth" label="Auth">
|
||||
|
||||
## Log Explorer
|
||||
[Auth logs](https://app.supabase.com/project/_/auth/logs) show all server logs for your [Auth usage](../../guides/auth).
|
||||
|
||||

|
||||

|
||||
|
||||
The log browser can be accessed in the sidebar under **Logs Explorer**. The **Logs Explorer** is for querying and aggregating project logs across products using SQL `SELECT` queries.
|
||||
</TabItem>
|
||||
<TabItem value="storage" label="Storage">
|
||||
|
||||
### Example
|
||||
[Storage logs](https://app.supabase.com/project/_/storage/logs) shows all server logs for your [Storage API](../../guides/storage).
|
||||
|
||||
For example, you may enter the following into the SQL editor to query for each user's IP address:
|
||||

|
||||
|
||||
</TabItem>
|
||||
<TabItem value="realtime" label="Realtime">
|
||||
|
||||
[Realtime logs](https://app.supabase.com/project/_/database/realtime-logs) show all server logs for your [Realtime API usage](../../guides/realtime).
|
||||
|
||||

|
||||
|
||||
</TabItem>
|
||||
<TabItem value="functions" label="Edge Functions">
|
||||
|
||||
For each [Edge Function](https://app.supabase.com/project/_/functions), logs are available under the following tabs:
|
||||
|
||||
**Invocations**
|
||||
|
||||
The Invocations tab displays the edge logs of function calls.
|
||||
|
||||

|
||||
|
||||
**Logs**
|
||||
|
||||
The Logs tab displays logs emitted during function execution.
|
||||
|
||||

|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||
You can access the following logs from the **Sources** drop-down:
|
||||
|
||||
- `auth_logs`: GoTrue server logs, containing authentication/authorization activity.
|
||||
- `edge_logs`: Edge network logs, containing request and response metadata retrieved from Cloudflare.
|
||||
- `function_edge_logs`: Edge network logs for only edge functions, containing network requests and response metadata for each execution.
|
||||
- `function_logs`: Function internal logs, containing any `console` logging from within the edge function.
|
||||
- `postgres_logs`: Postgres database logs, containing statements executed by connected applications.
|
||||
- `realtime_logs`: Realtime server logs, containing client connection information.
|
||||
- `storage_logs`: Storage server logs, containing object upload and retrieval information.
|
||||
|
||||
## Querying with the Logs Explorer
|
||||
|
||||
The Logs Explorer uses BigQuery and supports all [available SQL functions and operators](https://cloud.google.com/bigquery/docs/reference/standard-sql/functions-and-operators).
|
||||
|
||||
### Timestamp Display and Behavior
|
||||
|
||||
Each log entry is stored with a `timestamp` as a `TIMESTAMP` data type. Use the appropriate [timestamp function](https://cloud.google.com/bigquery/docs/reference/standard-sql/timestamp_functions#timestamp) to utilize the `timestamp` field in a query.
|
||||
|
||||
Raw top-level timestamp values are rendered as unix microsecond. To render the timestamps in a human-readable format, use the `DATETIME()` function to convert the unix timestamp display into an ISO-8601 timestamp.
|
||||
|
||||
```sql
|
||||
SELECT timestamp, h.x_real_ip
|
||||
FROM edge_logs
|
||||
LEFT JOIN UNNEST(metadata) as m ON TRUE
|
||||
LEFT JOIN UNNEST(m.request) AS r ON TRUE
|
||||
LEFT JOIN UNNEST(r.headers) AS h ON TRUE
|
||||
WHERE h.x_real_ip IS NOT NULL
|
||||
-- timestamp column without datetime()
|
||||
select timestamp from ....
|
||||
-- 1664270180000
|
||||
|
||||
-- timestamp column with datetime()
|
||||
select datetime(timestamp) from ....
|
||||
-- 2022-09-27T09:17:10.439Z
|
||||
```
|
||||
|
||||

|
||||
|
||||
The list of supported product sources can be found under the **Sources** dropdown.
|
||||
|
||||
### Unnesting Arrays
|
||||
|
||||
To query the metadata in the above example, you can unnest the field and "join" the unnested data. Clicking on the log row shows that log metadata is stored as an array of objects.
|
||||
Each log event stores metadata an array of objects with multiple levels, and can be seen by selecting single log events in the Logs Explorer. To query arrays, use `unnest()` on each array field and add it to the query as a join. This allows you to reference the nested objects with an alias and select their individual fields.
|
||||
|
||||
In order to query any value that is an array, we would need to `UNNEST()` that field and add it to the query as a join, thereby allowing us to reference the nested fields within the array.
|
||||
For example, to query the edge logs without any joins:
|
||||
|
||||
:::caution
|
||||
```sql
|
||||
select timestamp, metadata from edge_logs t
|
||||
```
|
||||
The resulting `metadata` key is rendered as an array of objects in the Logs Explorer. In the following diagram, each box represents a nested array of objects:
|
||||
|
||||
Large projects may run into a `Resources Exceeded` memory limit error when selecting large objects with many nested keys. To avoid this error, select individual keys separately or reduce the queried date range.
|
||||
:::
|
||||
<!-- Scene is here https://app.excalidraw.com/s/8gj16loJfGZ/3HzccK9MyLx -->
|
||||
|
||||
### Functions
|
||||
<ThemedImage
|
||||
alt="Without Unnesting"
|
||||
sources={{
|
||||
light: useBaseUrl("/img/guides/platform/logs/unnesting-none.png"),
|
||||
dark: useBaseUrl("/img/guides/platform/logs/unnesting-none-dark.png"),
|
||||
}}
|
||||
style={{maxHeight: 200}}
|
||||
/>
|
||||
|
||||
You may have also noticed from the above examples that we are able to use certain SQL functions within our queries. The querying engine currently supported is BigQuery, hence you may use [any valid BigQuery function](https://cloud.google.com/bigquery/docs/reference/standard-sql/functions-and-operators) within your query.
|
||||
|
||||
#### Timestamp Behavior
|
||||
Perform a `cross join unnest()` to work with the keys nested in the `metadata` key.
|
||||
|
||||
Each log entry is stored with a `timestamp`. In order to utilize the `timestamp` field in a query, you can use the appropriate [timestamp functions](https://cloud.google.com/bigquery/docs/reference/standard-sql/timestamp_functions#timestamp).
|
||||
To query for a nested value, add a join for each array level:
|
||||
|
||||
:::note
|
||||
```sql
|
||||
select timestamp, request.method, header.cf_ipcountry
|
||||
from edge_logs t
|
||||
cross join unnest(t.metadata) as metadata
|
||||
cross join unnest(metadata.request) as request
|
||||
cross join unnest(request.headers) as header
|
||||
```
|
||||
|
||||
In the Log Explorer, timestamps are rendered as unix microsecond timestamps. SQL queries, however, should always use the `TIMESTAMP` data type. If you are using a unix timestamp value in a query, cast the value to a `TIMESTAMP` data type.
|
||||
This surfaces the following columns available for selection:
|
||||
|
||||
:::
|
||||
<ThemedImage
|
||||
alt="With Two Level Unnesting"
|
||||
sources={{
|
||||
light: useBaseUrl("/img/guides/platform/logs/unnesting-2.png"),
|
||||
dark: useBaseUrl("/img/guides/platform/logs/unnesting-2-dark.png"),
|
||||
}}
|
||||
style={{maxHeight: 200}}
|
||||
/>
|
||||
|
||||
## Templates
|
||||
This allows you to select the `method` and `cf_ipcountry` columns. In JS dot notation, the full paths for each selected column are:
|
||||
|
||||
Templates are available to help craft you log queries. Templates are available under the **Templates** tab, or under the **Templates** Dropdown in the **Query** tab.
|
||||
- `metadata[].request[].method`
|
||||
- `metadata[].request[].headers[].cf_ipcountry`
|
||||
|
||||
### LIMIT and Result Row Limitations
|
||||
|
||||
The Logs Explorer has a maximum of 1000 rows per run. Use `LIMIT` to optimize your queries by reducing the number of rows returned further.
|
||||
|
||||
### Best Practices
|
||||
|
||||
1. Include a filter over **timestamp**
|
||||
|
||||
Querying your entire log history might seem appealing. For **Enterprise** customers that have a large retention range, you run the risk of timeouts due additional time required to scan the larger dataset.
|
||||
|
||||
2. Avoid selecting large nested objects. Select individual values instead.
|
||||
|
||||
When querying large objects, the columnar storage engine selects each column associated with each nested key, resulting in a large number of columns being selected. This inadvertently impacts the query speed and may result in timeouts or memory errors, especially for projects with a lot of logs.
|
||||
|
||||
Instead, select only the values required.
|
||||
|
||||
```sql
|
||||
-- ❌ Avoid doing this
|
||||
select
|
||||
datetime(timestamp),
|
||||
m as metadata -- <- metadata contains many nested keys
|
||||
from edge_logs t
|
||||
cross join unnest(t.metadata) as m;
|
||||
|
||||
-- ✅ Do this
|
||||
select
|
||||
datetime(timestamp),
|
||||
r.method -- <- select only the required values
|
||||
from edge_logs t
|
||||
cross join unnest(t.metadata) as m
|
||||
cross join unnest(m.request) as r
|
||||
```
|
||||
|
||||
### Examples and Templates
|
||||
|
||||
The Logs Explorer includes **Templates** (available in the Templates tab or the dropdown in the Query tab) to help you get started.
|
||||
|
||||
For example, you can enter the following query in the SQL Editor to retrieve each user's IP address:
|
||||
|
||||
```sql
|
||||
select datetime(timestamp), h.x_real_ip
|
||||
from edge_logs
|
||||
cross join unnest(metadata) as m
|
||||
cross join unnest(m.request) AS r
|
||||
cross join unnest(r.headers) AS h
|
||||
where h.x_real_ip is not null and r.method = "GET"
|
||||
```
|
||||
@@ -79,6 +79,7 @@ const sidebars = {
|
||||
items: [
|
||||
'guides/auth/row-level-security',
|
||||
'guides/auth/managing-user-data',
|
||||
'guides/auth/auth-captcha',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -219,6 +220,7 @@ const sidebars = {
|
||||
'guides/integrations/draftbit',
|
||||
'guides/integrations/pgmustard',
|
||||
'guides/integrations/plasmic',
|
||||
'guides/integrations/polyscale',
|
||||
'guides/integrations/prisma',
|
||||
'guides/integrations/sequin',
|
||||
'guides/integrations/snaplet',
|
||||
|
||||
@@ -117,9 +117,9 @@ const sidebars = {
|
||||
'generated/single',
|
||||
'generated/maybesingle',
|
||||
'generated/db-csv',
|
||||
'generated/db-geojson',
|
||||
'generated/db-explain',
|
||||
'generated/db-rollback',
|
||||
// 'generated/db-geojson',
|
||||
// 'generated/db-explain',
|
||||
// 'generated/db-rollback',
|
||||
],
|
||||
collapsed: true,
|
||||
},
|
||||
|
||||
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 952 KiB After Width: | Height: | Size: 989 KiB |
|
After Width: | Height: | Size: 926 KiB |
|
Before Width: | Height: | Size: 1021 KiB After Width: | Height: | Size: 974 KiB |
|
After Width: | Height: | Size: 781 KiB |
|
After Width: | Height: | Size: 818 KiB |
|
After Width: | Height: | Size: 937 KiB |
|
After Width: | Height: | Size: 962 KiB |
|
After Width: | Height: | Size: 923 KiB |
|
Before Width: | Height: | Size: 269 KiB |
|
Before Width: | Height: | Size: 901 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 40 KiB |
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
.vercel
|
||||
.next
|
||||
.env.local
|
||||
.env.production
|
||||
.env.dev
|
||||
.env.*
|
||||
@@ -70,5 +70,8 @@ ENABLE_PHONE_AUTOCONFIRM=true
|
||||
# Studio - Configuration for the Dashboard
|
||||
############
|
||||
|
||||
STUDIO_ORGANIZATION_NAME=Default Organization
|
||||
STUDIO_PROJECT_NAME=Default Project
|
||||
|
||||
STUDIO_PORT=3000
|
||||
PUBLIC_REST_URL=http://localhost:8000/rest/v1/ # replace if you intend to use Studio outside of localhost
|
||||
@@ -17,6 +17,9 @@ services:
|
||||
STUDIO_PG_META_URL: http://meta:8080
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
|
||||
DEFAULT_ORGANIZATION: ${STUDIO_DEFAULT_ORGANIZATION}
|
||||
DEFAULT_PROJECT: ${STUDIO_DEFAULT_PROJECT}
|
||||
|
||||
SUPABASE_URL: http://kong:8000
|
||||
SUPABASE_REST_URL: ${PUBLIC_REST_URL}
|
||||
SUPABASE_ANON_KEY: ${ANON_KEY}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
<p align="center">
|
||||
<img width="300" src="https://raw.githubusercontent.com/supabase/supabase/master/packages/common/assets/images/supabase-logo-wordmark--light.svg#gh-light-mode-only">
|
||||
<img width="300" src="https://raw.githubusercontent.com/supabase/supabase/master/packages/common/assets/images/supabase-logo-wordmark--dark.svg#gh-dark-mode-only">
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
# Supabase
|
||||
|
||||
[Supabase](https://supabase.com) je open source Firebase alternativa. Mi razvijamo funkcionalnosti Firebase-a koristeći profesionalne open source alate.
|
||||
|
||||
- [x] Hostovana Postgres Baza. [Dokumentacija](https://supabase.com/docs/guides/database)
|
||||
- [x] Autentifikacija i Autorizacija. [Dokumentacija](https://supabase.com/docs/guides/auth)
|
||||
- [x] Auto-generisani API-ji.
|
||||
- [x] REST. [Dokumentacija](https://supabase.com/docs/guides/api#rest-api)
|
||||
- [x] Subskripcije u realnom vremenu. [Dokumentacija](https://supabase.com/docs/guides/api#realtime-api)
|
||||
- [x] GraphQL (Beta). [Dokumentacija](https://supabase.com/docs/guides/api#graphql-api)
|
||||
- [x] Funkcije.
|
||||
- [x] Funkcije u bazi. [Dokumentacija](https://supabase.com/docs/guides/database/functions)
|
||||
- [x] Edge Funkcije [Dokumentacija](https://supabase.com/docs/guides/functions)
|
||||
- [x] Skladištenje fajlova. [Dokumentacija](https://supabase.com/docs/guides/storage)
|
||||
- [x] Kontrolna tabla
|
||||
|
||||

|
||||
|
||||
## Dokumentacija
|
||||
|
||||
Za punu dokumentaciju, posetite [supabase.com/docs](https://supabase.com/docs)
|
||||
|
||||
Da vidite kako možete doprineti razvoju, posetite [Getting Started](./DEVELOPERS.md)
|
||||
|
||||
## Zajednica & Podrška
|
||||
|
||||
- [Forum zajednice](https://github.com/supabase/supabase/discussions). Najbolje mesto za: pomoć u razvijanju, diskusije o najboljim praksama u vezi baze.
|
||||
- [GitHub Issues](https://github.com/supabase/supabase/issues). Najbolje mesto za: bagove i greške sa kojima se susretnete tokom korišćenja Supabase.
|
||||
- [Email Podrška](https://supabase.com/docs/support#business-support). Najbolje mesto za: probleme sa vašom bazom ili infrastrukturom.
|
||||
- [Discord](https://discord.supabase.com). Najbolje mesto za: deljenje vaših aplikacija i druženje sa zajednicom.
|
||||
|
||||
## Status
|
||||
|
||||
- [x] Alfa: Testiramo Supabase sa zatvorenim setom klijenata
|
||||
- [x] Javna Alfa: Bilo ko može da se prijavi na [app.supabase.com](https://app.supabase.com). Ali ne budite grubi, biće nekih poteškoća
|
||||
- [x] Javna Beta: Dovoljno stabilno za većinu ne-enterprise projekata
|
||||
- [ ] Objavljeno: spremno za produkciju
|
||||
|
||||
Trenutno smo u javnoj beti. Pratite "releasove" ovog repozitorijuma da bi ste ostali u toku sa bitnim objavama.
|
||||
|
||||
<kbd><img src="https://raw.githubusercontent.com/supabase/supabase/d5f7f413ab356dc1a92075cb3cee4e40a957d5b1/web/static/watch-repo.gif" alt="Pratite ovaj repo"/></kbd>
|
||||
|
||||
---
|
||||
|
||||
## Kako radi
|
||||
|
||||
Supabase je kombinacija open source alata. Razvijamo funkcionalnosti Firebase-a koristeći profesionalne open source projekte. Ako već postoje alati i zajednice, sa MIT, Apache 2 ili ekvivalentnim otvorenim licencama, koristićemo i podržati taj alat. Ako alat ne postoji, mi ga razvijamo i open sourcujemo sami. Supabase nije 1-na-1 mapiranje Firebase-a. Naš cilj je da pružimo developerima iskustvo u razvoju slično kao Firebase koristeći open source alate.
|
||||
|
||||
**Arhitektura**
|
||||
|
||||
Supabase je [hostovana platforma](https://app.supabase.com). Možete se prijaviti i početi sa korišćenjem Supabase-a bez instaliranja bilo čega.
|
||||
Takođe možete da [hostujete sami](https://supabase.com/docs/guides/hosting/overview) i da [razvijate u lokalu](https://supabase.com/docs/guides/local-development).
|
||||
|
||||

|
||||
|
||||
- [PostgreSQL](https://www.postgresql.org/) je sistem objektno-relacione baze sa preko 30 godina aktivnog razvoja čime je zaslužio jaku reputaciju za pouzdanost, robustnost funkcionalnosti i performanse.
|
||||
- [Realtime](https://github.com/supabase/realtime) je Elixir server koji vam omogućava da slušate PostgreSQL inserte, updateove, i brisanja koristeći websokete. Realtime polluje Postgres-ovu ugrađenu funkcionalnost replikacije za promene nad bazom, konvertuje promene u JSON, zatim broadcastuje taj JSON kroz websoket autorizovanim klijentima.
|
||||
- [PostgREST](http://postgrest.org/) je web server koji pretvara vašu PostgreSQL bazu direktno u RESTful API
|
||||
- [Storage](https://github.com/supabase/storage-api) daje RESTful interfejs za upravljanje fajlovima sačuvanim na S3, koristeći Postgres za upravljanje permisijama.
|
||||
- [postgres-meta](https://github.com/supabase/postgres-meta) je RESTful API za upravljanje vašom Postgres isntancom, omogućavajući vam da dohvatite tabele, dodate role, izvršavate upite, itd.
|
||||
- [GoTrue](https://github.com/netlify/gotrue) je SWT baziran API za upravljanje korisnicima i izdavanje SWT tokena.
|
||||
- [Kong](https://github.com/Kong/kong) je cloud-nativan API gateway.
|
||||
|
||||
#### Klijentske biblioteke
|
||||
|
||||
Naš pristup klijentskim bibliotekama je modularan. Svaka pod-biblioteka je samostalna implementacija za jedan eksterni sistem. Ovo je jedan od načina na koji podržavamo postojeće alate.
|
||||
|
||||
<table style="table-layout:fixed; white-space: nowrap;">
|
||||
<tr>
|
||||
<th>Jezik</th>
|
||||
<th>Klijent</th>
|
||||
<th colspan="5">Funkcionalni klijenti (spakovani u Supabase klijenta)</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Supabase</th>
|
||||
<th><a href="https://github.com/postgrest/postgrest" target="_blank" rel="noopener noreferrer">PostgREST</a></th>
|
||||
<th><a href="https://github.com/supabase/gotrue" target="_blank" rel="noopener noreferrer">GoTrue</a></th>
|
||||
<th><a href="https://github.com/supabase/realtime" target="_blank" rel="noopener noreferrer">Realtime</a></th>
|
||||
<th><a href="https://github.com/supabase/storage-api" target="_blank" rel="noopener noreferrer">Storage</a></th>
|
||||
<th>Functions</th>
|
||||
</tr>
|
||||
<!-- TEMPLATE FOR NEW ROW -->
|
||||
<!-- START ROW
|
||||
<tr>
|
||||
<td>lang</td>
|
||||
<td><a href="https://github.com/supabase-community/supabase-lang" target="_blank" rel="noopener noreferrer">supabase-lang</a></td>
|
||||
<td><a href="https://github.com/supabase-community/postgrest-lang" target="_blank" rel="noopener noreferrer">postgrest-lang</a></td>
|
||||
<td><a href="https://github.com/supabase-community/gotrue-lang" target="_blank" rel="noopener noreferrer">gotrue-lang</a></td>
|
||||
<td><a href="https://github.com/supabase-community/realtime-lang" target="_blank" rel="noopener noreferrer">realtime-lang</a></td>
|
||||
<td><a href="https://github.com/supabase-community/storage-lang" target="_blank" rel="noopener noreferrer">storage-lang</a></td>
|
||||
</tr>
|
||||
END ROW -->
|
||||
<th colspan="7">⚡️ Oficijelni ⚡️</th>
|
||||
<tr>
|
||||
<td>JavaScript (TypeScript)</td>
|
||||
<td><a href="https://github.com/supabase/supabase-js" target="_blank" rel="noopener noreferrer">supabase-js</a></td>
|
||||
<td><a href="https://github.com/supabase/postgrest-js" target="_blank" rel="noopener noreferrer">postgrest-js</a></td>
|
||||
<td><a href="https://github.com/supabase/gotrue-js" target="_blank" rel="noopener noreferrer">gotrue-js</a></td>
|
||||
<td><a href="https://github.com/supabase/realtime-js" target="_blank" rel="noopener noreferrer">realtime-js</a></td>
|
||||
<td><a href="https://github.com/supabase/storage-js" target="_blank" rel="noopener noreferrer">storage-js</a></td>
|
||||
<td><a href="https://github.com/supabase/functions-js" target="_blank" rel="noopener noreferrer">functions-js</a></td>
|
||||
</tr>
|
||||
<th colspan="7">💚 Od zajednice 💚</th>
|
||||
<tr>
|
||||
<td>C#</td>
|
||||
<td><a href="https://github.com/supabase-community/supabase-csharp" target="_blank" rel="noopener noreferrer">supabase-csharp</a></td>
|
||||
<td><a href="https://github.com/supabase-community/postgrest-csharp" target="_blank" rel="noopener noreferrer">postgrest-csharp</a></td>
|
||||
<td><a href="https://github.com/supabase-community/gotrue-csharp" target="_blank" rel="noopener noreferrer">gotrue-csharp</a></td>
|
||||
<td><a href="https://github.com/supabase-community/realtime-csharp" target="_blank" rel="noopener noreferrer">realtime-csharp</a></td>
|
||||
<td><a href="https://github.com/supabase-community/storage-csharp" target="_blank" rel="noopener noreferrer">storage-csharp</a></td>
|
||||
<td><a href="https://github.com/supabase-community/functions-csharp" target="_blank" rel="noopener noreferrer">functions-csharp</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Dart (Flutter)</td>
|
||||
<td><a href="https://github.com/supabase/supabase-dart" target="_blank" rel="noopener noreferrer">supabase-dart</a></td>
|
||||
<td><a href="https://github.com/supabase/postgrest-dart" target="_blank" rel="noopener noreferrer">postgrest-dart</a></td>
|
||||
<td><a href="https://github.com/supabase/gotrue-dart" target="_blank" rel="noopener noreferrer">gotrue-dart</a></td>
|
||||
<td><a href="https://github.com/supabase/realtime-dart" target="_blank" rel="noopener noreferrer">realtime-dart</a></td>
|
||||
<td><a href="https://github.com/supabase/storage-dart" target="_blank" rel="noopener noreferrer">storage-dart</a></td>
|
||||
<td><a href="https://github.com/supabase-community/functions-dart" target="_blank" rel="noopener noreferrer">functions-dart</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Go</td>
|
||||
<td>-</td>
|
||||
<td><a href="https://github.com/supabase-community/postgrest-go" target="_blank" rel="noopener noreferrer">postgrest-go</a></td>
|
||||
<td>-</td>
|
||||
<td>-</td>
|
||||
<td><a href="https://github.com/supabase-community/storage-go" target="_blank" rel="noopener noreferrer">storage-go</a></td>
|
||||
<td>-</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Java</td>
|
||||
<td>-</td>
|
||||
<td>-</td>
|
||||
<td><a href="https://github.com/supabase-community/gotrue-java" target="_blank" rel="noopener noreferrer">gotrue-java</a></td>
|
||||
<td>-</td>
|
||||
<td>-</td>
|
||||
<td>-</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Kotlin</td>
|
||||
<td>-</td>
|
||||
<td><a href="https://github.com/supabase-community/postgrest-kt" target="_blank" rel="noopener noreferrer">postgrest-kt</a></td>
|
||||
<td><a href="https://github.com/supabase-community/gotrue-kt" target="_blank" rel="noopener noreferrer">gotrue-kt</a></td>
|
||||
<td>-</td>
|
||||
<td>-</td>
|
||||
<td>-</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Python</td>
|
||||
<td><a href="https://github.com/supabase-community/supabase-py" target="_blank" rel="noopener noreferrer">supabase-py</a></td>
|
||||
<td><a href="https://github.com/supabase-community/postgrest-py" target="_blank" rel="noopener noreferrer">postgrest-py</a></td>
|
||||
<td><a href="https://github.com/supabase-community/gotrue-py" target="_blank" rel="noopener noreferrer">gotrue-py</a></td>
|
||||
<td><a href="https://github.com/supabase-community/realtime-py" target="_blank" rel="noopener noreferrer">realtime-py</a></td>
|
||||
<td><a href="https://github.com/supabase-community/storage-py" target="_blank" rel="noopener noreferrer">storage-py</a></td>
|
||||
<td><a href="https://github.com/supabase-community/functions-py" target="_blank" rel="noopener noreferrer">functions-py</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Ruby</td>
|
||||
<td><a href="https://github.com/supabase-community/supabase-rb" target="_blank" rel="noopener noreferrer">supabase-rb</a></td>
|
||||
<td><a href="https://github.com/supabase-community/postgrest-rb" target="_blank" rel="noopener noreferrer">postgrest-rb</a></td>
|
||||
<td>-</td>
|
||||
<td>-</td>
|
||||
<td>-</td>
|
||||
<td>-</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Rust</td>
|
||||
<td>-</td>
|
||||
<td><a href="https://github.com/supabase-community/postgrest-rs" target="_blank" rel="noopener noreferrer">postgrest-rs</a></td>
|
||||
<td>-</td>
|
||||
<td>-</td>
|
||||
<td>-</td>
|
||||
<td>-</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Swift</td>
|
||||
<td><a href="https://github.com/supabase-community/supabase-swift" target="_blank" rel="noopener noreferrer">supabase-swift</a></td>
|
||||
<td><a href="https://github.com/supabase-community/postgrest-swift" target="_blank" rel="noopener noreferrer">postgrest-swift</a></td>
|
||||
<td><a href="https://github.com/supabase-community/gotrue-swift" target="_blank" rel="noopener noreferrer">gotrue-swift</a></td>
|
||||
<td><a href="https://github.com/supabase-community/realtime-swift" target="_blank" rel="noopener noreferrer">realtime-swift</a></td>
|
||||
<td><a href="https://github.com/supabase-community/storage-swift" target="_blank" rel="noopener noreferrer">storage-swift</a></td>
|
||||
<td>-</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!--- Remove this list if you're translating to another language, it's hard to keep updated across multiple files-->
|
||||
<!--- Keep only the link to the list of translation files-->
|
||||
|
||||
## Prevodi
|
||||
|
||||
- [Lista prevoda](/i18n/languages.md) <!--- Keep only this -->
|
||||
|
||||
---
|
||||
|
||||
## Sponzori
|
||||
|
||||
[](https://github.com/sponsors/supabase)
|
||||
@@ -30,6 +30,7 @@
|
||||
- [Portuguese (Brazilian) / Português Brasileiro](/i18n/README.pt-br.md)
|
||||
- [Romanian / Română](/i18n/README.ro.md)
|
||||
- [Russian / Pусский](/i18n/README.ru.md)
|
||||
- [Serbian / Srpski](/i18n/README.sr.md)
|
||||
- [Sinhala / සිංහල](/i18n/README.si.md)
|
||||
- [Spanish / Español](/i18n/README.es.md)
|
||||
- [Simplified Chinese / 简体中文](/i18n/README.zh-cn.md)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
STUDIO_PG_META_URL=http://localhost:8000/pg
|
||||
POSTGRES_PASSWORD=your-super-secret-and-long-postgres-password
|
||||
|
||||
DEFAULT_ORGANIZATION_NAME=Default Organization
|
||||
DEFAULT_PROJECT_NAME=Default Project
|
||||
|
||||
SUPABASE_URL=http://localhost:8000
|
||||
SUPABASE_REST_URL=http://localhost:8000/rest/v1/
|
||||
SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE
|
||||
|
||||
@@ -6,25 +6,41 @@
|
||||
# docker build --target production --no-cache -t supabase/studio:latest .
|
||||
# docker builder prune
|
||||
|
||||
|
||||
FROM node:14-slim as base
|
||||
RUN apt-get update && apt-get install -y \
|
||||
FROM node:16-slim as base
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
FROM base as builder
|
||||
WORKDIR /usr/src/app
|
||||
# Do `npm ci` separately so we can cache `node_modules`
|
||||
# https://nodejs.org/en/docs/guides/nodejs-docker-webapp/
|
||||
COPY package*.json ./
|
||||
RUN npm clean-install && npm cache clean --force
|
||||
COPY . .
|
||||
COPY package*.json .
|
||||
COPY turbo.json .
|
||||
COPY packages packages
|
||||
COPY studio studio
|
||||
# the following command is still unsupported for npm // https://github.com/vercel/turborepo/issues/1830
|
||||
# RUN npm i -g turbo
|
||||
# RUN turbo prune --scope=studio --docker
|
||||
# RUN npm clean-install && npm cache clean --force
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
|
||||
FROM base as dev
|
||||
FROM builder as dev
|
||||
COPY --from=builder /usr/src/app ./
|
||||
RUN npm clean-install && npm cache clean --force
|
||||
EXPOSE 8082
|
||||
CMD ["npm", "run", "dev"]
|
||||
CMD ["npx", "turbo", "run", "dev", "--filter=studio"]
|
||||
|
||||
FROM base as production
|
||||
RUN npm run build && rm -rf .next/cache/webpack && npm prune --production
|
||||
FROM builder as productionprep
|
||||
WORKDIR /app
|
||||
COPY --from=builder /usr/src/app ./
|
||||
RUN npm clean-install \
|
||||
&& npm cache clean --force \
|
||||
&& npx turbo run build --scope=studio --include-dependencies --no-deps \
|
||||
&& rm -rf ./studio/.next/cache/webpack \
|
||||
&& npm prune --production
|
||||
|
||||
FROM node:16-slim as production
|
||||
COPY --from=productionprep /app /app
|
||||
WORKDIR /app/studio
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "run", "start"]
|
||||
@@ -67,3 +67,10 @@ Then run the following commands to install dependencies and start the dashboard.
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
If you would like to configure different defaults for "Default Organization" and "Default Project", you will need to update the `.env` in the studio folder with the corresponding values.
|
||||
|
||||
```
|
||||
DEFAULT_ORGANIZATION_NAME=
|
||||
DEFAULT_PROJECT_NAME=
|
||||
```
|
||||
@@ -79,10 +79,20 @@ export function initTable(
|
||||
? onLoadStorage(props.storageRef, table.name, table.schema)
|
||||
: undefined
|
||||
|
||||
// Load sort and filters via URL param only if given
|
||||
// Check for saved state on initial load and also, load sort and filters via URL param only if given
|
||||
// Otherwise load from local storage to resume user session
|
||||
if (sort === undefined && filter === undefined && (savedState?.sorts || savedState?.filters)) {
|
||||
return { savedState: { sorts: savedState.sorts, filters: savedState.filters } }
|
||||
if (
|
||||
!state.isInitialComplete &&
|
||||
sort === undefined &&
|
||||
filter === undefined &&
|
||||
(savedState?.sorts || savedState?.filters)
|
||||
) {
|
||||
return {
|
||||
savedState: {
|
||||
sorts: savedState.sorts,
|
||||
filters: savedState.filters,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const gridColumns = getGridColumns(table, {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import * as React from 'react'
|
||||
import { FC, useCallback, KeyboardEvent, ReactNode } from 'react'
|
||||
|
||||
type BlockKeysProps = {
|
||||
interface BlockKeysProps {
|
||||
value: string | null
|
||||
children: ReactNode
|
||||
onEscape?: (value: string | null) => void
|
||||
onEnter?: (value: string | null) => void
|
||||
value: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -11,9 +12,9 @@ type BlockKeysProps = {
|
||||
* We use this with cell editor to allow editor component to handle keys.
|
||||
* Example: press enter to add newline on textEditor
|
||||
*/
|
||||
export const BlockKeys: React.FC<BlockKeysProps> = ({ onEscape, onEnter, value, children }) => {
|
||||
const handleKeyDown = React.useCallback(
|
||||
(ev: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
export const BlockKeys: FC<BlockKeysProps> = ({ value, children, onEscape, onEnter }) => {
|
||||
const handleKeyDown = useCallback(
|
||||
(ev: KeyboardEvent<HTMLDivElement>) => {
|
||||
switch (ev.key) {
|
||||
case 'Escape':
|
||||
ev.stopPropagation()
|
||||
@@ -21,7 +22,10 @@ export const BlockKeys: React.FC<BlockKeysProps> = ({ onEscape, onEnter, value,
|
||||
break
|
||||
case 'Enter':
|
||||
ev.stopPropagation()
|
||||
if (onEnter) onEnter(value)
|
||||
if (!ev.shiftKey && onEnter) {
|
||||
ev.preventDefault()
|
||||
onEnter(value)
|
||||
}
|
||||
break
|
||||
}
|
||||
},
|
||||
@@ -29,7 +33,7 @@ export const BlockKeys: React.FC<BlockKeysProps> = ({ onEscape, onEnter, value,
|
||||
)
|
||||
|
||||
function onBlur() {
|
||||
if (onEscape) onEscape(value)
|
||||
if (onEnter) onEnter(value)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as React from 'react'
|
||||
import { useState, useCallback } from 'react'
|
||||
|
||||
import { EditorProps } from '@supabase/react-data-grid'
|
||||
import { useTrackedState } from '../../store'
|
||||
@@ -11,18 +11,22 @@ export function JsonEditor<TRow, TSummaryRow = unknown>({
|
||||
onRowChange,
|
||||
}: EditorProps<TRow, TSummaryRow>) {
|
||||
const state = useTrackedState()
|
||||
const [isPopoverOpen, setIsPopoverOpen] = React.useState(true)
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(true)
|
||||
const gridColumn = state.gridColumns.find((x) => x.name == column.key)
|
||||
const initialValue = row[column.key as keyof TRow] as unknown
|
||||
const jsonString = initialValue ? JSON.stringify(initialValue) : ''
|
||||
const prettyJsonValue = prettifyJSON(jsonString)
|
||||
const [value, setValue] = React.useState<string | null>(prettyJsonValue)
|
||||
const [value, setValue] = useState<string | null>(prettyJsonValue)
|
||||
|
||||
const onEscape = React.useCallback((newValue: string | null) => {
|
||||
commitChange(newValue)
|
||||
const cancelChanges = useCallback(() => {
|
||||
onRowChange(row, true)
|
||||
setIsPopoverOpen(false)
|
||||
}, [])
|
||||
|
||||
const saveChanges = useCallback((newValue: string | null) => {
|
||||
commitChange(newValue)
|
||||
}, [])
|
||||
|
||||
function onChange(_value: string | undefined) {
|
||||
if (!_value || _value == '') setValue(null)
|
||||
else setValue(_value)
|
||||
@@ -31,12 +35,14 @@ export function JsonEditor<TRow, TSummaryRow = unknown>({
|
||||
function commitChange(newValue: string | null) {
|
||||
if (!newValue) {
|
||||
onRowChange({ ...row, [column.key]: null }, true)
|
||||
setIsPopoverOpen(false)
|
||||
} else if (verifyJSON(newValue)) {
|
||||
const jsonValue = JSON.parse(newValue)
|
||||
onRowChange({ ...row, [column.key]: jsonValue }, true)
|
||||
setIsPopoverOpen(false)
|
||||
} else {
|
||||
const { onError } = state
|
||||
if (onError) onError(Error('invalid input'))
|
||||
if (onError) onError(Error('Please enter a valid JSON'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,13 +54,17 @@ export function JsonEditor<TRow, TSummaryRow = unknown>({
|
||||
sideOffset={-35}
|
||||
className="rounded-none"
|
||||
overlay={
|
||||
<BlockKeys value={value} onEscape={onEscape}>
|
||||
<BlockKeys value={value} onEscape={cancelChanges} onEnter={saveChanges}>
|
||||
<MonacoEditor
|
||||
width={`${gridColumn?.width || column.width}px`}
|
||||
value={value ?? ''}
|
||||
language="json"
|
||||
onChange={onChange}
|
||||
/>
|
||||
<div className="flex items-center justify-end p-2 bg-scale-400 space-x-2">
|
||||
<p className="text-xs text-scale-1100">Save changes</p>
|
||||
<code className="text-xs">⏎</code>
|
||||
</div>
|
||||
</BlockKeys>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as React from 'react'
|
||||
import { useState, useCallback } from 'react'
|
||||
import { EditorProps } from '@supabase/react-data-grid'
|
||||
import { useTrackedState } from '../../store'
|
||||
import { BlockKeys, MonacoEditor, NullValue, EmptyValue } from '../common'
|
||||
@@ -10,12 +10,17 @@ export function TextEditor<TRow, TSummaryRow = unknown>({
|
||||
onRowChange,
|
||||
}: EditorProps<TRow, TSummaryRow>) {
|
||||
const state = useTrackedState()
|
||||
const [isPopoverOpen, setIsPopoverOpen] = React.useState(true)
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(true)
|
||||
const gridColumn = state.gridColumns.find((x) => x.name == column.key)
|
||||
const initialValue = row[column.key as keyof TRow] as unknown as string
|
||||
const [value, setValue] = React.useState<string | null>(initialValue)
|
||||
const [value, setValue] = useState<string | null>(initialValue)
|
||||
|
||||
const onEscape = React.useCallback((newValue: string | null) => {
|
||||
const cancelChanges = useCallback(() => {
|
||||
onRowChange(row, true)
|
||||
setIsPopoverOpen(false)
|
||||
}, [])
|
||||
|
||||
const saveChanges = useCallback((newValue: string | null) => {
|
||||
onRowChange({ ...row, [column.key]: newValue }, true)
|
||||
setIsPopoverOpen(false)
|
||||
}, [])
|
||||
@@ -33,12 +38,16 @@ export function TextEditor<TRow, TSummaryRow = unknown>({
|
||||
sideOffset={-35}
|
||||
className="rounded-none"
|
||||
overlay={
|
||||
<BlockKeys value={value} onEscape={onEscape}>
|
||||
<BlockKeys value={value} onEscape={cancelChanges} onEnter={saveChanges}>
|
||||
<MonacoEditor
|
||||
width={`${gridColumn?.width || column.width}px`}
|
||||
value={value ?? ''}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<div className="flex items-center justify-end p-2 bg-scale-400 space-x-2">
|
||||
<p className="text-xs text-scale-1100">Save changes</p>
|
||||
<code className="text-xs">⏎</code>
|
||||
</div>
|
||||
</BlockKeys>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -156,11 +156,11 @@ const UserDropdown: FC<Props> = ({ user, canRemoveUser }) => {
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'bg-scale-100 rounded py-1 px-2 leading-none shadow',
|
||||
'border-scale-200 border',
|
||||
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
|
||||
'border border-scale-200',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-scale-1200 text-xs">
|
||||
<span className="text-xs text-scale-1200">
|
||||
You need additional permissions to delete users
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -53,8 +53,8 @@ const Users = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<div className="flex justify-between px-6 pt-6 pb-2">
|
||||
<div className="overflow-scroll">
|
||||
<div className="justify-between px-6 pt-6 pb-2 md:flex">
|
||||
<div className="relative flex space-x-4">
|
||||
<Input
|
||||
size="small"
|
||||
@@ -94,7 +94,7 @@ const Users = () => {
|
||||
</Listbox.Option>
|
||||
</Listbox>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="mt-4 flex items-center md:mt-0">
|
||||
<Button
|
||||
className="mr-2"
|
||||
size="tiny"
|
||||
@@ -108,9 +108,9 @@ const Users = () => {
|
||||
{inviteEnabled && <InviteUserModal />}
|
||||
</div>
|
||||
</div>
|
||||
<section className="overflow-visible mt-4 px-6">
|
||||
<div className="relative section-block--body rounded">
|
||||
<div className="align-middle inline-block min-w-full">
|
||||
<section className="mt-4 overflow-visible px-6">
|
||||
<div className="section-block--body relative overflow-scroll rounded">
|
||||
<div className="inline-block min-w-full align-middle">
|
||||
<UsersList />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,10 +20,10 @@ const UsersList = ({}) => {
|
||||
<>
|
||||
<Table.th>Email</Table.th>
|
||||
<Table.th>Phone</Table.th>
|
||||
<Table.th className="hidden 2xl:table-cell">Provider</Table.th>
|
||||
<Table.th className="hidden 2xl:table-cell">Created</Table.th>
|
||||
<Table.th className="hidden xl:table-cell">Last Sign In</Table.th>
|
||||
<Table.th className="hidden lg:table-cell">User UID</Table.th>
|
||||
<Table.th className="table-cell">Provider</Table.th>
|
||||
<Table.th className="table-cell">Created</Table.th>
|
||||
<Table.th className="table-cell">Last Sign In</Table.th>
|
||||
<Table.th className="table-cell">User UID</Table.th>
|
||||
<Table.th></Table.th>
|
||||
</>
|
||||
}
|
||||
@@ -34,7 +34,7 @@ const UsersList = ({}) => {
|
||||
{/* @ts-ignore */}
|
||||
<Table.td
|
||||
colSpan={7}
|
||||
className="h-14 p-4 whitespace-nowrap border-t leading-5 text-gray-300 text-sm"
|
||||
className="h-14 whitespace-nowrap border-t p-4 text-sm leading-5 text-gray-300"
|
||||
>
|
||||
<div className="flex items-center space-x-3 opacity-75">
|
||||
<IconAlertCircle size={16} strokeWidth={2} />
|
||||
|
||||
@@ -19,24 +19,24 @@ const UserListItem: FC<Props> = ({ user, canRemoveUser }) => {
|
||||
const lastSignedIn = getDateFromIsoString(user.last_sign_in_at)
|
||||
|
||||
return (
|
||||
<Table.tr key={user.id}>
|
||||
<Table.tr className="relative" key={user.id}>
|
||||
<Table.td className="whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-scale-1200">{!user.email ? '-' : user.email}</span>
|
||||
<span className="text-sm text-scale-1200">{!user.email ? '-' : user.email}</span>
|
||||
</div>
|
||||
</Table.td>
|
||||
<Table.td className="whitespace-nowrap">
|
||||
<span className="text-scale-1200">{!user.phone ? '-' : user.phone}</span>
|
||||
</Table.td>
|
||||
<Table.td className="hidden 2xl:table-cell">
|
||||
<span className="text-scale-1200 capitalize">
|
||||
<Table.td className="table-cell">
|
||||
<span className="capitalize text-scale-1200">
|
||||
{user?.raw_app_meta_data?.provider || user?.app_metadata?.provider}
|
||||
</span>
|
||||
</Table.td>
|
||||
<Table.td className="hidden 2xl:table-cell">
|
||||
<Table.td className="table-cell">
|
||||
<span className="text-scale-1200">{createdAt?.format('DD MMM, YYYY HH:mm')}</span>
|
||||
</Table.td>
|
||||
<Table.td className="hidden xl:table-cell">
|
||||
<Table.td className="table-cell">
|
||||
{!isUserConfirmed ? (
|
||||
<Badge color="yellow">Waiting for verification..</Badge>
|
||||
) : user.last_sign_in_at ? (
|
||||
@@ -45,10 +45,13 @@ const UserListItem: FC<Props> = ({ user, canRemoveUser }) => {
|
||||
'Never'
|
||||
)}
|
||||
</Table.td>
|
||||
<Table.td className="hidden lg:table-cell">
|
||||
<SimpleCodeBlock metastring="" className="font-xs bash">
|
||||
{user.id}
|
||||
</SimpleCodeBlock>
|
||||
<Table.td className="table-cell">
|
||||
<div className="flex max-w-[72px] items-baseline">
|
||||
<SimpleCodeBlock metastring="" className="font-xs bash">
|
||||
{user.id}
|
||||
</SimpleCodeBlock>
|
||||
<div>...</div>
|
||||
</div>
|
||||
</Table.td>
|
||||
<Table.td className="text-right">
|
||||
<UserDropdown user={user} canRemoveUser={canRemoveUser} />
|
||||
|
||||
@@ -462,14 +462,6 @@ export const ALL_TIMEZONES = [
|
||||
text: '(UTC) Coordinated Universal Time',
|
||||
utc: ['America/Danmarkshavn', 'Etc/GMT'],
|
||||
},
|
||||
{
|
||||
value: 'GMT Standard Time',
|
||||
abbr: 'GMT',
|
||||
offset: 0,
|
||||
isdst: false,
|
||||
text: '(UTC) Edinburgh, London',
|
||||
utc: ['Europe/Isle_of_Man', 'Europe/Guernsey', 'Europe/Jersey', 'Europe/London'],
|
||||
},
|
||||
{
|
||||
value: 'British Summer Time',
|
||||
abbr: 'BST',
|
||||
@@ -6,3 +6,9 @@ export interface Timezone {
|
||||
text: string
|
||||
utc: string[]
|
||||
}
|
||||
|
||||
export interface Time {
|
||||
h: number
|
||||
m: number
|
||||
s: number
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { ALL_TIMEZONES } from './PITR.constants'
|
||||
import { Time, Timezone } from './PITR.types'
|
||||
|
||||
export const getDatesBetweenRange = (start: Date, end: Date) => {
|
||||
const arr = []
|
||||
for (let i, dt = new Date(start); dt <= new Date(end); dt.setDate(dt.getDate() + 1)) {
|
||||
arr.push(new Date(dt))
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
export const getClientTimezone = () => {
|
||||
const defaultTz = dayjs.tz.guess()
|
||||
const utcTz = ALL_TIMEZONES.find((option) => option.value === 'UTC')
|
||||
const timezone = ALL_TIMEZONES.find((option) => {
|
||||
if (option.utc.includes(defaultTz)) return option
|
||||
else return undefined
|
||||
})
|
||||
return timezone ?? (utcTz || ALL_TIMEZONES[0])
|
||||
}
|
||||
|
||||
export const checkMatchingDates = (selectedDate: Date | undefined, targetDate: Date) => {
|
||||
if (!selectedDate) return false
|
||||
|
||||
const formattedSelectedDate = new Date(selectedDate.getTime())
|
||||
formattedSelectedDate.setHours(0, 0, 0, 0)
|
||||
|
||||
const formattedTargetDate = new Date(targetDate.getTime())
|
||||
formattedTargetDate.setHours(0, 0, 0, 0)
|
||||
|
||||
return Number(formattedSelectedDate) === Number(formattedTargetDate)
|
||||
}
|
||||
|
||||
export const formatNumberToTwoDigits = (number: Number) => {
|
||||
return number.toLocaleString('en-US', {
|
||||
minimumIntegerDigits: 2,
|
||||
useGrouping: false,
|
||||
})
|
||||
}
|
||||
|
||||
// Formats Time object to hh:mm:ss
|
||||
export const formatTimeToTimeString = (time: Time) => {
|
||||
return `${formatNumberToTwoDigits(time.h)}:${formatNumberToTwoDigits(
|
||||
time.m
|
||||
)}:${formatNumberToTwoDigits(time.s)}`
|
||||
}
|
||||
|
||||
export const getTimezoneOffsetText = (timezone: Timezone) => {
|
||||
return timezone.text.split(')')[0].slice(1)
|
||||
}
|
||||
|
||||
export const getTimezoneOffset = (timezone: Timezone) => {
|
||||
return timezone.text.split(')')[0].slice(4)
|
||||
}
|
||||
|
||||
export const convertDatetimetoUnixS = (date: Date, time: Time, timezone: Timezone) => {
|
||||
const dateString = dayjs(date).format('YYYY-MM-DD')
|
||||
const timeString = formatTimeToTimeString(time)
|
||||
const timezoneOffset = getTimezoneOffset(timezone)
|
||||
const datetimestringWithTimezone = `${dateString}T${timeString}${timezoneOffset || '+00:00'}`
|
||||
return dayjs(datetimestringWithTimezone).unix()
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { Button, IconCalendar } from '@supabase/ui'
|
||||
import { FormPanel } from 'components/ui/Forms'
|
||||
|
||||
const PITRNotice = ({}) => {
|
||||
const router = useRouter()
|
||||
const { ref } = router.query
|
||||
|
||||
return (
|
||||
<FormPanel
|
||||
disabled={true}
|
||||
footer={
|
||||
<div className="flex items-center justify-between p-6">
|
||||
<span className="text-scale-1000 text-sm">
|
||||
Recovery retention period can be increased - reach out to us!
|
||||
</span>
|
||||
<Link href={`/support/new?ref=${ref}&category=sales`}>
|
||||
<a>
|
||||
<Button as="span" type="default">
|
||||
Contact support
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="p-6 flex space-x-6">
|
||||
<div className="h-10 w-10 rounded flex items-center justify-center bg-scale-700">
|
||||
<IconCalendar strokeWidth={2} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm">Recovery retention period</p>
|
||||
<p className="text-sm text-scale-1100">
|
||||
Database changes are logged every <span className="text-scale-1200">2 minutes</span>,
|
||||
with a total recovery period of up to <span className="text-scale-1200">7 days</span>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</FormPanel>
|
||||
)
|
||||
}
|
||||
|
||||
export default PITRNotice
|
||||
@@ -0,0 +1,389 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { format } from 'date-fns'
|
||||
import { useEffect, useState } from 'react'
|
||||
import DatePicker from 'react-datepicker'
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconHelpCircle,
|
||||
Alert,
|
||||
} from '@supabase/ui'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
|
||||
import { useStore } from 'hooks'
|
||||
import { post } from 'lib/common/fetch'
|
||||
import { API_URL, PROJECT_STATUS } from 'lib/constants'
|
||||
import { FormHeader, FormPanel } from 'components/ui/Forms'
|
||||
import InformationBox from 'components/ui/InformationBox'
|
||||
import TimeInput from './TimeInput'
|
||||
import TimezoneSelection from './TimezoneSelection'
|
||||
import { Time, Timezone } from './PITR.types'
|
||||
import PITRStatus from './PITRStatus'
|
||||
import {
|
||||
checkMatchingDates,
|
||||
convertDatetimetoUnixS,
|
||||
formatNumberToTwoDigits,
|
||||
formatTimeToTimeString,
|
||||
getClientTimezone,
|
||||
getDatesBetweenRange,
|
||||
} from './PITR.utils'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
const DEFAULT_TIME = { h: 0, m: 0, s: 0 }
|
||||
|
||||
const PITRSelection = ({}) => {
|
||||
const router = useRouter()
|
||||
const { ref } = router.query
|
||||
|
||||
const { app, ui, backups } = useStore()
|
||||
const projectId = ui.selectedProject?.id ?? -1
|
||||
|
||||
const [selectedDate, setSelectedDate] = useState<Date>()
|
||||
const [selectedTime, setSelectedTime] = useState<Time>({ h: 0, m: 0, s: 0 })
|
||||
const [selectedTimezone, setSelectedTimezone] = useState<Timezone>(getClientTimezone())
|
||||
|
||||
const [showConfiguration, setShowConfiguration] = useState(false)
|
||||
const [showConfirmation, setShowConfirmation] = useState(false)
|
||||
const [isRestoring, setIsRestoring] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDate) {
|
||||
if (selectedDate < earliestAvailableBackupFormatted) {
|
||||
setSelectedDate(earliestAvailableBackupFormatted)
|
||||
} else if (selectedDate > latestAvailableBackupFormatted) {
|
||||
setSelectedDate(latestAvailableBackupFormatted)
|
||||
}
|
||||
}
|
||||
}, [selectedTimezone])
|
||||
|
||||
useEffect(() => {
|
||||
const formattedSelectedTime = dayjs(formatTimeToTimeString(selectedTime), 'HH:mm:ss', true)
|
||||
const formattedEarliestTime = dayjs(
|
||||
formatTimeToTimeString(earliestAvailableBackupTime),
|
||||
'HH:mm:ss',
|
||||
true
|
||||
)
|
||||
const formattedLatestTime = dayjs(
|
||||
formatTimeToTimeString(latestAvailableBackupTime),
|
||||
'HH:mm:ss',
|
||||
true
|
||||
)
|
||||
|
||||
if (isSelectedOnEarliest && formattedSelectedTime.isBefore(formattedEarliestTime)) {
|
||||
return setSelectedTime(earliestAvailableBackupTime)
|
||||
}
|
||||
|
||||
if (isSelectedOnLatest && formattedSelectedTime.isAfter(formattedLatestTime)) {
|
||||
return setSelectedTime(latestAvailableBackupTime)
|
||||
}
|
||||
}, [selectedDate, selectedTimezone])
|
||||
|
||||
const { earliestPhysicalBackupDateUnix, latestPhysicalBackupDateUnix } =
|
||||
backups?.configuration?.physicalBackupData ?? {}
|
||||
const earliestAvailableBackup = dayjs(earliestPhysicalBackupDateUnix * 1000).tz(
|
||||
selectedTimezone?.utc[0]
|
||||
)
|
||||
const latestAvailableBackup = dayjs(latestPhysicalBackupDateUnix * 1000).tz(
|
||||
selectedTimezone?.utc[0]
|
||||
)
|
||||
|
||||
// Start: Variables specifically for date picker component
|
||||
// Required as it only works with vanilla Date object which is not timezone localized
|
||||
const earliestAvailableBackupFormatted = new Date(earliestAvailableBackup.format('DD-MMM-YYYY'))
|
||||
const latestAvailableBackupFormatted = new Date(latestAvailableBackup.format('DD-MMM-YYYY'))
|
||||
const isSelectedOnEarliest = checkMatchingDates(selectedDate, earliestAvailableBackupFormatted)
|
||||
const isSelectedOnLatest = checkMatchingDates(selectedDate, latestAvailableBackupFormatted)
|
||||
const availableDates = getDatesBetweenRange(
|
||||
earliestAvailableBackupFormatted,
|
||||
latestAvailableBackupFormatted
|
||||
)
|
||||
// End: Variables specifically for date picker component
|
||||
|
||||
const earliestAvailableBackupTime = {
|
||||
h: earliestAvailableBackup.hour(),
|
||||
m: earliestAvailableBackup.minute(),
|
||||
s: earliestAvailableBackup.second(),
|
||||
}
|
||||
|
||||
const latestAvailableBackupTime = {
|
||||
h: latestAvailableBackup.hour(),
|
||||
m: latestAvailableBackup.minute(),
|
||||
s: latestAvailableBackup.second(),
|
||||
}
|
||||
|
||||
// This will be the actual unix timestamp for the backup
|
||||
const recoveryTimeTargetUnix = selectedDate
|
||||
? convertDatetimetoUnixS(selectedDate, selectedTime, selectedTimezone)
|
||||
: 0
|
||||
// Formatting from the unix again just to double check correctness
|
||||
const recoveryTimeString = dayjs(recoveryTimeTargetUnix * 1000)
|
||||
.tz(selectedTimezone?.utc[0])
|
||||
.format('DD MMM YYYY HH:mm:ss')
|
||||
|
||||
const isSelectedOutOfRange =
|
||||
selectedDate &&
|
||||
(recoveryTimeTargetUnix < earliestPhysicalBackupDateUnix ||
|
||||
recoveryTimeTargetUnix > latestPhysicalBackupDateUnix)
|
||||
|
||||
const onUpdateDate = (date: Date) => setSelectedDate(date)
|
||||
|
||||
const onCancel = () => {
|
||||
setShowConfiguration(false)
|
||||
setSelectedDate(undefined)
|
||||
setSelectedTime(DEFAULT_TIME)
|
||||
setSelectedTimezone(getClientTimezone())
|
||||
}
|
||||
|
||||
const onConfirmRestore = async () => {
|
||||
if (!recoveryTimeTargetUnix) return
|
||||
|
||||
setIsRestoring(true)
|
||||
const projectRef = ref as string
|
||||
const { error } = await post(`${API_URL}/database/${projectRef}/backups/pitr`, {
|
||||
recovery_time_target_unix: recoveryTimeTargetUnix,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
ui.setNotification({
|
||||
error,
|
||||
category: 'error',
|
||||
message: `Failed to being restoration: ${error.message}`,
|
||||
})
|
||||
setIsRestoring(false)
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
setShowConfirmation(false)
|
||||
app.onProjectStatusUpdated(projectId, PROJECT_STATUS.RESTORING)
|
||||
router.push(`/project/${projectRef}`)
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormHeader
|
||||
title="Restore your database from a backup"
|
||||
description="Database changes are watched and recorded, so that you can restore your database to any point in time"
|
||||
/>
|
||||
{!showConfiguration ? (
|
||||
<PITRStatus
|
||||
selectedTimezone={selectedTimezone}
|
||||
onUpdateTimezone={setSelectedTimezone}
|
||||
onSetConfiguration={() => setShowConfiguration(true)}
|
||||
/>
|
||||
) : (
|
||||
<FormPanel
|
||||
disabled={true}
|
||||
footer={
|
||||
<div className="flex items-center justify-end gap-3 p-6">
|
||||
<Button type="default" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger>
|
||||
<Button
|
||||
as="span"
|
||||
type="warning"
|
||||
disabled={isSelectedOutOfRange}
|
||||
onClick={() => setShowConfirmation(true)}
|
||||
>
|
||||
Review restore details
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
{isSelectedOutOfRange && (
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'bg-scale-100 rounded py-1 px-2 leading-none shadow',
|
||||
'border-scale-200 border w-48 text-center',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-scale-1200 text-xs">
|
||||
Selected date is out of range where backups are available
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex justify-between px-10 py-6 space-x-10">
|
||||
<div className="w-1/3 space-y-2">
|
||||
<DatePicker
|
||||
inline
|
||||
selected={selectedDate}
|
||||
onChange={onUpdateDate}
|
||||
dayClassName={() => 'cursor-pointer'}
|
||||
minDate={earliestAvailableBackupFormatted}
|
||||
maxDate={latestAvailableBackupFormatted}
|
||||
highlightDates={availableDates}
|
||||
renderCustomHeader={({
|
||||
date,
|
||||
decreaseMonth,
|
||||
increaseMonth,
|
||||
prevMonthButtonDisabled,
|
||||
nextMonthButtonDisabled,
|
||||
}) => (
|
||||
<div className="flex items-center justify-between px-2 py-2">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<button
|
||||
onClick={decreaseMonth}
|
||||
disabled={prevMonthButtonDisabled}
|
||||
type="button"
|
||||
className={`
|
||||
${prevMonthButtonDisabled && 'cursor-not-allowed opacity-50'}
|
||||
text-scale-1100 hover:text-scale-1200 focus:outline-none
|
||||
`}
|
||||
>
|
||||
<IconChevronLeft size={16} strokeWidth={2} />
|
||||
</button>
|
||||
<span className="text-scale-1100 text-sm">{format(date, 'MMMM yyyy')}</span>
|
||||
<button
|
||||
onClick={increaseMonth}
|
||||
disabled={nextMonthButtonDisabled}
|
||||
type="button"
|
||||
className={`
|
||||
${nextMonthButtonDisabled && 'cursor-not-allowed opacity-50'}
|
||||
text-scale-1100 hover:text-scale-1200 focus:outline-none
|
||||
`}
|
||||
>
|
||||
<IconChevronRight size={16} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="border w-4 h-4 border-scale-800 bg-brand-600" />
|
||||
<p className="text-xs text-scale-1000">Point in time back up available</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-2/3">
|
||||
{!selectedDate ? (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="mx-2">
|
||||
<InformationBox
|
||||
defaultVisibility
|
||||
hideCollapse
|
||||
icon={<IconHelpCircle strokeWidth={2} />}
|
||||
title="Select a date which you'd like to restore your database to"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8 py-2">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-scale-1100">Restore database to</p>
|
||||
<p className="text-3xl">
|
||||
<span>{dayjs(selectedDate).format('DD MMM YYYY')}</span>
|
||||
<span>
|
||||
, {formatNumberToTwoDigits(selectedTime.h)}:
|
||||
{formatNumberToTwoDigits(selectedTime.m)}:
|
||||
{formatNumberToTwoDigits(selectedTime.s)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-scale-1100">Time of recovery</p>
|
||||
<div className="w-[350px]">
|
||||
<TimezoneSelection
|
||||
hideLabel
|
||||
dropdownWidth="w-[400px]"
|
||||
selectedTimezone={selectedTimezone}
|
||||
onSelectTimezone={setSelectedTimezone}
|
||||
/>
|
||||
</div>
|
||||
<TimeInput
|
||||
defaultTime={selectedTime}
|
||||
minimumTime={isSelectedOnEarliest ? earliestAvailableBackupTime : undefined}
|
||||
maximumTime={isSelectedOnLatest ? latestAvailableBackupTime : undefined}
|
||||
onChange={setSelectedTime}
|
||||
/>
|
||||
<div className="!mt-4 space-y-1">
|
||||
{isSelectedOnEarliest && (
|
||||
<p className="text-sm text-scale-1000">
|
||||
Earliest available backup on this date is at{' '}
|
||||
{earliestAvailableBackup.format('HH:mm:ss')}
|
||||
</p>
|
||||
)}
|
||||
{isSelectedOnLatest && (
|
||||
<p className="text-sm text-scale-1000">
|
||||
Latest available backup on this date is at{' '}
|
||||
{latestAvailableBackup.format('HH:mm:ss')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</FormPanel>
|
||||
)}
|
||||
<Modal
|
||||
closable
|
||||
size="medium"
|
||||
visible={showConfirmation}
|
||||
onCancel={() => setShowConfirmation(false)}
|
||||
customFooter={
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<Button
|
||||
type="default"
|
||||
disabled={isRestoring}
|
||||
onClick={() => setShowConfirmation(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="warning"
|
||||
disabled={isRestoring}
|
||||
loading={isRestoring}
|
||||
onClick={onConfirmRestore}
|
||||
>
|
||||
I understand, begin restore
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4 py-3">
|
||||
<Modal.Content>
|
||||
<p>Point in time recovery review</p>
|
||||
</Modal.Content>
|
||||
<Modal.Seperator />
|
||||
<Modal.Content>
|
||||
<div className="py-2 space-y-1">
|
||||
<p className="text-sm text-scale-1100"> Your database will be restored to</p>
|
||||
<p className="text-2xl">{recoveryTimeString}</p>
|
||||
<p className="text-lg">{selectedTimezone?.text}</p>
|
||||
</div>
|
||||
</Modal.Content>
|
||||
<Modal.Seperator />
|
||||
<Modal.Content>
|
||||
<Alert withIcon variant="warning" title="Warning: This action cannot be undone">
|
||||
<div className="space-y-3">
|
||||
<p>
|
||||
Any changes made to your database after this point in time will be lost. This
|
||||
includes any changes to your project's storage and authentication.
|
||||
</p>
|
||||
</div>
|
||||
</Alert>
|
||||
</Modal.Content>
|
||||
<Modal.Seperator />
|
||||
<Modal.Content>
|
||||
<p className="text-sm">
|
||||
Restores may take from a few minutes up to several hours depending on the size of your
|
||||
database. During this period, your project will not be available.
|
||||
</p>
|
||||
</Modal.Content>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default PITRSelection
|
||||
@@ -0,0 +1,72 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { FC } from 'react'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { Button, IconAlertCircle } from '@supabase/ui'
|
||||
|
||||
import { useStore } from 'hooks'
|
||||
import { Timezone } from './PITR.types'
|
||||
import { FormPanel } from 'components/ui/Forms'
|
||||
import TimezoneSelection from './TimezoneSelection'
|
||||
|
||||
interface Props {
|
||||
selectedTimezone: Timezone
|
||||
onUpdateTimezone: (timezone: Timezone) => void
|
||||
onSetConfiguration: () => void
|
||||
}
|
||||
|
||||
const PITRStatus: FC<Props> = ({ selectedTimezone, onUpdateTimezone, onSetConfiguration }) => {
|
||||
const { backups } = useStore()
|
||||
const { earliestPhysicalBackupDateUnix, latestPhysicalBackupDateUnix } =
|
||||
backups?.configuration?.physicalBackupData ?? {}
|
||||
|
||||
const earliestAvailableBackup = dayjs
|
||||
.unix(earliestPhysicalBackupDateUnix)
|
||||
.tz(selectedTimezone?.utc[0])
|
||||
.format('DD MMM YYYY, HH:mm:ss')
|
||||
|
||||
const latestAvailableBackup = dayjs
|
||||
.unix(latestPhysicalBackupDateUnix)
|
||||
.tz(selectedTimezone?.utc[0])
|
||||
.format('DD MMM YYYY, HH:mm:ss')
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormPanel
|
||||
disabled={true}
|
||||
footer={
|
||||
<div className="flex items-center justify-between p-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
<IconAlertCircle className="text-scale-1100" size={18} strokeWidth={1.5} />
|
||||
<span className="text-scale-1000 text-sm">
|
||||
You'll be able to pick the right date and time when you begin
|
||||
</span>
|
||||
</div>
|
||||
<Button onClick={() => onSetConfiguration()}>Start a restore</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="w-[350px]">
|
||||
<TimezoneSelection
|
||||
hideLabel
|
||||
selectedTimezone={selectedTimezone}
|
||||
onSelectTimezone={onUpdateTimezone}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-20">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-scale-1100">Database restore available from</p>
|
||||
<p className="text-2xl">{earliestAvailableBackup}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-scale-1100">Latest restore available at</p>
|
||||
<p className="text-2xl">{latestAvailableBackup}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormPanel>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(PITRStatus)
|
||||
@@ -0,0 +1,187 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { isNaN } from 'lodash'
|
||||
import { ChangeEvent, FC, useEffect, useState } from 'react'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { IconClock } from '@supabase/ui'
|
||||
|
||||
import { Time } from './PITR.types'
|
||||
import { formatNumberToTwoDigits, formatTimeToTimeString } from './PITR.utils'
|
||||
|
||||
// [Joshen] This is trying to do the same thing as TimeSplitInput.tsx
|
||||
// so can we please look to try to combine these 2 components together if possible
|
||||
// The problem with TimeSplitInput is that it's tightly coupled to the date + its
|
||||
// tightly coupled to the context of a range. Ideally it should be more modular
|
||||
// which is what this component is trying to achieve
|
||||
|
||||
// [Joshen] Potential extension, give option to toggle 24 hours or AM/PM
|
||||
|
||||
interface Props {
|
||||
defaultTime?: Time
|
||||
minimumTime?: Time
|
||||
maximumTime?: Time
|
||||
onChange?: (time: Time) => void
|
||||
}
|
||||
|
||||
const TimeInput: FC<Props> = ({ defaultTime, minimumTime, maximumTime, onChange = () => {} }) => {
|
||||
const [isFocused, setIsFocused] = useState(false)
|
||||
const [error, setError] = useState<string>()
|
||||
const [time, setTime] = useState<Time>(defaultTime || { h: 0, m: 0, s: 0 })
|
||||
|
||||
const formattedMinimumTime = minimumTime
|
||||
? dayjs(formatTimeToTimeString(minimumTime), 'HH:mm:ss', true)
|
||||
: undefined
|
||||
|
||||
const formattedMaximumTime = maximumTime
|
||||
? dayjs(formatTimeToTimeString(maximumTime), 'HH:mm:ss', true)
|
||||
: undefined
|
||||
|
||||
useEffect(() => {
|
||||
if (minimumTime || maximumTime) validate(time)
|
||||
}, [JSON.stringify(minimumTime), JSON.stringify(maximumTime)])
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultTime) {
|
||||
setTime(defaultTime)
|
||||
validate(defaultTime)
|
||||
}
|
||||
}, [defaultTime])
|
||||
|
||||
const validate = (time: Time) => {
|
||||
let error = undefined
|
||||
const formattedTime = dayjs(formatTimeToTimeString(time), 'HH:mm:ss', true)
|
||||
|
||||
if (!formattedTime.isValid()) {
|
||||
error = 'Please enter a valid time'
|
||||
} else if (formattedMinimumTime && formattedTime.isBefore(formattedMinimumTime)) {
|
||||
error = 'Selected time is before the minimum time allowed'
|
||||
} else if (formattedMaximumTime && formattedTime.isAfter(formattedMaximumTime)) {
|
||||
error = 'Selected time is after the maximum time allowed'
|
||||
}
|
||||
|
||||
setError(error)
|
||||
return error === undefined
|
||||
}
|
||||
|
||||
const onFocus = () => setIsFocused(true)
|
||||
|
||||
const onInputChange = (event: ChangeEvent<HTMLInputElement>, unit: 'h' | 'm' | 's') => {
|
||||
if (isNaN(Number(event.target.value))) return
|
||||
setTime({ ...time, [unit]: event.target.value })
|
||||
}
|
||||
|
||||
const onInputBlur = (event: ChangeEvent<HTMLInputElement>, unit: 'h' | 'm' | 's') => {
|
||||
const formattedInput = Number(event.target.value)
|
||||
const updatedTime = { ...time, [unit]: formattedInput }
|
||||
setTime(updatedTime)
|
||||
|
||||
validate(updatedTime)
|
||||
onChange(updatedTime)
|
||||
setIsFocused(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={[
|
||||
'flex items-center justify-between transition',
|
||||
'rounded-md bg-scaleA-200 border px-3.5 py-2 w-[350px]',
|
||||
`${
|
||||
isFocused
|
||||
? 'border-scale-900'
|
||||
: error === undefined
|
||||
? 'border-scale-700'
|
||||
: 'border-red-800'
|
||||
}`,
|
||||
].join(' ')}
|
||||
>
|
||||
<IconClock className="text-scale-1100" size={18} strokeWidth={1.5} />
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger className="w-1/4">
|
||||
<input
|
||||
type="text"
|
||||
maxLength={2}
|
||||
pattern="[0-9]*"
|
||||
placeholder="HH"
|
||||
value={formatNumberToTwoDigits(time.h)}
|
||||
onFocus={onFocus}
|
||||
aria-label="Hours"
|
||||
onBlur={(event) => onInputBlur(event, 'h')}
|
||||
onChange={(event) => onInputChange(event, 'h')}
|
||||
className="w-full text-sm bg-transparent p-0 text-center outline-none border-none focus:ring-0"
|
||||
/>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'bg-scale-100 rounded py-1 px-2 leading-none shadow',
|
||||
'border-scale-200 border',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-scale-1200 text-xs">Hours (HH)</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<span>:</span>
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger className="w-1/4">
|
||||
<input
|
||||
type="text"
|
||||
maxLength={2}
|
||||
pattern="[0-9]*"
|
||||
placeholder="MM"
|
||||
value={formatNumberToTwoDigits(time.m)}
|
||||
onFocus={onFocus}
|
||||
aria-label="Minutes"
|
||||
onBlur={(event) => onInputBlur(event, 'm')}
|
||||
onChange={(event) => onInputChange(event, 'm')}
|
||||
className="w-full text-sm bg-transparent p-0 text-center outline-none border-none focus:ring-0"
|
||||
/>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'bg-scale-100 rounded py-1 px-2 leading-none shadow',
|
||||
'border-scale-200 border',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-scale-1200 text-xs">Minutes (MM)</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<span>:</span>
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger className="w-1/4">
|
||||
<input
|
||||
type="text"
|
||||
maxLength={2}
|
||||
pattern="[0-9]*"
|
||||
placeholder="SS"
|
||||
value={formatNumberToTwoDigits(time.s)}
|
||||
onFocus={onFocus}
|
||||
aria-label="Seconds"
|
||||
onBlur={(event) => onInputBlur(event, 's')}
|
||||
onChange={(event) => onInputChange(event, 's')}
|
||||
className="w-full text-sm bg-transparent p-0 text-center outline-none border-none focus:ring-0"
|
||||
/>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'bg-scale-100 rounded py-1 px-2 leading-none shadow',
|
||||
'border-scale-200 border',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-scale-1200 text-xs">Seconds (SS)</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-900">{error}</p>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default TimeInput
|
||||
@@ -0,0 +1,78 @@
|
||||
import { FC, useState, FormEvent } from 'react'
|
||||
import { Listbox, IconSearch, IconGlobe } from '@supabase/ui'
|
||||
|
||||
import { ALL_TIMEZONES } from './PITR.constants'
|
||||
|
||||
interface Props {
|
||||
hideLabel?: boolean
|
||||
dropdownWidth?: string
|
||||
selectedTimezone: any
|
||||
onSelectTimezone: (timezone: any) => void
|
||||
}
|
||||
|
||||
const TimezoneSelection: FC<Props> = ({
|
||||
hideLabel,
|
||||
dropdownWidth,
|
||||
selectedTimezone,
|
||||
onSelectTimezone,
|
||||
}) => {
|
||||
const [searchString, setSearchString] = useState<string>('')
|
||||
|
||||
const timezoneOptions =
|
||||
searchString.length > 0
|
||||
? ALL_TIMEZONES.map((option) => option.text).filter((option) =>
|
||||
option.toLowerCase().includes(searchString.toLowerCase())
|
||||
)
|
||||
: ALL_TIMEZONES.map((option) => option.text)
|
||||
|
||||
return (
|
||||
<div className="flex justify-between space-x-8">
|
||||
{!hideLabel && <p className="w-2/5 text-sm">Select timezone</p>}
|
||||
<div className={`${hideLabel ? 'w-full' : 'w-3/5'}`}>
|
||||
<Listbox
|
||||
value={selectedTimezone.text}
|
||||
icon={<IconGlobe />}
|
||||
onChange={(text) => {
|
||||
const selectedTimezone = ALL_TIMEZONES.find((option) => option.text === text)
|
||||
onSelectTimezone(selectedTimezone)
|
||||
}}
|
||||
onBlur={() => setSearchString('')}
|
||||
>
|
||||
<div
|
||||
className={[
|
||||
'fixed top-0 flex w-full items-center',
|
||||
'rounded-t-md border-b border-gray-600 bg-gray-500',
|
||||
'mb-4 space-x-2 px-4 py-2',
|
||||
].join(' ')}
|
||||
style={{ zIndex: 1 }}
|
||||
>
|
||||
<IconSearch size={14} />
|
||||
<input
|
||||
autoFocus
|
||||
className="placeholder-scale-1000 w-72 bg-transparent text-sm outline-none"
|
||||
value={searchString}
|
||||
placeholder={''}
|
||||
onChange={(e: FormEvent<HTMLInputElement>) => setSearchString(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
{/* Whitespace to shift listbox options down for searchfield */}
|
||||
<div className="h-8" />
|
||||
{timezoneOptions.map((option) => {
|
||||
return (
|
||||
<Listbox.Option key={option} label={option} value={option} className={dropdownWidth}>
|
||||
<div>{option}</div>
|
||||
</Listbox.Option>
|
||||
)
|
||||
})}
|
||||
{timezoneOptions.length === 0 && (
|
||||
<Listbox.Option disabled key="no-results" label="" value="">
|
||||
No timezones found
|
||||
</Listbox.Option>
|
||||
)}
|
||||
</Listbox>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TimezoneSelection
|
||||
@@ -0,0 +1,4 @@
|
||||
import PITRNotice from './PITRNotice'
|
||||
import PITRSelection from './PITRSelection'
|
||||
|
||||
export { PITRNotice, PITRSelection }
|
||||
@@ -1,312 +0,0 @@
|
||||
import dayjs from 'dayjs'
|
||||
import Link from 'next/link'
|
||||
import { FC, FormEvent, useState } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import {
|
||||
IconSearch,
|
||||
IconInfo,
|
||||
Input,
|
||||
Listbox,
|
||||
Popover,
|
||||
Button,
|
||||
Modal,
|
||||
IconAlertCircle,
|
||||
} from '@supabase/ui'
|
||||
|
||||
import { post } from 'lib/common/fetch'
|
||||
import { API_URL, PROJECT_STATUS } from 'lib/constants'
|
||||
import { useStore, checkPermissions } from 'hooks'
|
||||
import Loading from 'components/ui/Loading'
|
||||
import ConfirmationModal from 'components/ui/ConfirmationModal'
|
||||
|
||||
import BackupsError from '../BackupsError'
|
||||
import BackupsEmpty from '../BackupsEmpty'
|
||||
import { ALL_TIMEZONES } from './PITRBackupSelection.constants'
|
||||
import {
|
||||
getClientTimezone,
|
||||
getTimezoneOffsetText,
|
||||
convertTimeStringtoUnixS,
|
||||
} from './PITRBackupSelection.utils'
|
||||
import InformationBox from 'components/ui/InformationBox'
|
||||
|
||||
interface Props {}
|
||||
|
||||
const PITRBackupSelection: FC<Props> = () => {
|
||||
const router = useRouter()
|
||||
const { app, ui, backups } = useStore()
|
||||
|
||||
const projectId = ui.selectedProject?.id ?? -1
|
||||
const projectRef = ui.selectedProject?.ref ?? 'default'
|
||||
|
||||
const canTriggerPhysicalBackups = checkPermissions(
|
||||
PermissionAction.INFRA_EXECUTE,
|
||||
'queue_job.restore.prepare'
|
||||
)
|
||||
|
||||
const [searchString, setSearchString] = useState<string>('')
|
||||
const [selectedTimezone, setSelectedTimezone] = useState<any>(getClientTimezone())
|
||||
const [showConfirmation, setShowConfirmation] = useState<boolean>(false)
|
||||
|
||||
const [errors, setErrors] = useState<any>()
|
||||
const [recoveryPoint, setRecoveryPoint] = useState<string>()
|
||||
|
||||
const { configuration } = backups
|
||||
|
||||
if (backups.isLoading) {
|
||||
return <Loading />
|
||||
} else if (backups.error) {
|
||||
return <BackupsError />
|
||||
}
|
||||
|
||||
const hasPhysicalBackups =
|
||||
configuration.physicalBackupData.earliestPhysicalBackupDateUnix !== null &&
|
||||
configuration.physicalBackupData.latestPhysicalBackupDateUnix !== null &&
|
||||
configuration.physicalBackupData.earliestPhysicalBackupDateUnix !==
|
||||
configuration.physicalBackupData.latestPhysicalBackupDateUnix
|
||||
|
||||
if (!configuration.walg_enabled) {
|
||||
return (
|
||||
<div className="block w-full rounded border border-gray-400 border-opacity-50 bg-gray-300 p-3">
|
||||
<div className="flex space-x-3">
|
||||
<IconInfo size={20} strokeWidth={1.5} />
|
||||
<p className="text-sm">
|
||||
Point in time backups is an Enterprise feature. Reach out to us{' '}
|
||||
<Link href={`/support/new?ref=${projectRef}&category=sales`}>
|
||||
<a className="text-brand-900">here</a>
|
||||
</Link>{' '}
|
||||
if you're interested!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
} else if (!hasPhysicalBackups) {
|
||||
return <BackupsEmpty />
|
||||
}
|
||||
|
||||
const timezoneOptions =
|
||||
searchString.length > 0
|
||||
? ALL_TIMEZONES.map((option) => option.text).filter((option) =>
|
||||
option.toLowerCase().includes(searchString.toLowerCase())
|
||||
)
|
||||
: ALL_TIMEZONES.map((option) => option.text)
|
||||
|
||||
const earliestAvailableBackup = dayjs
|
||||
.unix(configuration.physicalBackupData.earliestPhysicalBackupDateUnix)
|
||||
.tz(selectedTimezone.utc[0])
|
||||
.format('YYYY-MM-DDTHH:mm:ss')
|
||||
|
||||
const latestAvailableBackup = dayjs
|
||||
.unix(configuration.physicalBackupData.latestPhysicalBackupDateUnix)
|
||||
.tz(selectedTimezone.utc[0])
|
||||
.format('YYYY-MM-DDTHH:mm:ss')
|
||||
|
||||
const onSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
|
||||
// Validation
|
||||
if (!recoveryPoint) {
|
||||
setErrors({ recoveryPoint: 'Please enter a date to recover your project from' })
|
||||
} else {
|
||||
const recoveryTimeTargetUnix = convertTimeStringtoUnixS(recoveryPoint, selectedTimezone)
|
||||
const { earliestPhysicalBackupDateUnix, latestPhysicalBackupDateUnix } =
|
||||
configuration.physicalBackupData
|
||||
|
||||
const isOutOfRange =
|
||||
recoveryTimeTargetUnix < earliestPhysicalBackupDateUnix ||
|
||||
recoveryTimeTargetUnix > latestPhysicalBackupDateUnix
|
||||
|
||||
if (isOutOfRange) {
|
||||
setErrors({
|
||||
recoveryPoint: 'Selected date is out of range for available points of recovery',
|
||||
})
|
||||
} else {
|
||||
setErrors(undefined)
|
||||
setShowConfirmation(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onConfirmRestore = async () => {
|
||||
if (!recoveryPoint) return
|
||||
|
||||
const recoveryTimeTargetUnix = convertTimeStringtoUnixS(recoveryPoint, selectedTimezone)
|
||||
const { error } = await post(`${API_URL}/database/${projectRef}/backups/pitr`, {
|
||||
recovery_time_target_unix: recoveryTimeTargetUnix,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
ui.setNotification({
|
||||
error,
|
||||
category: 'error',
|
||||
message: `Point in time recovery for project failed`,
|
||||
})
|
||||
setShowConfirmation(false)
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
setShowConfirmation(false)
|
||||
app.onProjectStatusUpdated(projectId, PROJECT_STATUS.RESTORING)
|
||||
ui.setNotification({
|
||||
category: 'success',
|
||||
message: `Restoring database back to ${dayjs(recoveryPoint).format(
|
||||
'DD MMM YYYY, HH:mm:ss'
|
||||
)} (
|
||||
${getTimezoneOffsetText(selectedTimezone)})`,
|
||||
})
|
||||
router.push(`/project/${projectRef}`)
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{!canTriggerPhysicalBackups && (
|
||||
<InformationBox
|
||||
icon={<IconAlertCircle className="text-scale-1100" strokeWidth={2} />}
|
||||
title="You need additional permissions to trigger a scheduled backup"
|
||||
/>
|
||||
)}
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4 rounded border border-gray-400 border-opacity-50 bg-gray-300 py-3">
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="flex justify-between space-x-8">
|
||||
<p className="w-2/5 pl-4 text-sm">Select timezone</p>
|
||||
<div className="w-3/5 px-4">
|
||||
<Listbox
|
||||
value={selectedTimezone.text}
|
||||
onChange={(text) => {
|
||||
const selectedTimezone = ALL_TIMEZONES.find((option) => option.text === text)
|
||||
setSelectedTimezone(selectedTimezone)
|
||||
}}
|
||||
onBlur={() => setSearchString('')}
|
||||
>
|
||||
<div
|
||||
className={[
|
||||
'fixed top-0 flex w-full items-center',
|
||||
'rounded-t-md border-b border-gray-600 bg-gray-500',
|
||||
'mb-4 space-x-2 px-4 py-2',
|
||||
].join(' ')}
|
||||
style={{ zIndex: 1 }}
|
||||
>
|
||||
<IconSearch size={14} />
|
||||
<input
|
||||
autoFocus
|
||||
className="placeholder-scale-1000 w-72 bg-transparent text-sm outline-none"
|
||||
value={searchString}
|
||||
placeholder={''}
|
||||
onChange={(e: FormEvent<HTMLInputElement>) =>
|
||||
setSearchString(e.currentTarget.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{/* Whitespace to shift listbox options down for searchfield */}
|
||||
<div className="h-8" />
|
||||
{timezoneOptions.map((option) => {
|
||||
return (
|
||||
<Listbox.Option key={option} label={option} value={option}>
|
||||
<div>{option}</div>
|
||||
</Listbox.Option>
|
||||
)
|
||||
})}
|
||||
{timezoneOptions.length === 0 && (
|
||||
<Listbox.Option disabled key="no-results" label="" value="">
|
||||
No timezones found
|
||||
</Listbox.Option>
|
||||
)}
|
||||
</Listbox>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between space-x-8">
|
||||
<div className="w-2/5 space-y-2 pl-4">
|
||||
<p className="text-sm">Earliest point of recovery</p>
|
||||
</div>
|
||||
<div className="w-3/5">
|
||||
<Input
|
||||
readOnly
|
||||
disabled
|
||||
step={1}
|
||||
type="datetime-local"
|
||||
className="px-4"
|
||||
value={earliestAvailableBackup}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between space-x-8">
|
||||
<div className="w-2/5 space-y-2 pl-4">
|
||||
<p className="text-sm">Latest point of recovery</p>
|
||||
</div>
|
||||
<div className="w-3/5">
|
||||
<Input
|
||||
readOnly
|
||||
disabled
|
||||
step={1}
|
||||
type="datetime-local"
|
||||
className="px-4"
|
||||
value={latestAvailableBackup}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Popover.Seperator />
|
||||
<div className="flex justify-between space-x-8 py-2">
|
||||
<div className="w-2/5 space-y-2 pl-4">
|
||||
<p className="text-sm">Recovery point</p>
|
||||
<p className="text-scale-1100 text-sm">
|
||||
Select a date and time that you would like to restore your project to
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-3/5">
|
||||
<Input
|
||||
step={1}
|
||||
type="datetime-local"
|
||||
error={errors?.recoveryPoint}
|
||||
className="px-4"
|
||||
onChange={(e) => {
|
||||
setErrors(undefined)
|
||||
setRecoveryPoint(e.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Popover.Seperator />
|
||||
<div className="flex items-center justify-end px-4">
|
||||
<Button type="default" htmlType="submit">
|
||||
Restore
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<ConfirmationModal
|
||||
visible={showConfirmation}
|
||||
size="medium"
|
||||
header="Confirm to restore"
|
||||
children={
|
||||
<Modal.Content>
|
||||
<div className="space-y-2 py-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-scale-1100 text-sm">
|
||||
Are you sure you want to restore your database from:
|
||||
</p>
|
||||
<p className="text-scale-1200 text-sm">
|
||||
{dayjs(recoveryPoint).format('DD MMM YYYY, HH:mm:ss')} (
|
||||
{getTimezoneOffsetText(selectedTimezone)})?
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-scale-1100 text-sm">
|
||||
This will destroy any new data written since this backup was made.
|
||||
</p>
|
||||
</div>
|
||||
</Modal.Content>
|
||||
}
|
||||
buttonLabel="Restore"
|
||||
buttonLoadingLabel="Restoring"
|
||||
onSelectCancel={() => setShowConfirmation(false)}
|
||||
onSelectConfirm={onConfirmRestore}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PITRBackupSelection
|
||||
@@ -1,27 +0,0 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { ALL_TIMEZONES } from './PITRBackupSelection.constants'
|
||||
import { Timezone } from './PITRBackupSelection.types'
|
||||
|
||||
export const getClientTimezone = () => {
|
||||
const defaultTz = dayjs.tz.guess()
|
||||
const timezone = ALL_TIMEZONES.find((option) => {
|
||||
if (option.utc.includes(defaultTz)) return option
|
||||
else return undefined
|
||||
})
|
||||
return timezone
|
||||
}
|
||||
|
||||
export const getTimezoneOffsetText = (timezone: Timezone) => {
|
||||
return timezone.text.split(')')[0].slice(1)
|
||||
}
|
||||
|
||||
export const getTimezoneOffset = (timezone: Timezone) => {
|
||||
return timezone.text.split(')')[0].slice(4)
|
||||
}
|
||||
|
||||
export const convertTimeStringtoUnixS = (datetime: string, timezone: Timezone) => {
|
||||
// Datetime in the format of YYYY-MM-DDTHH:mm:ss (from datetime-local input)
|
||||
const timezoneOffset = getTimezoneOffset(timezone)
|
||||
const datetimestringWithTimezone = `${datetime}${timezoneOffset}`
|
||||
return dayjs(datetimestringWithTimezone).unix()
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
import PITRBackupSelection from './PITRBackupSelection'
|
||||
export default PITRBackupSelection
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FC, ReactNode, useState } from 'react'
|
||||
import { FC, useState } from 'react'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import {
|
||||
Input,
|
||||
@@ -10,66 +10,40 @@ import {
|
||||
IconTrash,
|
||||
} from '@supabase/ui'
|
||||
|
||||
import Table from '../../../to-be-cleaned/Table'
|
||||
import { useStore } from 'hooks'
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import NoSearchResults from 'components/to-be-cleaned/NoSearchResults'
|
||||
import { PostgresTable } from '@supabase/postgres-meta'
|
||||
|
||||
const Header: FC<{
|
||||
filterString: string
|
||||
filterPlaceholder: string
|
||||
leftComponents: ReactNode
|
||||
rightComponents: ReactNode
|
||||
setFilterString: (value: string) => void
|
||||
}> = ({
|
||||
filterString,
|
||||
setFilterString,
|
||||
filterPlaceholder = 'Filter',
|
||||
leftComponents,
|
||||
rightComponents,
|
||||
}) => (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<div>{leftComponents}</div>
|
||||
<div>
|
||||
<Input
|
||||
size="small"
|
||||
placeholder={filterPlaceholder}
|
||||
value={filterString}
|
||||
onChange={(e: any) => setFilterString(e.target.value)}
|
||||
icon={<IconSearch size="tiny" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="">{rightComponents}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const ColumnList: FC<{
|
||||
selectedTable: any
|
||||
interface Props {
|
||||
selectedTable: PostgresTable
|
||||
onSelectBack: () => void
|
||||
onAddColumn: () => void
|
||||
onEditColumn: (column: any) => void
|
||||
onDeleteColumn: (column: any) => void
|
||||
}> = ({
|
||||
}
|
||||
|
||||
const ColumnList: FC<Props> = ({
|
||||
selectedTable,
|
||||
onSelectBack = () => {},
|
||||
onAddColumn = () => {},
|
||||
onEditColumn = () => {},
|
||||
onDeleteColumn = () => {},
|
||||
}) => {
|
||||
const { meta } = useStore()
|
||||
const [filterString, setFilterString] = useState<string>('')
|
||||
const columns =
|
||||
filterString.length === 0
|
||||
? selectedTable.columns
|
||||
: selectedTable.columns.filter((column: any) => column.name.includes(filterString))
|
||||
|
||||
const isLocked = meta.excludedSchemas.includes(selectedTable?.schema ?? '')
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<Header
|
||||
filterPlaceholder="Filter columns"
|
||||
filterString={filterString}
|
||||
setFilterString={setFilterString}
|
||||
leftComponents={
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center mr-4">
|
||||
<Button
|
||||
type="outline"
|
||||
@@ -80,13 +54,24 @@ const ColumnList: FC<{
|
||||
/>
|
||||
<code>{selectedTable.name}</code>
|
||||
</div>
|
||||
}
|
||||
rightComponents={
|
||||
<Button icon={<IconPlus />} onClick={() => onAddColumn()}>
|
||||
New
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
<Input
|
||||
size="small"
|
||||
placeholder="Filter columns"
|
||||
value={filterString}
|
||||
onChange={(e: any) => setFilterString(e.target.value)}
|
||||
icon={<IconSearch size="tiny" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!isLocked && (
|
||||
<div>
|
||||
<Button icon={<IconPlus />} onClick={() => onAddColumn()}>
|
||||
New column
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{columns.length === 0 ? (
|
||||
<NoSearchResults />
|
||||
@@ -111,8 +96,12 @@ const ColumnList: FC<{
|
||||
<Table.td>
|
||||
<p>{x.name}</p>
|
||||
</Table.td>
|
||||
<Table.td>
|
||||
<p>{x.comment}</p>
|
||||
<Table.td className="break-all whitespace-normal">
|
||||
{x.comment !== null ? (
|
||||
<p title={x.comment}>{x.comment}</p>
|
||||
) : (
|
||||
<p className="text-scale-800">No description</p>
|
||||
)}
|
||||
</Table.td>
|
||||
<Table.td>
|
||||
<code className="text-sm">{x.data_type}</code>
|
||||
@@ -127,12 +116,14 @@ const ColumnList: FC<{
|
||||
icon={<IconEdit3 />}
|
||||
style={{ padding: 5 }}
|
||||
type="text"
|
||||
disabled={isLocked}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => onDeleteColumn(x)}
|
||||
icon={<IconTrash />}
|
||||
style={{ padding: 5 }}
|
||||
type="text"
|
||||
disabled={isLocked}
|
||||
/>
|
||||
</div>
|
||||
</Table.td>
|
||||
|
||||
@@ -1,155 +1,195 @@
|
||||
import { FC, ReactNode, useState } from 'react'
|
||||
import { FC, useState } from 'react'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import {
|
||||
Button,
|
||||
IconPlus,
|
||||
Input,
|
||||
Listbox,
|
||||
IconSearch,
|
||||
IconTrash,
|
||||
IconEdit3,
|
||||
IconColumns,
|
||||
IconLock,
|
||||
} from '@supabase/ui'
|
||||
import { partition } from 'lodash'
|
||||
|
||||
import { useStore } from 'hooks'
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import NoSearchResults from 'components/to-be-cleaned/NoSearchResults'
|
||||
import { PostgresTable, PostgresSchema } from '@supabase/postgres-meta'
|
||||
|
||||
const Header: FC<{
|
||||
filterString: string
|
||||
filterPlaceholder: string
|
||||
leftComponents: ReactNode
|
||||
rightComponents: ReactNode
|
||||
setFilterString: (value: string) => void
|
||||
}> = ({
|
||||
filterString,
|
||||
setFilterString,
|
||||
filterPlaceholder = 'Filter',
|
||||
leftComponents,
|
||||
rightComponents,
|
||||
}) => (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<div>{leftComponents}</div>
|
||||
<div>
|
||||
<Input
|
||||
size="small"
|
||||
placeholder={filterPlaceholder}
|
||||
value={filterString}
|
||||
onChange={(e: any) => setFilterString(e.target.value)}
|
||||
icon={<IconSearch size="tiny" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="">{rightComponents}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const TableList: FC<{
|
||||
interface Props {
|
||||
selectedSchema: string
|
||||
onSelectSchema: (schema: string) => void
|
||||
onAddTable: () => void
|
||||
onEditTable: (table: any) => void
|
||||
onDeleteTable: (table: any) => void
|
||||
onOpenTable: (table: any) => void
|
||||
}> = observer(
|
||||
({
|
||||
onAddTable = () => {},
|
||||
onEditTable = () => {},
|
||||
onDeleteTable = () => {},
|
||||
onOpenTable = () => {},
|
||||
}) => {
|
||||
const { meta } = useStore()
|
||||
const [filterString, setFilterString] = useState<string>('')
|
||||
const tables =
|
||||
filterString.length === 0
|
||||
? meta.tables.list((table: any) => table.schema === 'public')
|
||||
: meta.tables.list(
|
||||
(table: any) => table.schema === 'public' && table.name.includes(filterString)
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
{/* @ts-ignore */}
|
||||
<Header
|
||||
filterString={filterString}
|
||||
setFilterString={setFilterString}
|
||||
rightComponents={
|
||||
<Button icon={<IconPlus />} onClick={() => onAddTable()}>
|
||||
New
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{tables.length === 0 ? (
|
||||
<NoSearchResults />
|
||||
) : (
|
||||
<div className="my-4 w-full">
|
||||
<Table
|
||||
head={[
|
||||
<Table.th key="name">Name</Table.th>,
|
||||
<Table.th key="schema">Schema</Table.th>,
|
||||
<Table.th key="description" className="hidden lg:table-cell">
|
||||
Description
|
||||
</Table.th>,
|
||||
<Table.th key="rows" className="hidden xl:table-cell">
|
||||
Rows (Estimated)
|
||||
</Table.th>,
|
||||
<Table.th key="size" className="hidden xl:table-cell">
|
||||
Size (Estimated)
|
||||
</Table.th>,
|
||||
<Table.th key="buttons"></Table.th>,
|
||||
]}
|
||||
body={tables.map((x: any, i: any) => (
|
||||
<Table.tr key={x.id} hoverable>
|
||||
<Table.td>
|
||||
<p>{x.name}</p>
|
||||
</Table.td>
|
||||
<Table.td>
|
||||
<p>{x.schema}</p>
|
||||
</Table.td>
|
||||
<Table.td className=" hidden max-w-sm truncate lg:table-cell">
|
||||
<p>{x.comment}</p>
|
||||
</Table.td>
|
||||
<Table.td className=" hidden xl:table-cell">
|
||||
<code className="text-sm">{x.live_rows_estimate ?? x.live_row_count}</code>
|
||||
</Table.td>
|
||||
<Table.td className=" hidden xl:table-cell">
|
||||
<code className="text-sm">{x.size}</code>
|
||||
</Table.td>
|
||||
<Table.td>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
iconRight={<IconColumns />}
|
||||
type="default"
|
||||
className="whitespace-nowrap hover:border-gray-500"
|
||||
style={{ paddingTop: 3, paddingBottom: 3 }}
|
||||
onClick={() => {
|
||||
onOpenTable(x)
|
||||
}}
|
||||
>
|
||||
{x.columns.length} columns
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onEditTable(x)}
|
||||
icon={<IconEdit3 />}
|
||||
style={{ padding: 5 }}
|
||||
type="text"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => onDeleteTable(x)}
|
||||
icon={<IconTrash />}
|
||||
style={{ padding: 5 }}
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
const TableList: FC<Props> = ({
|
||||
selectedSchema,
|
||||
onSelectSchema = () => {},
|
||||
onAddTable = () => {},
|
||||
onEditTable = () => {},
|
||||
onDeleteTable = () => {},
|
||||
onOpenTable = () => {},
|
||||
}) => {
|
||||
const { meta } = useStore()
|
||||
|
||||
const [filterString, setFilterString] = useState<string>('')
|
||||
|
||||
const schemas: PostgresSchema[] = meta.schemas.list()
|
||||
const [protectedSchemas, openSchemas] = partition(schemas, (schema) =>
|
||||
meta.excludedSchemas.includes(schema?.name ?? '')
|
||||
)
|
||||
|
||||
const allTables: PostgresTable[] = meta.tables.list(
|
||||
(table: PostgresTable) => table.schema === selectedSchema
|
||||
)
|
||||
const tables =
|
||||
filterString.length === 0
|
||||
? allTables
|
||||
: // @ts-ignore
|
||||
allTables.filter((table: PostgresTable) => table.name.includes(filterString))
|
||||
|
||||
// @ts-ignore
|
||||
const schema = schemas.find((schema) => schema.name === selectedSchema)
|
||||
const isLocked = protectedSchemas.some((s) => s.id === schema?.id)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="w-[230px]">
|
||||
<Listbox
|
||||
size="small"
|
||||
value={selectedSchema}
|
||||
onChange={onSelectSchema}
|
||||
icon={isLocked && <IconLock size={16} strokeWidth={2} />}
|
||||
>
|
||||
<Listbox.Option disabled key="normal-schemas" value="normal-schemas" label="Schemas">
|
||||
<p className="text-sm">Schemas</p>
|
||||
</Listbox.Option>
|
||||
{/* @ts-ignore */}
|
||||
{openSchemas.map((schema) => (
|
||||
<Listbox.Option
|
||||
key={schema.id}
|
||||
value={schema.name}
|
||||
label={schema.name}
|
||||
addOnBefore={() => <span className="text-scale-900">schema</span>}
|
||||
>
|
||||
<span className="text-scale-1200 text-sm">{schema.name}</span>
|
||||
</Listbox.Option>
|
||||
))}
|
||||
<Listbox.Option
|
||||
disabled
|
||||
key="protected-schemas"
|
||||
value="protected-schemas"
|
||||
label="Protected schemas"
|
||||
>
|
||||
<p className="text-sm">Protected schemas</p>
|
||||
</Listbox.Option>
|
||||
{protectedSchemas.map((schema) => (
|
||||
<Listbox.Option
|
||||
key={schema.id}
|
||||
value={schema.name}
|
||||
label={schema.name}
|
||||
addOnBefore={() => <span className="text-scale-900">schema</span>}
|
||||
>
|
||||
<span className="text-scale-1200 text-sm">{schema.name}</span>
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
size="small"
|
||||
placeholder="Filter tables"
|
||||
value={filterString}
|
||||
onChange={(e: any) => setFilterString(e.target.value)}
|
||||
icon={<IconSearch size="tiny" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!isLocked && (
|
||||
<div>
|
||||
<Button icon={<IconPlus />} onClick={() => onAddTable()}>
|
||||
New table
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
)
|
||||
</div>
|
||||
{tables.length === 0 ? (
|
||||
<NoSearchResults />
|
||||
) : (
|
||||
<div className="my-4 w-full">
|
||||
<Table
|
||||
head={[
|
||||
<Table.th key="name">Name</Table.th>,
|
||||
<Table.th key="description" className="hidden lg:table-cell">
|
||||
Description
|
||||
</Table.th>,
|
||||
<Table.th key="rows" className="hidden xl:table-cell">
|
||||
Rows (Estimated)
|
||||
</Table.th>,
|
||||
<Table.th key="size" className="hidden xl:table-cell">
|
||||
Size (Estimated)
|
||||
</Table.th>,
|
||||
<Table.th key="buttons"></Table.th>,
|
||||
]}
|
||||
body={tables.map((x: any, i: any) => (
|
||||
<Table.tr key={x.id}>
|
||||
<Table.td>
|
||||
<p title={x.name}>{x.name}</p>
|
||||
</Table.td>
|
||||
<Table.td className="hidden max-w-sm truncate lg:table-cell break-all whitespace-normal">
|
||||
{x.comment !== null ? (
|
||||
<p title={x.comment}>{x.comment}</p>
|
||||
) : (
|
||||
<p className="text-scale-800">No description</p>
|
||||
)}
|
||||
</Table.td>
|
||||
<Table.td className="hidden xl:table-cell">
|
||||
<code className="text-sm">{x.live_rows_estimate ?? x.live_row_count}</code>
|
||||
</Table.td>
|
||||
<Table.td className="hidden xl:table-cell">
|
||||
<code className="text-sm">{x.size}</code>
|
||||
</Table.td>
|
||||
<Table.td>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="default"
|
||||
iconRight={<IconColumns />}
|
||||
className="whitespace-nowrap hover:border-gray-500"
|
||||
style={{ paddingTop: 3, paddingBottom: 3 }}
|
||||
onClick={() => onOpenTable(x)}
|
||||
>
|
||||
{x.columns.length} columns
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<IconEdit3 />}
|
||||
style={{ padding: 5 }}
|
||||
disabled={isLocked}
|
||||
onClick={() => onEditTable(x)}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<IconTrash />}
|
||||
style={{ padding: 5 }}
|
||||
disabled={isLocked}
|
||||
onClick={() => onDeleteTable(x)}
|
||||
/>
|
||||
</div>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default TableList
|
||||
export default observer(TableList)
|
||||
@@ -10,7 +10,6 @@ import PublicationsList from './Publications/PublicationsList'
|
||||
import PublicationsTables from './Publications/PublicationsTables'
|
||||
|
||||
import BackupsList from './Backups/BackupsList'
|
||||
import PITRBackupSelection from './Backups/PITRBackupSelection'
|
||||
|
||||
import ConnectionPooling from './Pooling/ConnectionPooling'
|
||||
|
||||
@@ -29,7 +28,6 @@ export {
|
||||
PublicationsList,
|
||||
PublicationsTables,
|
||||
BackupsList,
|
||||
PITRBackupSelection,
|
||||
ConnectionPooling,
|
||||
CreateTrigger,
|
||||
DeleteTrigger,
|
||||
|
||||
@@ -30,7 +30,7 @@ const ExampleProject: FC<Props> = ({ framework, title, description, url }) => {
|
||||
<img
|
||||
className="transition-all group-hover:scale-110"
|
||||
src={`/img/libraries/${framework.toLowerCase()}${
|
||||
framework.toLowerCase() == 'nextjs' ? (isDarkTheme ? '-dark' : '') : ''
|
||||
['expo', 'nextjs'].includes(framework.toLowerCase()) ? (isDarkTheme ? '-dark' : '') : ''
|
||||
}-icon.svg`}
|
||||
alt={`${framework} logo`}
|
||||
width={26}
|
||||
|
||||
@@ -17,7 +17,6 @@ const APIKeys = () => {
|
||||
|
||||
const availableLanguages = [
|
||||
{ name: 'Javascript', key: 'js' },
|
||||
{ name: 'Python', key: 'python' },
|
||||
{ name: 'Dart', key: 'dart' },
|
||||
]
|
||||
const [selectedLanguage, setSelectedLanguage] = useState(availableLanguages[0])
|
||||
|
||||
@@ -2,7 +2,14 @@ import Link from 'next/link'
|
||||
import { FC } from 'react'
|
||||
import SVG from 'react-inlinesvg'
|
||||
import { useRouter } from 'next/router'
|
||||
import { Button, IconKey, IconArchive, IconExternalLink, IconCode } from '@supabase/ui'
|
||||
import {
|
||||
Button,
|
||||
IconKey,
|
||||
IconArchive,
|
||||
IconExternalLink,
|
||||
IconCode,
|
||||
IconActivity,
|
||||
} from '@supabase/ui'
|
||||
|
||||
import Panel from 'components/ui/Panel'
|
||||
import APIKeys from './APIKeys'
|
||||
@@ -18,7 +25,7 @@ const NewProjectPanel: FC<Props> = ({}) => {
|
||||
<div className="grid grid-cols-12 gap-4 lg:gap-20">
|
||||
<div className="col-span-12">
|
||||
<div className="flex flex-col space-y-20">
|
||||
<div className="flex flex-col justify-between h-full">
|
||||
<div className="flex h-full flex-col justify-between">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xl text-scale-1200">Welcome to your new project</h3>
|
||||
<p className="text-base text-scale-1100">
|
||||
@@ -29,7 +36,7 @@ const NewProjectPanel: FC<Props> = ({}) => {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
<div className="col-span-12 lg:col-span-7 space-y-8 flex flex-col justify-center">
|
||||
<div className="col-span-12 flex flex-col justify-center space-y-8 lg:col-span-7">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xl text-scale-1200">
|
||||
Get started by building out your database
|
||||
@@ -40,7 +47,7 @@ const NewProjectPanel: FC<Props> = ({}) => {
|
||||
you need something more.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link href={`/project/${ref}/editor`}>
|
||||
<a>
|
||||
<Button
|
||||
@@ -91,7 +98,7 @@ const NewProjectPanel: FC<Props> = ({}) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-between h-full space-y-6">
|
||||
<div className="flex h-full flex-col justify-between space-y-6">
|
||||
<div className="max-w-2xl space-y-2">
|
||||
<h3 className="text-xl text-scale-1200">Explore our other products</h3>
|
||||
<p className="text-base text-scale-1100">
|
||||
@@ -99,21 +106,21 @@ const NewProjectPanel: FC<Props> = ({}) => {
|
||||
it completely, or just the features you need.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 lg:gap-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 md:gap-4 md:gap-y-0 xl:grid-cols-4">
|
||||
<Panel>
|
||||
<Panel.Content className="flex flex-col space-y-4 lg:h-40">
|
||||
<Panel.Content className="flex flex-col space-y-4 md:px-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="bg-scale-600 text-scale-1000 shadow-sm rounded p-1.5">
|
||||
<div className="rounded bg-scale-600 p-1.5 text-scale-1000 shadow-sm">
|
||||
<IconKey strokeWidth={2} size={16} />
|
||||
</div>
|
||||
<h5>Authentication</h5>
|
||||
</div>
|
||||
<div className="flex flex-grow">
|
||||
<p className="text-scale-1000 text-sm">
|
||||
<div className="flex flex-grow md:min-h-[50px] xl:min-h-[75px]">
|
||||
<p className="text-sm text-scale-1000">
|
||||
A complete user management system that works without any additional tools.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-x-2 flex items-center">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Link href={`/project/${ref}/auth/users`}>
|
||||
<a>
|
||||
<Button type="default">Explore Auth</Button>
|
||||
@@ -135,19 +142,19 @@ const NewProjectPanel: FC<Props> = ({}) => {
|
||||
</Panel>
|
||||
|
||||
<Panel>
|
||||
<Panel.Content className="flex flex-col space-y-4 lg:h-40">
|
||||
<Panel.Content className="flex flex-col space-y-4 md:px-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="bg-scale-600 text-scale-1000 shadow-sm rounded p-1.5">
|
||||
<div className="rounded bg-scale-600 p-1.5 text-scale-1000 shadow-sm">
|
||||
<IconArchive strokeWidth={2} size={16} />
|
||||
</div>
|
||||
<h5>Storage</h5>
|
||||
</div>
|
||||
<div className="flex flex-grow">
|
||||
<p className="text-scale-1000 text-sm">
|
||||
<div className="flex md:min-h-[50px] xl:min-h-[75px]">
|
||||
<p className="text-sm text-scale-1000">
|
||||
Store, organize, and serve any file types of any size from multiple buckets.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-x-2 flex items-center">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Link href={`/project/${ref}/storage/buckets`}>
|
||||
<a>
|
||||
<Button type="default">Explore Storage</Button>
|
||||
@@ -169,20 +176,20 @@ const NewProjectPanel: FC<Props> = ({}) => {
|
||||
</Panel>
|
||||
|
||||
<Panel>
|
||||
<Panel.Content className="flex flex-col space-y-4 lg:h-40">
|
||||
<Panel.Content className="flex flex-col space-y-4 md:px-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="bg-scale-600 text-scale-1000 shadow-sm rounded p-1.5">
|
||||
<div className="rounded bg-scale-600 p-1.5 text-scale-1000 shadow-sm">
|
||||
<IconCode strokeWidth={2} size={16} />
|
||||
</div>
|
||||
<h5>Edge Functions</h5>
|
||||
</div>
|
||||
<div className="flex flex-grow">
|
||||
<p className="text-scale-1000 text-sm">
|
||||
<div className="flex md:min-h-[50px] xl:min-h-[75px]">
|
||||
<p className="text-sm text-scale-1000">
|
||||
Write custom code without deploying or scaling servers, with fast deploy times
|
||||
and low latency.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-x-2 flex items-center">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Link href={`/project/${ref}/functions`}>
|
||||
<a>
|
||||
<Button type="default">Explore Functions</Button>
|
||||
@@ -202,6 +209,34 @@ const NewProjectPanel: FC<Props> = ({}) => {
|
||||
</div>
|
||||
</Panel.Content>
|
||||
</Panel>
|
||||
<Panel>
|
||||
<Panel.Content className="flex flex-col space-y-4 md:px-3">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="rounded bg-scale-600 p-1.5 text-scale-1000 shadow-sm">
|
||||
<IconActivity strokeWidth={2} size={16} />
|
||||
</div>
|
||||
<h5>Realtime</h5>
|
||||
</div>
|
||||
<div className="flex md:min-h-[50px] xl:min-h-[75px]">
|
||||
<p className="text-sm text-scale-1000">
|
||||
Listen to your to PostgreSQL database in realtime via websockets.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Link href="https://supabase.com/docs/guides/realtime">
|
||||
<a>
|
||||
<Button
|
||||
className="translate-y-[1px]"
|
||||
icon={<IconExternalLink size={14} />}
|
||||
type="default"
|
||||
>
|
||||
About Realtime
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</Panel.Content>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -211,14 +246,14 @@ const NewProjectPanel: FC<Props> = ({}) => {
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xl text-scale-1200">Connecting to your new project</h3>
|
||||
<p className="text-base lg:max-w-sm text-scale-1100">
|
||||
<p className="text-base text-scale-1100 lg:max-w-sm">
|
||||
Interact with your database through the{' '}
|
||||
<Link href="https://supabase.com/docs/reference">
|
||||
<a className="text-brand-900">Supabase client libraries</a>
|
||||
</Link>{' '}
|
||||
with your API keys.
|
||||
</p>
|
||||
<p className="text-base lg:max-w-sm text-scale-1100">
|
||||
<p className="text-base text-scale-1100 lg:max-w-sm">
|
||||
More information about your project's keys can be found in your project's API
|
||||
settings.
|
||||
</p>
|
||||
|
||||
@@ -153,7 +153,7 @@ const LogTable = ({
|
||||
}, [stringData])
|
||||
|
||||
const LogsExplorerTableHeader = () => (
|
||||
<div className="w-full bg-scale-100 dark:bg-scale-300 rounded-tl rounded-tr border-t border-l border-r flex items-center justify-between px-5 py-2">
|
||||
<div className="flex w-full items-center justify-between rounded-tl rounded-tr border-t border-l border-r bg-scale-100 px-5 py-2 dark:bg-scale-300">
|
||||
<div className="flex items-center gap-2">
|
||||
{data && data.length ? (
|
||||
<>
|
||||
@@ -194,7 +194,7 @@ const LogTable = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center px-5 w-1/2">
|
||||
<div className="flex w-1/2 justify-center px-5">
|
||||
<Alert variant="danger" title="Sorry! An error occured when fetching data." withIcon>
|
||||
<Renderer {...childProps} />
|
||||
</Alert>
|
||||
@@ -203,11 +203,11 @@ const LogTable = ({
|
||||
}
|
||||
|
||||
const renderNoResultAlert = () => (
|
||||
<div className="flex flex-col items-center gap-6 text-center mt-16 opacity-100 scale-100 justify-center">
|
||||
<div className="mt-16 flex scale-100 flex-col items-center justify-center gap-6 text-center opacity-100">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="relative border border-scale-600 border-dashed dark:border-scale-900 w-32 h-4 rounded px-2 flex items-center"></div>
|
||||
<div className="relative border border-scale-600 border-dashed dark:border-scale-900 w-32 h-4 rounded px-2 flex items-center">
|
||||
<div className="absolute right-1 -bottom-4">
|
||||
<div className="relative flex h-4 w-32 items-center rounded border border-dashed border-scale-600 px-2 dark:border-scale-900"></div>
|
||||
<div className="relative flex h-4 w-32 items-center rounded border border-dashed border-scale-600 px-2 dark:border-scale-900">
|
||||
<div className="absolute right-1 -bottom-4 text-scale-1100">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-6 w-6"
|
||||
@@ -235,16 +235,16 @@ const LogTable = ({
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
className={'flex flex-col w-full ' + (!queryType ? 'shadow-lg' : '')}
|
||||
className={'flex w-full flex-col ' + (!queryType ? 'shadow-lg' : '')}
|
||||
style={{ maxHeight }}
|
||||
>
|
||||
{!queryType && <LogsExplorerTableHeader />}
|
||||
<div className={`flex flex-row h-full ${!queryType ? 'border-l border-r' : ''}`}>
|
||||
<div className={`flex h-full flex-row ${!queryType ? 'border-l border-r' : ''}`}>
|
||||
<DataGrid
|
||||
style={{ height: '100%' }}
|
||||
className={`
|
||||
flex-grow flex-1
|
||||
${!queryType ? 'data-grid--logs-explorer' : ' data-grid--simple-logs'}
|
||||
flex-1 flex-grow
|
||||
${!queryType ? 'data-grid--logs-explorer' : ' data-grid--simple-logs'}
|
||||
`}
|
||||
rowHeight={40}
|
||||
headerRowHeight={queryType ? 0 : 28}
|
||||
@@ -254,7 +254,7 @@ const LogTable = ({
|
||||
}}
|
||||
noRowsFallback={
|
||||
!isLoading ? (
|
||||
<div className="transition-all duration-500 delay-200 py-4 flex w-full h-full justify-center items-center mx-auto space-y-12">
|
||||
<div className="mx-auto flex h-full w-full items-center justify-center space-y-12 py-4 transition-all delay-200 duration-500">
|
||||
{!error && renderNoResultAlert()}
|
||||
{error && renderErrorAlert()}
|
||||
</div>
|
||||
@@ -276,10 +276,10 @@ const LogTable = ({
|
||||
<div
|
||||
className={
|
||||
queryType
|
||||
? 'w-1/2 flex flex-col'
|
||||
? 'flex w-1/2 flex-col'
|
||||
: focusedLog
|
||||
? 'w-1/2 flex flex-col'
|
||||
: 'w-0 hidden'
|
||||
? 'flex w-1/2 flex-col'
|
||||
: 'hidden w-0'
|
||||
}
|
||||
>
|
||||
<LogSelection
|
||||
|
||||
@@ -57,15 +57,10 @@ export const uploadAttachments = async (ref: string, files: File[]) => {
|
||||
}
|
||||
|
||||
export const formatMessage = (message: string, attachments: string[]) => {
|
||||
const formattedMessage = message.replace(/\n/g, '<br/>')
|
||||
if (attachments.length > 0) {
|
||||
const attachmentsImg = attachments.map(
|
||||
(url) => `<img src=${url} width="200" style="margin-right:10px" />`
|
||||
)
|
||||
return `${formattedMessage}<br/><hr/><b>Uploaded attachments:</b><br/><div style="display:flex;align-items:center">${attachmentsImg.join(
|
||||
''
|
||||
)}</div>`
|
||||
const attachmentsImg = attachments.map((url) => `\n${url}`)
|
||||
return `${message}\n${attachmentsImg.join('')}`
|
||||
} else {
|
||||
return formattedMessage
|
||||
return message
|
||||
}
|
||||
}
|
||||
@@ -123,7 +123,7 @@ const ColumnEditor: FC<Props> = ({
|
||||
if (isEmpty(errors)) {
|
||||
const payload = isNewRecord
|
||||
? generateCreateColumnPayload(selectedTable.id, columnFields)
|
||||
: generateUpdateColumnPayload(column!, columnFields)
|
||||
: generateUpdateColumnPayload(column!, selectedTable, columnFields)
|
||||
const foreignKey = columnFields.foreignKey
|
||||
? { ...columnFields.foreignKey, source_column_name: columnFields.name }
|
||||
: undefined
|
||||
@@ -178,6 +178,16 @@ const ColumnEditor: FC<Props> = ({
|
||||
value={columnFields?.comment ?? ''}
|
||||
onChange={(event: any) => onUpdateField({ comment: event.target.value })}
|
||||
/>
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
<div className="col-span-12 md:col-span-8 md:col-start-5">
|
||||
<Checkbox
|
||||
label="Is Primary Key"
|
||||
description="A primary key indicates that a column or group of columns can be used as a unique identifier for rows in the table."
|
||||
checked={columnFields?.isPrimaryKey ?? false}
|
||||
onChange={() => onUpdateField({ isPrimaryKey: !columnFields?.isPrimaryKey })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SidePanel.Content>
|
||||
|
||||
@@ -185,18 +195,6 @@ const ColumnEditor: FC<Props> = ({
|
||||
|
||||
<SidePanel.Content>
|
||||
<div className="space-y-10 py-6">
|
||||
{isNewRecord && !hasPrimaryKey && (
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
<div className="col-span-12 md:col-span-8 md:col-start-5">
|
||||
<Checkbox
|
||||
label={hasPrimaryKey ? 'Add to composite primary key' : 'Is Primary Key'}
|
||||
description="A primary key indicates that a column or group of columns can be used as a unique identifier for rows in the table."
|
||||
checked={columnFields?.isPrimaryKey ?? false}
|
||||
onChange={() => onUpdateField({ isPrimaryKey: !columnFields?.isPrimaryKey })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ColumnForeignKey
|
||||
column={columnFields}
|
||||
originalForeignKey={originalForeignKey}
|
||||
|
||||
@@ -105,8 +105,13 @@ export const generateCreateColumnPayload = (
|
||||
|
||||
export const generateUpdateColumnPayload = (
|
||||
originalColumn: PostgresColumn,
|
||||
table: PostgresTable,
|
||||
field: ColumnField
|
||||
): Partial<UpdateColumnPayload> => {
|
||||
// @ts-ignore
|
||||
const primaryKeyColumns = table.primary_keys.map((key) => key.name)
|
||||
const isOriginallyPrimaryKey = primaryKeyColumns.includes(originalColumn.name)
|
||||
|
||||
// Only append the properties which are getting updated
|
||||
const defaultValue = field.defaultValue
|
||||
const type = field.isArray ? `${field.format}[]` : field.format
|
||||
@@ -138,6 +143,9 @@ export const generateUpdateColumnPayload = (
|
||||
if (!isEqual(originalColumn.is_unique, field.isUnique)) {
|
||||
payload.isUnique = field.isUnique
|
||||
}
|
||||
if (!isEqual(isOriginallyPrimaryKey, field.isPrimaryKey)) {
|
||||
payload.isPrimaryKey = field.isPrimaryKey
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
@@ -222,6 +230,11 @@ export const unescapeLiteral = (value: string) => {
|
||||
return `${splits[0].toLowerCase()}')`
|
||||
}
|
||||
|
||||
// Handle nextval
|
||||
if (value.toLowerCase().includes('nextval')) {
|
||||
return `${splits[0]}')`
|
||||
}
|
||||
|
||||
// Handle json
|
||||
if (value.toLowerCase().includes('json')) {
|
||||
return splits[0].slice(1)
|
||||
|
||||
@@ -28,7 +28,7 @@ const InputField: FC<Props> = ({
|
||||
<Select
|
||||
size="medium"
|
||||
layout="horizontal"
|
||||
value={field.value}
|
||||
value={field.value ?? ''}
|
||||
label={field.name}
|
||||
labelOptional={field.format}
|
||||
descriptionText={field.comment}
|
||||
@@ -51,7 +51,7 @@ const InputField: FC<Props> = ({
|
||||
<Input
|
||||
layout="horizontal"
|
||||
label={field.name}
|
||||
value={field.value}
|
||||
value={field.value ?? ''}
|
||||
// @ts-ignore This is creating some validateDOMNesting errors
|
||||
// because descriptionText is a <p> element as a parent
|
||||
descriptionText={
|
||||
@@ -73,7 +73,7 @@ const InputField: FC<Props> = ({
|
||||
onChange={(event: any) => onUpdateField({ [field.name]: event.target.value })}
|
||||
actions={
|
||||
<Button
|
||||
disabled={field.value.length === 0}
|
||||
disabled={field.value === null || field.value?.length === 0}
|
||||
type="default"
|
||||
htmlType="button"
|
||||
onClick={onViewForeignKey}
|
||||
@@ -98,7 +98,7 @@ const InputField: FC<Props> = ({
|
||||
disabled={!isEditable}
|
||||
error={errors[field.name]}
|
||||
rows={5}
|
||||
value={field.value}
|
||||
value={field.value ?? ''}
|
||||
placeholder={
|
||||
typeof field.defaultValue === 'string' && field.defaultValue.length === 0
|
||||
? 'Default: Empty string'
|
||||
@@ -114,7 +114,7 @@ const InputField: FC<Props> = ({
|
||||
return (
|
||||
<Input
|
||||
layout="horizontal"
|
||||
value={field.value}
|
||||
value={field.value ?? ''}
|
||||
label={field.name}
|
||||
descriptionText={field.comment}
|
||||
labelOptional={field.format}
|
||||
@@ -141,7 +141,7 @@ const InputField: FC<Props> = ({
|
||||
<DateTimeInput
|
||||
name={field.name}
|
||||
format={field.format}
|
||||
value={field.value}
|
||||
value={field.value ?? ''}
|
||||
description={field.comment}
|
||||
onChange={(value: any) => onUpdateField({ [field.name]: value })}
|
||||
/>
|
||||
@@ -155,7 +155,7 @@ const InputField: FC<Props> = ({
|
||||
descriptionText={field.comment}
|
||||
labelOptional={field.format}
|
||||
error={errors[field.name]}
|
||||
value={field.value}
|
||||
value={field.value ?? ''}
|
||||
placeholder={field.isIdentity ? 'Automatically generated as identity' : field.defaultValue}
|
||||
disabled={!isEditable}
|
||||
onChange={(event: any) => onUpdateField({ [field.name]: event.target.value })}
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface RowField {
|
||||
comment: string
|
||||
format: string
|
||||
enums: string[]
|
||||
value: string
|
||||
value: string | null
|
||||
defaultValue: string
|
||||
foreignKey?: PostgresRelationship
|
||||
isNullable: boolean
|
||||
|
||||
@@ -60,16 +60,16 @@ export const generateRowFields = (
|
||||
export const validateFields = (fields: RowField[]) => {
|
||||
const errors = {} as any
|
||||
fields.forEach((field) => {
|
||||
if (field.format.startsWith('_') && field.value?.length > 0) {
|
||||
if (field.format.startsWith('_') && (field.value?.length ?? 0) > 0) {
|
||||
try {
|
||||
minifyJSON(field.value)
|
||||
minifyJSON(field.value ?? '')
|
||||
} catch {
|
||||
errors[field.name] = 'Invalid array'
|
||||
}
|
||||
}
|
||||
if (field.format.includes('json') && field.value?.length > 0) {
|
||||
if (field.format.includes('json') && (field.value?.length ?? 0) > 0) {
|
||||
try {
|
||||
minifyJSON(field.value)
|
||||
minifyJSON(field.value ?? '')
|
||||
} catch {
|
||||
errors[field.name] = 'Invalid JSON'
|
||||
}
|
||||
|
||||
@@ -120,7 +120,11 @@ const SidePanelEditor: FC<Props> = ({
|
||||
resolve: any
|
||||
) => {
|
||||
const response = isNewRecord
|
||||
? await meta.createColumn(payload as CreateColumnPayload, foreignKey)
|
||||
? await meta.createColumn(
|
||||
payload as CreateColumnPayload,
|
||||
selectedTable as PostgresTable,
|
||||
foreignKey
|
||||
)
|
||||
: await meta.updateColumn(
|
||||
configuration.columnId as string,
|
||||
payload as UpdateColumnPayload,
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface UpdateColumnPayload {
|
||||
isIdentity?: boolean
|
||||
isNullable?: boolean
|
||||
isUnique?: boolean
|
||||
isPrimaryKey?: boolean
|
||||
identityGeneration?: 'BY DEFAULT' | 'ALWAYS'
|
||||
}
|
||||
|
||||
|
||||
@@ -231,9 +231,9 @@ const Column: FC<Props> = ({
|
||||
</div>
|
||||
{!hasImportContent && (
|
||||
<div className="flex w-[5%] justify-end">
|
||||
<div className="cursor-pointer" onClick={() => onRemoveColumn()}>
|
||||
<button className="cursor-pointer" onClick={() => onRemoveColumn()}>
|
||||
<IconX strokeWidth={1} />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import Link from 'next/link'
|
||||
import React, { FC, useState } from 'react'
|
||||
import { partition, isEmpty, isUndefined } from 'lodash'
|
||||
import { Alert, Button, IconEdit, IconHelpCircle, IconKey, IconTrash } from '@supabase/ui'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
IconEdit,
|
||||
IconExternalLink,
|
||||
IconHelpCircle,
|
||||
IconKey,
|
||||
IconTrash,
|
||||
} from '@supabase/ui'
|
||||
import {
|
||||
PostgresTable,
|
||||
PostgresColumn,
|
||||
@@ -41,7 +50,7 @@ const ColumnManagement: FC<Props> = ({
|
||||
tables = [],
|
||||
columns = [],
|
||||
enumTypes = [],
|
||||
importContent = {},
|
||||
importContent,
|
||||
isNewRecord,
|
||||
onColumnsUpdated = () => {},
|
||||
onSelectImportData = () => {},
|
||||
@@ -316,11 +325,17 @@ const ColumnManagement: FC<Props> = ({
|
||||
Add column
|
||||
</Button>
|
||||
)}
|
||||
<p className='text-sm text-scale-1100'>
|
||||
<a href="https://supabase.com/docs/guides/database/tables#data-types" target="_blank" className="underline flex items-center gap-1">
|
||||
Learn more about data types
|
||||
<Link href="https://supabase.com/docs/guides/database/tables#data-types">
|
||||
<a>
|
||||
<Button
|
||||
type="text"
|
||||
className="text-scale-1000 hover:text-scale-1200"
|
||||
icon={<IconExternalLink size={12} strokeWidth={2} />}
|
||||
>
|
||||
Learn more about data types
|
||||
</Button>
|
||||
</a>
|
||||
</p>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<ForeignKeySelector
|
||||
|
||||
@@ -39,76 +39,75 @@ export const generateDatabaseMenu = (project?: Project): ProductMenuGroup[] => {
|
||||
},
|
||||
...(IS_PLATFORM
|
||||
? [
|
||||
{
|
||||
title: 'Logs and Usage',
|
||||
items: [
|
||||
{
|
||||
name: 'API logs',
|
||||
key: 'api-logs',
|
||||
url: `/project/${ref}/database/api-logs`,
|
||||
items: [],
|
||||
},
|
||||
...(reportsOverview
|
||||
? [
|
||||
{
|
||||
name: 'API usage',
|
||||
key: 'api-usage',
|
||||
url: `/project/${ref}/database/api-usage`,
|
||||
items: [],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: 'Postgres logs',
|
||||
key: 'postgres-logs',
|
||||
url: `/project/${ref}/database/postgres-logs`,
|
||||
items: [],
|
||||
},
|
||||
...(logsRealtime
|
||||
? [
|
||||
{
|
||||
name: 'Realtime logs',
|
||||
key: 'realtime-logs',
|
||||
url: `/project/${ref}/database/realtime-logs`,
|
||||
items: [],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
]
|
||||
{
|
||||
title: 'Logs and Usage',
|
||||
items: [
|
||||
{
|
||||
name: 'API logs',
|
||||
key: 'api-logs',
|
||||
url: `/project/${ref}/database/api-logs`,
|
||||
items: [],
|
||||
},
|
||||
...(reportsOverview
|
||||
? [
|
||||
{
|
||||
name: 'API usage',
|
||||
key: 'api-usage',
|
||||
url: `/project/${ref}/database/api-usage`,
|
||||
items: [],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: 'Postgres logs',
|
||||
key: 'postgres-logs',
|
||||
url: `/project/${ref}/database/postgres-logs`,
|
||||
items: [],
|
||||
},
|
||||
...(logsRealtime
|
||||
? [
|
||||
{
|
||||
name: 'Realtime logs',
|
||||
key: 'realtime-logs',
|
||||
url: `/project/${ref}/database/realtime-logs`,
|
||||
items: [],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(IS_PLATFORM
|
||||
? [
|
||||
{
|
||||
title: 'Alpha Preview',
|
||||
isPreview: true,
|
||||
items: [
|
||||
{
|
||||
name: 'Triggers',
|
||||
key: 'triggers',
|
||||
url: `/project/${ref}/database/triggers`,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
name: 'Functions',
|
||||
key: 'functions',
|
||||
url: `/project/${ref}/database/functions`,
|
||||
items: [],
|
||||
},
|
||||
...(showHooksRoute
|
||||
? [
|
||||
{
|
||||
name: 'Database Webhooks',
|
||||
key: 'hooks',
|
||||
url: `/project/${ref}/database/hooks`,
|
||||
items: [],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
]
|
||||
{
|
||||
title: 'Alpha Preview',
|
||||
items: [
|
||||
{
|
||||
name: 'Database Triggers',
|
||||
key: 'triggers',
|
||||
url: `/project/${ref}/database/triggers`,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
name: 'Database Functions',
|
||||
key: 'functions',
|
||||
url: `/project/${ref}/database/functions`,
|
||||
items: [],
|
||||
},
|
||||
...(showHooksRoute
|
||||
? [
|
||||
{
|
||||
name: 'Database Webhooks',
|
||||
key: 'hooks',
|
||||
url: `/project/${ref}/database/hooks`,
|
||||
items: [],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
}
|
||||
@@ -12,9 +12,12 @@ import { getWithTimeout } from 'lib/common/fetch'
|
||||
import { Project } from 'types'
|
||||
import { DisplayApiSettings, DisplayConfigSettings } from 'components/ui/ProjectSettings'
|
||||
|
||||
type ProjectBuildingState = { project: Project }
|
||||
const ProjectBuildingState: FC<ProjectBuildingState> = ({ project }) => {
|
||||
const { app } = useStore()
|
||||
interface Props {
|
||||
project: Project
|
||||
}
|
||||
|
||||
const BuildingState: FC<Props> = ({ project }) => {
|
||||
const { ui, app } = useStore()
|
||||
const checkServerInterval = useRef<number>()
|
||||
|
||||
async function checkServer() {
|
||||
@@ -150,7 +153,7 @@ const ProjectBuildingState: FC<ProjectBuildingState> = ({ project }) => {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default observer(ProjectBuildingState)
|
||||
export default observer(BuildingState)
|
||||
|
||||
const ChecklistItem = ({ description }: any) => {
|
||||
return (
|
||||
|
||||
@@ -12,6 +12,7 @@ import LayoutHeader from './LayoutHeader'
|
||||
import ConnectingState from './ConnectingState'
|
||||
import PausingState from './PausingState'
|
||||
import BuildingState from './BuildingState'
|
||||
import RestoringState from './RestoringState'
|
||||
|
||||
interface Props {
|
||||
title?: string
|
||||
@@ -112,6 +113,7 @@ const ContentWrapper: FC<ContentWrapperProps> = observer(({ isLoading, children
|
||||
const requiresDbConnection: boolean = router.pathname !== '/project/[ref]/settings/general'
|
||||
const requiresPostgrestConnection = !routesToIgnorePostgrestConnection.includes(router.pathname)
|
||||
|
||||
const isProjectRestoring = ui.selectedProject?.status === PROJECT_STATUS.RESTORING
|
||||
const isProjectBuilding = [PROJECT_STATUS.COMING_UP, PROJECT_STATUS.RESTORING].includes(
|
||||
ui.selectedProject?.status ?? ''
|
||||
)
|
||||
@@ -126,6 +128,8 @@ const ContentWrapper: FC<ContentWrapperProps> = observer(({ isLoading, children
|
||||
<PausingState project={ui.selectedProject} />
|
||||
) : requiresPostgrestConnection && isProjectOffline ? (
|
||||
<ConnectingState project={ui.selectedProject} />
|
||||
) : requiresDbConnection && isProjectRestoring ? (
|
||||
<RestoringState />
|
||||
) : requiresDbConnection && isProjectBuilding ? (
|
||||
<BuildingState project={ui.selectedProject} />
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { FC, useEffect, useRef, useState } from 'react'
|
||||
import { Button, IconCheckCircle, IconLoader } from '@supabase/ui'
|
||||
|
||||
import { useStore } from 'hooks'
|
||||
import { API_URL, PROJECT_STATUS } from 'lib/constants'
|
||||
import { getWithTimeout } from 'lib/common/fetch'
|
||||
|
||||
interface Props {}
|
||||
|
||||
const RestoringState: FC<Props> = ({}) => {
|
||||
const { app, ui } = useStore()
|
||||
const project = ui.selectedProject
|
||||
const checkServerInterval = useRef<number>()
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [isCompleted, setIsCompleted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
checkServerInterval.current = window.setInterval(checkServer, 4000)
|
||||
return () => clearInterval(checkServerInterval.current)
|
||||
}, [])
|
||||
|
||||
async function checkServer() {
|
||||
if (!project) return
|
||||
|
||||
const projectStatus = await getWithTimeout(`${API_URL}/projects/${project.ref}/status`, {
|
||||
timeout: 2000,
|
||||
})
|
||||
if (projectStatus && !projectStatus.error) {
|
||||
const { status } = projectStatus
|
||||
if (status === PROJECT_STATUS.ACTIVE_HEALTHY) {
|
||||
clearInterval(checkServerInterval.current)
|
||||
setIsCompleted(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onConfirm = async () => {
|
||||
setLoading(true)
|
||||
await app.projects.fetchDetail(project?.ref ?? '')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div
|
||||
className={[
|
||||
'grid grid-cols-12 gap-4',
|
||||
'bg-scale-300 border border-scale-400 rounded-md w-3/4 lg:w-1/2 px-8 py-6',
|
||||
].join(' ')}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<>
|
||||
<div className="col-span-1">
|
||||
<IconCheckCircle className="text-brand-900" strokeWidth={2} />
|
||||
</div>
|
||||
<div className="col-span-11 space-y-1">
|
||||
<p>Restoration complete!</p>
|
||||
<p className="text-sm text-scale-1100">
|
||||
Your project has been successfully restored and is now back online.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="col-span-1">
|
||||
<IconLoader className="animate-spin" />
|
||||
</div>
|
||||
<div className="col-span-11 space-y-1">
|
||||
<p>Restoration in progress</p>
|
||||
<p className="text-sm text-scale-1100">
|
||||
Restoration can take from a few minutes up to several hours depending on the size of
|
||||
your database. Your project will be offline while the restoration is running.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{isCompleted && (
|
||||
<div className="col-start-2 col-span-11">
|
||||
<Button disabled={loading} loading={loading} onClick={onConfirm}>
|
||||
Return to project
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RestoringState
|
||||
@@ -104,8 +104,8 @@ const TableEditorMenu: FC<Props> = ({
|
||||
<div className="my-6 mx-4 flex flex-grow flex-col space-y-6">
|
||||
{/* Schema selection dropdown */}
|
||||
<div className="px-3">
|
||||
{meta.schemas.isLoading ? (
|
||||
<div className="flex h-[30px] items-center space-x-3 rounded border border-gray-500 px-3">
|
||||
{!meta.schemas.isInitialized ? (
|
||||
<div className="flex h-[26px] items-center space-x-3 rounded border border-gray-500 px-3">
|
||||
<IconLoader className="animate-spin" size={12} />
|
||||
<span className="text-scale-900 text-xs">Loading schemas...</span>
|
||||
</div>
|
||||
@@ -121,7 +121,7 @@ const TableEditorMenu: FC<Props> = ({
|
||||
}}
|
||||
>
|
||||
<Listbox.Option disabled key="normal-schemas" value="normal-schemas" label="Schemas">
|
||||
<p className="text-sm">Schemas</p>
|
||||
<p className="text-xs text-scale-1100">Schemas</p>
|
||||
</Listbox.Option>
|
||||
{/* @ts-ignore */}
|
||||
{openSchemas.map((schema) => (
|
||||
@@ -129,9 +129,9 @@ const TableEditorMenu: FC<Props> = ({
|
||||
key={schema.id}
|
||||
value={schema.name}
|
||||
label={schema.name}
|
||||
addOnBefore={() => <span className="text-scale-900">schema</span>}
|
||||
addOnBefore={() => <span className="text-scale-900 text-xs">schema</span>}
|
||||
>
|
||||
<span className="text-scale-1200 text-sm">{schema.name}</span>
|
||||
<span className="text-scale-1200 text-xs">{schema.name}</span>
|
||||
</Listbox.Option>
|
||||
))}
|
||||
<Listbox.Option
|
||||
@@ -140,16 +140,16 @@ const TableEditorMenu: FC<Props> = ({
|
||||
value="protected-schemas"
|
||||
label="Protected schemas"
|
||||
>
|
||||
<p className="text-sm">Protected schemas</p>
|
||||
<p className="text-xs text-scale-1100">Protected schemas</p>
|
||||
</Listbox.Option>
|
||||
{protectedSchemas.map((schema) => (
|
||||
<Listbox.Option
|
||||
key={schema.id}
|
||||
value={schema.name}
|
||||
label={schema.name}
|
||||
addOnBefore={() => <span className="text-scale-900">schema</span>}
|
||||
addOnBefore={() => <span className="text-scale-900 text-xs">schema</span>}
|
||||
>
|
||||
<span className="text-scale-1200 text-sm">{schema.name}</span>
|
||||
<span className="text-scale-1200 text-xs">{schema.name}</span>
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox>
|
||||
|
||||
@@ -290,7 +290,7 @@ const UtilityTabResults = observer(() => {
|
||||
<div className="bg-table-header-light dark:bg-table-header-dark">
|
||||
<p className="m-0 border-0 px-6 py-4 text-sm text-scale-1000">
|
||||
Click <code>RUN</code> or hit{' '}
|
||||
<code>{window.navigator.platform.match(/^Mac/) ? 'cmd' : 'ctrl'} + ⏎</code> to execute
|
||||
<code>{window.navigator.platform.match(/^Mac/) ? '⌘' : 'Ctrl'} + Enter</code> to execute
|
||||
your query.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -35,7 +35,7 @@ const InformationBox: FC<Props> = ({
|
||||
>
|
||||
<div className="flex flex-col px-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex w-full space-x-3 lg:items-center">
|
||||
<div className="flex w-full space-x-3 lg:items-start">
|
||||
{icon && <span className="text-scale-900">{icon}</span>}
|
||||
<div className="flex-grow">
|
||||
<h5 className="text-scale-1200 text-sm">{title}</h5>
|
||||
@@ -54,26 +54,28 @@ const InformationBox: FC<Props> = ({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className={`flex flex-col space-y-3 overflow-hidden transition-all ${
|
||||
isExpanded ? 'mt-3' : ''
|
||||
}`}
|
||||
style={{ maxHeight: isExpanded ? 500 : 0 }}
|
||||
>
|
||||
<div className="text-scale-1100 text-sm">{description}</div>
|
||||
{(description || url || button) && (
|
||||
<div
|
||||
className={`flex flex-col space-y-3 overflow-hidden transition-all ${
|
||||
isExpanded ? 'mt-3' : ''
|
||||
}`}
|
||||
style={{ maxHeight: isExpanded ? 500 : 0 }}
|
||||
>
|
||||
<div className="text-scale-1100 text-sm">{description}</div>
|
||||
|
||||
{url && (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
className="text-scale-1100 hover:text-scale-1200 text-sm underline transition-colors"
|
||||
>
|
||||
{urlLabel}
|
||||
</a>
|
||||
)}
|
||||
{url && (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
className="text-scale-1100 hover:text-scale-1200 text-sm underline transition-colors"
|
||||
>
|
||||
{urlLabel}
|
||||
</a>
|
||||
)}
|
||||
|
||||
{button && <div>{button}</div>}
|
||||
</div>
|
||||
{button && <div>{button}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -38,11 +38,7 @@ const PasswordStrengthBar = ({
|
||||
></div>
|
||||
</div>
|
||||
)}
|
||||
<p
|
||||
className={
|
||||
passwordStrengthScore >= DEFAULT_MINIMUM_PASSWORD_STRENGTH ? 'text-green-600' : ''
|
||||
}
|
||||
>
|
||||
<p>
|
||||
{passwordStrengthMessage
|
||||
? passwordStrengthMessage
|
||||
: 'This is the password to your postgres database, so it must be a strong password and hard to guess.'}{' '}
|
||||
|
||||
@@ -16,11 +16,13 @@ export function usePermissions(profile?: any, returning?: 'minimal') {
|
||||
data: data,
|
||||
error,
|
||||
mutate,
|
||||
} = useSWR<any>(profile !== undefined ? url : null, get, { loadingTimeout: 10000 })
|
||||
} = useSWR<any>(profile !== undefined && IS_PLATFORM ? url : null, get, {
|
||||
loadingTimeout: 10000,
|
||||
})
|
||||
const anyError = data?.error || error
|
||||
|
||||
return {
|
||||
permissions: anyError ? undefined : data,
|
||||
permissions: IS_PLATFORM ? (anyError ? undefined : data) : [],
|
||||
isLoading: !anyError && !data,
|
||||
isError: !!anyError,
|
||||
mutate,
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"@supabase/react-data-grid": "^7.1.0-beta.7",
|
||||
"@supabase/shared-types": "^0.1.25",
|
||||
"@supabase/supabase-js": "^2.0.0-rc.2",
|
||||
"@supabase/ui": "0.37.0-alpha.80",
|
||||
"@supabase/ui": "0.37.0-alpha.81",
|
||||
"ajv": "^8.6.3",
|
||||
"analytics-node": "^3.5.0",
|
||||
"awesome-debounce-promise": "^2.1.0",
|
||||
@@ -59,7 +59,7 @@
|
||||
"react-beautiful-dnd": "^13.1.0",
|
||||
"react-contexify": "^5.0.0",
|
||||
"react-csv": "^2.0.3",
|
||||
"react-datepicker": "^4.6.0",
|
||||
"react-datepicker": "^4.8.0",
|
||||
"react-dnd": "^14.0.3",
|
||||
"react-dnd-html5-backend": "^14.0.1",
|
||||
"react-dom": "^17.0.2",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import apiWrapper from 'lib/api/apiWrapper'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
import apiWrapper from 'lib/api/apiWrapper'
|
||||
|
||||
export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler)
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
@@ -20,7 +21,7 @@ const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const response = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Default Organization',
|
||||
name: process.env.DEFAULT_ORGANIZATION_NAME || 'Default Organization',
|
||||
slug: 'default-org-slug',
|
||||
billing_email: 'billing@supabase.co',
|
||||
},
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
import { IS_PLATFORM } from 'lib/constants'
|
||||
import { createEncryptedDbConnectionString } from 'lib/api/apiHelpers'
|
||||
import apiWrapper from 'lib/api/apiWrapper'
|
||||
import { createEncryptedDbConnectionString } from 'lib/api/apiHelpers'
|
||||
|
||||
export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler)
|
||||
|
||||
@@ -28,14 +29,14 @@ const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
organizations: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Default Organization',
|
||||
name: process.env.DEFAULT_ORGANIZATION_NAME || 'Default Organization',
|
||||
slug: 'default-org-slug',
|
||||
billing_email: 'billing@supabase.co',
|
||||
projects: [
|
||||
{
|
||||
id: 1,
|
||||
ref: 'default',
|
||||
name: 'Default Project',
|
||||
name: process.env.DEFAULT_PROJECT_NAME || 'Default Project',
|
||||
organization_id: 1,
|
||||
cloud_provider: 'localhost',
|
||||
status: 'ACTIVE_HEALTHY',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import apiWrapper from 'lib/api/apiWrapper'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
import apiWrapper from 'lib/api/apiWrapper'
|
||||
|
||||
export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler)
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
@@ -20,7 +21,7 @@ const handleGet = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const response = {
|
||||
id: 1,
|
||||
ref: 'default',
|
||||
name: 'Default Project',
|
||||
name: process.env.DEFAULT_PROJECT_NAME || 'Default Project',
|
||||
organization_id: 1,
|
||||
cloud_provider: 'localhost',
|
||||
status: 'ACTIVE_HEALTHY',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import apiWrapper from 'lib/api/apiWrapper'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
import apiWrapper from 'lib/api/apiWrapper'
|
||||
|
||||
export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler)
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
@@ -21,7 +22,7 @@ const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
{
|
||||
id: 1,
|
||||
ref: 'default',
|
||||
name: 'Default Project',
|
||||
name: process.env.DEFAULT_PROJECT_NAME || 'Default Project',
|
||||
organization_id: 1,
|
||||
cloud_provider: 'localhost',
|
||||
status: 'ACTIVE_HEALTHY',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
import apiWrapper from 'lib/api/apiWrapper'
|
||||
|
||||
export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler)
|
||||
@@ -27,7 +28,7 @@ const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
db_user: 'postgres',
|
||||
id: 1,
|
||||
inserted_at: undefined,
|
||||
name: 'Default Project',
|
||||
name: process.env.DEFAULT_PROJECT_NAME || 'Default Project',
|
||||
ref: 'default',
|
||||
status: 'ACTIVE_HEALTHY',
|
||||
services: [
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
import apiWrapper from 'lib/api/apiWrapper'
|
||||
|
||||
export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler)
|
||||
@@ -21,7 +22,7 @@ const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
project: {
|
||||
id: 1,
|
||||
ref: 'default',
|
||||
name: 'Default Project',
|
||||
name: process.env.DEFAULT_PROJECT_NAME || 'Default Project',
|
||||
status: 'ACTIVE_HEALTHY',
|
||||
organization_id: 1,
|
||||
inserted_at: '2021-08-02T06:40:40.646362',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
import apiWrapper from 'lib/api/apiWrapper'
|
||||
|
||||
export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler)
|
||||
@@ -29,7 +30,7 @@ const handleGetAll = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
id: 1,
|
||||
inserted_at: undefined,
|
||||
jwt_secret: '-',
|
||||
name: 'Default Project',
|
||||
name: process.env.DEFAULT_PROJECT_NAME || 'Default Project',
|
||||
ref: 'default',
|
||||
region: 'ap-southeast-1',
|
||||
status: 'ACTIVE_HEALTHY',
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { Tabs } from '@supabase/ui'
|
||||
import { IconInfo, Tabs } from '@supabase/ui'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
|
||||
import { NextPageWithLayout } from 'types'
|
||||
import { useStore, checkPermissions } from 'hooks'
|
||||
import { DatabaseLayout } from 'components/layouts'
|
||||
import { PITRBackupSelection } from 'components/interfaces/Database'
|
||||
import { NextPageWithLayout } from 'types'
|
||||
import Loading from 'components/ui/Loading'
|
||||
import NoPermission from 'components/ui/NoPermission'
|
||||
import { PITRNotice, PITRSelection } from 'components/interfaces/Database/Backups/PITR'
|
||||
import BackupsError from 'components/interfaces/Database/Backups/BackupsError'
|
||||
|
||||
const DatabaseScheduledBackups: NextPageWithLayout = () => {
|
||||
const DatabasePhysicalBackups: NextPageWithLayout = () => {
|
||||
const { ui } = useStore()
|
||||
const router = useRouter()
|
||||
|
||||
const ref = ui.selectedProject?.ref ?? 'default'
|
||||
|
||||
const canReadPhysicalBackups = checkPermissions(PermissionAction.READ, 'physical_backups')
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-6 px-5 pt-12 pb-20">
|
||||
<h3 className="text-scale-1200 text-xl">Backups</h3>
|
||||
@@ -28,26 +28,55 @@ const DatabaseScheduledBackups: NextPageWithLayout = () => {
|
||||
if (id === 'scheduled') router.push(`/project/${ref}/database/backups/scheduled`)
|
||||
}}
|
||||
>
|
||||
<Tabs.Panel id="scheduled" label="Scheduled" />
|
||||
<Tabs.Panel id="scheduled" label="Scheduled backups" />
|
||||
<Tabs.Panel id="pitr" label="Point in Time" />
|
||||
</Tabs>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-scale-1100 text-sm">
|
||||
Restore your project from a specific date and time.
|
||||
</p>
|
||||
{canReadPhysicalBackups ? (
|
||||
<PITRBackupSelection />
|
||||
) : (
|
||||
<NoPermission resourceText="view PITR backups" />
|
||||
)}
|
||||
<div className="space-y-8">
|
||||
<PITR />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
DatabaseScheduledBackups.getLayout = (page) => (
|
||||
DatabasePhysicalBackups.getLayout = (page) => (
|
||||
<DatabaseLayout title="Database">{page}</DatabaseLayout>
|
||||
)
|
||||
|
||||
export default observer(DatabaseScheduledBackups)
|
||||
const PITR = () => {
|
||||
const { ui, backups } = useStore()
|
||||
const { configuration, error, isLoading } = backups
|
||||
|
||||
const ref = ui.selectedProject?.ref ?? 'default'
|
||||
const isEnabled = configuration.walg_enabled
|
||||
|
||||
const canReadPhysicalBackups = checkPermissions(PermissionAction.READ, 'physical_backups')
|
||||
if (!canReadPhysicalBackups) return <NoPermission resourceText="view PITR backups" />
|
||||
|
||||
if (isLoading) return <Loading />
|
||||
if (error) return <BackupsError />
|
||||
if (!isEnabled)
|
||||
return (
|
||||
<div className="block w-full rounded border border-gray-400 border-opacity-50 bg-gray-300 p-3">
|
||||
<div className="flex space-x-3">
|
||||
<IconInfo size={20} strokeWidth={1.5} />
|
||||
<p className="text-sm">
|
||||
Point in time backups is an Enterprise feature. Reach out to us{' '}
|
||||
<Link href={`/support/new?ref=${ref}&category=sales`}>
|
||||
<a className="text-brand-900">here</a>
|
||||
</Link>{' '}
|
||||
if you're interested!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<PITRNotice />
|
||||
<PITRSelection />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(DatabasePhysicalBackups)
|
||||
@@ -27,7 +27,7 @@ const DatabaseScheduledBackups: NextPageWithLayout = () => {
|
||||
if (id === 'pitr') router.push(`/project/${ref}/database/backups/pitr`)
|
||||
}}
|
||||
>
|
||||
<Tabs.Panel id="scheduled" label="Scheduled" />
|
||||
<Tabs.Panel id="scheduled" label="Scheduled backups" />
|
||||
<Tabs.Panel id="pitr" label="Point in Time" />
|
||||
</Tabs>
|
||||
|
||||
|
||||
@@ -16,8 +16,10 @@ const DatabaseTables: NextPageWithLayout = () => {
|
||||
const { meta, ui } = useStore()
|
||||
|
||||
const [isDeleting, setIsDeleting] = useState<boolean>(false)
|
||||
const [selectedSchema, setSelectedSchema] = useState('public')
|
||||
const [selectedTable, setSelectedTable] = useState<any>()
|
||||
const [sidePanelKey, setSidePanelKey] = useState<'column' | 'table'>()
|
||||
|
||||
const [selectedColumnToEdit, setSelectedColumnToEdit] = useState<PostgresColumn>()
|
||||
const [selectedTableToEdit, setSelectedTableToEdit] = useState<PostgresTable>()
|
||||
|
||||
@@ -121,6 +123,8 @@ const DatabaseTables: NextPageWithLayout = () => {
|
||||
<div className="p-4">
|
||||
{isUndefined(selectedTable) ? (
|
||||
<TableList
|
||||
selectedSchema={selectedSchema}
|
||||
onSelectSchema={setSelectedSchema}
|
||||
onAddTable={onAddTable}
|
||||
onEditTable={onEditTable}
|
||||
onDeleteTable={onDeleteTable}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { PostgresTable, PostgresColumn } from '@supabase/postgres-meta'
|
||||
|
||||
import Base64 from 'lib/base64'
|
||||
import { tryParseJson } from 'lib/helpers'
|
||||
import { useStore, withAuth } from 'hooks'
|
||||
import { useStore, withAuth, useUrlState } from 'hooks'
|
||||
import { Dictionary } from 'components/grid'
|
||||
import { TableEditorLayout } from 'components/layouts'
|
||||
import { TableGridEditor } from 'components/interfaces'
|
||||
@@ -17,6 +17,7 @@ import ConfirmationModal from 'components/ui/ConfirmationModal'
|
||||
const TableEditorPage: NextPage = () => {
|
||||
const router = useRouter()
|
||||
const { id }: any = router.query
|
||||
const [_, setParams] = useUrlState({ arrayKeys: ['filter', 'sort'] })
|
||||
|
||||
const { meta, ui } = useStore()
|
||||
const [selectedSchema, setSelectedSchema] = useState<string>()
|
||||
@@ -98,11 +99,32 @@ const TableEditorPage: NextPage = () => {
|
||||
setSidePanelKey(undefined)
|
||||
}
|
||||
|
||||
const removeDeletedColumnFromFiltersAndSorts = (columnName: string) => {
|
||||
setParams((prevParams) => {
|
||||
const existingFilters = (prevParams?.filter ?? []) as string[]
|
||||
const existingSorts = (prevParams?.sort ?? []) as string[]
|
||||
|
||||
return {
|
||||
...prevParams,
|
||||
filter: existingFilters.filter((filter: string) => {
|
||||
const [column] = filter.split(':')
|
||||
if (column !== columnName) return filter
|
||||
}),
|
||||
sort: existingSorts.filter((sort: string) => {
|
||||
const [column] = sort.split(':')
|
||||
if (column !== columnName) return sort
|
||||
}),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const onConfirmDeleteColumn = async () => {
|
||||
try {
|
||||
const response: any = await meta.columns.del(selectedColumnToDelete!.id)
|
||||
if (response.error) throw response.error
|
||||
|
||||
removeDeletedColumnFromFiltersAndSorts(selectedColumnToDelete!.name)
|
||||
|
||||
await meta.tables.loadById(selectedColumnToDelete!.table_id)
|
||||
} catch (error: any) {
|
||||
ui.setNotification({
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Project } from 'types'
|
||||
import { IRootStore } from '../RootStore'
|
||||
import { constructHeaders } from 'lib/api/apiHelpers'
|
||||
import { get } from 'lib/common/fetch'
|
||||
import { PROJECT_STATUS } from 'lib/constants'
|
||||
import { IS_PLATFORM, PROJECT_STATUS } from 'lib/constants'
|
||||
import PostgresMetaInterface, { IPostgresMetaInterface } from '../common/PostgresMetaInterface'
|
||||
|
||||
import pingPostgrest from 'lib/pingPostgrest'
|
||||
@@ -57,7 +57,9 @@ export default class ProjectStore extends PostgresMetaInterface<Project> {
|
||||
this.data[project.id] = project
|
||||
|
||||
// lazy fetches
|
||||
this.fetchSubscriptionTier(project)
|
||||
if (IS_PLATFORM) {
|
||||
this.fetchSubscriptionTier(project)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||