No files matched your search
@@ -51,3 +51,15 @@ jobs:
|
||||
# reporter: github-pr-review
|
||||
# level: warning
|
||||
# command: npx prettier -c 'i18n/**/*.{js,jsx,ts,tsx,css,md,json}'
|
||||
|
||||
format-sql:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Install plugin
|
||||
run: npm ci
|
||||
- name: Run prettier
|
||||
run: |-
|
||||
# Check mdx files which contain sql code blocks
|
||||
grep -lr '```sql' apps/docs/pages/**/*.mdx | xargs npx prettier -c
|
||||
@@ -4,4 +4,10 @@ node_modules
|
||||
package-lock.json
|
||||
docker*
|
||||
apps/**/out
|
||||
**/**.mdx
|
||||
# prettier-plugin-sql-cst only supports sqlite syntax
|
||||
**/supabase/migrations/*.sql
|
||||
apps/www/schema.sql
|
||||
examples/slack-clone/nextjs-slack-clone/full-schema.sql
|
||||
# ignore files with custom js formatting
|
||||
apps/docs/pages/guides/auth/*.mdx
|
||||
apps/docs/pages/guides/integrations/*.mdx
|
||||
@@ -4,5 +4,6 @@
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"endOfLine": "lf"
|
||||
"endOfLine": "lf",
|
||||
"sqlKeywordCase": "lower"
|
||||
}
|
||||
@@ -512,6 +512,24 @@ export const database = {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Postgres resources',
|
||||
url: undefined,
|
||||
items: [
|
||||
{
|
||||
name: 'Drop all tables in schema',
|
||||
url: '/guides/database/postgres/dropping-all-tables-in-schema',
|
||||
},
|
||||
{
|
||||
name: 'Select first row per group',
|
||||
url: '/guides/database/postgres/first-row-in-group',
|
||||
},
|
||||
{
|
||||
name: 'Print PostgreSQL version',
|
||||
url: '/guides/database/postgres/which-version-of-postgres',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Configuration',
|
||||
url: undefined,
|
||||
@@ -727,24 +745,6 @@ export const resources = {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Postgres resources',
|
||||
url: undefined,
|
||||
items: [
|
||||
{
|
||||
name: 'Drop all tables in schema',
|
||||
url: '/guides/resources/postgres/dropping-all-tables-in-schema',
|
||||
},
|
||||
{
|
||||
name: 'Select first row per group',
|
||||
url: '/guides/resources/postgres/first-row-in-group',
|
||||
},
|
||||
{
|
||||
name: 'Print PostgreSQL version',
|
||||
url: '/guides/resources/postgres/which-version-of-postgres',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
"@radix-ui/react-accordion": "^1.0.1",
|
||||
"@supabase/auth-helpers-nextjs": "^0.5.4",
|
||||
"@supabase/auth-helpers-react": "^0.3.1",
|
||||
"@supabase/supabase-js": "^2.4.1",
|
||||
"@supabase/supabase-js": "^2.8.0",
|
||||
"algoliasearch": "^4.14.2",
|
||||
"babel": "^6.23.0",
|
||||
"clsx": "^1.2.1",
|
||||
@@ -101,7 +101,6 @@
|
||||
"next-transpile-modules": "9.0.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"openapi-types": "^12.0.2",
|
||||
"prettier": "^2.2.1",
|
||||
"sass": "^1.55.0",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig": "*",
|
||||
|
||||
@@ -83,6 +83,24 @@ async function signInWithGoogle() {
|
||||
}
|
||||
```
|
||||
|
||||
Google OAuth2.0 doesn't return the `provider_refresh_token` by default. If you need the `provider_refresh_token` returned, you will need to add additional query parameters:
|
||||
|
||||
```js
|
||||
async function signInWithGoogle() {
|
||||
const { data, error } = await supabase.auth.signInWithOAuth({
|
||||
provider: 'google',
|
||||
options: {
|
||||
queryParams: {
|
||||
access_type: 'offline',
|
||||
prompt: 'consent',
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
You can view the full list of query parameters and their descriptions [here](https://developers.google.com/identity/protocols/oauth2/web-server#creatingclient).
|
||||
|
||||
When your user signs out, call [signOut()](/docs/reference/javascript/auth-signout) to remove them from the browser session and any objects from localStorage:
|
||||
|
||||
```js
|
||||
|
||||
@@ -178,9 +178,9 @@ This creates a new migration named `supabase/migrations/<timestamp>_create_emplo
|
||||
Use the seed script in `supabase/seed.sql` (created with [`supabase init`](/docs/reference/cli/usage#supabase-init)) to add sample data to the table.
|
||||
|
||||
```sql
|
||||
-- in supabase/seed.sql
|
||||
insert into
|
||||
public.employees (name)
|
||||
-- in supabase/seed.sql
|
||||
insert into public.employees
|
||||
(name)
|
||||
values
|
||||
('Erlich Bachman'),
|
||||
('Richard Hendricks'),
|
||||
@@ -200,9 +200,9 @@ You should now see the contents of `employees` in Studio.
|
||||
Use the [`reset`](/docs/reference/cli/usage#supabase-db-reset) command to revert any changes to the local database.
|
||||
|
||||
```sql
|
||||
-- run on local database to make a change
|
||||
alter table
|
||||
employees add department text default 'Hooli';
|
||||
-- run on local database to make a change
|
||||
alter table employees
|
||||
add department text default 'Hooli';
|
||||
```
|
||||
|
||||
Run the following command to reset the local database:
|
||||
|
||||
@@ -390,7 +390,7 @@ This ensures that any `delete()` or `update()` would fail if there are no accomp
|
||||
To confirm that safeupdate is enabled for queries going through the API of your project, the following query could be run:
|
||||
|
||||
```sql
|
||||
select usename,useconfig from pg_shadow where usename = 'authenticator' ;
|
||||
select usename, useconfig from pg_shadow where usename = 'authenticator';
|
||||
```
|
||||
|
||||
The expected value for `useconfig` should be:
|
||||
|
||||
@@ -30,7 +30,10 @@ Create a test table with a text array (an array of strings):
|
||||
<TabPanel id="sql" label="SQL">
|
||||
|
||||
```sql
|
||||
CREATE TABLE arraytest (id integer NOT NULL, textarray text ARRAY);
|
||||
create table arraytest (
|
||||
id integer not null,
|
||||
textarray text array
|
||||
);
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
@@ -95,7 +98,7 @@ You should see:
|
||||
<TabPanel id="sql" label="SQL">
|
||||
|
||||
```sql
|
||||
SELECT * FROM arraytest;
|
||||
select * from arraytest;
|
||||
```
|
||||
|
||||
You should see:
|
||||
|
||||
@@ -76,6 +76,7 @@ The following table shows a subset of available columns:
|
||||
A full list of statistics is available in the [pg_stat_monitor docs](https://docs.percona.com/pg-stat-monitor/reference.html#postgresql-15).
|
||||
|
||||
## Functions
|
||||
|
||||
- [`pg_stat_monitor_reset()`](https://docs.percona.com/pg-stat-monitor/functions.html): Resets the statistics tracked by the `pg_stat_monitor` view and deletes all previous data.
|
||||
- [`pg_stat_monitor_version()`](https://docs.percona.com/pg-stat-monitor/functions.html): Displays the version of the `pg_stat_monitor` extension.
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ select cron.schedule (
|
||||
Vacuum every day at 3:00am (GMT)
|
||||
|
||||
```sql
|
||||
SELECT cron.schedule('nightly-vacuum', '0 3 * * *', 'VACUUM');
|
||||
select cron.schedule('nightly-vacuum', '0 3 * * *', 'VACUUM');
|
||||
```
|
||||
|
||||
### Invoke Supabase Edge Function every minute
|
||||
@@ -112,7 +112,7 @@ select
|
||||
Unschedules a job called `'nightly-vacuum'`
|
||||
|
||||
```sql
|
||||
SELECT cron.unschedule('nightly-vacuum');
|
||||
select cron.unschedule('nightly-vacuum');
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
@@ -16,7 +16,6 @@ The pg_net API is in alpha. Functions signatures may change.
|
||||
|
||||
It differs from the `http` extension in that it is asynchronous by default. This makes it useful in blocking functions (like triggers).
|
||||
|
||||
|
||||
## Enable the extension
|
||||
|
||||
<Tabs
|
||||
|
||||
@@ -8,7 +8,6 @@ export const meta = {
|
||||
|
||||
`PGroonga` is a PostgreSQL extension adding a full text search indexing method based on [Groonga](https://groonga.org). While native PostgreSQL supports full text indexing, it is limited to alphabet and digit based languages. `PGroonga` offers a wider range of character support making it viable for a superset of languages supported by PostgreSQL including Japanese, Chinese, etc.
|
||||
|
||||
|
||||
## Enable the extension
|
||||
|
||||
<Tabs
|
||||
|
||||
@@ -60,13 +60,12 @@ To disable an extension, call `drop extension`.
|
||||
### Create a table to store vectors
|
||||
|
||||
```sql
|
||||
create table
|
||||
posts (
|
||||
id serial primary key,
|
||||
title text not null,
|
||||
body text not null,
|
||||
embedding vector (1536)
|
||||
);
|
||||
create table posts (
|
||||
id serial primary key,
|
||||
title text not null,
|
||||
body text not null,
|
||||
embedding vector(1536)
|
||||
);
|
||||
```
|
||||
|
||||
### Storing a vector / embedding
|
||||
|
||||
@@ -15,7 +15,6 @@ While Postgres natively runs SQL, it can also run other "procedural languages".
|
||||
|
||||
It can be used for database functions, triggers, queries and more.
|
||||
|
||||
|
||||
## Enable the extension
|
||||
|
||||
<Tabs
|
||||
|
||||
@@ -91,7 +91,7 @@ You can insert geographical data through SQL or through our API.
|
||||
|
||||
```sql
|
||||
insert into public.restaurants
|
||||
(name, location)
|
||||
(name, location)
|
||||
values
|
||||
('Supa Burger', st_point(-73.946823, 40.807416)),
|
||||
('Supa Pizza', st_point(-73.94581, 40.807475)),
|
||||
|
||||
@@ -6,7 +6,7 @@ export const meta = {
|
||||
description: '3rd party integrations for PostgreSQL ',
|
||||
}
|
||||
|
||||
[supabase/wrappers](https://supabase.github.io/wrappers/) is a PostgreSQL extension that provides integrations with external sources so you can interact with third-party data using SQL.
|
||||
[supabase/wrappers](https://supabase.github.io/wrappers/) is a PostgreSQL extension that provides integrations with external sources so you can interact with third-party data using SQL.
|
||||
|
||||
For example, the [Stripe wrapper](https://supabase.github.io/wrappers/stripe/) connects to [Stripe's API](https://stripe.com/docs/api) and exposes each endpoint as a SQL table.
|
||||
|
||||
|
||||
@@ -49,13 +49,26 @@ create table books (
|
||||
description text
|
||||
);
|
||||
|
||||
insert into books (title, author, description)
|
||||
insert into books
|
||||
(title, author, description)
|
||||
values
|
||||
('The Poky Little Puppy','Janette Sebring Lowrey','Puppy is slower than other, bigger animals.'),
|
||||
('The Tale of Peter Rabbit','Beatrix Potter','Rabbit eats some vegetables.'),
|
||||
('Tootle','Gertrude Crampton','Little toy train has big dreams.'),
|
||||
('Green Eggs and Ham','Dr. Seuss','Sam has changing food preferences and eats unusually colored food.'),
|
||||
('Harry Potter and the Goblet of Fire','J.K. Rowling','Fourth year of school starts, big drama ensues.');
|
||||
(
|
||||
'The Poky Little Puppy',
|
||||
'Janette Sebring Lowrey',
|
||||
'Puppy is slower than other, bigger animals.'
|
||||
),
|
||||
('The Tale of Peter Rabbit', 'Beatrix Potter', 'Rabbit eats some vegetables.'),
|
||||
('Tootle', 'Gertrude Crampton', 'Little toy train has big dreams.'),
|
||||
(
|
||||
'Green Eggs and Ham',
|
||||
'Dr. Seuss',
|
||||
'Sam has changing food preferences and eats unusually colored food.'
|
||||
),
|
||||
(
|
||||
'Harry Potter and the Goblet of Fire',
|
||||
'J.K. Rowling',
|
||||
'Fourth year of school starts, big drama ensues.'
|
||||
);
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
@@ -70,8 +83,7 @@ The functions we'll cover in this guide are:
|
||||
Converts your data into searchable "tokens". `to_tsvector()` stands for "to text search vector". For example:
|
||||
|
||||
```sql
|
||||
select to_tsvector('green eggs and ham')
|
||||
|
||||
select to_tsvector('green eggs and ham');
|
||||
-- Returns 'egg':2 'green':1 'ham':4
|
||||
```
|
||||
|
||||
|
||||
@@ -138,7 +138,8 @@ create table planets (
|
||||
name text
|
||||
);
|
||||
|
||||
insert into planets (id, name)
|
||||
insert into planets
|
||||
(id, name)
|
||||
values
|
||||
(1, 'Tattoine'),
|
||||
(2, 'Alderaan'),
|
||||
@@ -150,7 +151,8 @@ create table people (
|
||||
planet_id bigint references planets
|
||||
);
|
||||
|
||||
insert into people (id, name, planet_id)
|
||||
insert into people
|
||||
(id, name, planet_id)
|
||||
values
|
||||
(1, 'Anakin Skywalker', 1),
|
||||
(2, 'Luke Skywalker', 1),
|
||||
|
||||
@@ -221,12 +221,11 @@ Select the title, description, price, and age range for each book.
|
||||
```sql
|
||||
select
|
||||
title,
|
||||
metadata -> 'description' AS description,
|
||||
metadata -> 'description' as description,
|
||||
metadata -> 'price' as price,
|
||||
metadata -> 'ages' -> 0 as low_age,
|
||||
metadata -> 'ages' -> 1 as high_age
|
||||
from
|
||||
books;
|
||||
from books;
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
|
||||
@@ -12,7 +12,7 @@ Run the following query using the [SQL Editor](https://app.supabase.com/project/
|
||||
|
||||
```sql
|
||||
select
|
||||
version ();
|
||||
version();
|
||||
```
|
||||
|
||||
Which should return something like:
|
||||
@@ -11,10 +11,11 @@ export const meta = {
|
||||
Select a set of columns from a single table with where, order by, and limit clauses.
|
||||
|
||||
```sql
|
||||
select first_name, last_name, team_id, age from players
|
||||
select first_name, last_name, team_id, age
|
||||
from players
|
||||
where age between 20 and 24 and team_id <> 'STL'
|
||||
order by last_name, first_name desc
|
||||
limit 20
|
||||
limit 20;
|
||||
```
|
||||
|
||||
```js
|
||||
@@ -32,8 +33,9 @@ const { data, error } = await supabase
|
||||
Select all columns from a single table with a complex where clause: OR AND OR
|
||||
|
||||
```sql
|
||||
select * from players
|
||||
where ((team_id = 'CHN' or team_id is null) and (age > 35 or age is null))
|
||||
select *
|
||||
from players
|
||||
where ((team_id = 'CHN' or team_id is null) and (age > 35 or age is null));
|
||||
```
|
||||
|
||||
```js
|
||||
@@ -48,8 +50,9 @@ const { data, error } = await supabase
|
||||
Select all columns from a single table with a complex where clause: AND OR AND
|
||||
|
||||
```sql
|
||||
select * from players
|
||||
where ((team_id = 'CHN' and age > 35) or (team_id <> 'CHN' and age is not null))
|
||||
select *
|
||||
from players
|
||||
where ((team_id = 'CHN' and age > 35) or (team_id <> 'CHN' and age is not null));
|
||||
```
|
||||
|
||||
```js
|
||||
@@ -62,8 +65,9 @@ const { data, error } = await supabase
|
||||
Get a count of rows, but don't return any data.
|
||||
|
||||
```sql
|
||||
select count(*) from players
|
||||
where team_id = 'NYM'
|
||||
select count(*)
|
||||
from players
|
||||
where team_id = 'NYM';
|
||||
```
|
||||
|
||||
```js
|
||||
|
||||
@@ -189,8 +189,14 @@ Use the "Bulk Loading" instructions if you are loading large data sets.
|
||||
insert into movies
|
||||
(name, description)
|
||||
values
|
||||
('The Empire Strikes Back', 'After the Rebels are brutally overpowered by the Empire on the ice planet Hoth, Luke Skywalker begins Jedi training with Yoda.'),
|
||||
('Return of the Jedi', 'After a daring mission to rescue Han Solo from Jabba the Hutt, the Rebels dispatch to Endor to destroy the second Death Star.');
|
||||
(
|
||||
'The Empire Strikes Back',
|
||||
'After the Rebels are brutally overpowered by the Empire on the ice planet Hoth, Luke Skywalker begins Jedi training with Yoda.'
|
||||
),
|
||||
(
|
||||
'Return of the Jedi',
|
||||
'After a daring mission to rescue Han Solo from Jabba the Hutt, the Rebels dispatch to Endor to destroy the second Death Star.'
|
||||
);
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
@@ -425,14 +431,15 @@ As a query becomes complex it becomes a hassle to call it. Especially when we ru
|
||||
|
||||
```sql
|
||||
select
|
||||
students.name,
|
||||
students.type,
|
||||
courses.title,
|
||||
courses.code,
|
||||
grades.result
|
||||
from grades
|
||||
left join students on grades.student_id = students.id
|
||||
left join courses on grades.course_id = courses.id;
|
||||
students.name,
|
||||
students.type,
|
||||
courses.title,
|
||||
courses.code,
|
||||
grades.result
|
||||
from
|
||||
grades
|
||||
left join students on grades.student_id = students.id
|
||||
left join courses on grades.course_id = courses.id;
|
||||
```
|
||||
|
||||
We can run this instead:
|
||||
@@ -449,14 +456,15 @@ Views ensure that the likelihood of mistakes decreases when repeatedly executing
|
||||
|
||||
```sql
|
||||
select
|
||||
students.name,
|
||||
students.type,
|
||||
courses.title,
|
||||
courses.code,
|
||||
grades.result
|
||||
from grades
|
||||
left join students on grades.student_id = students.id
|
||||
left join courses on grades.course_id = courses.id
|
||||
students.name,
|
||||
students.type,
|
||||
courses.title,
|
||||
courses.code,
|
||||
grades.result
|
||||
from
|
||||
grades
|
||||
left join students on grades.student_id = students.id
|
||||
left join courses on grades.course_id = courses.id
|
||||
where courses.code != 'PG101';
|
||||
```
|
||||
|
||||
@@ -478,13 +486,14 @@ Using our example above, a materialized view can be created like this:
|
||||
|
||||
```sql
|
||||
create materialized view transcripts as
|
||||
select
|
||||
students.name,
|
||||
students.type,
|
||||
courses.title,
|
||||
courses.code,
|
||||
grades.result
|
||||
from grades
|
||||
select
|
||||
students.name,
|
||||
students.type,
|
||||
courses.title,
|
||||
courses.code,
|
||||
grades.result
|
||||
from
|
||||
grades
|
||||
left join students on grades.student_id = students.id
|
||||
left join courses on grades.course_id = courses.id;
|
||||
```
|
||||
|
||||
@@ -19,9 +19,8 @@ This SQL query will show the current size of your Postgres database:
|
||||
|
||||
```sql
|
||||
select
|
||||
sum(pg_database_size (pg_database.datname)) / (1024 * 1024) as db_size_mb
|
||||
from
|
||||
pg_database;
|
||||
sum(pg_database_size(pg_database.datname)) / (1024 * 1024) as db_size_mb
|
||||
from pg_database;
|
||||
```
|
||||
|
||||
This value is reported in the [database settings page](https://app.supabase.com/project/_/settings/database).
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Layout from '~/layouts/DefaultGuideLayout'
|
||||
import { logConstants } from 'shared-data'
|
||||
|
||||
export const meta = {
|
||||
id: 'logs',
|
||||
@@ -168,7 +169,7 @@ Each log event stores metadata an array of objects with multiple levels, and can
|
||||
For example, to query the edge logs without any joins:
|
||||
|
||||
```sql
|
||||
select timestamp, metadata from edge_logs t
|
||||
select timestamp, metadata from edge_logs as 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:
|
||||
@@ -183,10 +184,11 @@ To query for a nested value, add a join for each array level:
|
||||
|
||||
```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
|
||||
from
|
||||
edge_logs as t
|
||||
cross join unnest(t.metadata) as metadata
|
||||
cross join unnest(metadata.request) as request
|
||||
cross join unnest(request.headers) as header;
|
||||
```
|
||||
|
||||
This surfaces the following columns available for selection:
|
||||
@@ -217,17 +219,19 @@ Instead, select only the values required.
|
||||
-- ❌ 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;
|
||||
m as metadata -- <- metadata contains many nested keys
|
||||
from
|
||||
edge_logs as 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
|
||||
datetime(timestamp),
|
||||
r.method -- <- select only the required values
|
||||
from
|
||||
edge_logs as t
|
||||
cross join unnest(t.metadata) as m
|
||||
cross join unnest(m.request) as r;
|
||||
```
|
||||
|
||||
### Examples and Templates
|
||||
@@ -238,13 +242,41 @@ For example, you can enter the following query in the SQL Editor to retrieve eac
|
||||
|
||||
```sql
|
||||
select datetime(timestamp), h.x_real_ip
|
||||
from edge_logs
|
||||
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"
|
||||
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";
|
||||
```
|
||||
|
||||
### Log Source Reference
|
||||
|
||||
Refer to the full field reference for each available source below. Do note that in order to access each nested key, you would need to perform the [necessary unnesting joins](#unnesting-arrays)
|
||||
|
||||
<Tabs scrollable size="small" type="underlined" defaultActiveId="edge_logs">
|
||||
{logConstants.schemas.map((schema) => (
|
||||
<TabPanel id={schema.reference} label={schema.name}>
|
||||
<table>
|
||||
<thead>
|
||||
<td className="font-bold">Path</td>
|
||||
<td className="font-bold">Type</td>
|
||||
</thead>
|
||||
<tbody>
|
||||
{schema.fields
|
||||
.sort((a, b) => a.path - b.path)
|
||||
.map((field) => (
|
||||
<tr>
|
||||
<td className="font-mono">{field.path}</td>
|
||||
<td className="font-mono">{field.type}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TabPanel>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
export const Page = ({ children }) => <Layout meta={meta} children={children} />
|
||||
|
||||
export default Page
|
||||
@@ -14,13 +14,13 @@ Unoptimized queries are a major cause of poor database performance. The techniqu
|
||||
|
||||
Database performance is a large topic and many factors can contribute. Some of the most common causes of poor performance include:
|
||||
|
||||
* An inefficiently designed schema
|
||||
* Inefficiently designed queries
|
||||
* A lack of indexes causing slower than required queries over large tables
|
||||
* Unused indexes causing slow `INSERT`, `UPDATE` and `DELETE` operations
|
||||
* Not enough compute resources, such as memory, causing your database to go to disk for results too often
|
||||
* Lock contention from multiple queries operating on highly utilized tables
|
||||
* Large amount of bloat on your tables causing poor query planning
|
||||
- An inefficiently designed schema
|
||||
- Inefficiently designed queries
|
||||
- A lack of indexes causing slower than required queries over large tables
|
||||
- Unused indexes causing slow `INSERT`, `UPDATE` and `DELETE` operations
|
||||
- Not enough compute resources, such as memory, causing your database to go to disk for results too often
|
||||
- Lock contention from multiple queries operating on highly utilized tables
|
||||
- Large amount of bloat on your tables causing poor query planning
|
||||
|
||||
Thankfully there are solutions to all these issues, which we will cover in the following sections.
|
||||
|
||||
@@ -48,13 +48,11 @@ select
|
||||
-- max_time,
|
||||
-- mean_time,
|
||||
statements.rows / statements.calls as avg_rows
|
||||
|
||||
from pg_stat_statements as statements
|
||||
from
|
||||
pg_stat_statements as statements
|
||||
inner join pg_authid as auth on statements.userid = auth.oid
|
||||
order by
|
||||
statements.calls desc
|
||||
limit
|
||||
100;
|
||||
order by statements.calls desc
|
||||
limit 100;
|
||||
```
|
||||
|
||||
This query shows:
|
||||
@@ -63,7 +61,7 @@ This query shows:
|
||||
- the role that ran the query
|
||||
- the number of times it has been called
|
||||
- the average number of rows returned
|
||||
- the cumulative total time the query has spent running
|
||||
- the cumulative total time the query has spent running
|
||||
- the min, max and mean query times.
|
||||
|
||||
This provides useful information about the queries you run most frequently. Queries that have high `max_time` or `mean_time` times and are being called often can be good candidates for optimization.
|
||||
@@ -86,12 +84,11 @@ select
|
||||
-- max_time,
|
||||
-- mean_time,
|
||||
statements.rows / statements.calls as avg_rows
|
||||
from pg_stat_statements as statements
|
||||
inner join pg_authid as auth on statements.userid = auth.oid
|
||||
order by
|
||||
max_time desc
|
||||
limit
|
||||
100;
|
||||
from
|
||||
pg_stat_statements as statements
|
||||
inner join pg_authid as auth on statements.userid = auth.oid
|
||||
order by max_time desc
|
||||
limit 100;
|
||||
```
|
||||
|
||||
This query will show you statistics about queries ordered by the maximum execution time. It is similar to the query above ordered by calls, but this one highlights outliers that may have high executions times. Queries which have high or mean execution times are good candidates for optimisation.
|
||||
@@ -104,13 +101,19 @@ select
|
||||
statements.query,
|
||||
statements.calls,
|
||||
statements.total_exec_time + statements.total_plan_time as total_time,
|
||||
to_char(((statements.total_exec_time + statements.total_plan_time)/sum(statements.total_exec_time + statements.total_plan_time) over()) * 100, 'FM90D0') || '%' as prop_total_time
|
||||
from pg_stat_statements as statements
|
||||
to_char(
|
||||
(
|
||||
(statements.total_exec_time + statements.total_plan_time) / sum(
|
||||
statements.total_exec_time + statements.total_plan_time
|
||||
) over ()
|
||||
) * 100,
|
||||
'FM90D0'
|
||||
) || '%' as prop_total_time
|
||||
from
|
||||
pg_stat_statements as statements
|
||||
inner join pg_authid as auth on statements.userid = auth.oid
|
||||
order by
|
||||
total_time desc
|
||||
limit
|
||||
100;
|
||||
order by total_time desc
|
||||
limit 100;
|
||||
```
|
||||
|
||||
This query will show you statistics about queries ordered by the cumulative total execution time. It shows the total time the query has spent running as well as the proportion of total execution time the query has taken up.
|
||||
@@ -128,12 +131,12 @@ You can view your cache and index hit rate by executing the following query:
|
||||
```sql
|
||||
select
|
||||
'index hit rate' as name,
|
||||
(sum(idx_blks_hit)) / nullif(sum(idx_blks_hit + idx_blks_read),0) * 100 as ratio
|
||||
(sum(idx_blks_hit)) / nullif(sum(idx_blks_hit + idx_blks_read), 0) * 100 as ratio
|
||||
from pg_statio_user_indexes
|
||||
union all
|
||||
select
|
||||
'table hit rate' as name,
|
||||
sum(heap_blks_hit) / nullif(sum(heap_blks_hit) + sum(heap_blks_read),0) * 100 as ratio
|
||||
sum(heap_blks_hit) / nullif(sum(heap_blks_hit) + sum(heap_blks_read), 0) * 100 as ratio
|
||||
from pg_statio_user_tables;
|
||||
```
|
||||
|
||||
@@ -141,7 +144,6 @@ This shows the ratio of data blocks fetched from the Postgres [shared_buffers](h
|
||||
|
||||
If either of your index or table hit rate are < 99% then this can indicate your compute plan is too small for your current workload and you would benefit from more memory. [Upgrading your compute](https://supabase.com/docs/guides/platform/compute-add-ons) is easy and can be done from your [project dashboard](https://app.supabase.com/project/_/settings/billing/subscription).
|
||||
|
||||
|
||||
### Optimizing poor performing queries
|
||||
|
||||
Postgres has built in tooling to help you optimize poorly performing queries. You can use the [query plan analyzer](https://www.postgresql.org/docs/current/sql-explain.html) on any expensive queries that you have identified:
|
||||
|
||||
@@ -41,8 +41,10 @@ client libraries. Here we create a bucket called "avatars":
|
||||
```sql
|
||||
-- Use Postgres to create a bucket.
|
||||
|
||||
insert into storage.buckets (id, name)
|
||||
values ('avatars', 'avatars');
|
||||
insert into storage.buckets
|
||||
(id, name)
|
||||
values
|
||||
('avatars', 'avatars');
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
|
||||
@@ -158,8 +158,8 @@ Supabase comes with two built-in helper functions: `auth.uid()` and `auth.jwt()`
|
||||
To create your own functions, navigate to the SQL editor and create a a new query.
|
||||
|
||||
```sql
|
||||
create or replace function user_agent()
|
||||
returns text
|
||||
create or replace function user_agent()
|
||||
returns text
|
||||
language sql
|
||||
as $$
|
||||
select nullif(current_setting('request.headers', true)::json->>'user-agent', '')::text;
|
||||
|
||||
@@ -45,12 +45,16 @@ Let's say we create a leaderboard table. We want people on our website to be abl
|
||||
|
||||
```sql
|
||||
create table leaderboard (
|
||||
name text,
|
||||
score int
|
||||
name text,
|
||||
score int
|
||||
);
|
||||
|
||||
insert into leaderboard(name, score)
|
||||
values ('Paul', 100), ('Leto', 50), ('Chani', 200);
|
||||
insert into leaderboard
|
||||
(name, score)
|
||||
values
|
||||
('Paul', 100),
|
||||
('Leto', 50),
|
||||
('Chani', 200);
|
||||
```
|
||||
|
||||
Now let's set up a client to read the data, I've created a repl here to show a living example: [https://replit.com/@awalias/supabase-leaderboard-demo#index.js](https://replit.com/@awalias/supabase-leaderboard-demo#index.js). If you copy the snippet you can plug in your own Supabase URL and anon key.
|
||||
|
||||
@@ -1862,4 +1862,19 @@ module.exports = [
|
||||
source: '/docs/guides/platform/database-usage',
|
||||
destination: '/docs/guides/platform/database-size',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/guides/resources/postgres/dropping-all-tables-in-schema',
|
||||
destination: '/docs/guides/database/postgres/dropping-all-tables-in-schema',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/guides/resources/postgres/first-row-in-group',
|
||||
destination: '/docs/guides/database/postgres/first-row-in-group',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/docs/guides/resources/postgres/which-version-of-postgres',
|
||||
destination: '/docs/guides/database/postgres/which-version-of-postgres',
|
||||
},
|
||||
]
|
||||
@@ -78,7 +78,6 @@
|
||||
"file-loader": "^6.2.0",
|
||||
"postcss": "^8.4.18",
|
||||
"postcss-preset-env": "^6.7.0",
|
||||
"prettier": "^2.2.1",
|
||||
"tailwindcss": "^3.1.8",
|
||||
"tsconfig": "*"
|
||||
},
|
||||
|
||||
|
After Width: | Height: | Size: 760 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 349 KiB |
|
After Width: | Height: | Size: 328 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 703 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 485 KiB |
|
After Width: | Height: | Size: 185 KiB |
@@ -33,10 +33,12 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/json-stringify-safe": "^5.0.0",
|
||||
"aws-sdk": "^2.1315.0",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-config-custom": "*",
|
||||
"json-stringify-safe": "^5.0.1",
|
||||
"prettier": "^2.5.1",
|
||||
"prettier": "^2.8.4",
|
||||
"prettier-plugin-sql-cst": "^0.5.0",
|
||||
"ts-jest": "^27.0.7",
|
||||
"turbo": "^1.4.7"
|
||||
},
|
||||
@@ -55,16 +57,5 @@
|
||||
"functions",
|
||||
"database",
|
||||
"auth"
|
||||
],
|
||||
"dependencies": {
|
||||
"@code-hike/mdx": "^0.7.4",
|
||||
"@mdx-js/react": "^2.1.1",
|
||||
"aws-sdk": "^2.1315.0",
|
||||
"mdx-mermaid": "^1.3.2",
|
||||
"mermaid": "^9.2.2",
|
||||
"mini-svg-data-uri": "^1.4.4"
|
||||
},
|
||||
"overrides": {
|
||||
"pgsql-parser": "13.4.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import tweets from './tweets'
|
||||
import logConstants from './logConstants'
|
||||
|
||||
export { tweets }
|
||||
export { tweets, logConstants }
|
||||
@@ -0,0 +1,237 @@
|
||||
const schemas = [
|
||||
{
|
||||
name: 'API Edge',
|
||||
reference: 'edge_logs',
|
||||
fields: [
|
||||
{ path: 'id', type: 'string' },
|
||||
{ path: 'timestamp', type: 'datetime' },
|
||||
{ path: 'event_message', type: 'string' },
|
||||
{ path: 'metadata.request.cf.asOrganization', type: 'string' },
|
||||
{ path: 'metadata.request.cf.asn', type: 'number' },
|
||||
{ path: 'metadata.request.cf.botManagement.corporateProxy', type: 'boolean' },
|
||||
{ path: 'metadata.request.cf.botManagement.detectionIds', type: 'number[]' },
|
||||
{ path: 'metadata.request.cf.botManagement.ja3Hash', type: 'string' },
|
||||
{ path: 'metadata.request.cf.botManagement.score', type: 'number' },
|
||||
{ path: 'metadata.request.cf.botManagement.staticResource', type: 'boolean' },
|
||||
{ path: 'metadata.request.cf.botManagement.verifiedBot', type: 'boolean' },
|
||||
{ path: 'metadata.request.cf.city', type: 'string' },
|
||||
{ path: 'metadata.request.cf.clientTcpRtt', type: 'number' },
|
||||
{ path: 'metadata.request.cf.clientTrustScore', type: 'number' },
|
||||
{ path: 'metadata.request.cf.colo', type: 'string' },
|
||||
{ path: 'metadata.request.cf.continent', type: 'string' },
|
||||
{ path: 'metadata.request.cf.country', type: 'string' },
|
||||
{ path: 'metadata.request.cf.edgeRequestKeepAliveStatus', type: 'number' },
|
||||
{ path: 'metadata.request.cf.httpProtocol', type: 'string' },
|
||||
{ path: 'metadata.request.cf.latitude', type: 'string' },
|
||||
{ path: 'metadata.request.cf.longitude', type: 'string' },
|
||||
{ path: 'metadata.request.cf.metroCode', type: 'string' },
|
||||
{ path: 'metadata.request.cf.postalCode', type: 'string' },
|
||||
{ path: 'metadata.request.cf.region', type: 'string' },
|
||||
{ path: 'metadata.request.cf.timezone', type: 'string' },
|
||||
{ path: 'metadata.request.cf.tlsCipher', type: 'string' },
|
||||
{ path: 'metadata.request.cf.tlsClientAuth.certPresented', type: 'string' },
|
||||
{ path: 'metadata.request.cf.tlsClientAuth.certRevoked', type: 'string' },
|
||||
{ path: 'metadata.request.cf.tlsClientAuth.certVerified', type: 'string' },
|
||||
{ path: 'metadata.request.cf.tlsExportedAuthenticator.clientFinished', type: 'string' },
|
||||
{ path: 'metadata.request.cf.tlsExportedAuthenticator.clientHandshake', type: 'string' },
|
||||
{ path: 'metadata.request.cf.tlsExportedAuthenticator.serverFinished', type: 'string' },
|
||||
{ path: 'metadata.request.cf.tlsExportedAuthenticator.serverHandshake', type: 'string' },
|
||||
{ path: 'metadata.request.cf.tlsVersion', type: 'string' },
|
||||
{ path: 'metadata.request.headers.cf_connecting_ip', type: 'string' },
|
||||
{ path: 'metadata.request.headers.cf_ipcountry', type: 'string' },
|
||||
{ path: 'metadata.request.headers.cf_ray', type: 'string' },
|
||||
{ path: 'metadata.request.headers.host', type: 'string' },
|
||||
{ path: 'metadata.request.headers.x_client_info', type: 'string' },
|
||||
{ path: 'metadata.request.headers.x_forwarded_proto', type: 'string' },
|
||||
{ path: 'metadata.request.headers.x_real_ip', type: 'string' },
|
||||
{ path: 'metadata.request.host', type: 'string' },
|
||||
{ path: 'metadata.request.method', type: 'string' },
|
||||
{ path: 'metadata.request.path', type: 'string' },
|
||||
{ path: 'metadata.request.protocol', type: 'string' },
|
||||
{ path: 'metadata.request.search', type: 'string' },
|
||||
{ path: 'metadata.request.url', type: 'string' },
|
||||
{ path: 'metadata.response.headers.cf_cache_status', type: 'string' },
|
||||
{ path: 'metadata.response.headers.cf_ray', type: 'string' },
|
||||
{ path: 'metadata.response.headers.content_location', type: 'string' },
|
||||
{ path: 'metadata.response.headers.content_range', type: 'string' },
|
||||
{ path: 'metadata.response.headers.content_type', type: 'string' },
|
||||
{ path: 'metadata.response.headers.date', type: 'string' },
|
||||
{ path: 'metadata.response.headers.sb_gateway_version', type: 'string' },
|
||||
{ path: 'metadata.response.headers.transfer_encoding', type: 'string' },
|
||||
{ path: 'metadata.response.headers.x_kong_proxy_latency', type: 'string' },
|
||||
{ path: 'metadata.response.origin_time', type: 'number' },
|
||||
{ path: 'metadata.response.status_code', type: 'number' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Auth',
|
||||
reference: 'auth_logs',
|
||||
fields: [
|
||||
{ path: 'event_message', type: 'string' },
|
||||
{ path: 'id', type: 'string' },
|
||||
{ path: 'timestamp', type: 'datetime' },
|
||||
{ path: 'metadata.auth_event.action', type: 'string' },
|
||||
{ path: 'metadata.auth_event.actor_id', type: 'string' },
|
||||
{ path: 'metadata.auth_event.actor_username', type: 'string' },
|
||||
{ path: 'metadata.auth_event.log_type', type: 'string' },
|
||||
{ path: 'metadata.auth_event.traits.provider', type: 'string' },
|
||||
{ path: 'metadata.auth_event.traits.user_email', type: 'string' },
|
||||
{ path: 'metadata.auth_event.traits.user_id', type: 'string' },
|
||||
{ path: 'metadata.auth_event.traits.user_phone', type: 'string' },
|
||||
{ path: 'metadata.component', type: 'string' },
|
||||
{ path: 'metadata.duration', type: 'number' },
|
||||
{ path: 'metadata.host', type: 'string' },
|
||||
{ path: 'metadata.level', type: 'string' },
|
||||
{ path: 'metadata.method', type: 'string' },
|
||||
{ path: 'metadata.msg', type: 'string' },
|
||||
{ path: 'metadata.path', type: 'string' },
|
||||
{ path: 'metadata.referer', type: 'string' },
|
||||
{ path: 'metadata.remote_addr', type: 'string' },
|
||||
{ path: 'metadata.status', type: 'number' },
|
||||
{ path: 'metadata.timestamp', type: 'string' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Storage',
|
||||
reference: 'storage_logs',
|
||||
fields: [
|
||||
{ path: 'event_message', type: 'string' },
|
||||
{ path: 'id', type: 'string' },
|
||||
{ path: 'timestamp', type: 'datetime' },
|
||||
{ path: 'metadata.context.host', type: 'string' },
|
||||
{ path: 'metadata.context.pid', type: 'number' },
|
||||
{ path: 'metadata.level', type: 'string' },
|
||||
{ path: 'metadata.project', type: 'string' },
|
||||
{ path: 'metadata.req.headers.accept', type: 'string' },
|
||||
{ path: 'metadata.req.headers.cf_connecting_ip', type: 'string' },
|
||||
{ path: 'metadata.req.headers.cf_ray', type: 'string' },
|
||||
{ path: 'metadata.req.headers.content_length', type: 'string' },
|
||||
{ path: 'metadata.req.headers.content_type', type: 'string' },
|
||||
{ path: 'metadata.req.headers.host', type: 'string' },
|
||||
{ path: 'metadata.req.headers.referer', type: 'string' },
|
||||
{ path: 'metadata.req.headers.user_agent', type: 'string' },
|
||||
{ path: 'metadata.req.headers.x_client_info', type: 'string' },
|
||||
{ path: 'metadata.req.headers.x_forwarded_proto', type: 'string' },
|
||||
{ path: 'metadata.req.hostname', type: 'string' },
|
||||
{ path: 'metadata.req.method', type: 'string' },
|
||||
{ path: 'metadata.req.remoteAddress', type: 'string' },
|
||||
{ path: 'metadata.req.remotePort', type: 'number' },
|
||||
{ path: 'metadata.req.url', type: 'string' },
|
||||
{ path: 'metadata.reqId', type: 'string' },
|
||||
{ path: 'metadata.res.statusCode', type: 'number' },
|
||||
{ path: 'metadata.responseTime', type: 'number' },
|
||||
{ path: 'metadata.tenantId', type: 'string' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Function Edge',
|
||||
reference: 'function_edge_logs',
|
||||
fields: [
|
||||
{ path: 'event_message', type: 'string' },
|
||||
{ path: 'id', type: 'string' },
|
||||
{ path: 'timestamp', type: 'datetime' },
|
||||
{ path: 'metadata.deployment_id', type: 'string' },
|
||||
{ path: 'metadata.execution_time_ms', type: 'number' },
|
||||
{ path: 'metadata.function_id', type: 'string' },
|
||||
{ path: 'metadata.project_ref', type: 'string' },
|
||||
{ path: 'metadata.request.headers.accept', type: 'string' },
|
||||
{ path: 'metadata.request.headers.content_length', type: 'string' },
|
||||
{ path: 'metadata.request.headers.host', type: 'string' },
|
||||
{ path: 'metadata.request.headers.user_agent', type: 'string' },
|
||||
{ path: 'metadata.request.host', type: 'string' },
|
||||
{ path: 'metadata.request.method', type: 'string' },
|
||||
{ path: 'metadata.request.pathname', type: 'string' },
|
||||
{ path: 'metadata.request.protocol', type: 'string' },
|
||||
{ path: 'metadata.request.url', type: 'string' },
|
||||
{ path: 'metadata.response.headers.content_length', type: 'string' },
|
||||
{ path: 'metadata.response.headers.content_type', type: 'string' },
|
||||
{ path: 'metadata.response.headers.date', type: 'string' },
|
||||
{ path: 'metadata.response.headers.server', type: 'string' },
|
||||
{ path: 'metadata.response.headers.vary', type: 'string' },
|
||||
{ path: 'metadata.response.status_code', type: 'number' },
|
||||
{ path: 'metadata.version', type: 'string' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Function Runtime',
|
||||
reference: 'function_logs',
|
||||
fields: [
|
||||
{ path: 'event_message', type: 'string' },
|
||||
{ path: 'id', type: 'string' },
|
||||
{ path: 'timestamp', type: 'datetime' },
|
||||
{ path: 'metadata.deployment_id', type: 'string' },
|
||||
{ path: 'metadata.event_type', type: 'string' },
|
||||
{ path: 'metadata.execution_id', type: 'string' },
|
||||
{ path: 'metadata.function_id', type: 'string' },
|
||||
{ path: 'metadata.level', type: 'string' },
|
||||
{ path: 'metadata.project_ref', type: 'string' },
|
||||
{ path: 'metadata.region', type: 'string' },
|
||||
{ path: 'metadata.timestamp', type: 'string' },
|
||||
{ path: 'metadata.version', type: 'string' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Postgres',
|
||||
reference: 'postgres_logs',
|
||||
fields: [
|
||||
{ path: 'event_message', type: 'string' },
|
||||
{ path: 'id', type: 'string' },
|
||||
{ path: 'timestamp', type: 'datetime' },
|
||||
{ path: 'metadata.host', type: 'string' },
|
||||
{ path: 'metadata.parsed.backend_type', type: 'string' },
|
||||
{ path: 'metadata.parsed.command_tag', type: 'string' },
|
||||
{ path: 'metadata.parsed.connection_from', type: 'string' },
|
||||
{ path: 'metadata.parsed.database_name', type: 'string' },
|
||||
{ path: 'metadata.parsed.error_severity', type: 'string' },
|
||||
{ path: 'metadata.parsed.process_id', type: 'number' },
|
||||
{ path: 'metadata.parsed.query_id', type: 'number' },
|
||||
{ path: 'metadata.parsed.session_id', type: 'string' },
|
||||
{ path: 'metadata.parsed.session_line_num', type: 'number' },
|
||||
{ path: 'metadata.parsed.session_start_time', type: 'string' },
|
||||
{ path: 'metadata.parsed.sql_state_code', type: 'string' },
|
||||
{ path: 'metadata.parsed.timestamp', type: 'string' },
|
||||
{ path: 'metadata.parsed.transaction_id', type: 'number' },
|
||||
{ path: 'metadata.parsed.user_name', type: 'string' },
|
||||
{ path: 'metadata.parsed.virtual_transaction_id', type: 'string' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Realtime',
|
||||
reference: 'realtime_logs',
|
||||
fields: [
|
||||
{ path: 'event_message', type: 'string' },
|
||||
{ path: 'id', type: 'string' },
|
||||
{ path: 'timestamp', type: 'datetime' },
|
||||
{ path: 'metadata.level', type: 'string' },
|
||||
{ path: 'metadata.measurements.connected', type: 'number' },
|
||||
{ path: 'metadata.measurements.connected_cluster', type: 'number' },
|
||||
{ path: 'metadata.measurements.limit', type: 'number' },
|
||||
{ path: 'metadata.measurements.sum', type: 'number' },
|
||||
{ path: 'metadata.external_id', type: 'string' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'PostgREST',
|
||||
reference: 'postgrest_logs',
|
||||
fields: [
|
||||
{ path: 'event_message', type: 'string' },
|
||||
{ path: 'id', type: 'string' },
|
||||
{ path: 'timestamp', type: 'datetime' },
|
||||
{ path: 'metadata.host', type: 'string' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'PgBouncer',
|
||||
reference: 'pgbouncer_logs',
|
||||
fields: [
|
||||
{ path: 'event_message', type: 'string' },
|
||||
{ path: 'id', type: 'string' },
|
||||
{ path: 'timestamp', type: 'datetime' },
|
||||
{ path: 'metadata.host', type: 'string' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export default {
|
||||
schemas,
|
||||
}
|
||||
@@ -173,13 +173,18 @@ interface RadioProps {
|
||||
children: React.ReactNode
|
||||
value: string
|
||||
ItemIndicator?: React.ReactNode
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function Radio({ children, value, ItemIndicator }: RadioProps) {
|
||||
export function Radio({ children, value, ItemIndicator, disabled }: RadioProps) {
|
||||
let __styles = styleHandler('dropdown')
|
||||
|
||||
return (
|
||||
<RadixDropdown.RadioItem value={value} className={`${__styles.item} ${__styles.input}`}>
|
||||
<RadixDropdown.RadioItem
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
className={`${__styles.item} ${__styles.input}`}
|
||||
>
|
||||
<RadixDropdown.ItemIndicator className={__styles.check}>
|
||||
{ItemIndicator ? ItemIndicator : <IconTarget strokeWidth={6} size={10} />}
|
||||
</RadixDropdown.ItemIndicator>
|
||||
|
||||
@@ -7,9 +7,8 @@ import { DataGridHandle } from '@supabase/react-data-grid'
|
||||
|
||||
import { useUrlState } from 'hooks'
|
||||
import { Dictionary, SupabaseGridProps, SupabaseGridRef } from './types'
|
||||
import { useTableRowsQuery } from 'data/table-rows/table-rows-query'
|
||||
import { StoreProvider, useDispatch, useTrackedState } from './store'
|
||||
import { fetchCount, fetchPage, refreshPageDebounced } from './utils'
|
||||
import { REFRESH_PAGE_IMMEDIATELY, TOTAL_ROWS_RESET } from './constants'
|
||||
import { Shortcuts } from './components/common'
|
||||
import { Grid } from './components/grid'
|
||||
import Header from './components/header'
|
||||
@@ -22,6 +21,7 @@ import {
|
||||
formatFilterURLParams,
|
||||
formatSortURLParams,
|
||||
} from './SupabaseGrid.utils'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
|
||||
/** Supabase Grid: React component to render database table */
|
||||
|
||||
@@ -71,9 +71,30 @@ const SupabaseGridLayout = forwardRef<SupabaseGridRef, SupabaseGridProps>((props
|
||||
const sorts = formatSortURLParams(sort as string[])
|
||||
const filters = formatFilterURLParams(filter as string[])
|
||||
|
||||
// [Joshen] This is not a perfect fix, but the useEffect to initialize the data in the editor
|
||||
// was not getting retriggered for views, works fine for tables.
|
||||
const table = typeof props.table === 'string' ? { name: props.table } : props.table
|
||||
const table = props.table
|
||||
|
||||
const { project } = useProjectContext()
|
||||
const { data, isLoading, isRefetching } = useTableRowsQuery(
|
||||
{
|
||||
queryKey: [table.schema, table.name],
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
table,
|
||||
sorts,
|
||||
filters,
|
||||
page: state.page,
|
||||
limit: state.rowsPerPage,
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
onSuccess(data) {
|
||||
dispatch({
|
||||
type: 'SET_ROWS_COUNT',
|
||||
payload: data.rows.length,
|
||||
})
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
rowAdded(row: Dictionary<any>) {
|
||||
@@ -94,20 +115,6 @@ const SupabaseGridLayout = forwardRef<SupabaseGridRef, SupabaseGridProps>((props
|
||||
if (!mounted) setMounted(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (state.refreshPageFlag === REFRESH_PAGE_IMMEDIATELY) {
|
||||
fetchPage(state, dispatch, sorts, filters)
|
||||
} else if (state.refreshPageFlag !== 0) {
|
||||
refreshPageDebounced(state, dispatch, sorts, filters)
|
||||
}
|
||||
}, [state.refreshPageFlag])
|
||||
|
||||
useEffect(() => {
|
||||
if (state.totalRows === TOTAL_ROWS_RESET) {
|
||||
fetchCount(state, dispatch, filters)
|
||||
}
|
||||
}, [state.totalRows])
|
||||
|
||||
useEffect(() => {
|
||||
if (mounted) {
|
||||
dispatch({ type: 'UPDATE_FILTERS', payload: {} })
|
||||
@@ -191,10 +198,10 @@ const SupabaseGridLayout = forwardRef<SupabaseGridRef, SupabaseGridProps>((props
|
||||
onAddColumn={editable ? props.onAddColumn : undefined}
|
||||
headerActions={headerActions}
|
||||
/>
|
||||
<Grid ref={gridRef} {...gridProps} rows={state.rows} />
|
||||
<Footer />
|
||||
<Grid ref={gridRef} {...gridProps} rows={data?.rows ?? []} updateRow={props.updateTableRow} />
|
||||
<Footer isLoading={isLoading || isRefetching} />
|
||||
<Shortcuts gridRef={gridRef} />
|
||||
{mounted && createPortal(<RowContextMenu />, document.body)}
|
||||
{mounted && createPortal(<RowContextMenu table={table} />, document.body)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -1,15 +1,17 @@
|
||||
import * as React from 'react'
|
||||
import Pagination from './pagination'
|
||||
|
||||
type FooterProps = {}
|
||||
export interface FooterProps {
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
const Footer: React.FC<FooterProps> = () => {
|
||||
const Footer = ({ isLoading }: FooterProps) => {
|
||||
return (
|
||||
<div className="sb-grid-footer">
|
||||
<div className="sb-grid-footer__inner">
|
||||
<Pagination />
|
||||
<Pagination isLoading={isLoading} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Footer
|
||||
@@ -1,9 +1,13 @@
|
||||
import { FC, useState, useEffect } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import AwesomeDebouncePromise from 'awesome-debounce-promise'
|
||||
import { Button, InputNumber, IconArrowRight, IconArrowLeft, IconLoader } from 'ui'
|
||||
import { useUrlState } from 'hooks'
|
||||
import { useTableRowsCountQuery } from 'data/table-rows/table-rows-count-query'
|
||||
import { DropdownControl } from '../../common'
|
||||
import { useDispatch, useTrackedState } from '../../../store'
|
||||
import { confirmAlert } from 'components/to-be-cleaned/ModalsDeprecated/ConfirmModal'
|
||||
import { formatFilterURLParams } from 'components/grid/SupabaseGrid.utils'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
|
||||
const updatePage = (payload: number, dispatch: (value: unknown) => void) => {
|
||||
dispatch({
|
||||
@@ -19,14 +23,34 @@ const rowsPerPageOptions = [
|
||||
{ value: 1000, label: '1000 rows' },
|
||||
]
|
||||
|
||||
type PaginationProps = {}
|
||||
export interface PaginationProps {
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
const Pagination: FC<PaginationProps> = () => {
|
||||
const Pagination = ({ isLoading: isLoadingRows = false }: PaginationProps) => {
|
||||
const state = useTrackedState()
|
||||
const dispatch = useDispatch()
|
||||
const [page, setPage] = useState<number | null>(state.page)
|
||||
const maxPages = Math.ceil(state.totalRows / state.rowsPerPage)
|
||||
const totalPages = state.totalRows > 0 ? maxPages : 1
|
||||
|
||||
const [{ filter }] = useUrlState({
|
||||
arrayKeys: ['filter'],
|
||||
})
|
||||
const filters = formatFilterURLParams(filter as string[])
|
||||
const table = state.table ?? undefined
|
||||
const { project } = useProjectContext()
|
||||
const { data, isLoading, isSuccess, isError } = useTableRowsCountQuery(
|
||||
{
|
||||
queryKey: [table?.schema, table?.name, 'count'],
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
table,
|
||||
filters,
|
||||
},
|
||||
{ keepPreviousData: true }
|
||||
)
|
||||
|
||||
const maxPages = Math.ceil((data?.count ?? 0) / state.rowsPerPage)
|
||||
const totalPages = (data?.count ?? 0) > 0 ? maxPages : 1
|
||||
|
||||
// [Joshen] Oddly without this, state.selectedRows will be stale
|
||||
useEffect(() => {}, [state.selectedRows])
|
||||
@@ -75,6 +99,8 @@ const Pagination: FC<PaginationProps> = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: look at aborting useTableRowsQuery if the user presses the button quickly
|
||||
|
||||
const goToPreviousPage = () => {
|
||||
const previousPage = state.page - 1
|
||||
setPage(previousPage)
|
||||
@@ -102,14 +128,14 @@ const Pagination: FC<PaginationProps> = () => {
|
||||
|
||||
return (
|
||||
<div className="sb-grid-pagination">
|
||||
{state.totalRows < 0 ? (
|
||||
<p className="text-sm text-scale-1100">Loading records...</p>
|
||||
) : (
|
||||
{isLoading && <p className="text-sm text-scale-1100">Loading records count...</p>}
|
||||
|
||||
{isSuccess && (
|
||||
<>
|
||||
<Button
|
||||
icon={<IconArrowLeft />}
|
||||
type="outline"
|
||||
disabled={state.page <= 1 || state.isLoading}
|
||||
disabled={state.page <= 1 || isLoading}
|
||||
onClick={onPreviousPage}
|
||||
style={{ padding: '3px 10px' }}
|
||||
/>
|
||||
@@ -132,7 +158,7 @@ const Pagination: FC<PaginationProps> = () => {
|
||||
<Button
|
||||
icon={<IconArrowRight />}
|
||||
type="outline"
|
||||
disabled={state.page >= maxPages || state.isLoading}
|
||||
disabled={state.page >= maxPages || isLoading}
|
||||
onClick={onNextPage}
|
||||
style={{ padding: '3px 10px' }}
|
||||
/>
|
||||
@@ -149,12 +175,18 @@ const Pagination: FC<PaginationProps> = () => {
|
||||
style={{ padding: '3px 10px' }}
|
||||
>{`${state.rowsPerPage} rows`}</Button>
|
||||
</DropdownControl>
|
||||
<p className="text-sm text-scale-1100">{`${state.totalRows.toLocaleString()} ${
|
||||
state.totalRows === 0 || state.totalRows > 1 ? `records` : 'record'
|
||||
<p className="text-sm text-scale-1100">{`${data.count.toLocaleString()} ${
|
||||
data.count === 0 || data.count > 1 ? `records` : 'record'
|
||||
}`}</p>
|
||||
{state.isLoading && <IconLoader size={14} className="animate-spin" />}
|
||||
{isLoadingRows && <IconLoader size={14} className="animate-spin" />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<p className="text-sm text-scale-1100">
|
||||
Error fetching records count. Please refresh the page.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { GridProps, SupaRow } from '../../types'
|
||||
import { useDispatch, useTrackedState } from '../../store'
|
||||
import RowRenderer from './RowRenderer'
|
||||
import AwesomeDebouncePromise from 'awesome-debounce-promise'
|
||||
import { useTableRowUpdateMutation } from 'data/table-rows/table-row-update-mutation'
|
||||
|
||||
function rowKeyGetter(row: SupaRow) {
|
||||
return row?.idx ?? -1
|
||||
@@ -13,13 +14,14 @@ function rowKeyGetter(row: SupaRow) {
|
||||
|
||||
interface IGrid extends GridProps {
|
||||
rows: any[]
|
||||
updateRow: (previousRow: any, updatedData: any) => void
|
||||
}
|
||||
|
||||
// [Joshen] Just for visibility this is causing some hook errors in the browser
|
||||
export const Grid = memo(
|
||||
forwardRef<DataGridHandle, IGrid>(
|
||||
(
|
||||
{ width, height, containerClass, gridClass, rowClass, rows },
|
||||
{ width, height, containerClass, gridClass, rowClass, rows, updateRow },
|
||||
ref: React.Ref<DataGridHandle> | undefined
|
||||
) => {
|
||||
const dispatch = useDispatch()
|
||||
@@ -41,18 +43,7 @@ export const Grid = memo(
|
||||
)
|
||||
|
||||
if (changedColumn) {
|
||||
const { error } = state.rowService!.update(
|
||||
rowData,
|
||||
originRowData,
|
||||
changedColumn,
|
||||
(payload) => {
|
||||
dispatch({
|
||||
type: 'EDIT_ROW',
|
||||
payload,
|
||||
})
|
||||
}
|
||||
)
|
||||
if (error && onErrorFunc) onErrorFunc(error)
|
||||
updateRow(originRowData, { [changedColumn]: rowData[changedColumn] })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { saveAs } from 'file-saver'
|
||||
import { FC, useState, ReactNode } from 'react'
|
||||
import { useState, ReactNode } from 'react'
|
||||
import { Button, IconDownload, IconX, IconTrash, Dropdown, IconChevronDown } from 'ui'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
|
||||
@@ -11,14 +11,21 @@ import { confirmAlert } from 'components/to-be-cleaned/ModalsDeprecated/ConfirmM
|
||||
import { Sort, Filter, SupaTable } from 'components/grid/types'
|
||||
import { exportRowsToCsv } from 'components/grid/utils'
|
||||
import { useDispatch, useTrackedState } from 'components/grid/store'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { useTableRowDeleteMutation } from 'data/table-rows/table-row-delete-mutation'
|
||||
import { useTableRowDeleteAllMutation } from 'data/table-rows/table-row-delete-all-mutation'
|
||||
import { useTableRowTruncateMutation } from 'data/table-rows/table-row-truncate-mutation'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useTableRowsCountQuery } from 'data/table-rows/table-rows-count-query'
|
||||
import { useTableRowsQuery } from 'data/table-rows/table-rows-query'
|
||||
|
||||
// [Joshen] CSV exports require this guard as a fail-safe if the table is
|
||||
// just too large for a browser to keep all the rows in memory before
|
||||
// exporting. Either that or export as multiple CSV sheets with max n rows each
|
||||
const MAX_EXPORT_ROW_COUNT = 500000
|
||||
|
||||
interface HeaderProps {
|
||||
table: Partial<SupaTable>
|
||||
export type HeaderProps = {
|
||||
table: SupaTable
|
||||
sorts: Sort[]
|
||||
filters: Filter[]
|
||||
onAddColumn?: () => void
|
||||
@@ -26,50 +33,30 @@ interface HeaderProps {
|
||||
headerActions?: ReactNode
|
||||
}
|
||||
|
||||
const Header: FC<HeaderProps> = ({
|
||||
table,
|
||||
sorts,
|
||||
filters,
|
||||
onAddColumn,
|
||||
onAddRow,
|
||||
headerActions,
|
||||
}) => {
|
||||
const Header = ({ table, sorts, filters, onAddColumn, onAddRow, headerActions }: HeaderProps) => {
|
||||
const state = useTrackedState()
|
||||
const { selectedRows } = state
|
||||
|
||||
return (
|
||||
<div className="flex h-10 items-center justify-between bg-scale-100 px-5 py-1.5 dark:bg-scale-300">
|
||||
{selectedRows.size > 0 ? (
|
||||
<RowHeader sorts={sorts} filters={filters} />
|
||||
<RowHeader table={table} sorts={sorts} filters={filters} />
|
||||
) : (
|
||||
<DefaultHeader
|
||||
table={table}
|
||||
sorts={sorts}
|
||||
filters={filters}
|
||||
onAddColumn={onAddColumn}
|
||||
onAddRow={onAddRow}
|
||||
/>
|
||||
<DefaultHeader table={table} onAddColumn={onAddColumn} onAddRow={onAddRow} />
|
||||
)}
|
||||
<div className="sb-grid-header__inner">{headerActions}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Header
|
||||
|
||||
interface DefaultHeaderProps {
|
||||
table: Partial<SupaTable>
|
||||
sorts: Sort[]
|
||||
filters: Filter[]
|
||||
type DefaultHeaderProps = {
|
||||
table: SupaTable
|
||||
onAddColumn?: () => void
|
||||
onAddRow?: () => void
|
||||
}
|
||||
const DefaultHeader: FC<DefaultHeaderProps> = ({
|
||||
table,
|
||||
sorts,
|
||||
filters,
|
||||
onAddColumn,
|
||||
onAddRow,
|
||||
}) => {
|
||||
const DefaultHeader = ({ table, onAddColumn, onAddRow }: DefaultHeaderProps) => {
|
||||
const canAddNew = onAddRow !== undefined || onAddColumn !== undefined
|
||||
|
||||
// [Joshen] Using this logic to block both column and row creation/update/delete
|
||||
@@ -78,7 +65,7 @@ const DefaultHeader: FC<DefaultHeaderProps> = ({
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<RefreshButton filters={filters} sorts={sorts} />
|
||||
<RefreshButton table={table} />
|
||||
<FilterDropdown />
|
||||
<SortPopover />
|
||||
</div>
|
||||
@@ -160,18 +147,49 @@ const DefaultHeader: FC<DefaultHeaderProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
interface RowHeaderProps {
|
||||
type RowHeaderProps = {
|
||||
table: SupaTable
|
||||
sorts: Sort[]
|
||||
filters: Filter[]
|
||||
}
|
||||
const RowHeader: FC<RowHeaderProps> = ({ sorts, filters }) => {
|
||||
const RowHeader = ({ table, sorts, filters }: RowHeaderProps) => {
|
||||
const queryClient = useQueryClient()
|
||||
const { ui } = useStore()
|
||||
const state = useTrackedState()
|
||||
const dispatch = useDispatch()
|
||||
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
const { project } = useProjectContext()
|
||||
const { mutateAsync: deleteRows } = useTableRowDeleteMutation()
|
||||
const { mutateAsync: deleteAllRows } = useTableRowDeleteAllMutation()
|
||||
const { mutateAsync: truncateRows } = useTableRowTruncateMutation()
|
||||
|
||||
const { selectedRows, rows: allRows, editable, allRowsSelected, totalRows } = state
|
||||
const { data } = useTableRowsQuery({
|
||||
queryKey: [table.schema, table.name],
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
table,
|
||||
sorts,
|
||||
filters,
|
||||
page: state.page,
|
||||
limit: state.rowsPerPage,
|
||||
})
|
||||
|
||||
const allRows = data?.rows ?? []
|
||||
|
||||
const { data: countData } = useTableRowsCountQuery(
|
||||
{
|
||||
queryKey: [table?.schema, table?.name, 'count'],
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
table,
|
||||
filters,
|
||||
},
|
||||
{ keepPreviousData: true }
|
||||
)
|
||||
|
||||
const totalRows = countData?.count ?? 0
|
||||
|
||||
const { selectedRows, editable, allRowsSelected } = state
|
||||
|
||||
const onSelectAllRows = () => {
|
||||
dispatch({
|
||||
@@ -189,38 +207,60 @@ const RowHeader: FC<RowHeaderProps> = ({ sorts, filters }) => {
|
||||
}? This action cannot be undone.`,
|
||||
confirmText: `Delete ${numRows} rows`,
|
||||
onAsyncConfirm: async () => {
|
||||
if (!project) return
|
||||
|
||||
if (allRowsSelected) {
|
||||
const { error } =
|
||||
filters.length === 0
|
||||
? await state.rowService!.truncate()
|
||||
: await state.rowService!.deleteAll(filters)
|
||||
if (error) {
|
||||
if (state.onError) state.onError(error)
|
||||
} else {
|
||||
try {
|
||||
if (filters.length === 0) {
|
||||
await truncateRows({
|
||||
projectRef: project.ref,
|
||||
connectionString: project.connectionString,
|
||||
table,
|
||||
})
|
||||
} else {
|
||||
await deleteAllRows({
|
||||
projectRef: project.ref,
|
||||
connectionString: project.connectionString,
|
||||
table,
|
||||
filters,
|
||||
})
|
||||
}
|
||||
|
||||
dispatch({ type: 'REMOVE_ALL_ROWS' })
|
||||
dispatch({
|
||||
type: 'SELECTED_ROWS_CHANGE',
|
||||
payload: { selectedRows: new Set() },
|
||||
})
|
||||
} catch (error) {
|
||||
if (state.onError) state.onError(error)
|
||||
}
|
||||
} else {
|
||||
const rowIdxs = Array.from(selectedRows) as number[]
|
||||
const rows = allRows.filter((x) => rowIdxs.includes(x.idx))
|
||||
const { error } = await state.rowService!.delete(rows)
|
||||
if (error) {
|
||||
if (state.onError) state.onError(error)
|
||||
} else {
|
||||
|
||||
try {
|
||||
await deleteRows({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
table,
|
||||
rows,
|
||||
})
|
||||
|
||||
dispatch({ type: 'REMOVE_ROWS', payload: { rowIdxs } })
|
||||
dispatch({
|
||||
type: 'SELECTED_ROWS_CHANGE',
|
||||
payload: { selectedRows: new Set() },
|
||||
})
|
||||
} catch (error) {
|
||||
if (state.onError) state.onError(error)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
|
||||
async function onRowsExportCSV() {
|
||||
setIsExporting(true)
|
||||
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
import { FC, useState, useEffect } from 'react'
|
||||
import { Button, IconRefreshCw, IconCheck } from 'ui'
|
||||
import { useQueryClient, useIsFetching } from '@tanstack/react-query'
|
||||
import { SupaTable } from 'components/grid/types'
|
||||
import { sqlKeys } from 'data/sql/keys'
|
||||
import { useParams } from 'hooks'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button, IconCheck, IconRefreshCw } from 'ui'
|
||||
import { SupabaseGridQueue } from '../../constants'
|
||||
import { Filter, Sort } from 'components/grid/types'
|
||||
import { useDispatch, useTrackedState } from 'components/grid/store'
|
||||
import { fetchCount, fetchPage } from 'components/grid/utils'
|
||||
|
||||
interface Props {
|
||||
sorts: Sort[]
|
||||
filters: Filter[]
|
||||
export type RefreshButtonProps = {
|
||||
table: SupaTable
|
||||
}
|
||||
|
||||
const RefreshButton: FC<Props> = ({ sorts, filters }) => {
|
||||
const state = useTrackedState()
|
||||
const dispatch = useDispatch()
|
||||
const RefreshButton = ({ table }: RefreshButtonProps) => {
|
||||
const { ref } = useParams()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const queryKey = sqlKeys.query(ref, [table.schema, table.name])
|
||||
|
||||
const loading = useIsFetching({ queryKey }) > 0
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [status, setStatus] = useState<string>()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -40,10 +43,7 @@ const RefreshButton: FC<Props> = ({ sorts, filters }) => {
|
||||
}, [])
|
||||
|
||||
async function onClick() {
|
||||
setLoading(true)
|
||||
await fetchCount(state, dispatch, filters)
|
||||
await fetchPage(state, dispatch, sorts, filters)
|
||||
setLoading(false)
|
||||
await queryClient.invalidateQueries(queryKey)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import * as React from 'react'
|
||||
import { Menu, Item, ItemParams, PredicateParams } from 'react-contexify'
|
||||
import { IconTrash, IconClipboard, IconEdit } from 'ui'
|
||||
import { useDispatch, useTrackedState } from '../../store'
|
||||
import { formatClipboardValue, copyToClipboard } from '../../utils'
|
||||
import { useTableRowDeleteMutation } from 'data/table-rows/table-row-delete-mutation'
|
||||
import { confirmAlert } from 'components/to-be-cleaned/ModalsDeprecated/ConfirmModal'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { SupaTable } from 'components/grid/types'
|
||||
|
||||
export const ROW_CONTEXT_MENU_ID = 'row-context-menu-id'
|
||||
|
||||
type RowContextMenuProps = {}
|
||||
export type RowContextMenuProps = {
|
||||
table: SupaTable
|
||||
}
|
||||
|
||||
const RowContextMenu: React.FC<RowContextMenuProps> = ({}) => {
|
||||
const RowContextMenu = ({ table }: RowContextMenuProps) => {
|
||||
const state = useTrackedState()
|
||||
const dispatch = useDispatch()
|
||||
|
||||
const { project } = useProjectContext()
|
||||
const { mutateAsync: deleteRows } = useTableRowDeleteMutation()
|
||||
|
||||
function onDeleteRow(p: ItemParams) {
|
||||
confirmAlert({
|
||||
title: 'Confirm to delete',
|
||||
@@ -21,17 +28,23 @@ const RowContextMenu: React.FC<RowContextMenuProps> = ({}) => {
|
||||
const { props } = p
|
||||
const { rowIdx } = props
|
||||
const row = state.rows[rowIdx]
|
||||
if (!row) return
|
||||
if (!row || !project) return
|
||||
|
||||
try {
|
||||
await deleteRows({
|
||||
projectRef: project.ref,
|
||||
connectionString: project.connectionString,
|
||||
table,
|
||||
rows: [row],
|
||||
})
|
||||
|
||||
const { error } = await state.rowService!.delete([row])
|
||||
if (error) {
|
||||
if (state.onError) state.onError(error)
|
||||
} else {
|
||||
dispatch({ type: 'REMOVE_ROWS', payload: { rowIdxs: [row.idx] } })
|
||||
dispatch({
|
||||
type: 'SELECTED_ROWS_CHANGE',
|
||||
payload: { selectedRows: new Set() },
|
||||
})
|
||||
} catch (error) {
|
||||
if (state.onError) state.onError(error)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface SupabaseGridProps {
|
||||
/**
|
||||
* database table swagger or table name
|
||||
*/
|
||||
table: SupaTable | string
|
||||
table: SupaTable
|
||||
/**
|
||||
*
|
||||
* run sql query
|
||||
@@ -74,6 +74,7 @@ export interface SupabaseGridProps {
|
||||
*/
|
||||
refreshDocs: () => void
|
||||
onExpandJSONEditor: (column: string, row: SupaRow) => void
|
||||
updateTableRow: (previousRow: any, updatedData: any) => void
|
||||
}
|
||||
|
||||
export interface SupabaseGridRef {
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
/* Based on https://github.com/graphql/graphiql/blob/main/packages/graphiql/src/components/GraphiQL.tsx */
|
||||
|
||||
import {
|
||||
Button,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
CopyIcon,
|
||||
Dialog,
|
||||
ExecuteButton,
|
||||
GraphiQLProvider,
|
||||
HeaderEditor,
|
||||
KeyboardShortcutIcon,
|
||||
MergeIcon,
|
||||
PlusIcon,
|
||||
PrettifyIcon,
|
||||
QueryEditor,
|
||||
ReloadIcon,
|
||||
ResponseEditor,
|
||||
SettingsIcon,
|
||||
Spinner,
|
||||
Tab,
|
||||
Tabs,
|
||||
@@ -31,14 +27,12 @@ import {
|
||||
usePluginContext,
|
||||
usePrettifyEditors,
|
||||
useSchemaContext,
|
||||
useStorageContext,
|
||||
useTheme,
|
||||
VariableEditor,
|
||||
} from '@graphiql/react'
|
||||
import { Fetcher } from '@graphiql/toolkit'
|
||||
import clsx from 'clsx'
|
||||
import 'graphiql/graphiql.css'
|
||||
import { PropsWithChildren, useEffect, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import styles from './graphiql.module.css'
|
||||
|
||||
export type GraphiQLProps = {
|
||||
@@ -77,7 +71,6 @@ export const GraphiQLInterface = ({ theme }: GraphiQLInterfaceProps) => {
|
||||
const editorContext = useEditorContext({ nonNull: true })
|
||||
const executionContext = useExecutionContext({ nonNull: true })
|
||||
const schemaContext = useSchemaContext({ nonNull: true })
|
||||
const storageContext = useStorageContext()
|
||||
const pluginContext = usePluginContext()
|
||||
|
||||
const copy = useCopyQuery()
|
||||
@@ -124,10 +117,6 @@ export const GraphiQLInterface = ({ theme }: GraphiQLInterfaceProps) => {
|
||||
: 'variables'
|
||||
}
|
||||
)
|
||||
const [showDialog, setShowDialog] = useState<'settings' | 'short-keys' | null>(null)
|
||||
const [clearStorageStatus, setClearStorageStatus] = useState<'success' | 'error' | null>(null)
|
||||
|
||||
const logo = <GraphiQLLogo />
|
||||
|
||||
const toolbar = (
|
||||
<>
|
||||
@@ -149,19 +138,19 @@ export const GraphiQLInterface = ({ theme }: GraphiQLInterfaceProps) => {
|
||||
}
|
||||
}
|
||||
|
||||
const modifier =
|
||||
window.navigator.platform.toLowerCase().indexOf('mac') === 0 ? (
|
||||
<code className="graphiql-key">Cmd</code>
|
||||
) : (
|
||||
<code className="graphiql-key">Ctrl</code>
|
||||
)
|
||||
const hasSingleTab = editorContext.tabs.length === 1
|
||||
|
||||
return (
|
||||
<div className={clsx('graphiql-container', styles.graphiqlContainer)}>
|
||||
<div className="graphiql-main">
|
||||
<div ref={pluginResize.firstRef} style={{ minWidth: 0 }}>
|
||||
<div className={clsx('graphiql-sessions', styles.graphiqlSessions)}>
|
||||
<div className="graphiql-session-header">
|
||||
<div
|
||||
className={clsx(
|
||||
'graphiql-session-header',
|
||||
!hasSingleTab && styles.graphiqlSessionHeader
|
||||
)}
|
||||
>
|
||||
<Tabs aria-label="Select active operation">
|
||||
{editorContext.tabs.length > 1 ? (
|
||||
<>
|
||||
@@ -203,8 +192,8 @@ export const GraphiQLInterface = ({ theme }: GraphiQLInterfaceProps) => {
|
||||
) : null}
|
||||
</Tabs>
|
||||
<div className="graphiql-session-header-right">
|
||||
{editorContext.tabs.length === 1 ? (
|
||||
<div className="graphiql-add-tab-wrapper">
|
||||
{hasSingleTab ? (
|
||||
<div className={clsx('graphiql-add-tab-wrapper', styles.graphiqlAddTabWrapper)}>
|
||||
<Tooltip label="Add tab">
|
||||
<UnStyledButton
|
||||
type="button"
|
||||
@@ -217,7 +206,6 @@ export const GraphiQLInterface = ({ theme }: GraphiQLInterfaceProps) => {
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : null}
|
||||
{logo}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -228,12 +216,17 @@ export const GraphiQLInterface = ({ theme }: GraphiQLInterfaceProps) => {
|
||||
>
|
||||
<div ref={editorResize.firstRef}>
|
||||
<div
|
||||
className={`graphiql-editors ${styles.graphiqlEditors}${
|
||||
editorContext.tabs.length === 1 ? ' full-height' : ''
|
||||
}`}
|
||||
className={clsx(
|
||||
'graphiql-editors',
|
||||
styles.graphiqlEditors,
|
||||
hasSingleTab && 'full-height'
|
||||
)}
|
||||
>
|
||||
<div ref={editorToolsResize.firstRef}>
|
||||
<section className="graphiql-query-editor" aria-label="Query Editor">
|
||||
<section
|
||||
className={clsx('graphiql-query-editor', styles.graphiqlQueryEditor)}
|
||||
aria-label="Query Editor"
|
||||
>
|
||||
<div className="graphiql-query-editor-wrapper">
|
||||
<QueryEditor />
|
||||
</div>
|
||||
@@ -327,10 +320,19 @@ export const GraphiQLInterface = ({ theme }: GraphiQLInterfaceProps) => {
|
||||
</div>
|
||||
</div>
|
||||
<div ref={editorResize.dragBarRef}>
|
||||
<div className="graphiql-horizontal-drag-bar" />
|
||||
<div
|
||||
className={clsx('graphiql-horizontal-drag-bar', styles.graphiqlHorizontalDragBar)}
|
||||
/>
|
||||
</div>
|
||||
<div ref={editorResize.secondRef}>
|
||||
<div className="graphiql-response">
|
||||
<div
|
||||
className={clsx(
|
||||
'graphiql-response',
|
||||
hasSingleTab
|
||||
? styles.graphiqlResponseSingleTab
|
||||
: styles.graphiqlResponseMultiTab
|
||||
)}
|
||||
>
|
||||
{executionContext.isFetching ? <Spinner /> : null}
|
||||
<ResponseEditor />
|
||||
</div>
|
||||
@@ -340,7 +342,11 @@ export const GraphiQLInterface = ({ theme }: GraphiQLInterfaceProps) => {
|
||||
</div>
|
||||
|
||||
<div ref={pluginResize.dragBarRef}>
|
||||
{pluginContext?.visiblePlugin ? <div className="graphiql-horizontal-drag-bar" /> : null}
|
||||
{pluginContext?.visiblePlugin ? (
|
||||
<div
|
||||
className={clsx('graphiql-horizontal-drag-bar', styles.graphiqlHorizontalDragBar)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -354,7 +360,7 @@ export const GraphiQLInterface = ({ theme }: GraphiQLInterfaceProps) => {
|
||||
<div className="graphiql-plugin">{PluginContent ? <PluginContent /> : null}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="graphiql-sidebar">
|
||||
<div className={clsx('graphiql-sidebar', styles.graphiqlSidebar)}>
|
||||
<div className="graphiql-sidebar-section">
|
||||
{pluginContext?.plugins.map((plugin) => {
|
||||
const isVisible = plugin === pluginContext.visiblePlugin
|
||||
@@ -396,195 +402,10 @@ export const GraphiQLInterface = ({ theme }: GraphiQLInterfaceProps) => {
|
||||
/>
|
||||
</UnStyledButton>
|
||||
</Tooltip>
|
||||
<Tooltip label="Open short keys dialog">
|
||||
<UnStyledButton
|
||||
type="button"
|
||||
onClick={() => setShowDialog('short-keys')}
|
||||
aria-label="Open short keys dialog"
|
||||
>
|
||||
<KeyboardShortcutIcon aria-hidden="true" />
|
||||
</UnStyledButton>
|
||||
</Tooltip>
|
||||
<Tooltip label="Open settings dialog">
|
||||
<UnStyledButton
|
||||
type="button"
|
||||
onClick={() => setShowDialog('settings')}
|
||||
aria-label="Open settings dialog"
|
||||
>
|
||||
<SettingsIcon aria-hidden="true" />
|
||||
</UnStyledButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog isOpen={showDialog === 'short-keys'} onDismiss={() => setShowDialog(null)}>
|
||||
<div className="graphiql-dialog-header">
|
||||
<div className="graphiql-dialog-title">Short Keys</div>
|
||||
<Dialog.Close onClick={() => setShowDialog(null)} />
|
||||
</div>
|
||||
<div className="graphiql-dialog-section">
|
||||
<div>
|
||||
<table className="graphiql-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Short key</th>
|
||||
<th>Function</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
{modifier}
|
||||
{' + '}
|
||||
<code className="graphiql-key">F</code>
|
||||
</td>
|
||||
<td>Search in editor</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
{modifier}
|
||||
{' + '}
|
||||
<code className="graphiql-key">K</code>
|
||||
</td>
|
||||
<td>Search in documentation</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
{modifier}
|
||||
{' + '}
|
||||
<code className="graphiql-key">Enter</code>
|
||||
</td>
|
||||
<td>Execute query</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code className="graphiql-key">Ctrl</code>
|
||||
{' + '}
|
||||
<code className="graphiql-key">Shift</code>
|
||||
{' + '}
|
||||
<code className="graphiql-key">P</code>
|
||||
</td>
|
||||
<td>Prettify editors</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code className="graphiql-key">Ctrl</code>
|
||||
{' + '}
|
||||
<code className="graphiql-key">Shift</code>
|
||||
{' + '}
|
||||
<code className="graphiql-key">M</code>
|
||||
</td>
|
||||
<td>Merge fragments definitions into operation definition</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code className="graphiql-key">Ctrl</code>
|
||||
{' + '}
|
||||
<code className="graphiql-key">Shift</code>
|
||||
{' + '}
|
||||
<code className="graphiql-key">C</code>
|
||||
</td>
|
||||
<td>Copy query</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<code className="graphiql-key">Ctrl</code>
|
||||
{' + '}
|
||||
<code className="graphiql-key">Shift</code>
|
||||
{' + '}
|
||||
<code className="graphiql-key">R</code>
|
||||
</td>
|
||||
<td>Re-fetch schema using introspection</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>
|
||||
The editors use{' '}
|
||||
<a
|
||||
href="https://codemirror.net/5/doc/manual.html#keymaps"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
CodeMirror Key Maps
|
||||
</a>{' '}
|
||||
that add more short keys. This instance of Graph<em>i</em>QL uses <code>sublime</code>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
<Dialog
|
||||
isOpen={showDialog === 'settings'}
|
||||
onDismiss={() => {
|
||||
setShowDialog(null)
|
||||
setClearStorageStatus(null)
|
||||
}}
|
||||
>
|
||||
<div className="graphiql-dialog-header">
|
||||
<div className="graphiql-dialog-title">Settings</div>
|
||||
<Dialog.Close
|
||||
onClick={() => {
|
||||
setShowDialog(null)
|
||||
setClearStorageStatus(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{storageContext ? (
|
||||
<div className="graphiql-dialog-section">
|
||||
<div>
|
||||
<div className="graphiql-dialog-section-title">Clear storage</div>
|
||||
<div className="graphiql-dialog-section-caption">
|
||||
Remove all locally stored data and start fresh.
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
state={clearStorageStatus || undefined}
|
||||
disabled={clearStorageStatus === 'success'}
|
||||
onClick={() => {
|
||||
try {
|
||||
storageContext?.clear()
|
||||
setClearStorageStatus('success')
|
||||
} catch {
|
||||
setClearStorageStatus('error')
|
||||
}
|
||||
}}
|
||||
>
|
||||
{clearStorageStatus === 'success'
|
||||
? 'Cleared data'
|
||||
: clearStorageStatus === 'error'
|
||||
? 'Failed'
|
||||
: 'Clear data'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Configure the UI by providing this Component as a child of GraphiQL.
|
||||
function GraphiQLLogo<TProps>(props: PropsWithChildren<TProps>) {
|
||||
return (
|
||||
<div className="graphiql-logo">
|
||||
{props.children || (
|
||||
<a
|
||||
className="graphiql-logo-link"
|
||||
href="https://github.com/graphql/graphiql"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Graph
|
||||
<em>i</em>
|
||||
QL
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
GraphiQLLogo.displayName = 'GraphiQLLogo'
|
||||
|
||||
export default GraphiQL
|
||||
@@ -15,7 +15,38 @@
|
||||
margin-top: calc(0px - var(--session-header-height));
|
||||
}
|
||||
|
||||
/* Colors */
|
||||
.graphiqlContainer .graphiqlQueryEditor {
|
||||
@apply border-b border-scale-500;
|
||||
}
|
||||
|
||||
.graphiqlContainer .graphiqlSessionHeader {
|
||||
background-color: hsl(var(--color-base));
|
||||
@apply border-b border-scale-500;
|
||||
}
|
||||
|
||||
.graphiqlContainer .graphiqlResponseSingleTab {
|
||||
margin-top: calc(24px - var(--session-header-height));
|
||||
}
|
||||
|
||||
.graphiqlContainer .graphiqlResponseMultiTab {
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.graphiqlContainer .graphiqlSidebar {
|
||||
@apply border-l border-scale-500;
|
||||
}
|
||||
|
||||
.graphiqlHorizontalDragBar {
|
||||
@apply border-l border-scale-500;
|
||||
}
|
||||
|
||||
.graphiqlAddTabWrapper {
|
||||
padding: var(--px-12);
|
||||
@apply z-10;
|
||||
}
|
||||
|
||||
/* Variables */
|
||||
|
||||
:global(body.graphiql-dark) .graphiqlContainer {
|
||||
--color-base: 0, 0%, 11%;
|
||||
--color-primary: 153, 50%, 50%;
|
||||
@@ -25,3 +56,8 @@
|
||||
--color-base: 210, 17%, 98%;
|
||||
--color-primary: 153, 50%, 50%;
|
||||
}
|
||||
|
||||
.graphiqlContainer {
|
||||
--font-family: theme(fontFamily.sans);
|
||||
--font-family-mono: theme(fontFamily.mono);
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import { FC, useState, ComponentProps } from 'react'
|
||||
import {
|
||||
Dropdown,
|
||||
Popover,
|
||||
Button,
|
||||
IconPlus,
|
||||
IconChevronDown,
|
||||
Select,
|
||||
Input,
|
||||
IconX,
|
||||
IconKey,
|
||||
IconBox,
|
||||
IconCode,
|
||||
IconDatabase,
|
||||
IconZapOff,
|
||||
} from 'ui'
|
||||
import DatePickers from '../Settings/Logs/Logs.DatePickers'
|
||||
import { REPORTS_DATEPICKER_HELPERS } from './Reports.constants'
|
||||
import { ReportFilterItem } from './Reports.types'
|
||||
|
||||
interface Props {
|
||||
filters: ReportFilterItem[]
|
||||
onAddFilter: (filter: ReportFilterItem) => void
|
||||
onRemoveFilters: (filters: ReportFilterItem[]) => void
|
||||
onDatepickerChange: ComponentProps<typeof DatePickers>['onChange']
|
||||
datepickerTo?: string
|
||||
datepickerFrom?: string
|
||||
datepickerHelpers: typeof REPORTS_DATEPICKER_HELPERS
|
||||
}
|
||||
|
||||
const PRODUCT_FILTERS = [
|
||||
{
|
||||
key: 'rest',
|
||||
filterKey: 'request.path',
|
||||
filterValue: '/rest',
|
||||
label: 'REST',
|
||||
description: 'Requests made to PostgREST',
|
||||
icon: IconDatabase,
|
||||
},
|
||||
{
|
||||
key: 'auth',
|
||||
filterKey: 'request.path',
|
||||
filterValue: '/auth',
|
||||
label: 'Auth',
|
||||
description: 'Auth and authorization requests',
|
||||
icon: IconKey,
|
||||
},
|
||||
{
|
||||
key: 'storage',
|
||||
filterKey: 'request.path',
|
||||
filterValue: '/storage',
|
||||
label: 'Storage',
|
||||
description: 'Storage asset requests',
|
||||
icon: IconBox,
|
||||
},
|
||||
{
|
||||
key: 'realtime',
|
||||
filterKey: 'request.path',
|
||||
filterValue: '/realtime',
|
||||
label: 'Realtime',
|
||||
description: 'Realtime connection requests',
|
||||
icon: IconZapOff,
|
||||
},
|
||||
// TODO: support functions once union parsing is fixed
|
||||
// {
|
||||
// key: 'functions',
|
||||
// filterKey: 'request.host',
|
||||
// filterValue: '.functions.',
|
||||
// label: 'Edge Functions',
|
||||
// description: 'Edge function calls',
|
||||
// icon: IconCode,
|
||||
// },
|
||||
{
|
||||
key: 'graphql',
|
||||
filterKey: 'request.path',
|
||||
filterValue: '/graphql',
|
||||
label: 'GraphQL',
|
||||
description: 'Requests made to pg_graphql',
|
||||
icon: IconCode,
|
||||
},
|
||||
]
|
||||
|
||||
const ReportFilterBar: FC<Props> = ({
|
||||
filters,
|
||||
onAddFilter,
|
||||
onDatepickerChange,
|
||||
datepickerTo = '',
|
||||
datepickerFrom = '',
|
||||
onRemoveFilters,
|
||||
datepickerHelpers,
|
||||
}) => {
|
||||
const filterKeys = [
|
||||
'request.path',
|
||||
'request.method',
|
||||
'request.search',
|
||||
'request.headers.x_client_info',
|
||||
'request.headers.user_agent',
|
||||
'response.status_code',
|
||||
]
|
||||
const [showAdder, setShowAdder] = useState(false)
|
||||
const [currentProductFilter, setCurrentProductFilter] = useState<
|
||||
null | typeof PRODUCT_FILTERS[number]
|
||||
>(null)
|
||||
const [addFilterValues, setAddFilterValues] = useState<ReportFilterItem>({
|
||||
key: filterKeys[0],
|
||||
compare: 'matches',
|
||||
value: '',
|
||||
})
|
||||
|
||||
const resetFilterValues = () => {
|
||||
setAddFilterValues({
|
||||
key: filterKeys[0],
|
||||
compare: 'matches',
|
||||
value: '',
|
||||
})
|
||||
}
|
||||
|
||||
const handleProductFilterChange = async (
|
||||
nextProductFilter: null | typeof PRODUCT_FILTERS[number]
|
||||
) => {
|
||||
const toRemove = PRODUCT_FILTERS.map(
|
||||
(productFilter) =>
|
||||
({
|
||||
key: productFilter.filterKey,
|
||||
compare: 'matches',
|
||||
value: productFilter.filterValue,
|
||||
} as ReportFilterItem)
|
||||
)
|
||||
onRemoveFilters(toRemove)
|
||||
if (nextProductFilter) {
|
||||
onAddFilter({
|
||||
key: nextProductFilter.filterKey,
|
||||
compare: 'matches',
|
||||
value: nextProductFilter.filterValue,
|
||||
})
|
||||
}
|
||||
setCurrentProductFilter(nextProductFilter)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-row justify-start items-center flex-wrap gap-2">
|
||||
<DatePickers
|
||||
onChange={onDatepickerChange}
|
||||
to={datepickerTo}
|
||||
from={datepickerFrom}
|
||||
helpers={datepickerHelpers}
|
||||
/>
|
||||
<Dropdown
|
||||
size="small"
|
||||
side="bottom"
|
||||
align="start"
|
||||
overlay={
|
||||
<>
|
||||
<Dropdown.Item onClick={() => handleProductFilterChange(null)}>
|
||||
All Requests
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Separator />
|
||||
{PRODUCT_FILTERS.map((productFilter) => {
|
||||
const Icon = productFilter.icon
|
||||
return (
|
||||
<Dropdown.Item
|
||||
key={productFilter.key}
|
||||
disabled={productFilter.key === currentProductFilter?.key}
|
||||
onClick={() => handleProductFilterChange(productFilter)}
|
||||
className="hover:bg-scale-600"
|
||||
icon={<Icon size={20} className="mr-2" />}
|
||||
>
|
||||
<p
|
||||
className={[
|
||||
productFilter.key === currentProductFilter?.key ? 'font-bold' : '',
|
||||
'inline-block',
|
||||
].join(' ')}
|
||||
>
|
||||
{productFilter.label}
|
||||
</p>
|
||||
<p className=" text-left text-scale-1000 inline-block w-[180px]">
|
||||
{productFilter.description}
|
||||
</p>
|
||||
</Dropdown.Item>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
as="span"
|
||||
type="default"
|
||||
className="inline-flex flex-row gap-2"
|
||||
iconRight={<IconChevronDown size={14} />}
|
||||
>
|
||||
{currentProductFilter === null ? 'All Requests' : currentProductFilter.label}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
{filters
|
||||
.filter(
|
||||
(filter) =>
|
||||
filter.value !== currentProductFilter?.filterValue ||
|
||||
filter.key !== currentProductFilter?.filterKey
|
||||
)
|
||||
.map((filter) => (
|
||||
<div
|
||||
key={`${filter.key}-${filter.compare}-${filter.value}`}
|
||||
className="text-xs rounded border border-scale-1000 bg-scale-500 px-2 h-7 flex flex-row justify-center gap-1 items-center"
|
||||
>
|
||||
{filter.key} {filter.compare} {filter.value}
|
||||
<Button
|
||||
type="text"
|
||||
size="tiny"
|
||||
className="!p-0 !space-x-0"
|
||||
onClick={() => onRemoveFilters([filter])}
|
||||
icon={<IconX size="tiny" className="text-scale-1100" />}
|
||||
>
|
||||
<span className="sr-only">Remove</span>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Popover
|
||||
align="end"
|
||||
header={
|
||||
<div className="flex justify-between items-center py-1">
|
||||
<h5 className="text-sm text-scale-1200">Add Filter</h5>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
size="tiny"
|
||||
onClick={() => {
|
||||
onAddFilter(addFilterValues)
|
||||
setShowAdder(false)
|
||||
resetFilterValues()
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
open={showAdder}
|
||||
onOpenChange={(openValue) => setShowAdder(openValue)}
|
||||
overlay={
|
||||
<div className="px-3 py-3 flex flex-col gap-2">
|
||||
<Select
|
||||
size="tiny"
|
||||
value={addFilterValues.key}
|
||||
onChange={(e) => {
|
||||
setAddFilterValues((prev) => ({ ...prev, key: e.target.value }))
|
||||
}}
|
||||
label="Attribute Filter"
|
||||
>
|
||||
{filterKeys.map((key) => (
|
||||
<Select.Option key={key} value={key}>
|
||||
{key}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
size="tiny"
|
||||
value={addFilterValues.compare}
|
||||
onChange={(e) => {
|
||||
setAddFilterValues((prev) => ({
|
||||
...prev,
|
||||
compare: e.target.value as ReportFilterItem['compare'],
|
||||
}))
|
||||
}}
|
||||
label="Comparison"
|
||||
>
|
||||
{['matches', 'is'].map((value) => (
|
||||
<Select.Option key={value} value={value}>
|
||||
{value}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Input
|
||||
size="tiny"
|
||||
label="Value"
|
||||
onChange={(e) => {
|
||||
setAddFilterValues((prev) => ({ ...prev, value: e.target.value }))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
showClose
|
||||
>
|
||||
<Button
|
||||
as="span"
|
||||
type="default"
|
||||
size="tiny"
|
||||
icon={<IconPlus size="tiny" className={`text-scale-1100 `} />}
|
||||
>
|
||||
Add filter
|
||||
</Button>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default ReportFilterBar
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Button, IconRefreshCw } from 'ui'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
onRefresh: () => void
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
const ReportHeader: React.FC<Props> = ({ title, onRefresh, isLoading }) => (
|
||||
<div className="flex flex-row justify-between gap-4 items-center">
|
||||
<h1 className="text-2xl text-scale-1200">{title}</h1>
|
||||
<Button
|
||||
type="default"
|
||||
size="tiny"
|
||||
onClick={onRefresh}
|
||||
disabled={isLoading ? true : false}
|
||||
icon={
|
||||
<IconRefreshCw
|
||||
size="tiny"
|
||||
className={`text-scale-1100 ${isLoading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{isLoading ? 'Refreshing' : 'Refresh'}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
export default ReportHeader
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Standardized padding and width layout for non-custom reports
|
||||
*/
|
||||
const ReportPadding: React.FC = ({ children }) => (
|
||||
<div className="flex flex-col gap-4 px-5 py-6 h-full mx-auto 1xl:px-28 lg:px-16 xl:px-24 2xl:px-32">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
export default ReportPadding
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Button, Dropdown, IconMoreVertical } from 'ui'
|
||||
import LoadingOpacity from 'components/ui/LoadingOpacity'
|
||||
import Panel from 'components/ui/Panel'
|
||||
import { useRouter } from 'next/router'
|
||||
import { NextRouter, useRouter } from 'next/router'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { Button, IconExternalLink, IconHelpCircle } from 'ui'
|
||||
|
||||
import { BaseReportParams } from './Reports.types'
|
||||
import { LogsEndpointParams } from '../Settings/Logs'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { IconHelpCircle } from '@supabase/ui'
|
||||
import Panel from 'components/ui/Panel'
|
||||
import LoadingOpacity from 'components/ui/LoadingOpacity'
|
||||
|
||||
export interface ReportWidgetProps<T = any> {
|
||||
data: T[]
|
||||
@@ -13,17 +13,25 @@ export interface ReportWidgetProps<T = any> {
|
||||
description?: string
|
||||
tooltip?: string
|
||||
className?: string
|
||||
renderer: (props: ReportWidgetProps) => React.ReactNode
|
||||
renderer: (props: ReportWidgetRendererProps) => React.ReactNode
|
||||
params: BaseReportParams | LogsEndpointParams
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export interface ReportWidgetRendererProps extends ReportWidgetProps {
|
||||
router: NextRouter
|
||||
projectRef: string
|
||||
}
|
||||
|
||||
const ReportWidget: React.FC<ReportWidgetProps> = (props) => {
|
||||
const router = useRouter()
|
||||
const { ref } = router.query
|
||||
const projectRef = ref as string
|
||||
|
||||
return (
|
||||
<Panel
|
||||
noMargin
|
||||
noHideOverflow
|
||||
className={'pb-0 ' + props.className}
|
||||
bodyClassName="h-full"
|
||||
wrapWithLoading={false}
|
||||
@@ -32,61 +40,63 @@ const ReportWidget: React.FC<ReportWidgetProps> = (props) => {
|
||||
<div className="flex flex-row items-start justify-between">
|
||||
<div className="gap-2">
|
||||
<div className="flex flex-row gap-2">
|
||||
|
||||
<h3 className="w-full h-6">{props.title}</h3>{' '}
|
||||
{props?.tooltip && (
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger>
|
||||
<IconHelpCircle className="text-scale-900" size="tiny" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="top">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-scale-100 py-1 px-2 max-w-xs leading-none shadow',
|
||||
'border border-scale-200',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-xs text-scale-1200">{props.tooltip}</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
<h3 className="w-full h-6">{props.title}</h3>{' '}
|
||||
{props?.tooltip && (
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger>
|
||||
<IconHelpCircle className="text-scale-1100" size="tiny" strokeWidth={1.5} />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-scale-100 py-1 px-2 max-w-xs leading-none shadow',
|
||||
'border border-scale-200',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-xs text-scale-1200">{props.tooltip}</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-scale-1100">{props.description}</p>
|
||||
</div>
|
||||
<Dropdown
|
||||
align="end"
|
||||
overlay={
|
||||
<>
|
||||
<Dropdown.Item
|
||||
onClick={() => {
|
||||
router.push({
|
||||
pathname: `/project/${ref}/logs/explorer`,
|
||||
query: {
|
||||
q: props.params?.sql,
|
||||
its: props.params.iso_timestamp_start,
|
||||
ite: props.params.iso_timestamp_end,
|
||||
},
|
||||
})
|
||||
}}
|
||||
>
|
||||
Open in Logs Explorer
|
||||
</Dropdown.Item>
|
||||
</>
|
||||
}
|
||||
side="bottom"
|
||||
>
|
||||
<Button
|
||||
title="Actions..."
|
||||
as="span"
|
||||
type="text"
|
||||
icon={<IconMoreVertical className="text-scale-1000" size="tiny" />}
|
||||
></Button>
|
||||
</Dropdown>
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger>
|
||||
<Button
|
||||
type="default"
|
||||
icon={<IconExternalLink strokeWidth={1.5} />}
|
||||
className="px-1"
|
||||
onClick={() => {
|
||||
router.push({
|
||||
pathname: `/project/${projectRef}/logs/explorer`,
|
||||
query: {
|
||||
q: props.params?.sql,
|
||||
its: props.params.iso_timestamp_start,
|
||||
ite: props.params.iso_timestamp_end,
|
||||
},
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-scale-100 py-1 px-2 max-w-xs leading-none shadow',
|
||||
'border border-scale-200',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-xs text-scale-1200">Open in Logs Explorer</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
|
||||
<LoadingOpacity active={props.isLoading}>{props.renderer(props)}</LoadingOpacity>
|
||||
<LoadingOpacity className="w-full" active={props.isLoading}>
|
||||
{props.data === undefined ? null : props.renderer({ ...props, router, projectRef })}
|
||||
</LoadingOpacity>
|
||||
</Panel.Content>
|
||||
</Panel>
|
||||
)
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { DatetimeHelper } from '../Settings/Logs'
|
||||
import { PresetConfig, Presets } from './Reports.types'
|
||||
import { PresetConfig, Presets, ReportFilterItem } from './Reports.types'
|
||||
|
||||
export const LAYOUT_COLUMN_COUNT = 24
|
||||
|
||||
export const REPORTS_DATEPICKER_HELPERS: DatetimeHelper[] = [
|
||||
{
|
||||
text: 'Last 24 hours',
|
||||
calcFrom: () => dayjs().subtract(1, 'day').startOf('day').toISOString(),
|
||||
calcTo: () => '',
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
text: 'Last 7 days',
|
||||
calcFrom: () => dayjs().subtract(7, 'day').startOf('day').toISOString(),
|
||||
calcTo: () => '',
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
text: 'Last 14 days',
|
||||
calcFrom: () => dayjs().subtract(7, 'day').startOf('day').toISOString(),
|
||||
calcTo: () => '',
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
text: 'Last 30 days',
|
||||
@@ -29,218 +33,92 @@ export const DEFAULT_QUERY_PARAMS = {
|
||||
iso_timestamp_end: REPORTS_DATEPICKER_HELPERS[0].calcTo(),
|
||||
}
|
||||
|
||||
const generateRexepWhere = (filters: ReportFilterItem[], prepend = true) => {
|
||||
if (filters.length === 0) return ''
|
||||
const conditions = filters
|
||||
.map((filter) => {
|
||||
const splitKey = filter.key.split('.')
|
||||
const normalizedKey = [splitKey[splitKey.length - 2], splitKey[splitKey.length - 1]].join('.')
|
||||
if (filter.compare === 'matches') {
|
||||
return `REGEXP_CONTAINS(${normalizedKey}, '${filter.value}')`
|
||||
} else if (filter.compare === 'is') {
|
||||
return `${normalizedKey} = ${filter.value}`
|
||||
}
|
||||
})
|
||||
.join(' AND ')
|
||||
if (prepend) {
|
||||
return 'WHERE ' + conditions
|
||||
} else {
|
||||
return conditions
|
||||
}
|
||||
}
|
||||
|
||||
export const PRESET_CONFIG: Record<Presets, PresetConfig> = {
|
||||
[Presets.API]: {
|
||||
title: 'API',
|
||||
queries: {
|
||||
statusCodes: {
|
||||
totalRequests: {
|
||||
queryType: 'logs',
|
||||
sql: `
|
||||
sql: (filters) => `
|
||||
select
|
||||
timestamp_trunc(timestamp, hour) as timestamp,
|
||||
r.status_code as status_code,
|
||||
count(status_code) as count
|
||||
FROM
|
||||
edge_logs
|
||||
CROSS JOIN UNNEST(metadata) AS m
|
||||
CROSS JOIN UNNEST(m.response) AS r
|
||||
cast(timestamp_trunc(t.timestamp, hour) as datetime) as timestamp,
|
||||
count(t.id) as count
|
||||
FROM edge_logs t
|
||||
cross join unnest(metadata) as m
|
||||
cross join unnest(m.response) as response
|
||||
cross join unnest(m.request) as request
|
||||
cross join unnest(request.headers) as headers
|
||||
${generateRexepWhere(filters)}
|
||||
GROUP BY
|
||||
timestamp,
|
||||
status_code
|
||||
timestamp
|
||||
ORDER BY
|
||||
timestamp ASC`,
|
||||
},
|
||||
errorRates: {
|
||||
errorCounts: {
|
||||
queryType: 'logs',
|
||||
sql: `
|
||||
sql: (filters) => `
|
||||
select
|
||||
timestamp_trunc(timestamp, hour) as timestamp,
|
||||
count(timestamp) as count
|
||||
FROM
|
||||
edge_logs
|
||||
LEFT JOIN UNNEST(metadata) AS f1 ON TRUE
|
||||
LEFT JOIN UNNEST(f1.response) AS f3 ON TRUE
|
||||
cast(timestamp_trunc(t.timestamp, hour) as datetime) as timestamp,
|
||||
count(t.id) as count
|
||||
FROM edge_logs t
|
||||
cross join unnest(metadata) as m
|
||||
cross join unnest(m.response) as response
|
||||
cross join unnest(m.request) as request
|
||||
cross join unnest(request.headers) as headers
|
||||
WHERE
|
||||
f3.status_code >= 400
|
||||
response.status_code >= 400
|
||||
${generateRexepWhere(filters, false)}
|
||||
GROUP BY
|
||||
timestamp
|
||||
ORDER BY
|
||||
timestamp ASC
|
||||
`,
|
||||
},
|
||||
requestPaths: {
|
||||
responseSpeed: {
|
||||
queryType: 'logs',
|
||||
sql: `
|
||||
sql: (filters) => `
|
||||
select
|
||||
f2.path as path,
|
||||
f2.search as query_params,
|
||||
f2.method as method,
|
||||
f3.status_code as status_code,
|
||||
avg(f3.origin_time) as avg_origin_time,
|
||||
sum(f3.origin_time) as sum,
|
||||
APPROX_QUANTILES(f3.origin_time, 100) as quantiles,
|
||||
count(timestamp) as count
|
||||
cast(timestamp_trunc(t.timestamp, hour) as datetime) as timestamp,
|
||||
avg(response.origin_time) as avg,
|
||||
APPROX_QUANTILES(response.origin_time, 100) as quantiles
|
||||
FROM
|
||||
edge_logs
|
||||
LEFT JOIN UNNEST(metadata) AS f1 ON TRUE
|
||||
LEFT JOIN UNNEST(f1.request) AS f2 ON TRUE
|
||||
LEFT JOIN UNNEST(f1.response) AS f3 ON TRUE
|
||||
edge_logs t
|
||||
cross join unnest(metadata) as m
|
||||
cross join unnest(m.response) as response
|
||||
cross join unnest(m.request) as request
|
||||
cross join unnest(request.headers) as headers
|
||||
${generateRexepWhere(filters)}
|
||||
GROUP BY
|
||||
path,
|
||||
query_params,
|
||||
method,
|
||||
status_code
|
||||
timestamp
|
||||
ORDER BY
|
||||
sum DESC,
|
||||
count desc,
|
||||
avg_origin_time DESC
|
||||
LIMIT 50
|
||||
timestamp ASC
|
||||
`,
|
||||
},
|
||||
userAgents: {
|
||||
queryType: 'logs',
|
||||
sql: `
|
||||
select
|
||||
h.user_agent as user_agent,
|
||||
cf.asOrganization as request_source,
|
||||
count(f.id) as count
|
||||
from
|
||||
edge_logs as f
|
||||
cross join unnest(f.metadata) as m
|
||||
cross join unnest(m.request) as r
|
||||
cross join unnest(r.cf) as cf
|
||||
cross join unnest(r.headers) as h
|
||||
group by
|
||||
user_agent,
|
||||
request_source
|
||||
order by
|
||||
count desc
|
||||
limit 12
|
||||
`,
|
||||
},
|
||||
botScores: {
|
||||
queryType: 'logs',
|
||||
sql: `
|
||||
select
|
||||
h.cf_connecting_ip as ip,
|
||||
h.cf_ipcountry as country,
|
||||
h.user_agent as user_agent,
|
||||
r.path as path,
|
||||
b.score as bot_score,
|
||||
b.verifiedBot as bot_verified,
|
||||
count(f.id) as count
|
||||
from
|
||||
edge_logs as f
|
||||
cross join unnest(f.metadata) as m
|
||||
cross join unnest(m.request) as r
|
||||
cross join unnest(r.cf) as cf
|
||||
cross join unnest(r.headers) as h
|
||||
cross join unnest(cf.botManagement) as b
|
||||
group by
|
||||
ip,
|
||||
country,
|
||||
path,
|
||||
user_agent,
|
||||
bot_score,
|
||||
bot_verified
|
||||
order by
|
||||
bot_score desc
|
||||
limit 20
|
||||
`,
|
||||
},
|
||||
},
|
||||
},
|
||||
[Presets.AUTH]: {
|
||||
title: 'Auth',
|
||||
queries: {
|
||||
bannedUsers: {
|
||||
queryType: 'db',
|
||||
sql: (_params) => `
|
||||
select
|
||||
count(distinct u.id) as count
|
||||
from auth.users u
|
||||
where u.banned_until is not null and u.banned_until > now()
|
||||
limit 1
|
||||
`,
|
||||
},
|
||||
unverifiedUsers: {
|
||||
queryType: 'db',
|
||||
sql: (params) => `
|
||||
select
|
||||
date_trunc('day', u.created_at) as timestamp,
|
||||
count(distinct u.id) as count
|
||||
from auth.users u
|
||||
where u.confirmed_at is null and '${params.iso_timestamp_start}' < u.created_at
|
||||
group by timestamp
|
||||
order by timestamp desc
|
||||
`,
|
||||
},
|
||||
signUpProviders: {
|
||||
queryType: 'db',
|
||||
sql: (params) => `
|
||||
select
|
||||
sum(count(u.id)) over (order by date_trunc('day', u.created_at)) as count,
|
||||
date_trunc('day', u.created_at) as timestamp,
|
||||
i.provider as provider
|
||||
from auth.users u
|
||||
join auth.identities as i on u.id = i.user_id
|
||||
where date_trunc('day', u.created_at) > '${params.iso_timestamp_start}'
|
||||
group by timestamp, provider
|
||||
order by timestamp desc
|
||||
`,
|
||||
},
|
||||
failedMigrations: {
|
||||
queryType: 'logs',
|
||||
sql: `
|
||||
select count(f.id)
|
||||
from auth_logs
|
||||
where json_value(f.event_message, "$.level") = "fatal"
|
||||
and regexp_contains(f.event_message, "error executing migrations")
|
||||
limit 1
|
||||
`,
|
||||
},
|
||||
dailyActiveUsers: {
|
||||
queryType: 'logs',
|
||||
sql: `
|
||||
select
|
||||
timestamp_trunc(timestamp, day) as timestamp,
|
||||
count(distinct json_value(f.event_message, "$.user_id")) as count
|
||||
from auth_logs f
|
||||
where json_value(f.event_message, "$.action") = "login"
|
||||
group by
|
||||
timestamp
|
||||
order by
|
||||
timestamp desc
|
||||
`,
|
||||
},
|
||||
cumulativeUsers: {
|
||||
queryType: 'db',
|
||||
sql: (params) => `
|
||||
|
||||
select
|
||||
date_trunc('day', u.created_at) as timestamp,
|
||||
sum(count(u.id)) over (order by date_trunc('day', u.created_at)) as count
|
||||
from auth.users as u
|
||||
where u.confirmed_at is not null and date_trunc('day', u.created_at) > '${params.iso_timestamp_start}'
|
||||
group by timestamp
|
||||
order by timestamp desc
|
||||
`,
|
||||
},
|
||||
newUsers: {
|
||||
queryType: 'db',
|
||||
sql: (params) => `
|
||||
select
|
||||
date_trunc('day', u.created_at) as timestamp,
|
||||
count(u.id) as count
|
||||
from auth.users as u
|
||||
where u.confirmed_at is not null and date_trunc('day', u.created_at) > '${params.iso_timestamp_start}'
|
||||
group by
|
||||
timestamp
|
||||
order by
|
||||
timestamp desc
|
||||
`,
|
||||
},
|
||||
},
|
||||
title: '',
|
||||
queries: {},
|
||||
},
|
||||
[Presets.QUERY_PERFORMANCE]: {
|
||||
title: 'Query performance',
|
||||
|
||||
@@ -26,18 +26,15 @@ export type MetaQueryResponse = any & { error: ResponseError }
|
||||
export type BaseReportParams = typeof DEFAULT_QUERY_PARAMS & { sql?: string } & unknown
|
||||
export interface PresetConfig {
|
||||
title: string
|
||||
queries: Record<string, DbQuery | LogsQuery>
|
||||
queries: BaseQueries<string>
|
||||
}
|
||||
export type BaseQueries<Keys extends string> = Record<Keys, ReportQuery>
|
||||
|
||||
export interface ReportQuery {
|
||||
queryType: 'db' | 'logs'
|
||||
sql: (filters: ReportFilterItem[]) => string
|
||||
}
|
||||
|
||||
export interface DbQuery {
|
||||
queryType: 'db'
|
||||
sql: string | ((params: BaseReportParams) => string)
|
||||
}
|
||||
|
||||
export interface LogsQuery {
|
||||
queryType: 'logs'
|
||||
sql: string
|
||||
}
|
||||
export interface StatusCodesDatum {
|
||||
timestamp: number
|
||||
status_code: number
|
||||
@@ -53,3 +50,9 @@ export interface PathsDatum {
|
||||
avg_origin_time: number
|
||||
quantiles: number[]
|
||||
}
|
||||
|
||||
export interface ReportFilterItem {
|
||||
key: string
|
||||
value: string | number
|
||||
compare: 'matches' | 'is'
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { IconRefreshCw } from '@supabase/ui'
|
||||
import { useStore } from 'hooks'
|
||||
import useDbQuery from 'hooks/analytics/useDbQuery'
|
||||
import useLogsQuery, { LogsQueryData, LogsQueryHandlers } from 'hooks/analytics/useLogsQuery'
|
||||
import React from 'react'
|
||||
import { Button } from 'ui'
|
||||
import { DatePickerToFrom } from '../Settings/Logs'
|
||||
import DatePickers from '../Settings/Logs/Logs.DatePickers'
|
||||
import { DEFAULT_QUERY_PARAMS, REPORTS_DATEPICKER_HELPERS } from './Reports.constants'
|
||||
import { DbQueryData, DbQueryHandler, PresetConfig } from './Reports.types'
|
||||
import {
|
||||
BaseQueries,
|
||||
DbQueryData,
|
||||
DbQueryHandler,
|
||||
PresetConfig,
|
||||
ReportQuery,
|
||||
} from './Reports.types'
|
||||
|
||||
/**
|
||||
* Converts a query params string to an object
|
||||
@@ -18,8 +19,12 @@ export const queryParamsToObject = (params: string) => {
|
||||
// generate hooks based on preset config
|
||||
type PresetHookResult = [LogsQueryData | DbQueryData, LogsQueryHandlers | DbQueryHandler]
|
||||
type PresetHooks = Record<keyof PresetConfig['queries'], () => PresetHookResult>
|
||||
export const hooksFactory = (projectRef: string, config: PresetConfig) => {
|
||||
const hooks: PresetHooks = Object.entries(config.queries).reduce(
|
||||
|
||||
export const queriesFactory = <T extends string>(queries: BaseQueries<T>): PresetHooks => {
|
||||
const { ui } = useStore()
|
||||
const projectRef = ui.selectedProject?.ref ?? 'default'
|
||||
|
||||
const hooks: PresetHooks = Object.entries<ReportQuery>(queries).reduce(
|
||||
(acc, [k, { sql, queryType }]) => {
|
||||
if (queryType === 'db') {
|
||||
return {
|
||||
@@ -29,11 +34,7 @@ export const hooksFactory = (projectRef: string, config: PresetConfig) => {
|
||||
} else {
|
||||
return {
|
||||
...acc,
|
||||
[k]: () =>
|
||||
useLogsQuery(projectRef, {
|
||||
sql: sql as string,
|
||||
...DEFAULT_QUERY_PARAMS,
|
||||
}),
|
||||
[k]: () => useLogsQuery(projectRef),
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -41,70 +42,3 @@ export const hooksFactory = (projectRef: string, config: PresetConfig) => {
|
||||
)
|
||||
return hooks
|
||||
}
|
||||
|
||||
export const usePresetReport = (hooks: PresetHookResult[]) => {
|
||||
const data = hooks.map((h) => h[0])
|
||||
const handlers = hooks.map((h) => h[1])
|
||||
const handleRefresh = () => {
|
||||
handlers.forEach((handler) => {
|
||||
handler.runQuery()
|
||||
})
|
||||
}
|
||||
const handleDatepickerChange = ({ to, from }: DatePickerToFrom) => {
|
||||
const newParams = { iso_timestamp_start: from || '', iso_timestamp_end: to || '' }
|
||||
|
||||
handlers.forEach((handler) => {
|
||||
if (handler.setParams) {
|
||||
handler.setParams((prev) => ({
|
||||
...prev,
|
||||
...newParams,
|
||||
}))
|
||||
}
|
||||
})
|
||||
}
|
||||
const isLoading = data.map((datum) => datum.isLoading).some((v) => v)
|
||||
|
||||
const Layout: React.FC<{ title: string; showDatePickers?: boolean }> = ({
|
||||
title,
|
||||
showDatePickers = true,
|
||||
children,
|
||||
}) => (
|
||||
<div className="flex flex-col gap-4 px-5 py-6 mx-auto 1xl:px-28 lg:px-16 xl:px-24 2xl:px-32">
|
||||
<h1 className="text-2xl text-scale-1200">{title}</h1>
|
||||
<div className="flex flex-row justify-between">
|
||||
<div>
|
||||
{showDatePickers && (
|
||||
<DatePickers
|
||||
onChange={handleDatepickerChange}
|
||||
to={data[0]?.params?.iso_timestamp_end || ''}
|
||||
from={data[0]?.params?.iso_timestamp_start || ''}
|
||||
helpers={REPORTS_DATEPICKER_HELPERS}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="default"
|
||||
size="tiny"
|
||||
onClick={handleRefresh}
|
||||
disabled={isLoading ? true : false}
|
||||
icon={
|
||||
<IconRefreshCw
|
||||
size="tiny"
|
||||
className={`text-scale-1100 ${isLoading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
return {
|
||||
isLoading,
|
||||
Layout,
|
||||
handleRefresh,
|
||||
handleDatepickerChange,
|
||||
}
|
||||
}
|
||||
@@ -1,266 +1,76 @@
|
||||
import { Button, IconChevronRight } from 'ui'
|
||||
import { jsonSyntaxHighlight } from 'components/interfaces/Settings/Logs/LogsFormatters'
|
||||
import { BarChart } from 'components/to-be-cleaned/Charts/ChartRenderer'
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import { USAGE_COLORS } from 'components/ui/Charts/Charts.constants'
|
||||
import StackedAreaChart from 'components/ui/Charts/StackedAreaChart'
|
||||
import { useState } from 'react'
|
||||
import { DATETIME_FORMAT } from '../Reports.constants'
|
||||
import { PathsDatum, StatusCodesDatum } from '../Reports.types'
|
||||
import { queryParamsToObject } from '../Reports.utils'
|
||||
import { ReportWidgetProps } from '../ReportWidget'
|
||||
import BarChart from 'components/ui/Charts/BarChart'
|
||||
|
||||
export const renderUserAgents = (
|
||||
export const renderTotalRequests = (
|
||||
props: ReportWidgetProps<{
|
||||
user_agent: string
|
||||
request_source: string
|
||||
timestamp: string
|
||||
count: number
|
||||
}>
|
||||
) => {
|
||||
const total = props.data.reduce((acc, datum) => {
|
||||
return acc + datum.count
|
||||
}, 0)
|
||||
return (
|
||||
<Table
|
||||
containerClassName="max-h-72 w-full overflow-y-auto"
|
||||
className="relative rounded border border-scale-600"
|
||||
head={
|
||||
<>
|
||||
<Table.th className="sticky top-0 z-10">User Agents</Table.th>
|
||||
<Table.th className="sticky top-0 z-10">Request Source</Table.th>
|
||||
<Table.th className="sticky top-0 z-10">Count</Table.th>
|
||||
</>
|
||||
}
|
||||
body={
|
||||
<>
|
||||
{props.data.map((row, index) => {
|
||||
return (
|
||||
<Table.tr key={index}>
|
||||
<Table.td className="max-w-sm lg:max-w-lg" style={{ padding: '0.3rem' }}>
|
||||
{row.user_agent ? (
|
||||
row.user_agent
|
||||
) : (
|
||||
<span className="text-scale-1000">No user agent</span>
|
||||
)}
|
||||
</Table.td>
|
||||
<Table.td>{row.request_source}</Table.td>
|
||||
<Table.td style={{ padding: '0.5rem' }} className="align-top text-xs">
|
||||
{row.count}
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
}
|
||||
<BarChart
|
||||
size="small"
|
||||
minimalHeader
|
||||
highlightedValue={total}
|
||||
className="w-full"
|
||||
data={props.data}
|
||||
yAxisKey="count"
|
||||
xAxisKey="timestamp"
|
||||
displayDateInUtc
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const renderBotScores = (
|
||||
export const renderErrorCounts = (
|
||||
props: ReportWidgetProps<{
|
||||
ip: string
|
||||
country: string
|
||||
user_agent: string
|
||||
path: string
|
||||
bot_score: number
|
||||
bot_verified: boolean
|
||||
timestamp: string
|
||||
count: number
|
||||
}>
|
||||
) => {
|
||||
const total = props.data.reduce((acc, datum) => {
|
||||
return acc + datum.count
|
||||
}, 0)
|
||||
return (
|
||||
<Table
|
||||
containerClassName="max-h-72 w-full overflow-y-auto"
|
||||
className="relative rounded border border-scale-600"
|
||||
head={
|
||||
<>
|
||||
<Table.th className="sticky top-0 z-10">IP (Country)</Table.th>
|
||||
<Table.th className="sticky top-0 z-10">Request Path</Table.th>
|
||||
<Table.th className="sticky top-0 z-10">User Agent</Table.th>
|
||||
<Table.th className="sticky top-0 z-10">Bot Score</Table.th>
|
||||
<Table.th className="sticky top-0 z-10">Is Verified Bot</Table.th>
|
||||
<Table.th className="sticky top-0 z-10">Count</Table.th>
|
||||
</>
|
||||
}
|
||||
body={
|
||||
<>
|
||||
{props.data.map((row, index) => {
|
||||
return (
|
||||
<Table.tr key={index}>
|
||||
<Table.td className="max-w-sm lg:max-w-lg" style={{ padding: '0.3rem' }}>
|
||||
{row.ip} ({row.country})
|
||||
</Table.td>
|
||||
<Table.td>{row.path}</Table.td>
|
||||
<Table.td className="max-w-xs truncate">{row.user_agent}</Table.td>
|
||||
<Table.td>{row.bot_score}</Table.td>
|
||||
<Table.td>{row.bot_verified ? 'Yes' : 'No'}</Table.td>
|
||||
<Table.td style={{ padding: '0.5rem' }} className="align-top text-xs">
|
||||
{row.count}
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
}
|
||||
<BarChart
|
||||
size="small"
|
||||
minimalHeader
|
||||
className="w-full"
|
||||
highlightedValue={total}
|
||||
data={props.data}
|
||||
yAxisKey="count"
|
||||
xAxisKey="timestamp"
|
||||
displayDateInUtc
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const renderStatusCodesChart = (props: ReportWidgetProps<StatusCodesDatum>) => (
|
||||
<StackedAreaChart
|
||||
dateFormat={DATETIME_FORMAT}
|
||||
data={props.data}
|
||||
stackKey="status_code"
|
||||
xAxisKey="timestamp"
|
||||
yAxisKey="count"
|
||||
isLoading={false}
|
||||
xAxisFormatAsDate
|
||||
size="large"
|
||||
styleMap={{
|
||||
200: { stroke: USAGE_COLORS['200'], fill: USAGE_COLORS['200'] },
|
||||
201: { stroke: USAGE_COLORS['201'], fill: USAGE_COLORS['201'] },
|
||||
400: { stroke: USAGE_COLORS['400'], fill: USAGE_COLORS['400'] },
|
||||
401: { stroke: USAGE_COLORS['401'], fill: USAGE_COLORS['401'] },
|
||||
404: { stroke: USAGE_COLORS['404'], fill: USAGE_COLORS['404'] },
|
||||
500: { stroke: USAGE_COLORS['500'], fill: USAGE_COLORS['500'] },
|
||||
}}
|
||||
/>
|
||||
)
|
||||
export const renderErrorRateChart = (props: ReportWidgetProps) => {
|
||||
return (
|
||||
<div className="flex w-full flex-col">
|
||||
<BarChart
|
||||
data={props.data}
|
||||
attribute="count"
|
||||
label=""
|
||||
minimalHeader
|
||||
customDateFormat={DATETIME_FORMAT}
|
||||
noDataMessage={'No errors yet'}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const renderRequestsPathsTable = (props: ReportWidgetProps<PathsDatum>) => {
|
||||
const transformedData = props.data.map((data: PathsDatum) => ({
|
||||
...data,
|
||||
p99_time: data.quantiles[98],
|
||||
export const renderResponseSpeed = (
|
||||
props: ReportWidgetProps<{
|
||||
timestamp: string
|
||||
avg: number
|
||||
quantiles: number[]
|
||||
}>
|
||||
) => {
|
||||
const transformedData = props.data.map((datum) => ({
|
||||
timestamp: datum.timestamp,
|
||||
avg: datum.avg,
|
||||
median: datum.quantiles[49],
|
||||
}))
|
||||
const requestPathsSums = transformedData.map((v) => v.sum) as number[]
|
||||
const requestPathsSumMax = Math.max(...requestPathsSums)
|
||||
const requestPathsSumMin = Math.min(...requestPathsSums)
|
||||
const requestPathsAvgs = transformedData.map((v) => v.avg_origin_time) as number[]
|
||||
const requestPathsAvgMax = Math.max(...requestPathsAvgs)
|
||||
const requestPathsAvgMin = Math.min(...requestPathsAvgs)
|
||||
const requestPathsP99s = transformedData.map((v) => v.p99_time) as number[]
|
||||
const requestPathsP99Max = Math.max(...requestPathsP99s)
|
||||
const requestPathsP99Min = Math.min(...requestPathsP99s)
|
||||
|
||||
const renderNumericCellWithColorScale = (value: number, percentage: number) => (
|
||||
<Table.td
|
||||
style={{
|
||||
padding: '0.5rem',
|
||||
}}
|
||||
className={`${
|
||||
percentage >= 80
|
||||
? 'bg-orange-600'
|
||||
: percentage > 60
|
||||
? 'bg-orange-500'
|
||||
: percentage > 40
|
||||
? 'bg-yellow-500'
|
||||
: percentage > 20
|
||||
? 'bg-yellow-400'
|
||||
: percentage > 10
|
||||
? 'bg-yellow-200'
|
||||
: 'bg-green-100'
|
||||
} align-top text-xs`}
|
||||
>
|
||||
<span className="text-scale-1100">{Number(value).toFixed(1)}</span>
|
||||
</Table.td>
|
||||
)
|
||||
const lastAvg = props.data[props.data.length - 1]?.avg
|
||||
return (
|
||||
<Table
|
||||
containerClassName="max-h-72 w-full overflow-y-auto"
|
||||
className="relative rounded border border-scale-600"
|
||||
head={
|
||||
<>
|
||||
<Table.th className="sticky top-0 z-10">Path</Table.th>
|
||||
<Table.th className="sticky top-0 z-10">Count</Table.th>
|
||||
<Table.th className="sticky top-0 z-10">Avg. Time (ms)</Table.th>
|
||||
<Table.th className="sticky top-0 z-10">p99 Time (ms)</Table.th>
|
||||
<Table.th className="sticky top-0 z-10">Total Query Time</Table.th>
|
||||
</>
|
||||
}
|
||||
body={
|
||||
<>
|
||||
{transformedData.map((row, index) => {
|
||||
const [show, setShow] = useState(false)
|
||||
|
||||
const totalQueryTimePercentage =
|
||||
((row.sum - requestPathsSumMin) / requestPathsSumMax) * 100
|
||||
const avgTimePercentage =
|
||||
((row.avg_origin_time - requestPathsAvgMin) / requestPathsAvgMax) * 100
|
||||
const p99TimePercentage =
|
||||
((row.p99_time - requestPathsP99Min) / requestPathsP99Max) * 100
|
||||
return (
|
||||
<>
|
||||
<Table.tr key={index}>
|
||||
<Table.td className="max-w-sm lg:max-w-lg" style={{ padding: '0.3rem' }}>
|
||||
<Button
|
||||
onClick={() => setShow(!show)}
|
||||
type="text"
|
||||
className="flex w-full justify-start space-x-1 text-scale-1200"
|
||||
icon={
|
||||
<span className={`transition ${show ? 'rotate-90' : 'rotate-0'}`}>
|
||||
<IconChevronRight size="tiny" />
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="overflow-x-none flex items-center space-x-2">
|
||||
<p className="font-mono text-xs text-scale-1200">{row.method}</p>
|
||||
<p className="max-w-xs truncate font-mono text-xs text-scale-1200 ">
|
||||
{row.path}
|
||||
<span className="text-scale-1000">{row.query_params}</span>
|
||||
</p>
|
||||
</div>
|
||||
</Button>
|
||||
</Table.td>
|
||||
<Table.td style={{ padding: '0.5rem' }} className="align-top text-xs">
|
||||
{row.count}
|
||||
</Table.td>
|
||||
{renderNumericCellWithColorScale(row.avg_origin_time, avgTimePercentage)}
|
||||
{renderNumericCellWithColorScale(row.p99_time, p99TimePercentage)}
|
||||
<Table.td className="py-1 align-top">
|
||||
<div
|
||||
className={`mt-1 h-2 w-full rounded bg-green-1100`}
|
||||
style={{
|
||||
width: `${totalQueryTimePercentage}%`,
|
||||
}}
|
||||
/>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
<Table.tr className="transition-all duration-500">
|
||||
<Table.td
|
||||
colSpan={4}
|
||||
className={`overflow-none w-full ${
|
||||
show ? 'h-auto opacity-100' : 'table-cell !h-0 !p-0 opacity-0'
|
||||
}`}
|
||||
>
|
||||
<pre
|
||||
className={`syntax-highlight max-w-lg overflow-auto rounded bg-scale-300 p-2 text-xs ${
|
||||
show ? '' : 'h-0 !p-0'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="text-wrap"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: jsonSyntaxHighlight(queryParamsToObject(row.query_params)),
|
||||
}}
|
||||
/>
|
||||
</pre>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
</>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
}
|
||||
<BarChart
|
||||
size="small"
|
||||
highlightedValue={lastAvg}
|
||||
format="ms"
|
||||
minimalHeader
|
||||
className="w-full"
|
||||
data={transformedData}
|
||||
yAxisKey="avg"
|
||||
xAxisKey="timestamp"
|
||||
displayDateInUtc
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import { BarChart } from 'components/to-be-cleaned/Charts/ChartRenderer'
|
||||
import StackedBarChart from 'components/ui/Charts/StackedBarChart'
|
||||
import { DATETIME_FORMAT } from '../Reports.constants'
|
||||
import { ReportWidgetProps } from '../ReportWidget'
|
||||
import Statistic from '../Statistic'
|
||||
|
||||
export const renderBannedUsers = (
|
||||
props: ReportWidgetProps<{
|
||||
count: number
|
||||
}>
|
||||
) => {
|
||||
const count = props.data.length === 0 ? 0 : props?.data?.[0]?.count
|
||||
return <Statistic value={count} />
|
||||
}
|
||||
export const renderUnverifiedUsers = (
|
||||
props: ReportWidgetProps<{
|
||||
timestamp: string
|
||||
count: number
|
||||
}>
|
||||
) => {
|
||||
const count = props.data.length === 0 ? 0 : props?.data?.[0]?.count
|
||||
|
||||
return (
|
||||
<Statistic
|
||||
value={count}
|
||||
sparklineData={props.data.length >= 3 ? props.data : null}
|
||||
sparklineXAxis="timestamp"
|
||||
sparklineYAxis="count"
|
||||
/>
|
||||
)
|
||||
}
|
||||
export const renderSignUpProviders = (
|
||||
props: ReportWidgetProps<{
|
||||
timestamp: string
|
||||
provider: string
|
||||
count: number
|
||||
}>
|
||||
) => {
|
||||
return (
|
||||
<StackedBarChart
|
||||
hideHeader
|
||||
variant="percentages"
|
||||
data={props.data}
|
||||
xAxisKey="timestamp"
|
||||
yAxisKey="count"
|
||||
stackKey="provider"
|
||||
/>
|
||||
)
|
||||
}
|
||||
export const renderFailedMigrations = (
|
||||
props: ReportWidgetProps<{
|
||||
timestamp: string
|
||||
count: number
|
||||
}>
|
||||
) => {
|
||||
const count = props.data.length === 0 ? 0 : props?.data?.[0]?.count
|
||||
return <Statistic value={count} />
|
||||
}
|
||||
export const renderCumulativeUsers = (
|
||||
props: ReportWidgetProps<{
|
||||
count: number
|
||||
timestamp: string
|
||||
}>
|
||||
) => {
|
||||
const total = props.data?.[0]?.count
|
||||
return (
|
||||
<div className="flex w-full flex-col">
|
||||
<BarChart
|
||||
data={props.data}
|
||||
attribute="count"
|
||||
label=""
|
||||
minimalHeader
|
||||
highlightedValue={total}
|
||||
customDateFormat={DATETIME_FORMAT}
|
||||
noDataMessage={'No confirmed users yet'}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const renderNewUsers = (
|
||||
props: ReportWidgetProps<{
|
||||
count: number
|
||||
timestamp: string
|
||||
}>
|
||||
) => {
|
||||
const sum = (props.data || []).reduce((acc, datum) => datum.count + acc, 0)
|
||||
return (
|
||||
<div className="flex w-full flex-col">
|
||||
<BarChart
|
||||
data={props.data}
|
||||
attribute="count"
|
||||
label=""
|
||||
minimalHeader
|
||||
highlightedValue={sum}
|
||||
customDateFormat={DATETIME_FORMAT}
|
||||
noDataMessage={'No new user signups during this time period'}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const renderDailyActiveUsers = (
|
||||
props: ReportWidgetProps<{
|
||||
count: number
|
||||
timestamp: string
|
||||
}>
|
||||
) => {
|
||||
const last = props.data?.[props.data.length - 1]?.count
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col">
|
||||
<BarChart
|
||||
data={props.data}
|
||||
attribute="count"
|
||||
label=""
|
||||
minimalHeader
|
||||
highlightedValue={last}
|
||||
customDateFormat={DATETIME_FORMAT}
|
||||
noDataMessage={'No authenticated users during this time period'}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { FC } from 'react'
|
||||
import { Input } from 'ui'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
|
||||
import { useStore } from 'hooks'
|
||||
import { useFlag, useParams, useStore } from 'hooks'
|
||||
import { PRICING_TIER_PRODUCT_IDS } from 'lib/constants'
|
||||
import {
|
||||
FormHeader,
|
||||
@@ -11,16 +11,26 @@ import {
|
||||
FormSectionLabel,
|
||||
FormSectionContent,
|
||||
} from 'components/ui/Forms'
|
||||
import ProjectUpgradeAlert from './ProjectUpgradeAlert'
|
||||
import PauseProjectButton from './PauseProjectButton'
|
||||
import RestartServerButton from './RestartServerButton'
|
||||
import { useProjectSubscriptionQuery } from 'data/subscriptions/project-subscription-query'
|
||||
import { useProjectUpgradeEligibilityQuery } from 'data/config/project-upgrade-eligibility-query'
|
||||
|
||||
interface Props {}
|
||||
|
||||
const Infrastructure: FC<Props> = ({}) => {
|
||||
const { ui } = useStore()
|
||||
const { ref } = useParams()
|
||||
|
||||
const project = ui.selectedProject
|
||||
const isFreeProject = project?.subscription_tier === PRICING_TIER_PRODUCT_IDS.FREE
|
||||
const { data: subscription } = useProjectSubscriptionQuery({ projectRef: ref })
|
||||
const isFreeProject = subscription?.tier.supabase_prod_id === PRICING_TIER_PRODUCT_IDS.FREE
|
||||
|
||||
const { data, isLoading } = useProjectUpgradeEligibilityQuery({ projectRef: ref })
|
||||
const currentPgVersion = (data?.current_app_version ?? '').split('supabase-postgres-')[1]
|
||||
|
||||
const showDbUpgrades = useFlag('databaseUpgrades')
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -67,6 +77,13 @@ const Infrastructure: FC<Props> = ({}) => {
|
||||
<Input readOnly disabled value={project?.region} label="Region" />
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
|
||||
<FormSection header={<FormSectionLabel>Postgres</FormSectionLabel>}>
|
||||
<FormSectionContent loading={isLoading}>
|
||||
<Input readOnly disabled value={currentPgVersion} label="Current version" />
|
||||
{data?.eligible && showDbUpgrades && <ProjectUpgradeAlert />}
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
</FormPanel>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// [Joshen] TODO: Change the keys to enums from shared-types when ready
|
||||
|
||||
export const BREAKING_CHANGES = {
|
||||
'postgrest-v10': {
|
||||
title: 'Postgrest will be upgraded to V10',
|
||||
url: 'https://postgrest.org/en/stable/releases/v10.0.0.html#breaking-changes',
|
||||
},
|
||||
}
|
||||
|
||||
export const MANUAL_INTERVENTIONS = {
|
||||
project_unsupported_too_old: {
|
||||
title: 'Project is too old',
|
||||
description: '',
|
||||
},
|
||||
project_kps_enabled: {
|
||||
title: 'Project has KPS enabled',
|
||||
description: '',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { FC, useState } from 'react'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Form,
|
||||
Modal,
|
||||
IconPackage,
|
||||
Listbox,
|
||||
IconAlertCircle,
|
||||
IconExternalLink,
|
||||
} from 'ui'
|
||||
|
||||
import { useParams, useStore } from 'hooks'
|
||||
import { post } from 'lib/common/fetch'
|
||||
import { API_ADMIN_URL, PROJECT_STATUS } from 'lib/constants'
|
||||
import InformationBox from 'components/ui/InformationBox'
|
||||
import { BREAKING_CHANGES } from './ProjectUpgradeAlert.constants'
|
||||
import { useProjectUpgradeEligibilityQuery } from 'data/config/project-upgrade-eligibility-query'
|
||||
|
||||
interface Props {}
|
||||
|
||||
const ProjectUpgradeAlert: FC<Props> = ({}) => {
|
||||
const router = useRouter()
|
||||
const { app, ui } = useStore()
|
||||
const { ref } = useParams()
|
||||
const [showUpgradeModal, setShowUpgradeModal] = useState(false)
|
||||
|
||||
const formId = 'project-upgrade-form'
|
||||
const { data } = useProjectUpgradeEligibilityQuery({ projectRef: ref })
|
||||
const currentPgVersion = (data?.current_app_version ?? '').split('supabase-postgres-')[1]
|
||||
const latestPgVersion = (data?.latest_app_version ?? '').split('supabase-postgres-')[1]
|
||||
|
||||
const initialValues = { version: data?.target_upgrade_versions?.[0]?.postgres_version ?? 0 }
|
||||
|
||||
const onConfirmUpgrade = async (values: any, { setSubmitting }: any) => {
|
||||
setSubmitting(true)
|
||||
|
||||
const res = await post(`${API_ADMIN_URL}/projects/${ref}/upgrade`, {
|
||||
target_version: values.version,
|
||||
})
|
||||
if (res.error) {
|
||||
ui.setNotification({
|
||||
category: 'error',
|
||||
error: res.error,
|
||||
message: `Failed to upgrade: ${res.error.message}`,
|
||||
})
|
||||
setSubmitting(false)
|
||||
} else {
|
||||
const projectId = ui.selectedProject?.id
|
||||
if (projectId !== undefined) {
|
||||
app.onProjectStatusUpdated(projectId, PROJECT_STATUS.UPGRADING)
|
||||
ui.setNotification({ category: 'success', message: 'Upgrading project' })
|
||||
router.push(`/project/${ref}?upgradeInitiated=true`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Alert
|
||||
icon={<IconPackage className="text-brand-900" strokeWidth={1.5} />}
|
||||
variant="success"
|
||||
title="Your project can be upgraded to the latest version of Postgres"
|
||||
>
|
||||
<p className="mb-3">
|
||||
The latest version of Postgres ({latestPgVersion}) is available for your project.
|
||||
</p>
|
||||
<Button size="tiny" type="primary" onClick={() => setShowUpgradeModal(true)}>
|
||||
Upgrade project
|
||||
</Button>
|
||||
</Alert>
|
||||
<Modal
|
||||
hideFooter
|
||||
visible={showUpgradeModal}
|
||||
onCancel={() => setShowUpgradeModal(false)}
|
||||
header={<h3>Upgrade project's Postgres</h3>}
|
||||
>
|
||||
<Form id={formId} initialValues={initialValues} onSubmit={onConfirmUpgrade}>
|
||||
{({ values, isSubmitting }: { values: any; isSubmitting: boolean }) => {
|
||||
return (
|
||||
<>
|
||||
<div className="py-6">
|
||||
<Modal.Content>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm">
|
||||
All of your project's services, such as GoTrue, PostgREST, Supautils, and
|
||||
all database extensions will be upgraded as well, and this action cannot be
|
||||
undone.
|
||||
</p>
|
||||
{(data?.potential_breaking_changes ?? []).length > 0 && (
|
||||
<InformationBox
|
||||
icon={<IconAlertCircle strokeWidth={2} />}
|
||||
title="There will be breaking changes involved with the upgrade"
|
||||
description={
|
||||
<ul className="list-disc pl-4">
|
||||
{data?.potential_breaking_changes.map((reason) => {
|
||||
const change = (BREAKING_CHANGES as any)[reason]
|
||||
if (change !== undefined)
|
||||
return (
|
||||
<li key={reason}>
|
||||
<p className="text-scale-1200">{change.title}</p>
|
||||
<p className="flex items-center space-x-1">
|
||||
<span>
|
||||
Breaking changes with this version are highlighted{' '}
|
||||
</span>
|
||||
<Link href={change.url}>
|
||||
<a className="text-brand-900 opacity-90 flex items-center space-x-1">
|
||||
<span>here</span>
|
||||
<IconExternalLink size="tiny" strokeWidth={2} />
|
||||
</a>
|
||||
</Link>
|
||||
</p>
|
||||
</li>
|
||||
)
|
||||
else return null
|
||||
})}
|
||||
</ul>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Alert
|
||||
withIcon
|
||||
variant="warning"
|
||||
title="Your project will be offline while the upgrade is in progress"
|
||||
>
|
||||
Upgrades will take a few minutes depending on the size of your database. It
|
||||
is advised to upgrade at a time when there will be minimal impact for your
|
||||
application
|
||||
</Alert>
|
||||
<Listbox
|
||||
id="version"
|
||||
name="version"
|
||||
label="Select the version of Postgres to upgrade to"
|
||||
descriptionText={`Your project's Postgres will be upgraded from ${currentPgVersion} to ${values.version}`}
|
||||
>
|
||||
{data?.target_upgrade_versions.map((version) => (
|
||||
<Listbox.Option
|
||||
key={version.postgres_version}
|
||||
value={version.postgres_version}
|
||||
label={`${version.postgres_version} (${
|
||||
version.app_version.split('supabase-postgres-')[1]
|
||||
})`}
|
||||
>
|
||||
{version.postgres_version} (
|
||||
{version.app_version.split('supabase-postgres-')[1]})
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox>
|
||||
</div>
|
||||
</Modal.Content>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 justify-end px-4 py-4 border-t">
|
||||
<Button
|
||||
type="default"
|
||||
onClick={() => setShowUpgradeModal(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button htmlType="submit" disabled={isSubmitting} loading={isSubmitting}>
|
||||
Confirm upgrade
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProjectUpgradeAlert
|
||||
@@ -0,0 +1,2 @@
|
||||
import ProjectUpgradeAlert from './ProjectUpgradeAlert'
|
||||
export default ProjectUpgradeAlert
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import { Alert, Button, IconEye, IconEyeOff } from 'ui'
|
||||
import DataGrid from '@supabase/react-data-grid'
|
||||
import DataGrid, { Row, RowRendererProps } from '@supabase/react-data-grid'
|
||||
|
||||
import LogSelection, { LogSelectionProps } from './LogSelection'
|
||||
import { LogData, QueryType } from './Logs.types'
|
||||
@@ -76,7 +76,7 @@ const LogTable = ({
|
||||
formatter = (received: FormatterArg) => {
|
||||
const value = received.row?.[v]
|
||||
if (value && typeof value === 'object') {
|
||||
return `[Object]`
|
||||
return JSON.stringify(value)
|
||||
} else if (value === null) {
|
||||
return 'NULL'
|
||||
} else {
|
||||
@@ -163,6 +163,15 @@ const LogTable = ({
|
||||
}
|
||||
}, [stringData])
|
||||
|
||||
const RowRenderer = (props: RowRendererProps<any>) => {
|
||||
return (
|
||||
<Row
|
||||
{...props}
|
||||
className="font-mono tracking-tight cursor-pointer !bg-scale-200 hover:!bg-scale-300"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const LogsExplorerTableHeader = () => (
|
||||
<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">
|
||||
@@ -282,6 +291,7 @@ const LogTable = ({
|
||||
return row.id
|
||||
}}
|
||||
onRowClick={(r) => setFocusedLog(r)}
|
||||
rowRenderer={RowRenderer}
|
||||
/>
|
||||
{logDataRows.length > 0 ? (
|
||||
<div
|
||||
|
||||
@@ -48,8 +48,14 @@ const DatePickers: React.FC<Props> = ({ to, from, onChange, helpers }) => {
|
||||
<>
|
||||
<Dropdown.RadioGroup onChange={handleHelperChange} value={selectedHelper?.text || ''}>
|
||||
{helpers.map((helper) => (
|
||||
<Dropdown.Radio key={helper.text} value={helper.text}>
|
||||
{helper.text}
|
||||
<Dropdown.Radio key={helper.text} value={helper.text} disabled={helper.disabled}>
|
||||
<span
|
||||
className={[helper.disabled ? 'text-scale-1000 cursor-not-allowed' : ''].join(
|
||||
' '
|
||||
)}
|
||||
>
|
||||
{helper.text}
|
||||
</span>
|
||||
</Dropdown.Radio>
|
||||
))}
|
||||
</Dropdown.RadioGroup>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { DatetimeHelper, FilterTableSet, LogTemplate } from '.'
|
||||
|
||||
export const LOGS_EXPLORER_DOCS_URL = "https://supabase.com/docs/guides/platform/logs#querying-with-the-logs-explorer"
|
||||
export const LOGS_EXPLORER_DOCS_URL =
|
||||
'https://supabase.com/docs/guides/platform/logs#querying-with-the-logs-explorer'
|
||||
|
||||
export const LOGS_LARGE_DATE_RANGE_DAYS_THRESHOLD = 4
|
||||
|
||||
@@ -276,19 +277,19 @@ export const SQL_FILTER_TEMPLATES: any = {
|
||||
},
|
||||
auth_logs: {
|
||||
..._SQL_FILTER_COMMON,
|
||||
"severity.error": `REGEXP_CONTAINS(event_message, "level.{3}error|level.{3}fatal")`,
|
||||
"severity.warning": `REGEXP_CONTAINS(event_message, "level.{3}warning")`,
|
||||
"severity.info": `REGEXP_CONTAINS(event_message, "level.{3}info")`,
|
||||
"status_code.server_error": `REGEXP_CONTAINS(event_message, "status.{3}5[0-9]{2}")`,
|
||||
"status_code.client_error": `REGEXP_CONTAINS(event_message, "status.{3}4[0-9]{2}")`,
|
||||
"status_code.redirection": `REGEXP_CONTAINS(event_message, "status.{3}3[0-9]{2}")`,
|
||||
"status_code.success": `REGEXP_CONTAINS(event_message, "status.{3}2[0-9]{2}")`,
|
||||
"endpoints.admin": `REGEXP_CONTAINS(event_message, "path.{3}/admin")`,
|
||||
"endpoints.signup": `REGEXP_CONTAINS(event_message, "path.{3}/signup|path.{3}/invite|path.{3}/verify")`,
|
||||
"endpoints.authentication": `REGEXP_CONTAINS(event_message, "path.{3}/token|path.{3}/authorize|path.{3}/callback|path.{3}/otp|path.{3}/magiclink")`,
|
||||
"endpoints.recover": `REGEXP_CONTAINS(event_message, "path.{3}/recover")`,
|
||||
"endpoints.user": `REGEXP_CONTAINS(event_message, "path.{3}/user")`,
|
||||
"endpoints.logout": `REGEXP_CONTAINS(event_message, "path.{3}/logout")`,
|
||||
'severity.error': `REGEXP_CONTAINS(event_message, "level.{3}error|level.{3}fatal")`,
|
||||
'severity.warning': `REGEXP_CONTAINS(event_message, "level.{3}warning")`,
|
||||
'severity.info': `REGEXP_CONTAINS(event_message, "level.{3}info")`,
|
||||
'status_code.server_error': `REGEXP_CONTAINS(event_message, "status.{3}5[0-9]{2}")`,
|
||||
'status_code.client_error': `REGEXP_CONTAINS(event_message, "status.{3}4[0-9]{2}")`,
|
||||
'status_code.redirection': `REGEXP_CONTAINS(event_message, "status.{3}3[0-9]{2}")`,
|
||||
'status_code.success': `REGEXP_CONTAINS(event_message, "status.{3}2[0-9]{2}")`,
|
||||
'endpoints.admin': `REGEXP_CONTAINS(event_message, "path.{3}/admin")`,
|
||||
'endpoints.signup': `REGEXP_CONTAINS(event_message, "path.{3}/signup|path.{3}/invite|path.{3}/verify")`,
|
||||
'endpoints.authentication': `REGEXP_CONTAINS(event_message, "path.{3}/token|path.{3}/authorize|path.{3}/callback|path.{3}/otp|path.{3}/magiclink")`,
|
||||
'endpoints.recover': `REGEXP_CONTAINS(event_message, "path.{3}/recover")`,
|
||||
'endpoints.user': `REGEXP_CONTAINS(event_message, "path.{3}/user")`,
|
||||
'endpoints.logout': `REGEXP_CONTAINS(event_message, "path.{3}/logout")`,
|
||||
},
|
||||
realtime_logs: {
|
||||
..._SQL_FILTER_COMMON,
|
||||
@@ -301,7 +302,7 @@ export const SQL_FILTER_TEMPLATES: any = {
|
||||
},
|
||||
pgbouncer_logs: {
|
||||
..._SQL_FILTER_COMMON,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export enum LogsTableName {
|
||||
@@ -312,8 +313,8 @@ export enum LogsTableName {
|
||||
AUTH = 'auth_logs',
|
||||
REALTIME = 'realtime_logs',
|
||||
STORAGE = 'storage_logs',
|
||||
PGBOUNCER = "pgbouncer_logs",
|
||||
POSTGREST = "postgrest_logs"
|
||||
PGBOUNCER = 'pgbouncer_logs',
|
||||
POSTGREST = 'postgrest_logs',
|
||||
}
|
||||
|
||||
export const LOGS_TABLES = {
|
||||
@@ -325,7 +326,7 @@ export const LOGS_TABLES = {
|
||||
realtime: LogsTableName.REALTIME,
|
||||
storage: LogsTableName.STORAGE,
|
||||
postgrest: LogsTableName.POSTGREST,
|
||||
pgbouncer: LogsTableName.PGBOUNCER
|
||||
pgbouncer: LogsTableName.PGBOUNCER,
|
||||
}
|
||||
|
||||
export const LOGS_SOURCE_DESCRIPTION = {
|
||||
@@ -587,7 +588,7 @@ export const FILTER_OPTIONS: FilterTableSet = {
|
||||
},
|
||||
{
|
||||
key: 'recover',
|
||||
label: "Password Recovery",
|
||||
label: 'Password Recovery',
|
||||
description: 'Show all password recovery requests',
|
||||
},
|
||||
{
|
||||
@@ -607,7 +608,7 @@ export const FILTER_OPTIONS: FilterTableSet = {
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const LOGS_TAILWIND_CLASSES = {
|
||||
|
||||
@@ -84,8 +84,8 @@ export type QueryType =
|
||||
| 'auth'
|
||||
| 'realtime'
|
||||
| 'storage'
|
||||
| "pgbouncer"
|
||||
| "postgrest"
|
||||
| 'pgbouncer'
|
||||
| 'postgrest'
|
||||
|
||||
export type Mode = 'simple' | 'custom'
|
||||
|
||||
@@ -134,4 +134,5 @@ export interface DatetimeHelper {
|
||||
calcTo: () => string
|
||||
calcFrom: () => string
|
||||
default?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
@@ -2,6 +2,12 @@ import { Filters, LogData, LogsEndpointParams, LogsTableName, SQL_FILTER_TEMPLAT
|
||||
import dayjs, { Dayjs } from 'dayjs'
|
||||
import { get } from 'lodash'
|
||||
import { StripeSubscription } from 'components/interfaces/Billing'
|
||||
import { useMonaco } from '@monaco-editor/react'
|
||||
import logConstants from 'shared-data/logConstants'
|
||||
import BackwardIterator from 'components/to-be-cleaned/SqlEditor/BackwardIterator'
|
||||
import { uniqBy } from 'lodash'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
|
||||
/**
|
||||
* Convert a micro timestamp from number/string to iso timestamp
|
||||
@@ -264,3 +270,84 @@ export const ensureNoTimestampConflict = (
|
||||
return [nextStart, nextEnd]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Adds SQL code hints to logs explorer code editor
|
||||
*/
|
||||
export const useEditorHints = ()=>{
|
||||
const monaco = useMonaco()
|
||||
|
||||
useEffect(() => {
|
||||
if (monaco) {
|
||||
const competionProvider = {
|
||||
triggerCharacters: ['`', ' ', '.'],
|
||||
provideCompletionItems: function (model: any, position: any, context: any) {
|
||||
let iterator = new BackwardIterator(model, position.column - 2, position.lineNumber - 1)
|
||||
if (iterator.isNextDQuote()) return { suggestions: [] }
|
||||
let suggestions: { label: string; kind: any; insertText: string }[] = []
|
||||
|
||||
let schemasInUse = logConstants.schemas.filter((schema) =>
|
||||
iterator._text.includes(schema.reference)
|
||||
)
|
||||
if (schemasInUse.length === 0) {
|
||||
schemasInUse = logConstants.schemas
|
||||
}
|
||||
|
||||
if (iterator.isNextPeriod()) {
|
||||
// should be nested key reference, suggest all tail endings of available fields
|
||||
const fields = schemasInUse.flatMap((schema) => schema.fields)
|
||||
const trailingKeys = fields.flatMap((field) => {
|
||||
const [_head, ...rest] = field.path.split('.')
|
||||
return rest
|
||||
})
|
||||
|
||||
const trailingToAdd = trailingKeys.map((key) => ({
|
||||
label: key,
|
||||
kind: monaco.languages.CompletionItemKind.Property,
|
||||
insertText: key,
|
||||
}))
|
||||
suggestions = suggestions.concat(trailingToAdd)
|
||||
}
|
||||
|
||||
if (context.triggerCharacter === '`' || context.triggerCharacter === ' ') {
|
||||
// should be reference or start of key
|
||||
const referencesToAdd = logConstants.schemas.map((schema) => ({
|
||||
label: schema.reference,
|
||||
kind: monaco.languages.CompletionItemKind.Class,
|
||||
insertText: schema.reference,
|
||||
}))
|
||||
|
||||
const fields = schemasInUse.flatMap((schema) => schema.fields)
|
||||
const leadingKeys = fields.flatMap((field) => {
|
||||
const splitPath = field.path.split('.')
|
||||
|
||||
return splitPath.slice(0, -1)
|
||||
})
|
||||
|
||||
const leadingToAdd = leadingKeys.map((key) => ({
|
||||
label: key,
|
||||
kind: monaco.languages.CompletionItemKind.Property,
|
||||
insertText: key,
|
||||
}))
|
||||
suggestions = suggestions.concat(leadingToAdd)
|
||||
suggestions = suggestions.concat(referencesToAdd)
|
||||
}
|
||||
return {
|
||||
suggestions: uniqBy(suggestions, 'label'),
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// register completion item provider for pgsql
|
||||
const completeProvider = monaco.languages.registerCompletionItemProvider(
|
||||
'pgsql',
|
||||
competionProvider
|
||||
)
|
||||
|
||||
return () => {
|
||||
completeProvider.dispose()
|
||||
}
|
||||
}
|
||||
}, [monaco])
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
EXPLORER_DATEPICKER_HELPERS,
|
||||
LogsTableName,
|
||||
LogsWarning,
|
||||
LOGS_EXPLORER_DOCS_URL,
|
||||
LOGS_SOURCE_DESCRIPTION,
|
||||
LogTemplate,
|
||||
} from '.'
|
||||
@@ -14,7 +13,6 @@ import DatePickers from './Logs.DatePickers'
|
||||
import Link from 'next/link'
|
||||
import React from 'react'
|
||||
import { checkPermissions, useStore } from 'hooks'
|
||||
import { IconBookOpen } from '@supabase/ui'
|
||||
|
||||
interface Props {
|
||||
templates?: LogTemplate[]
|
||||
@@ -132,11 +130,6 @@ const LogsQueryPanel: React.FC<Props> = ({
|
||||
<div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<a target="_blank" href={LOGS_EXPLORER_DOCS_URL}>
|
||||
<Button type="default" icon={<IconBookOpen size={12} />}>
|
||||
Docs
|
||||
</Button>
|
||||
</a>
|
||||
<Button type="default" onClick={onClear}>
|
||||
Clear query
|
||||
</Button>
|
||||
|
||||
@@ -40,7 +40,7 @@ interface Props {
|
||||
const SupportForm: FC<Props> = ({ setSentCategory }) => {
|
||||
const { ui, app } = useStore()
|
||||
const router = useRouter()
|
||||
const { ref, subject, category } = router.query
|
||||
const { ref, subject, category, message } = router.query
|
||||
|
||||
const uploadButtonRef = useRef()
|
||||
const enableFreeSupport = useFlag('enableFreeSupport')
|
||||
@@ -108,7 +108,7 @@ const SupportForm: FC<Props> = ({ setSentCategory }) => {
|
||||
organizationSlug: initialOrganizationSlug,
|
||||
library: 'no-library',
|
||||
subject: subject ?? '',
|
||||
message: '',
|
||||
message: message || '',
|
||||
allowSupportAccess: false,
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,12 @@ import { Modal } from 'ui'
|
||||
import type { PostgresRelationship, PostgresTable, PostgresColumn } from '@supabase/postgres-meta'
|
||||
|
||||
import { useStore } from 'hooks'
|
||||
import { useTableRowCreateMutation } from 'data/table-rows/table-row-create-mutation'
|
||||
import { useTableRowUpdateMutation } from 'data/table-rows/table-row-update-mutation'
|
||||
import { RowEditor, ColumnEditor, TableEditor } from '.'
|
||||
import { ImportContent } from './TableEditor/TableEditor.types'
|
||||
import { ColumnField, CreateColumnPayload, UpdateColumnPayload } from './SidePanelEditor.types'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import ConfirmationModal from 'components/ui/ConfirmationModal'
|
||||
import JsonEdit from './RowEditor/JsonEditor/JsonEditor'
|
||||
import { JsonEditValue } from './RowEditor/RowEditor.types'
|
||||
@@ -53,13 +56,19 @@ const SidePanelEditor: FC<Props> = ({
|
||||
|
||||
const tables = meta.tables.list()
|
||||
|
||||
const { project } = useProjectContext()
|
||||
const { mutateAsync: createTableRow } = useTableRowCreateMutation()
|
||||
const { mutateAsync: updateTableRow } = useTableRowUpdateMutation()
|
||||
|
||||
const saveRow = async (
|
||||
payload: any,
|
||||
isNewRecord: boolean,
|
||||
configuration: { identifiers: any; rowIdx: number },
|
||||
onComplete: Function
|
||||
) => {
|
||||
if (selectedTable === undefined) return
|
||||
if (!project || selectedTable === undefined) {
|
||||
return console.error('no project or table selected')
|
||||
}
|
||||
|
||||
let saveRowError = false
|
||||
// @ts-ignore
|
||||
@@ -70,34 +79,40 @@ const SidePanelEditor: FC<Props> = ({
|
||||
.map((column) => column.name)
|
||||
|
||||
if (isNewRecord) {
|
||||
const insertQuery = new Query()
|
||||
.from(selectedTable.name, selectedTable.schema)
|
||||
.insert([payload], { returning: true, enumArrayColumns })
|
||||
.toSql()
|
||||
try {
|
||||
const result = await createTableRow({
|
||||
projectRef: project.ref,
|
||||
connectionString: project.connectionString,
|
||||
table: selectedTable as any,
|
||||
payload,
|
||||
enumArrayColumns,
|
||||
})
|
||||
|
||||
const res: any = await meta.query(insertQuery)
|
||||
if (res.error) {
|
||||
onRowCreated(result[0])
|
||||
} catch (error: any) {
|
||||
saveRowError = true
|
||||
ui.setNotification({ category: 'error', message: res.error?.message })
|
||||
} else {
|
||||
onRowCreated(res[0])
|
||||
ui.setNotification({ category: 'error', message: error?.message })
|
||||
}
|
||||
} else {
|
||||
const hasChanges = !isEmpty(payload)
|
||||
if (hasChanges) {
|
||||
if (selectedTable.primary_keys.length > 0) {
|
||||
const updateQuery = new Query()
|
||||
.from(selectedTable.name, selectedTable.schema)
|
||||
.update(payload, { returning: true, enumArrayColumns })
|
||||
.match(configuration.identifiers)
|
||||
.toSql()
|
||||
if (selectedTable!.primary_keys.length > 0) {
|
||||
try {
|
||||
const result = await updateTableRow({
|
||||
projectRef: project.ref,
|
||||
connectionString: project.connectionString,
|
||||
table: selectedTable as any,
|
||||
configuration,
|
||||
payload,
|
||||
enumArrayColumns,
|
||||
})
|
||||
|
||||
const res: any = await meta.query(updateQuery)
|
||||
if (res.error) {
|
||||
saveRowError = true
|
||||
ui.setNotification({ category: 'error', message: res.error?.message })
|
||||
} else {
|
||||
onRowUpdated(res[0], configuration.rowIdx)
|
||||
onRowUpdated(result[0], configuration.rowIdx)
|
||||
} catch (error: any) {
|
||||
saveRowError = true
|
||||
ui.setNotification({ category: 'error', message: error?.message })
|
||||
}
|
||||
}
|
||||
} else {
|
||||
saveRowError = true
|
||||
|
||||
@@ -4,22 +4,32 @@ import { useRouter } from 'next/router'
|
||||
import { find, isUndefined } from 'lodash'
|
||||
import type { PostgresColumn, PostgresTable } from '@supabase/postgres-meta'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { QueryKey, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { SchemaView } from 'types'
|
||||
import { checkPermissions, useFlag, useStore, useParams } from 'hooks'
|
||||
import GridHeaderActions from './GridHeaderActions'
|
||||
import NotFoundState from './NotFoundState'
|
||||
import SidePanelEditor from './SidePanelEditor'
|
||||
import { Dictionary, parseSupaTable, SupabaseGrid, SupabaseGridRef } from 'components/grid'
|
||||
import {
|
||||
Dictionary,
|
||||
parseSupaTable,
|
||||
SupabaseGrid,
|
||||
SupabaseGridRef,
|
||||
SupaTable,
|
||||
} from 'components/grid'
|
||||
import { IconBookOpen, SidePanel } from 'ui'
|
||||
import ActionBar from './SidePanelEditor/ActionBar'
|
||||
import { GeneralContent, ResourceContent } from '../Docs'
|
||||
import { sqlKeys } from 'data/sql/keys'
|
||||
import { useProjectApiQuery } from 'data/config/project-api-query'
|
||||
import { useProjectJsonSchemaQuery } from 'data/docs/project-json-schema-query'
|
||||
import { useTableRowUpdateMutation } from 'data/table-rows/table-row-update-mutation'
|
||||
import { snakeToCamel } from 'lib/helpers'
|
||||
import { JsonEditValue } from './SidePanelEditor/RowEditor/RowEditor.types'
|
||||
import LangSelector from '../Docs/LangSelector'
|
||||
import GeneratingTypes from '../Docs/GeneratingTypes'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
|
||||
interface Props {
|
||||
/** Theme for the editor */
|
||||
@@ -67,6 +77,7 @@ const TableGridEditor: FC<Props> = ({
|
||||
onExpandJSONEditor = () => {},
|
||||
onClosePanel = () => {},
|
||||
}) => {
|
||||
const { project } = useProjectContext()
|
||||
const { meta, ui, vault } = useStore()
|
||||
const router = useRouter()
|
||||
const { ref: projectRef, page, id } = useParams()
|
||||
@@ -100,6 +111,65 @@ const TableGridEditor: FC<Props> = ({
|
||||
setEncryptedColumns(columns)
|
||||
}
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const { mutate: mutateUpdateTableRow } = useTableRowUpdateMutation({
|
||||
async onMutate({ projectRef, table, configuration, payload }) {
|
||||
const primaryKeyColumns = new Set(Object.keys(configuration.identifiers))
|
||||
|
||||
const queryKey = sqlKeys.query(projectRef, [
|
||||
table.schema,
|
||||
table.name,
|
||||
{ table: { name: table.name, schema: table.schema } },
|
||||
])
|
||||
|
||||
await queryClient.cancelQueries(queryKey)
|
||||
|
||||
const previousRowsQueries = queryClient.getQueriesData<{ result: any[] }>(queryKey)
|
||||
|
||||
queryClient.setQueriesData<{ result: any[] }>(queryKey, (old) => {
|
||||
return {
|
||||
result:
|
||||
old?.result.map((row) => {
|
||||
// match primary keys
|
||||
if (
|
||||
Object.entries(row)
|
||||
.filter(([key]) => primaryKeyColumns.has(key))
|
||||
.every(([key, value]) => value === configuration.identifiers[key])
|
||||
) {
|
||||
return { ...row, ...payload }
|
||||
}
|
||||
|
||||
return row
|
||||
}) ?? [],
|
||||
}
|
||||
})
|
||||
|
||||
return { previousRowsQueries }
|
||||
},
|
||||
onError(error, _variables, context) {
|
||||
const { previousRowsQueries } = context as {
|
||||
previousRowsQueries: [
|
||||
QueryKey,
|
||||
(
|
||||
| {
|
||||
result: any[]
|
||||
}
|
||||
| undefined
|
||||
)
|
||||
][]
|
||||
}
|
||||
|
||||
previousRowsQueries.forEach(([queryKey, previousRows]) => {
|
||||
if (previousRows) {
|
||||
queryClient.setQueriesData(queryKey, previousRows)
|
||||
}
|
||||
queryClient.invalidateQueries(queryKey)
|
||||
})
|
||||
|
||||
onError(error)
|
||||
},
|
||||
})
|
||||
|
||||
function getResourcesFromJsonSchema(value: any) {
|
||||
const { paths } = value || {}
|
||||
const functionPath = 'rpc/'
|
||||
@@ -143,6 +213,7 @@ const TableGridEditor: FC<Props> = ({
|
||||
}
|
||||
}, [selectedTable?.id])
|
||||
|
||||
// NOTE: DO NOT PUT HOOKS AFTER THIS LINE
|
||||
if (isUndefined(selectedTable)) {
|
||||
return <NotFoundState id={Number(id)} />
|
||||
}
|
||||
@@ -168,7 +239,12 @@ const TableGridEditor: FC<Props> = ({
|
||||
},
|
||||
encryptedColumns
|
||||
)
|
||||
: (selectedTable as SchemaView).name
|
||||
: parseSupaTable({
|
||||
table: selectedTable as SchemaView,
|
||||
columns: (selectedTable as SchemaView).columns ?? [],
|
||||
primaryKeys: [],
|
||||
relationships: [],
|
||||
})
|
||||
|
||||
const gridKey = `${selectedTable.schema}_${selectedTable.name}`
|
||||
|
||||
@@ -227,6 +303,32 @@ const TableGridEditor: FC<Props> = ({
|
||||
})
|
||||
}
|
||||
|
||||
const updateTableRow = (previousRow: any, updatedData: any) => {
|
||||
if (!project) return
|
||||
|
||||
const enumArrayColumns = selectedTable.columns
|
||||
.filter((column: any) => {
|
||||
return (column?.enums ?? []).length > 0 && column.data_type.toLowerCase() === 'array'
|
||||
})
|
||||
.map((column: any) => column.name)
|
||||
|
||||
const identifiers = {} as Dictionary<any>
|
||||
;(selectedTable as PostgresTable).primary_keys.forEach(
|
||||
(column) => (identifiers[column.name] = previousRow[column.name])
|
||||
)
|
||||
|
||||
const configuration = { identifiers }
|
||||
|
||||
mutateUpdateTableRow({
|
||||
projectRef: project.ref,
|
||||
connectionString: project.connectionString,
|
||||
table: gridTable as SupaTable,
|
||||
configuration,
|
||||
payload: updatedData,
|
||||
enumArrayColumns,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SupabaseGrid
|
||||
@@ -253,6 +355,7 @@ const TableGridEditor: FC<Props> = ({
|
||||
onEditColumn={onSelectEditColumn}
|
||||
onDeleteColumn={onSelectDeleteColumn}
|
||||
onAddRow={onAddRow}
|
||||
updateTableRow={updateTableRow}
|
||||
onEditRow={onEditRow}
|
||||
onError={onError}
|
||||
onSqlQuery={onSqlQuery}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useStore } from 'hooks'
|
||||
import { useParams, useStore } from 'hooks'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { createContext, PropsWithChildren, useContext, useMemo } from 'react'
|
||||
import { Project } from 'types'
|
||||
@@ -44,3 +44,9 @@ export const ProjectContextProvider = observer(
|
||||
return <ProjectContext.Provider value={value}>{children}</ProjectContext.Provider>
|
||||
}
|
||||
)
|
||||
|
||||
export const ProjectContextFromParamsProvider = ({ children }: PropsWithChildren<{}>) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
|
||||
return <ProjectContextProvider projectRef={projectRef}>{children}</ProjectContextProvider>
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import PausingState from './PausingState'
|
||||
import BuildingState from './BuildingState'
|
||||
import { ProjectContextProvider } from './ProjectContext'
|
||||
import RestoringState from './RestoringState'
|
||||
import UpgradingState from './UpgradingState'
|
||||
|
||||
interface Props {
|
||||
title?: string
|
||||
@@ -115,10 +116,9 @@ const ContentWrapper: FC<ContentWrapperProps> = observer(({ isLoading, children
|
||||
const requiresDbConnection: boolean = router.pathname !== '/project/[ref]/settings/general'
|
||||
const requiresPostgrestConnection = !routesToIgnorePostgrestConnection.includes(router.pathname)
|
||||
|
||||
const isProjectUpgrading = ui.selectedProject?.status === PROJECT_STATUS.UPGRADING
|
||||
const isProjectRestoring = ui.selectedProject?.status === PROJECT_STATUS.RESTORING
|
||||
const isProjectBuilding = [PROJECT_STATUS.COMING_UP, PROJECT_STATUS.RESTORING].includes(
|
||||
ui.selectedProject?.status ?? ''
|
||||
)
|
||||
const isProjectBuilding = ui.selectedProject?.status === PROJECT_STATUS.COMING_UP
|
||||
const isProjectPausing = ui.selectedProject?.status === PROJECT_STATUS.GOING_DOWN
|
||||
const isProjectOffline = ui.selectedProject?.postgrestStatus === 'OFFLINE'
|
||||
|
||||
@@ -126,6 +126,8 @@ const ContentWrapper: FC<ContentWrapperProps> = observer(({ isLoading, children
|
||||
<>
|
||||
{isLoading || ui.selectedProject === undefined ? (
|
||||
<Connecting />
|
||||
) : isProjectUpgrading ? (
|
||||
<UpgradingState />
|
||||
) : isProjectPausing ? (
|
||||
<PausingState project={ui.selectedProject} />
|
||||
) : requiresPostgrestConnection && isProjectOffline ? (
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { DatabaseUpgradeProgress } from '@supabase/shared-types/out/events'
|
||||
|
||||
export const DATABASE_UPGRADE_MESSAGES = [
|
||||
{
|
||||
key: DatabaseUpgradeProgress.Started,
|
||||
initial: 'Prepare new server',
|
||||
progress: 'Preparing new server',
|
||||
completed: 'Prepared new server',
|
||||
},
|
||||
{
|
||||
key: DatabaseUpgradeProgress.LaunchedUpgradedInstance,
|
||||
initial: 'Prepare new server for migration',
|
||||
progress: 'Preparing new server for migration',
|
||||
completed: 'New server ready for migration',
|
||||
},
|
||||
{
|
||||
key: DatabaseUpgradeProgress.DetachedVolumeFromUpgradedInstance,
|
||||
initial: 'Execute pre-upgrade checks',
|
||||
progress: 'Executing pre-upgrade checks',
|
||||
completed: 'Completed pre-upgrade checks',
|
||||
},
|
||||
{
|
||||
key: DatabaseUpgradeProgress.AttachedVolumeToOriginalInstance,
|
||||
initial: 'Shut down API services',
|
||||
progress: 'Shutting down API services',
|
||||
completed: 'Completed shutting down API services',
|
||||
},
|
||||
{
|
||||
key: DatabaseUpgradeProgress.InitiatedDataUpgrade,
|
||||
initial: 'Migrate to new database',
|
||||
progress: 'Migrating to new database',
|
||||
completed: 'Completed migration to new database',
|
||||
},
|
||||
{
|
||||
key: DatabaseUpgradeProgress.CompletedDataUpgrade,
|
||||
initial: 'Execute database migration',
|
||||
progress: 'Executing database migration',
|
||||
completed: 'Completed database migration',
|
||||
},
|
||||
{
|
||||
key: DatabaseUpgradeProgress.DetachedVolumeFromOriginalInstance,
|
||||
initial: 'Update database extensions',
|
||||
progress: 'Updating database extensions',
|
||||
completed: 'Updated database extensions',
|
||||
},
|
||||
{
|
||||
key: DatabaseUpgradeProgress.AttachedVolumeToUpgradedInstance,
|
||||
initial: 'Optimize database',
|
||||
progress: 'Optimizing database',
|
||||
completed: 'Completed optimization of database',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,267 @@
|
||||
import dayjs from 'dayjs'
|
||||
import Link from 'next/link'
|
||||
import { useState, useEffect } from 'react'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import {
|
||||
Button,
|
||||
IconCircle,
|
||||
IconCheckCircle,
|
||||
IconSettings,
|
||||
IconAlertCircle,
|
||||
IconLoader,
|
||||
IconCheck,
|
||||
IconMaximize2,
|
||||
IconMinimize2,
|
||||
} from 'ui'
|
||||
import { DatabaseUpgradeStatus } from '@supabase/shared-types/out/events'
|
||||
|
||||
import { useParams, useStore } from 'hooks'
|
||||
import { DATABASE_UPGRADE_MESSAGES } from './UpgradingState.constants'
|
||||
import { useProjectUpgradingStatusQuery } from 'data/config/project-upgrade-status-query'
|
||||
|
||||
const UpgradingState = () => {
|
||||
const { ui } = useStore()
|
||||
const project = ui.selectedProject
|
||||
|
||||
// [Joshen] If routing to this state from ProjectUpgradeAlert, give it a few seconds before starting
|
||||
// to poll the upgrade status endpoint as the job is async and might not be immediately updated.
|
||||
const { upgradeInitiated } = useParams()
|
||||
const [isInitialized, setIsInitialized] = useState(upgradeInitiated !== 'true')
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInitialized) setTimeout(() => setIsInitialized(true), 5000)
|
||||
}, [])
|
||||
|
||||
if (isInitialized) {
|
||||
return <UpgradingPollingState />
|
||||
} else {
|
||||
return (
|
||||
<div className="mx-auto my-16 w-full max-w-7xl space-y-16">
|
||||
<div className="mx-6 space-y-16">
|
||||
<div className="flex flex-col space-y-4 lg:flex-row lg:items-center lg:space-y-0 lg:space-x-6">
|
||||
<h1 className="text-3xl">{project?.name}</h1>
|
||||
</div>
|
||||
<div className="mx-auto mt-8 mb-16 w-full max-w-7xl">
|
||||
<div className="flex h-[500px] items-center justify-center rounded border border-scale-400 bg-scale-300 p-8">
|
||||
<div className="grid w-[480px] gap-4">
|
||||
<div className="relative mx-auto max-w-[300px]">
|
||||
<div className="absolute flex h-full w-full items-center justify-center">
|
||||
<IconSettings className="animate-spin" size={20} strokeWidth={2} />
|
||||
</div>
|
||||
<IconCircle className="text-scale-900" size={50} strokeWidth={1.5} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-center">Upgrading in progress</p>
|
||||
<p className="text-center text-sm text-scale-1100">
|
||||
Upgrades can take from a few minutes up to several hours depending on the size
|
||||
of your database. Your project will be offline while it is being upgraded.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const UpgradingPollingState = () => {
|
||||
const { ref } = useParams()
|
||||
const { app, ui, meta } = useStore()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const { data } = useProjectUpgradingStatusQuery({ projectRef: ref })
|
||||
|
||||
const project = ui.selectedProject
|
||||
const { initiated_at, status, progress, target_version, error } =
|
||||
data?.databaseUpgradeStatus ?? {}
|
||||
const progressStage = Number((progress || '').split('_')[0])
|
||||
|
||||
const isFailed = status === DatabaseUpgradeStatus.Failed
|
||||
const isCompleted = status === DatabaseUpgradeStatus.Upgraded
|
||||
|
||||
const initiatedAt = dayjs(initiated_at ?? 0).format('DD MMM YYYY HH:mm:ss (ZZ)')
|
||||
const initiatedAtUTC = dayjs(initiated_at ?? 0)
|
||||
.utc()
|
||||
.format('DD MMM YYYY HH:mm:ss')
|
||||
|
||||
const refetchProjectDetails = async () => {
|
||||
setLoading(true)
|
||||
await app.projects.fetchDetail(project?.ref ?? '', (project) => meta.setProjectDetails(project))
|
||||
}
|
||||
|
||||
const subject = 'Upgrade%20failed%20for%20project'
|
||||
const message = `Upgrade information:%0A• Initiated at: ${initiated_at}%0A• Target Version: ${target_version}%0A• Error: ${error}`
|
||||
|
||||
return (
|
||||
<div className="mx-auto my-16 w-full max-w-7xl space-y-16">
|
||||
<div className="mx-6 space-y-16">
|
||||
<div className="flex flex-col space-y-4 lg:flex-row lg:items-center lg:space-y-0 lg:space-x-6">
|
||||
<h1 className="text-3xl">{project?.name}</h1>
|
||||
</div>
|
||||
<div className="mx-auto mt-8 mb-16 w-full max-w-7xl">
|
||||
<div className="flex h-[500px] items-center justify-center rounded border border-scale-400 bg-scale-300 p-8">
|
||||
{isCompleted ? (
|
||||
<div className="grid gap-4">
|
||||
<div className="relative mx-auto max-w-[300px]">
|
||||
<IconCheckCircle className="text-brand-900" size={40} strokeWidth={1.5} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-center">Upgrade completed!</p>
|
||||
<p className="mt-4 text-center text-sm text-scale-1100 w-[300px] mx-auto">
|
||||
Your project has been successfully upgraded to Postgres {target_version} and is
|
||||
now back online.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mx-auto">
|
||||
<Button loading={loading} disabled={loading} onClick={refetchProjectDetails}>
|
||||
Return to project
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : isFailed ? (
|
||||
<div className="grid gap-4">
|
||||
<div className="relative mx-auto max-w-[300px]">
|
||||
<IconAlertCircle className="text-amber-900" size={40} strokeWidth={1.5} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-center">We ran into an issue while upgrading your project</p>
|
||||
<p className="mt-4 text-center text-sm text-scale-1100 w-[450px] mx-auto">
|
||||
Your project is back online and its data is not affected. Please reach out to us
|
||||
via our support form for assistance with the upgrade.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center mx-auto space-x-2">
|
||||
<Link
|
||||
href={`/support/new?category=Database_unresponsive&ref=${ref}&subject=${subject}&message=${message}`}
|
||||
>
|
||||
<a target="_blank">
|
||||
<Button type="default">Contact support</Button>
|
||||
</a>
|
||||
</Link>
|
||||
<Button loading={loading} disabled={loading} onClick={refetchProjectDetails}>
|
||||
Return to project
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid w-[480px] gap-4">
|
||||
<div className="relative mx-auto max-w-[300px]">
|
||||
<div className="absolute flex h-full w-full items-center justify-center">
|
||||
<IconSettings className="animate-spin" size={20} strokeWidth={2} />
|
||||
</div>
|
||||
<IconCircle className="text-scale-900" size={50} strokeWidth={1.5} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-center">Upgrading in progress</p>
|
||||
<p className="text-center text-sm text-scale-1100">
|
||||
Upgrades can take from a few minutes up to several hours depending on the size
|
||||
of your database. Your project will be offline while it is being upgraded.
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="!mt-4 !mb-2 py-3 px-4 transition-all overflow-hidden border rounded relative"
|
||||
style={{ maxHeight: isExpanded ? '500px' : '110px' }}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<IconMinimize2
|
||||
size="tiny"
|
||||
strokeWidth={2}
|
||||
className="absolute top-3 right-3 cursor-pointer z-10"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
/>
|
||||
) : (
|
||||
<IconMaximize2
|
||||
size="tiny"
|
||||
strokeWidth={2}
|
||||
className="absolute top-3 right-3 cursor-pointer z-10"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="space-y-2 transition-all"
|
||||
style={{
|
||||
translate: isExpanded
|
||||
? '0px 0px'
|
||||
: `0px ${
|
||||
(progressStage - 2 <= 0
|
||||
? 0
|
||||
: progressStage > 6
|
||||
? 5
|
||||
: progressStage - 2) * -28
|
||||
}px`,
|
||||
}}
|
||||
>
|
||||
{DATABASE_UPGRADE_MESSAGES.map((message, idx: number) => {
|
||||
const isCurrent = message.key === progress
|
||||
const isCompleted = progressStage > idx
|
||||
return (
|
||||
<div key={message.key} className="flex items-center space-x-4">
|
||||
{isCurrent ? (
|
||||
<div className="h-5 w-5 flex items-center justify-center rounded-full">
|
||||
<IconLoader
|
||||
size={20}
|
||||
className="animate-spin text-scale-1100"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</div>
|
||||
) : isCompleted ? (
|
||||
<div className="h-5 w-5 flex items-center justify-center rounded-full border bg-brand-800 border-brand-700">
|
||||
<IconCheck size={12} className="text-white" strokeWidth={2} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-5 w-5 flex items-center justify-center border rounded-full bg-scale-600" />
|
||||
)}
|
||||
<p
|
||||
className={`text-sm ${
|
||||
isCurrent
|
||||
? 'text-scale-1200'
|
||||
: isCompleted
|
||||
? 'text-scale-1100'
|
||||
: 'text-scale-1000'
|
||||
} hover:text-scale-1200 transition`}
|
||||
>
|
||||
{isCurrent
|
||||
? message.progress
|
||||
: isCompleted
|
||||
? message.completed
|
||||
: message.initial}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{initiated_at !== undefined && (
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger className="w-full">
|
||||
<p className="text-center text-sm text-scale-1000">
|
||||
Started on: {initiatedAtUTC} (UTC)
|
||||
</p>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-scale-100 py-1 px-2 leading-none shadow', // background
|
||||
'border border-scale-200 ', //border
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-xs text-scale-1200">{initiatedAt}</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UpgradingState
|
||||
@@ -0,0 +1,2 @@
|
||||
import UpgradingState from './UpgradingState'
|
||||
export default UpgradingState
|
||||
@@ -24,15 +24,10 @@ export const generateReportsMenu = (project?: Project): ProductMenuGroup[] => {
|
||||
? [
|
||||
{
|
||||
name: 'API',
|
||||
key: 'api',
|
||||
url: `/project/${ref}/reports/api`,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
name: 'Auth',
|
||||
key: 'auth',
|
||||
url: `/project/${ref}/reports/auth`,
|
||||
key: 'api-overview',
|
||||
url: `/project/${ref}/reports/api-overview`,
|
||||
items: [],
|
||||
label: 'NEW',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
@@ -126,6 +126,9 @@ const Header = ({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated please use studio/components/ui/Charts/BarChart.tsx instead
|
||||
*/
|
||||
export function BarChart({
|
||||
data,
|
||||
attribute,
|
||||
@@ -267,4 +270,4 @@ export function BarChart({
|
||||
</div>
|
||||
</Loading>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -26,15 +26,21 @@ export const AreaChart = () => (
|
||||
{ title: 'No title', props: { title: undefined } },
|
||||
{ title: 'Minimal Header', props: { minimalHeader: true } },
|
||||
{ title: 'Minimal Highlighted Value', props: { minimalHeader: true, highlightedValue: 123 } },
|
||||
{ title: 'Highlighted Value', props: { highlightedValue: 123, highlightedLabel: "some custom label" } },
|
||||
{ title: 'Formatter - Auto 2 Precision for floats', props: { data: DATA.map(d=> ({...d, ram_usage: d.ram_usage + 0.1231233})) } },
|
||||
{
|
||||
title: 'Highlighted Value',
|
||||
props: { highlightedValue: 123, highlightedLabel: 'some custom label' },
|
||||
},
|
||||
{
|
||||
title: 'Formatter - Auto 2 Precision for floats',
|
||||
props: { data: DATA.map((d) => ({ ...d, ram_usage: d.ram_usage + 0.1231233 })) },
|
||||
},
|
||||
{ title: 'UTC Dates', props: { displayDateInUtc: true } },
|
||||
{ title: 'Custom date format', props: { customDateFormat: 'HH:mm' } },
|
||||
{ title: 'Value format', props: { format: 'c' } },
|
||||
{ title: 'Minimal Value format', props: { minimalHeader: true, format: 'c' } },
|
||||
{ title: 'No data', props: { data: [] } },
|
||||
].map(({ title, props }) => (
|
||||
<div className="w-72 h-72">
|
||||
<div className="w-72 h-72" key={title}>
|
||||
<ExampleName>{title}</ExampleName>
|
||||
<div className="w-full h-full mt-4">
|
||||
<Chart {...PROPS} {...props} />
|
||||
|
||||
@@ -13,7 +13,7 @@ import ChartHeader from './ChartHeader'
|
||||
import { Datum, CommonChartProps } from './Charts.types'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
import ChartNoData from './NoDataPlaceholder'
|
||||
import { numberFormatter } from './Charts.utils'
|
||||
import { numberFormatter, useChartSize } from './Charts.utils'
|
||||
dayjs.extend(utc)
|
||||
|
||||
export interface AreaChartProps<D = Datum> extends CommonChartProps<D> {
|
||||
@@ -36,13 +36,12 @@ const AreaChart: React.FC<AreaChartProps> = ({
|
||||
displayDateInUtc,
|
||||
minimalHeader,
|
||||
className = '',
|
||||
size = 'normal',
|
||||
}) => {
|
||||
const { Container } = useChartSize(size)
|
||||
const [focusDataIndex, setFocusDataIndex] = useState<number | null>(null)
|
||||
|
||||
// For future reference: https://github.com/supabase/supabase/pull/5311#discussion_r800852828
|
||||
const chartHeight = 160
|
||||
|
||||
if (data.length === 0) return <ChartNoData />
|
||||
if (data.length === 0) return <ChartNoData className={className} />
|
||||
|
||||
const day = (value: number | string) => (displayDateInUtc ? dayjs(value).utc() : dayjs(value))
|
||||
const resolvedHighlightedLabel =
|
||||
@@ -69,7 +68,7 @@ const AreaChart: React.FC<AreaChartProps> = ({
|
||||
highlightedLabel={resolvedHighlightedLabel}
|
||||
minimalHeader={minimalHeader}
|
||||
/>
|
||||
<ResponsiveContainer width="100%" height={chartHeight}>
|
||||
<Container>
|
||||
<RechartAreaChart
|
||||
data={data}
|
||||
margin={{
|
||||
@@ -112,7 +111,7 @@ const AreaChart: React.FC<AreaChartProps> = ({
|
||||
fill="url(#colorUv)"
|
||||
/>
|
||||
</RechartAreaChart>
|
||||
</ResponsiveContainer>
|
||||
</Container>
|
||||
{data && (
|
||||
<div className="text-scale-900 -mt-5 flex items-center justify-between text-xs">
|
||||
<span>{dayjs(data[0][xAxisKey]).format(customDateFormat)}</span>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from 'react'
|
||||
import { ComponentMeta } from '@storybook/react'
|
||||
import Chart, { BarChartProps } from './BarChart'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
export default {
|
||||
title: 'Charts',
|
||||
} as ComponentMeta<typeof Chart>
|
||||
|
||||
const DATA = [290, 430, 649, 422, 321, 893, 111].map((value, index) => ({
|
||||
ram_usage: value,
|
||||
timestamp: dayjs().subtract(index, 'day').toISOString(),
|
||||
}))
|
||||
|
||||
const PROPS: BarChartProps<typeof DATA[number]> = {
|
||||
title: 'Memory usage',
|
||||
xAxisKey: 'timestamp',
|
||||
yAxisKey: 'ram_usage',
|
||||
data: DATA,
|
||||
}
|
||||
|
||||
export const BarChart = () => (
|
||||
<div className="flex flex-row gap-4 flex-wrap p-10">
|
||||
{[
|
||||
{ title: 'Normal', props: {} },
|
||||
{ title: 'No title', props: { title: undefined } },
|
||||
{ title: 'Minimal Header', props: { minimalHeader: true } },
|
||||
{ title: 'Minimal Highlighted Value', props: { minimalHeader: true, highlightedValue: 123 } },
|
||||
{
|
||||
title: 'Highlighted Value',
|
||||
props: { highlightedValue: 123, highlightedLabel: 'some custom label' },
|
||||
},
|
||||
{
|
||||
title: 'Formatter - Auto 2 Precision for floats',
|
||||
props: { data: DATA.map((d) => ({ ...d, ram_usage: d.ram_usage + 0.1231233 })) },
|
||||
},
|
||||
{ title: 'UTC Dates', props: { displayDateInUtc: true } },
|
||||
{ title: 'Custom date format', props: { customDateFormat: 'HH:mm' } },
|
||||
{ title: 'Value format', props: { format: 'c' } },
|
||||
{ title: 'Minimal Value format', props: { minimalHeader: true, format: 'c' } },
|
||||
{ title: 'No data', props: { data: [] } },
|
||||
{ title: 'onBarClick', props: { onBarClick: console.log } },
|
||||
].map(({ title, props }) => (
|
||||
<div className="w-72 h-72" key={title}>
|
||||
<ExampleName>{title}</ExampleName>
|
||||
<div className="w-full h-full mt-4">
|
||||
<Chart {...PROPS} {...props} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
const ExampleName: React.FC = ({ children }) => (
|
||||
<h1 className="font-bold text-scale-1100">{children}</h1>
|
||||
)
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useState } from 'react'
|
||||
import { BarChart as RechartBarChart, XAxis, Tooltip, Bar, Cell, BarProps } from 'recharts'
|
||||
import dayjs from 'dayjs'
|
||||
import { CHART_COLORS, DateTimeFormats } from 'components/ui/Charts/Charts.constants'
|
||||
import ChartHeader from './ChartHeader'
|
||||
import { Datum, CommonChartProps } from './Charts.types'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
import ChartNoData from './NoDataPlaceholder'
|
||||
import { numberFormatter, useChartSize } from './Charts.utils'
|
||||
import { CategoricalChartProps } from 'recharts/types/chart/generateCategoricalChart'
|
||||
dayjs.extend(utc)
|
||||
|
||||
export interface BarChartProps<D = Datum> extends CommonChartProps<D> {
|
||||
yAxisKey: string
|
||||
xAxisKey: string
|
||||
format?: string
|
||||
customDateFormat?: string
|
||||
displayDateInUtc?: boolean
|
||||
onBarClick?: (datum: Datum, tooltipData?: Parameters<CategoricalChartProps['onClick']>[0]) => void
|
||||
}
|
||||
|
||||
const BarChart: React.FC<BarChartProps> = ({
|
||||
data,
|
||||
yAxisKey,
|
||||
xAxisKey,
|
||||
format,
|
||||
customDateFormat = DateTimeFormats.FULL,
|
||||
title,
|
||||
highlightedValue,
|
||||
highlightedLabel,
|
||||
displayDateInUtc,
|
||||
minimalHeader,
|
||||
className = '',
|
||||
size = 'normal',
|
||||
onBarClick,
|
||||
}) => {
|
||||
const { Container } = useChartSize(size)
|
||||
const [focusDataIndex, setFocusDataIndex] = useState<number | null>(null)
|
||||
|
||||
if (data.length === 0) return <ChartNoData className={className} />
|
||||
|
||||
const day = (value: number | string) => (displayDateInUtc ? dayjs(value).utc() : dayjs(value))
|
||||
const resolvedHighlightedLabel =
|
||||
(focusDataIndex !== null &&
|
||||
data &&
|
||||
data[focusDataIndex] &&
|
||||
day(data[focusDataIndex][xAxisKey]).format(customDateFormat)) ||
|
||||
highlightedLabel
|
||||
|
||||
const resolvedHighlightedValue =
|
||||
(focusDataIndex !== null ? data[focusDataIndex]?.[yAxisKey] : null) || highlightedValue
|
||||
|
||||
return (
|
||||
<div className={['flex flex-col gap-3', className].join(' ')}>
|
||||
<ChartHeader
|
||||
title={title}
|
||||
format={format}
|
||||
customDateFormat={customDateFormat}
|
||||
highlightedValue={
|
||||
typeof resolvedHighlightedValue === 'number'
|
||||
? numberFormatter(resolvedHighlightedValue)
|
||||
: resolvedHighlightedValue
|
||||
}
|
||||
highlightedLabel={resolvedHighlightedLabel}
|
||||
minimalHeader={minimalHeader}
|
||||
/>
|
||||
<Container>
|
||||
<RechartBarChart
|
||||
data={data}
|
||||
margin={{
|
||||
top: 0,
|
||||
right: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
className="overflow-visible"
|
||||
// mouse hover focusing logic
|
||||
onMouseMove={(e: any) => {
|
||||
if (e.activeTooltipIndex !== focusDataIndex) {
|
||||
setFocusDataIndex(e.activeTooltipIndex)
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => setFocusDataIndex(null)}
|
||||
onClick={(tooltipData: any) => {
|
||||
// receives tooltip data https://github.com/recharts/recharts/blob/2a3405ff64a0c050d2cf94c36f0beef738d9e9c2/src/chart/generateCategoricalChart.tsx
|
||||
const datum = tooltipData?.activePayload?.[0]?.payload
|
||||
if (onBarClick) onBarClick(datum, tooltipData)
|
||||
}}
|
||||
>
|
||||
<XAxis
|
||||
dataKey={xAxisKey}
|
||||
interval={data.length - 2}
|
||||
angle={0}
|
||||
// hide the tick
|
||||
tick={{ fontSize: '0px' }}
|
||||
// color the axis
|
||||
axisLine={{ stroke: CHART_COLORS.AXIS }}
|
||||
tickLine={{ stroke: CHART_COLORS.AXIS }}
|
||||
/>
|
||||
<Tooltip content={() => null} />
|
||||
<Bar
|
||||
dataKey={yAxisKey}
|
||||
fill={CHART_COLORS.GREEN_1}
|
||||
animationDuration={300}
|
||||
// max bar size required to prevent bars from expanding to max width.
|
||||
maxBarSize={48}
|
||||
>
|
||||
{data?.map((_entry: Datum, index: any) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
className={`transition-all duration-300 ${onBarClick ? 'cursor-pointer' : ''}`}
|
||||
fill={
|
||||
focusDataIndex === index || focusDataIndex === null
|
||||
? CHART_COLORS.GREEN_1
|
||||
: CHART_COLORS.GREEN_2
|
||||
}
|
||||
enableBackground={12}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</RechartBarChart>
|
||||
</Container>
|
||||
{data && (
|
||||
<div className="text-scale-900 -mt-9 flex items-center justify-between text-xs">
|
||||
<span>{dayjs(data[0][xAxisKey]).format(customDateFormat)}</span>
|
||||
<span>{dayjs(data[data?.length - 1]?.[xAxisKey]).format(customDateFormat)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default BarChart
|
||||
@@ -33,7 +33,7 @@ const ChartHeader: React.FC<ChartHeaderProps> = ({
|
||||
if (minimalHeader) {
|
||||
return (
|
||||
<div className="flex flex-row items-center gap-x-4" style={{ minHeight: '1.8rem' }}>
|
||||
{chartTitle}
|
||||
{title && chartTitle}
|
||||
<div className="flex flex-row items-baseline gap-x-2">
|
||||
{highlightedValue && highlighted}
|
||||
{label}
|
||||
@@ -44,7 +44,7 @@ const ChartHeader: React.FC<ChartHeaderProps> = ({
|
||||
|
||||
return (
|
||||
<div className="h-16">
|
||||
{chartTitle}
|
||||
{title && chartTitle}
|
||||
{highlightedValue && highlighted}
|
||||
{label}
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface CommonChartProps<D>
|
||||
title?: string
|
||||
className?: string
|
||||
isLoading?: boolean
|
||||
size?: 'small' | 'normal' | 'large'
|
||||
size?: 'tiny' | 'small' | 'normal' | 'large'
|
||||
}
|
||||
|
||||
export interface StackedChartProps<D> extends CommonChartProps<D> {
|
||||
|
||||
@@ -70,10 +70,16 @@ export const timestampFormatter = (
|
||||
export const useChartSize = (
|
||||
size: CommonChartProps<any>['size'] = 'normal',
|
||||
sizeMap: {
|
||||
tiny: number
|
||||
small: number
|
||||
normal: number
|
||||
large: number
|
||||
} = { small: 120, normal: 160, large: 280 }
|
||||
} = {
|
||||
tiny: 76,
|
||||
small: 96,
|
||||
normal: 160,
|
||||
large: 280,
|
||||
}
|
||||
) => {
|
||||
const minHeight = sizeMap[size]
|
||||
const Container: React.FC = useMemo(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IconBarChart2 } from '@supabase/ui'
|
||||
import { IconBarChart2 } from 'ui'
|
||||
|
||||
interface Props {
|
||||
title?: string
|
||||
@@ -12,9 +12,10 @@ const NoDataPlaceholder: React.FC<Props> = ({
|
||||
}) => (
|
||||
<div
|
||||
className={
|
||||
'border-scale-600 flex h-full w-full flex-col items-center justify-center space-y-2 border border-dashed text-center ' +
|
||||
'border-scale-600 flex w-full flex-col items-center justify-center space-y-2 border border-dashed text-center ' +
|
||||
className
|
||||
}
|
||||
style={{minHeight: "100px"}}
|
||||
>
|
||||
<IconBarChart2 className="text-scale-800" />
|
||||
<div>
|
||||
|
||||
@@ -8,6 +8,7 @@ interface Props extends CommonChartProps<any> {
|
||||
}
|
||||
const Sparkline: React.FC<Props> = ({ size, data, xAxisKey, yAxisKey }) => {
|
||||
const { Container } = useChartSize(size, {
|
||||
tiny: 30,
|
||||
small: 50,
|
||||
normal: 90,
|
||||
large: 140,
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import React from 'react'
|
||||
|
||||
const LoadingOpacity = ({ children, active }: { children?: React.ReactNode; active: boolean }) => {
|
||||
const LoadingOpacity = ({
|
||||
children,
|
||||
active,
|
||||
className,
|
||||
}: {
|
||||
children?: React.ReactNode
|
||||
active: boolean
|
||||
className?: string
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'flex h-full flex-grow transition-opacity ' + (active ? 'opacity-30' : 'opacity-100')
|
||||
}
|
||||
className={[
|
||||
className,
|
||||
'flex h-full flex-grow transition-opacity ',
|
||||
active ? 'opacity-30' : 'opacity-100',
|
||||
].join(' ')}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -1,23 +1,182 @@
|
||||
import { IconList } from 'ui'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { LOGS_EXPLORER_DOCS_URL } from 'components/interfaces/Settings/Logs'
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import { useStore } from 'hooks'
|
||||
import { copyToClipboard } from 'lib/helpers'
|
||||
import Link from 'next/link'
|
||||
import { FC, useState } from 'react'
|
||||
import { logConstants } from 'shared-data'
|
||||
import {
|
||||
Button,
|
||||
IconBookOpen,
|
||||
IconCheck,
|
||||
IconClipboard,
|
||||
IconExternalLink,
|
||||
IconList,
|
||||
IconX,
|
||||
SidePanel,
|
||||
Tabs,
|
||||
} from 'ui'
|
||||
|
||||
interface Props {
|
||||
subtitle?: string
|
||||
}
|
||||
|
||||
const LogsExplorerHeader: React.FC<Props> = ({ subtitle }) => (
|
||||
<div className={['flex flex-col gap-8 transition-all pb-6'].join(' ')}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="flex h-6 w-6 items-center justify-center rounded border
|
||||
const LogsExplorerHeader: FC<Props> = ({ subtitle }) => {
|
||||
const { ui } = useStore()
|
||||
const [showReference, setShowReference] = useState(false)
|
||||
|
||||
return (
|
||||
<div className={['flex items-center gap-8 transition-all pb-6 justify-between'].join(' ')}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="flex h-6 w-6 items-center justify-center rounded border
|
||||
border-brand-600 bg-brand-300 text-brand-900
|
||||
"
|
||||
>
|
||||
<IconList size={14} strokeWidth={3} />
|
||||
</div>
|
||||
>
|
||||
<IconList size={14} strokeWidth={3} />
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl text-scale-1200">Logs Explorer</h1>
|
||||
{subtitle && <span className="text-2xl text-scale-1100">{subtitle}</span>}
|
||||
<h1 className="text-2xl text-scale-1200">Logs Explorer</h1>
|
||||
{subtitle && <span className="text-2xl text-scale-1000">{subtitle}</span>}
|
||||
</div>
|
||||
<div className="flex flex-row gap-2">
|
||||
<Link href={LOGS_EXPLORER_DOCS_URL}>
|
||||
<a>
|
||||
<Button type="default" icon={<IconExternalLink strokeWidth={1.5} />}>
|
||||
Documentation
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
|
||||
<SidePanel
|
||||
size="large"
|
||||
header={
|
||||
<div className="flex flex-row justify-between items-center">
|
||||
<h3>Field Reference</h3>
|
||||
<Button
|
||||
type="text"
|
||||
className="px-1"
|
||||
onClick={() => setShowReference(false)}
|
||||
icon={<IconX size={18} strokeWidth={1.5} />}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
visible={showReference}
|
||||
cancelText="Close"
|
||||
onCancel={() => setShowReference(false)}
|
||||
hideFooter
|
||||
triggerElement={
|
||||
<Button
|
||||
type="default"
|
||||
onClick={() => setShowReference(true)}
|
||||
icon={<IconBookOpen strokeWidth={1.5} />}
|
||||
>
|
||||
Field Reference
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<SidePanel.Content>
|
||||
<div className="pt-4 pb-2 space-y-1">
|
||||
<p className="text-sm">
|
||||
The following table shows all the available paths that can be queried from each
|
||||
respective source. Do note that to access nested keys, you would need to perform the
|
||||
necessary{' '}
|
||||
<Link href="https://supabase.com/docs/guides/platform/logs#unnesting-arrays">
|
||||
<a target="_blank" className="text-brand-900">
|
||||
unnesting joins
|
||||
<IconExternalLink
|
||||
size="tiny"
|
||||
className="ml-1 inline -translate-y-[2px]"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</a>
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</SidePanel.Content>
|
||||
<SidePanel.Separator />
|
||||
<Tabs
|
||||
scrollable
|
||||
size="small"
|
||||
type="underlined"
|
||||
defaultActiveId="edge_logs"
|
||||
listClassNames="px-2"
|
||||
>
|
||||
{logConstants.schemas.map((schema) => (
|
||||
<Tabs.Panel id={schema.reference} label={schema.name} className="px-4 pb-4">
|
||||
<Table
|
||||
head={[
|
||||
<Table.th className="text-xs !p-2" key="path">
|
||||
Path
|
||||
</Table.th>,
|
||||
<Table.th key="type" className="text-xs !p-2">
|
||||
Type
|
||||
</Table.th>,
|
||||
]}
|
||||
body={schema.fields
|
||||
.sort((a: any, b: any) => a.path - b.path)
|
||||
.map((field) => {
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
return (
|
||||
<Table.tr key={field.path}>
|
||||
<Table.td
|
||||
className="font-mono text-xs !p-2 cursor-pointer hover:text-scale-1200 transition flex items-center space-x-2"
|
||||
onClick={() =>
|
||||
copyToClipboard(field.path, () => {
|
||||
setIsCopied(true)
|
||||
setTimeout(() => setIsCopied(false), 3000)
|
||||
})
|
||||
}
|
||||
>
|
||||
<span>{field.path}</span>
|
||||
{isCopied ? (
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger>
|
||||
<IconCheck size={14} strokeWidth={3} className="text-brand-900" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
|
||||
'border border-scale-200',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-xs text-scale-1200">Copied</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
) : (
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger>
|
||||
<IconClipboard size="tiny" strokeWidth={1.5} />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
|
||||
'border border-scale-200',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-xs text-scale-1200">Copy value</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
</Table.td>
|
||||
<Table.td className="font-mono text-xs !p-2">{field.type}</Table.td>
|
||||
</Table.tr>
|
||||
)
|
||||
})}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
))}
|
||||
</Tabs>
|
||||
</SidePanel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
||||
export default LogsExplorerHeader
|
||||
@@ -11,6 +11,7 @@ interface Props {
|
||||
noMargin?: boolean
|
||||
title?: JSX.Element | false
|
||||
wrapWithLoading?: boolean
|
||||
noHideOverflow?: boolean
|
||||
}
|
||||
function Panel(props: Props) {
|
||||
let headerClasses: string[] = []
|
||||
@@ -31,7 +32,7 @@ function Panel(props: Props) {
|
||||
const content = (
|
||||
<div
|
||||
className={`
|
||||
overflow-hidden rounded-md border
|
||||
${props.noHideOverflow ? '' : 'overflow-hidden'} rounded-md border
|
||||
border-panel-border-light shadow-sm
|
||||
dark:border-panel-border-dark ${props.noMargin ? '' : 'mb-8'} ${props.className}`}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import dayjs from 'dayjs'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { Alert, Button, IconX } from 'ui'
|
||||
import { useParams } from 'hooks'
|
||||
import { useProjectUpgradingStatusQuery } from 'data/config/project-upgrade-status-query'
|
||||
import { DatabaseUpgradeStatus } from '@supabase/shared-types/out/events'
|
||||
|
||||
// [Joshen] Think twice about the category though - it doesn't correspond
|
||||
|
||||
const ProjectUpgradeFailedBanner = () => {
|
||||
const { ref } = useParams()
|
||||
const { data } = useProjectUpgradingStatusQuery({ projectRef: ref })
|
||||
const { target_version, status, initiated_at, error } = data?.databaseUpgradeStatus ?? {}
|
||||
|
||||
const key = `supabase-upgrade-${ref}-${initiated_at}`
|
||||
const isAcknowledged = localStorage.getItem(key) === 'true'
|
||||
const [showMessage, setShowMessage] = useState(!isAcknowledged)
|
||||
|
||||
const isFailed = status === DatabaseUpgradeStatus.Failed
|
||||
const initiatedAtUTC = dayjs(initiated_at ?? 0)
|
||||
.utc()
|
||||
.format('DD MMM YYYY HH:mm:ss')
|
||||
|
||||
const subject = 'Upgrade%20failed%20for%20project'
|
||||
const message = `Upgrade information:%0A• Initiated at: ${initiated_at}%0A• Target Version: ${target_version}%0A• Error: ${error}`
|
||||
|
||||
const acknowledgeMessage = () => {
|
||||
setShowMessage(false)
|
||||
localStorage.setItem(key, 'true')
|
||||
}
|
||||
|
||||
if (!isFailed || !showMessage) return null
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl">
|
||||
<Alert
|
||||
withIcon
|
||||
variant={'warning'}
|
||||
title={`Postgres version upgrade to ${target_version} was not successful (Initiated at ${initiatedAtUTC} UTC)`}
|
||||
actions={
|
||||
<div className="flex h-full items-center space-x-4">
|
||||
<Link
|
||||
href={`/support/new?category=Database_unresponsive&ref=${ref}&subject=${subject}&message=${message}`}
|
||||
>
|
||||
<a target="_blank">
|
||||
<Button type="default">Contact support</Button>
|
||||
</a>
|
||||
</Link>
|
||||
<Button
|
||||
type="text"
|
||||
className="px-1"
|
||||
icon={<IconX size={16} strokeWidth={1.5} />}
|
||||
onClick={() => acknowledgeMessage()}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
Your project and its data are not affected. Please reach out to us via our support form for
|
||||
assistance with the upgrade.
|
||||
</Alert>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProjectUpgradeFailedBanner
|
||||