Pull master and resolve conflicts

This commit is contained in:
Joshen Lim committed 2022-10-03 15:19:49 +08:00
commit fc94fb22b8
107 files changed
+6685 -53323

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
node_modules/
.vercel
.next
.env.local
.env.production
.env.dev
.env.*
+1
View File
@@ -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.
![site_secret_settings.png](/img/guides/auth-captcha/site_secret_settings.png)
The **Sitekey** can be found in the **Settings** of the active site you created.
![sites_dashboard.png](/img/guides/auth-captcha/sites_dashboard.png)
In the Settings page, look for the **Sitekey** section and copy the key.
![sitekey_settings.png](/img/guides/auth-captcha/sitekey_settings.png)
### 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.
![supabase_auth_general_settings.png](/img/guides/auth-captcha/supabase_auth_general_settings.png)
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.
File diff suppressed because it is too large. Load diff
@@ -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`.
![supabase-host](/img/guides/integrations/polyscale/supabase-host.png)
### 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**
![create-cache-supabase](/img/guides/integrations/polyscale/create-cache-supabase-400.png)
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/).
+172 -38
View File
@@ -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).
![API Logs](/img/guides/platform/logs/logs-api.png)
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).
![Postgres Logs](/img/guides/platform/logs/logs-database.png)
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).
![Sidebar navigation steps](/img/guides/platform/logs/sidebar-navigation.png)
![Auth Logs](/img/guides/platform/logs/logs-auth.png)
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:
![Storage Logs](/img/guides/platform/logs/logs-storage.png)
</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).
![Realtime Logs](/img/guides/platform/logs/logs-realtime.png)
</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.
![Function Edge Logs](/img/guides/platform/logs/logs-functions-edge.png)
**Logs**
The Logs tab displays logs emitted during function execution.
![Function Logs](/img/guides/platform/logs/logs-functions.png)
</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.
![Logs Explorer](/img/guides/platform/logs/logs-explorer.png)
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
```
![SELECT query example](/img/guides/platform/logs/select-query.png)
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"
```
+2
View File
@@ -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',
+3 -3
View File
@@ -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,
},
Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 952 KiB

After

Width:  |  Height:  |  Size: 989 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 926 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1021 KiB

After

Width:  |  Height:  |  Size: 974 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 781 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 818 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 937 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 962 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 923 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 901 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+7
View File
@@ -0,0 +1,7 @@
node_modules/
.vercel
.next
.env.local
.env.production
.env.dev
.env.*
+3
View File
@@ -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
+3
View File
@@ -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}
+206
View File
@@ -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
![Supabase kontrolna tabla](https://raw.githubusercontent.com/supabase/supabase/master/apps/www/public/images/github/supabase-dashboard.png)
## 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).
![Arhitektura](https://user-images.githubusercontent.com/70828596/187547862-ffa9d058-0c3a-4851-a3e7-92ccfca4b596.png)
- [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
[![Novi sponzor](https://user-images.githubusercontent.com/10214025/90518111-e74bbb00-e198-11ea-8f88-c9e3c1aa4b5b.png)](https://github.com/sponsors/supabase)
+1
View File
@@ -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)
File diff suppressed because it is too large. Load diff
+3
View File
@@ -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
+28 -12
View File
@@ -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"]
+7
View File
@@ -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=
```
+13 -3
View File
@@ -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>
+21 -19
View File
@@ -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>
)
+1 -5
View File
@@ -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.'}{' '}
+4 -2
View File
@@ -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,
-51289
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -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",
+3 -2
View File
@@ -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',
},
+4 -3
View File
@@ -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',
+3 -2
View File
@@ -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',
+3 -2
View File
@@ -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',
+2 -1
View File
@@ -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}
+23 -1
View File
@@ -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({
+4 -2
View File
@@ -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)
}
}
}
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff. Show more