Merge pull request #12754 from supabase/master

Prod deploy dashboard
This commit is contained in:
Joshen Lim authored and GitHub committed 2023-03-02 15:35:20 +08:00
commit 92e72bac9f
135 files changed
+5511 -3294

No files matched your search

+12
View File
@@ -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
+7 -1
View File
@@ -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
+2 -1
View File
@@ -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',
},
],
},
],
}
+1 -2
View File
@@ -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:
+1 -1
View File
@@ -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:
+5 -2
View File
@@ -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),
+2 -3
View File
@@ -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:
+12 -8
View File
@@ -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
+34 -25
View File
@@ -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).
+49 -17
View File
@@ -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
+31 -29
View File
@@ -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.
File diff suppressed because it is too large. Load diff
+15
View File
@@ -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',
},
]
-1
View File
@@ -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": "*"
},
Binary file not shown.

After

Width:  |  Height:  |  Size: 760 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 703 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

+477 -1674
View File
File diff suppressed because it is too large. Load diff
+4 -13
View File
@@ -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"
}
]
}
+2 -1
View File
@@ -1,3 +1,4 @@
import tweets from './tweets'
import logConstants from './logConstants'
export { tweets }
export { tweets, logConstants }
+237
View File
@@ -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>
+29 -22
View File
@@ -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)
}
},
})
+2 -1
View File
@@ -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 {
+36 -215
View File
@@ -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} />
+6 -7
View File
@@ -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>
)
+132
View File
@@ -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
+2 -2
View File
@@ -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>
+1 -1
View File
@@ -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> {
+7 -1
View File
@@ -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,
+14 -4
View File
@@ -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>
+172 -13
View File
@@ -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
+2 -1
View File
@@ -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
Loaded 100 of 135 files, more files were not shown because too many files have changed in this diff. Show more