diff --git a/README.md b/README.md index a8a07c99b33..2bee6570259 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,17 @@ [Supabase](https://supabase.com) is an open source Firebase alternative. We're building the features of Firebase using enterprise-grade open source tools. -- [x] Hosted Postgres Database -- [x] Realtime subscriptions -- [x] Authentication and authorization -- [x] Auto-generated APIs +- [x] Hosted Postgres Database. [Docs](https://supabase.com/docs/guides/database) +- [x] Authentication and Authorization. [Docs](https://supabase.com/docs/guides/auth) +- [x] Auto-generated APIs. + - [x] REST. [Docs](https://supabase.com/docs/guides/api#rest-api) + - [x] Realtime subscriptions. [Docs](https://supabase.com/docs/guides/api#realtime-api) + - [x] GraphQL (Beta). [Docs](https://supabase.com/docs/guides/api#graphql-api) +- [x] Functions. + - [x] Database Functions. [Docs](https://supabase.com/docs/guides/database/functions) + - [ ] Edge Functions (coming soon). +- [x] File Storage. [Docs](https://supabase.com/docs/guides/storage) - [x] Dashboard -- [x] Storage -- [ ] Functions (coming soon) ## Documentation diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 4b9758c97f7..46699f6c553 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -85,7 +85,7 @@ services: restart: unless-stopped environment: PGRST_DB_URI: postgres://postgres:${POSTGRES_PASSWORD}@db:5432/postgres - PGRST_DB_SCHEMAS: public,storage + PGRST_DB_SCHEMAS: public,storage, graphql_public PGRST_DB_ANON_ROLE: anon PGRST_JWT_SECRET: ${JWT_SECRET} PGRST_DB_USE_LEGACY_GUCS: "false" @@ -151,7 +151,7 @@ services: db: container_name: supabase-db - image: supabase/postgres:14.1.0 + image: supabase/postgres:14.1.0.15 command: postgres -c config_file=/etc/postgresql/postgresql.conf restart: unless-stopped ports: diff --git a/docker/volumes/api/kong.yml b/docker/volumes/api/kong.yml index aab2fe55aa0..a95baa779b1 100644 --- a/docker/volumes/api/kong.yml +++ b/docker/volumes/api/kong.yml @@ -95,6 +95,32 @@ services: - admin - anon + ## Secure GraphQL routes + - name: graphql-v1 + _comment: "PostgREST: /rest/v1/* -> http://rest:3000/rpc/graphql" + url: http://rest:3000/rpc/graphql + routes: + - name: graphql-v1-all + strip_path: true + paths: + - /graphql/v1 + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: true + - name: request-transformer + config: + add: + headers: + - Content-Profile:graphql_public + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + ## Secure Realtime routes - name: realtime-v1 _comment: "Realtime: /realtime/v1/* -> ws://realtime:4000/socket/*" diff --git a/docker/volumes/db/init/04-pg-graphql.sql b/docker/volumes/db/init/04-pg-graphql.sql new file mode 100644 index 00000000000..a4929abdbae --- /dev/null +++ b/docker/volumes/db/init/04-pg-graphql.sql @@ -0,0 +1,141 @@ +create schema if not exists graphql_public; + +-- GraphQL Placeholder Entrypoint +create or replace function graphql_public.graphql( + "operationName" text default null, + query text default null, + variables jsonb default null, + extensions jsonb default null +) + returns jsonb + language plpgsql +as $$ + DECLARE + server_version float; + BEGIN + server_version = (SELECT (SPLIT_PART((select version()), ' ', 2))::float); + + IF server_version >= 14 THEN + RETURN jsonb_build_object( + 'data', null::jsonb, + 'errors', array['pg_graphql extension is not enabled.'] + ); + ELSE + RETURN jsonb_build_object( + 'data', null::jsonb, + 'errors', array['pg_graphql is only available on projects running Postgres 14 onwards.'] + ); + END IF; + END; +$$; + +grant usage on schema graphql_public to postgres, anon, authenticated, service_role; +alter default privileges in schema graphql_public grant all on tables to postgres, anon, authenticated, service_role; +alter default privileges in schema graphql_public grant all on functions to postgres, anon, authenticated, service_role; +alter default privileges in schema graphql_public grant all on sequences to postgres, anon, authenticated, service_role; + +alter default privileges for user supabase_admin in schema graphql_public grant all + on sequences to postgres, anon, authenticated, service_role; +alter default privileges for user supabase_admin in schema graphql_public grant all + on tables to postgres, anon, authenticated, service_role; +alter default privileges for user supabase_admin in schema graphql_public grant all + on functions to postgres, anon, authenticated, service_role; + +-- Trigger upon enabling pg_graphql +CREATE OR REPLACE FUNCTION extensions.grant_pg_graphql_access() +RETURNS event_trigger +LANGUAGE plpgsql +AS $func$ + DECLARE + func_is_graphql_resolve bool; + BEGIN + func_is_graphql_resolve = ( + SELECT n.proname = 'resolve' + FROM pg_event_trigger_ddl_commands() AS ev + LEFT JOIN pg_catalog.pg_proc AS n + ON ev.objid = n.oid + ); + + IF func_is_graphql_resolve + THEN + grant usage on schema graphql to postgres, anon, authenticated, service_role; + grant all on function graphql.resolve to postgres, anon, authenticated, service_role; + + alter default privileges in schema graphql grant all on tables to postgres, anon, authenticated, service_role; + alter default privileges in schema graphql grant all on functions to postgres, anon, authenticated, service_role; + alter default privileges in schema graphql grant all on sequences to postgres, anon, authenticated, service_role; + + create or replace function graphql_public.graphql( + "operationName" text default null, + query text default null, + variables jsonb default null, + extensions jsonb default null + ) + returns jsonb + language sql + as $$ + SELECT graphql.resolve(query, coalesce(variables, '{}')); + $$; + + grant select on graphql.field, graphql.type, graphql.enum_value to postgres, anon, authenticated, service_role; + grant execute on function graphql.resolve to postgres, anon, authenticated, service_role; + END IF; + + END; +$func$; + +CREATE EVENT TRIGGER issue_pg_graphql_access ON ddl_command_end WHEN TAG in ('CREATE FUNCTION') +EXECUTE PROCEDURE extensions.grant_pg_graphql_access(); +COMMENT ON FUNCTION extensions.grant_pg_graphql_access IS 'Grants access to pg_graphql'; + +-- Trigger upon dropping the pg_graphql extension +CREATE OR REPLACE FUNCTION extensions.set_graphql_placeholder() +RETURNS event_trigger +LANGUAGE plpgsql +AS $func$ + DECLARE + graphql_is_dropped bool; + BEGIN + graphql_is_dropped = ( + SELECT ev.schema_name = 'graphql_public' + FROM pg_event_trigger_dropped_objects() AS ev + WHERE ev.schema_name = 'graphql_public' + ); + + IF graphql_is_dropped + THEN + create or replace function graphql_public.graphql( + "operationName" text default null, + query text default null, + variables jsonb default null, + extensions jsonb default null + ) + returns jsonb + language plpgsql + as $$ + DECLARE + server_version float; + BEGIN + server_version = (SELECT (SPLIT_PART((select version()), ' ', 2))::float); + + IF server_version >= 14 THEN + RETURN jsonb_build_object( + 'data', null::jsonb, + 'errors', array['pg_graphql extension is not enabled.'] + ); + ELSE + RETURN jsonb_build_object( + 'data', null::jsonb, + 'errors', array['pg_graphql is only available on projects running Postgres 14 onwards.'] + ); + END IF; + END; + $$; + END IF; + + END; +$func$; + +CREATE EVENT TRIGGER issue_graphql_placeholder ON sql_drop WHEN TAG in ('DROP EXTENSION') +EXECUTE PROCEDURE extensions.set_graphql_placeholder(); +COMMENT ON FUNCTION extensions.set_graphql_placeholder IS 'Reintroduces placeholder function for graphql_public.graphql'; \ No newline at end of file diff --git a/studio/.storybook/preview.css b/studio/.storybook/preview.css index 2afd110d2e3..c12137dbd9d 100644 --- a/studio/.storybook/preview.css +++ b/studio/.storybook/preview.css @@ -8,3 +8,106 @@ .dark .docs-story { @apply bg-scale-200; } + +/* // date picker */ +/* !!!! THIS IS A COPY OF THE styles/date-picker.scss !! */ + +.react-datepicker__input-container input { + @apply block w-full text-base md:text-sm bg-white border border-scale-300 rounded shadow-sm; +} + +.react-datepicker-popper { + @apply z-40 w-72 text-sm bg-scale-100 dark:bg-scale-300 shadow px-3 py-2 border-2 border-scale-200 rounded; +} + +.react-datepicker-left { + @apply absolute left-0 right-auto top-11 transform-none !important; +} + +.react-datepicker-right { + @apply absolute right-0 left-auto top-11 transform-none !important; +} + +.react-datepicker__portal { + @apply absolute z-10 w-72 text-sm transform-none bg-white shadow px-3 py-2 top-12 right-0 border-2 border-scale-200 rounded; +} + +.react-datepicker__month-container { + @apply flex flex-col; +} + +.react-datepicker__month { + @apply flex flex-col; + @apply -space-y-px; +} + +.react-datepicker__current-month { + @apply ml-2.5 text-lg font-semibold text-scale-800; +} + +.react-datepicker__week { + @apply flex -space-x-px; +} + +.react-datepicker__day-names { + @apply flex -space-x-px text-scale-900 font-normal text-center text-xs; +} + +.react-datepicker__day-name { + @apply w-10 h-10 flex items-center justify-center py-1 rounded-full; +} + +.react-datepicker__navigation { + @apply absolute top-2; +} + +.react-datepicker__navigation--previous { + @apply right-12 w-10 h-10 rounded transition flex items-center justify-center hover:bg-scale-200; +} + +.react-datepicker__navigation--next { + @apply right-4 w-10 h-10 rounded transition flex items-center justify-center hover:bg-scale-200; +} + +.react-datepicker__day { + @apply w-10 h-10 flex items-center font-normal justify-center text-sm leading-loose transition text-scale-1100; + /* @apply mb-1 py-1; */ + @apply -space-y-px; + @apply border border-scale-500; +} + +.react-datepicker__day--disabled { + @apply cursor-not-allowed opacity-40 hover:bg-transparent; +} + +.react-datepicker__day--outside-month { + @apply text-scale-800; +} + +.react-datepicker__day--in-range { + @apply bg-brand-800 text-scale-fixed-100 border-brand-800; +} + +.react-datepicker__day--in-selecting-range { + @apply bg-brand-700 text-scale-fixed-100 border-brand-700; +} + +.react-datepicker__day--selecting-range-start { + @apply bg-scale-200; +} + +.react-datepicker__day--selecting-range-end { + @apply bg-scale-200; +} + +.react-datepicker__day--selected { + @apply bg-brand-800 text-white; +} + +.react-datepicker__day--range-start { + @apply bg-brand-900 text-white hover:text-scale-700 hover:bg-white; +} + +.react-datepicker__day--range-end { + @apply bg-brand-900 text-white hover:text-scale-700 hover:bg-white; +} diff --git a/studio/components/interfaces/Settings/Logs/LogColumnRenderers/DatabaseApiColumnRender.tsx b/studio/components/interfaces/Settings/Logs/LogColumnRenderers/DatabaseApiColumnRender.tsx new file mode 100644 index 00000000000..918398bc33d --- /dev/null +++ b/studio/components/interfaces/Settings/Logs/LogColumnRenderers/DatabaseApiColumnRender.tsx @@ -0,0 +1,22 @@ +import dayjs from 'dayjs' +import { ResponseCodeFormatter } from '../LogsFormatters' + +export default [ + { + formatter: (data: any) => ( +
+
+ + {data.row.metadata[0].request[0].method} + {data.row.metadata[0].request[0].path} +
+
+ + {dayjs(data?.row?.timestamp / 1000).format('DD MMM')} + {dayjs(data?.row?.timestamp / 1000).format('HH:mm:ss')} + +
+
+ ), + }, +] diff --git a/studio/components/interfaces/Settings/Logs/LogColumnRenderers/DatabasePostgresColumnRender.tsx b/studio/components/interfaces/Settings/Logs/LogColumnRenderers/DatabasePostgresColumnRender.tsx new file mode 100644 index 00000000000..64ac9837e7b --- /dev/null +++ b/studio/components/interfaces/Settings/Logs/LogColumnRenderers/DatabasePostgresColumnRender.tsx @@ -0,0 +1,23 @@ +import dayjs from 'dayjs' +import { SeverityFormatter } from '../LogsFormatters' + +export default [ + { + formatter: (data: any) => ( +
+
+ + {dayjs(data?.row?.timestamp / 1000).format('DD MMM')} + {dayjs(data?.row?.timestamp / 1000).format('HH:mm:ss')} + +
+ +
+
+
+ {data.row.event_message} +
+
+ ), + }, +] diff --git a/studio/components/interfaces/Settings/Logs/LogColumnRenderers/FunctionsEdgeColumnRender.tsx b/studio/components/interfaces/Settings/Logs/LogColumnRenderers/FunctionsEdgeColumnRender.tsx new file mode 100644 index 00000000000..ad311bf1ae9 --- /dev/null +++ b/studio/components/interfaces/Settings/Logs/LogColumnRenderers/FunctionsEdgeColumnRender.tsx @@ -0,0 +1,43 @@ +import dayjs from 'dayjs' +import { HeaderFormmater, ResponseCodeFormatter } from '../LogsFormatters' + +export default [ + { + key: 'timestamp', + headerRenderer: () => ( +
+ +
+ ), + name: 'timestamp', + formatter: (data: any) => ( + + {dayjs(data?.row?.timestamp / 1000).format('DD MMM')} + {dayjs(data?.row?.timestamp / 1000).format('HH:mm:ss')} + {/* {data?.row?.timestamp} */} + + ), + width: 128, + }, + { + key: 'status_code', + headerRenderer: () => , + name: 'status_code', + formatter: (data: any) => , + + width: 0, + resizable: true, + }, + { + key: 'method', + headerRenderer: () => , + width: 0, + resizable: true, + }, + { + key: 'id', + headerRenderer: () => , + name: 'id', + resizable: true, + }, +] diff --git a/studio/components/interfaces/Settings/Logs/LogColumnRenderers/FunctionsLogsColumnRender.tsx b/studio/components/interfaces/Settings/Logs/LogColumnRenderers/FunctionsLogsColumnRender.tsx new file mode 100644 index 00000000000..106f4d46314 --- /dev/null +++ b/studio/components/interfaces/Settings/Logs/LogColumnRenderers/FunctionsLogsColumnRender.tsx @@ -0,0 +1,40 @@ +import dayjs from 'dayjs' +import { HeaderFormmater, SeverityFormatter } from '../LogsFormatters' + +export default [ + { + key: 'timestamp', + headerRenderer: () => ( +
+ +
+ ), + name: 'timestamp', + formatter: (data: any) => ( + + + {dayjs(data?.row?.timestamp / 1000).format('DD MMM')} + + + {dayjs(data?.row?.timestamp / 1000).format('HH:mm:ss')} + + {/* {data?.row?.timestamp} */} + + ), + width: 128, + resizable: true, + }, + { + key: 'level', + headerRenderer: () => , + name: 'level', + formatter: (data: any) => , + width: 24, + resizable: true, + }, + { + key: 'event_message', + headerRenderer: () => , + resizable: true, + }, +] diff --git a/studio/components/interfaces/Settings/Logs/LogEventChart.tsx b/studio/components/interfaces/Settings/Logs/LogEventChart.tsx index 822d76b3d27..d1844b61cd4 100644 --- a/studio/components/interfaces/Settings/Logs/LogEventChart.tsx +++ b/studio/components/interfaces/Settings/Logs/LogEventChart.tsx @@ -16,9 +16,8 @@ const LogEventChart: React.FC = ({ data, onBarClick }) => { return ( = ({ data, onBarClick }) => { }} customDateFormat="MMM D, HH:mm" displayDateInUtc + noDataMessage={''} /> ) } const useAggregated = (data: LogData[]) => { const truncateToMinute = (micro: number) => Math.floor(micro / 1000 / 1000 / 60) * 60 + const truncateToHour = (micro: number) => Math.floor(micro / 1000 / 1000 / 60 / 60) * 60 * 60 + const getDiffMinute = (currentTimestamp: number, olderTimestamp: number) => + Math.round((currentTimestamp - olderTimestamp) / 1000 / 60) + const getDiffHour = (currentTimestamp: number, olderTimestamp: number) => + Math.round((currentTimestamp - olderTimestamp) / 1000 / 60 / 60) + const diffMultiplierMinute = (v: number) => v * 60 + const diffMultiplierHour = (v: number) => v * 60 * 60 return useMemo(() => { + const oldest = data[data.length - 1] + if (!oldest) return + const latest = data[0] + const oldestDayjs = dayjs(oldest.timestamp / 1000) + const latestDayjs = dayjs(latest.timestamp / 1000) + let truncFunc = truncateToMinute + let getDiff = getDiffMinute + let diffMultiplier = diffMultiplierMinute + if (Math.abs(oldestDayjs.diff(latestDayjs, 'day', true)) > 0.25) { + truncFunc = truncateToHour + getDiff = getDiffHour + diffMultiplier = diffMultiplierHour + } + const countMap = data .map((d) => { // truncate to per-minute @@ -55,12 +76,10 @@ const useAggregated = (data: LogData[]) => { if (!oldestEvent) return [] const currentTimestamp = new Date().getTime() const oldestTimestampMicro = oldestEvent.timestamp - const latestTimestamp = truncateToMinute(data[0]['timestamp']) - const minutesDifference = Math.round( - (currentTimestamp - oldestTimestampMicro / 1000) / 1000 / 60 - ) - for (const minToAdd of Array.from(Array(minutesDifference).keys())) { - const tsToCheck = truncateToMinute(oldestTimestampMicro) + minToAdd * 60 + const latestTimestamp = truncFunc(data[0]['timestamp']) + const diff = getDiff(currentTimestamp, oldestTimestampMicro / 1000) + for (const toAdd of Array.from(Array(diff).keys())) { + const tsToCheck = truncFunc(oldestTimestampMicro) + diffMultiplier(toAdd) if (!(tsToCheck in countMap) && tsToCheck <= latestTimestamp) { countMap[tsToCheck] = 0 } diff --git a/studio/components/interfaces/Settings/Logs/LogPanel.tsx b/studio/components/interfaces/Settings/Logs/LogPanel.tsx deleted file mode 100644 index 2eb82e391c5..00000000000 --- a/studio/components/interfaces/Settings/Logs/LogPanel.tsx +++ /dev/null @@ -1,262 +0,0 @@ -import React, { FC, useEffect, useState } from 'react' -import { - Button, - Input, - Dropdown, - Typography, - IconChevronDown, - IconRefreshCw, - IconX, - Toggle, - IconSearch, - IconClock, - Popover, -} from '@supabase/ui' -import { LogSearchCallback, LogTemplate } from '.' -import dayjs from 'dayjs' -import utc from 'dayjs/plugin/utc' -interface Props { - defaultSearchValue?: string - defaultFromValue?: string - defaultToValue?: string - templates?: any - isLoading: boolean - isCustomQuery: boolean - newCount: number - onRefresh?: () => void - onSearch?: LogSearchCallback - onCustomClick?: () => void - onSelectTemplate: (template: LogTemplate) => void - isShowingEventChart: boolean - onToggleEventChart: () => void -} - -dayjs.extend(utc) - -/** - * Logs control panel header + wrapper - */ -const LogPanel: FC = ({ - templates = [], - isLoading, - isCustomQuery, - newCount, - onRefresh, - onSearch = () => {}, - defaultSearchValue = '', - defaultFromValue = '', - defaultToValue = '', - onCustomClick, - onSelectTemplate, - isShowingEventChart, - onToggleEventChart, -}) => { - const [search, setSearch] = useState('') - const [to, setTo] = useState({ value: '', error: '' }) - const [from, setFrom] = useState({ value: '', error: '' }) - const [defaultTimestamp, setDefaultTimestamp] = useState(dayjs().utc().toISOString()) - - // Sync local state with provided default value - useEffect(() => { - if (search !== defaultSearchValue) { - setSearch(defaultSearchValue) - } - }, [defaultSearchValue]) - - useEffect(() => { - if (to.value !== defaultToValue) { - setTo({ value: defaultToValue, error: '' }) - } - if (from.value !== defaultFromValue) { - setFrom({ value: defaultFromValue, error: '' }) - } - }, [defaultToValue, defaultFromValue]) - - const handleToChange = (e: React.ChangeEvent) => { - const value = e.target.value - if (value !== '' && isNaN(Date.parse(value))) { - setTo({ value, error: 'Invalid ISO 8601 timestamp' }) - } else { - setTo({ value, error: '' }) - } - } - - const handleFromChange = (e: React.ChangeEvent) => { - const value = e.target.value - if (value !== '' && isNaN(Date.parse(value))) { - setFrom({ value, error: 'Invalid ISO 8601 timestamp' }) - } else { - setFrom({ value, error: '' }) - } - } - const handleReset = async () => { - setTo({ value: '', error: '' }) - setFrom({ value: '', error: '' }) - const value = dayjs().utc().toISOString() - setDefaultTimestamp(value) - onSearch({ query: search, to: '', from: '' }) - } - - const handleSearch = () => onSearch({ query: search, to: to.value, from: from.value }) - - const showReset = to.value !== '' || from.value !== '' - return ( -
-
-
- {!isCustomQuery && ( -
- } - loading={isLoading} - onClick={onRefresh} - > - Refresh - - )} - ( - onSelectTemplate(template)}> - {template.label} - - ))} - > - - - - {!isCustomQuery && ( - - )} -
-
- {!isCustomQuery && ( - <> -
- - - -
- -
- - } - > - -
- {showReset && ( -
- {!isCustomQuery && ( -
-

Show event chart

- -
- )} - {/* wrap with form so that if user presses enter, the search value will submit automatically */} -
-
- - ) -} - -export default LogPanel diff --git a/studio/components/interfaces/Settings/Logs/LogSelection.tsx b/studio/components/interfaces/Settings/Logs/LogSelection.tsx index f4a37c23abf..536a970e38c 100644 --- a/studio/components/interfaces/Settings/Logs/LogSelection.tsx +++ b/studio/components/interfaces/Settings/Logs/LogSelection.tsx @@ -1,67 +1,135 @@ -import dayjs from 'dayjs' import { FC } from 'react' -import { Typography, IconX, Input } from '@supabase/ui' +import { IconX } from '@supabase/ui' -import { LogData } from './Logs.types' +import { LogData, QueryType } from './Logs.types' + +import DatabaseApiSelectionRender, { + DatabaseApiSelectionHeaderRender, +} from './LogSelectionRenderers/DatabaseApiSelectionRender' +import DatabasePostgresSelectionRender from './LogSelectionRenderers/DatabasePostgresSelectionRender' +import FunctionInvocationSelectionRender, { + FunctionInvocationHeaderRender, +} from './LogSelectionRenderers/FunctionInvocationSelectionRender' +import FunctionLogsSelectionRender from './LogSelectionRenderers/FunctionLogsSelectionRender' +import DefaultSelectionRenderer from './LogSelectionRenderers/DefaultSelectionRenderer' interface Props { - log: LogData + log: LogData | null onClose: () => void + queryType?: QueryType + isLoading?: boolean } -/** - * Log selection display - */ -const LogSelection: FC = ({ log, onClose }) => { +const LogSelection: FC = ({ log, onClose, queryType, isLoading }) => { + const Formatter = () => { + switch (queryType) { + case 'api': + return + + case 'database': + return + + case 'fn_edge': + return + break + + case 'functions': + return + break + + default: + return + } + } + + function header() { + switch (queryType) { + case 'api': + return DatabaseApiSelectionHeaderRender(log) + + break + case 'fn_edge': + return FunctionInvocationHeaderRender(log) + + default: + return null + } + } + return (
-
-
- {dayjs(log.timestamp / 1000).toString()} -
- +
+
+
+
+ + +
+
+

Select an Event

+

+ Select an Event to view the code snippet (pretty view) or complete JSON payload (raw + view). +

+
-
-
-
-

Event message

- -
-
-

Metadata

- -
+
+
+
+
{log && }
) diff --git a/studio/components/interfaces/Settings/Logs/LogSelectionRendererHandler.tsx b/studio/components/interfaces/Settings/Logs/LogSelectionRendererHandler.tsx new file mode 100644 index 00000000000..6efe69f9a2e --- /dev/null +++ b/studio/components/interfaces/Settings/Logs/LogSelectionRendererHandler.tsx @@ -0,0 +1,13 @@ +import { QueryType } from './Logs.types' + +const SelectionRenderHandler = ({ type }: { type: QueryType }) => { + switch (type) { + case 'api': + break + + default: + break + } +} + +export default SelectionRenderHandler diff --git a/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DatabaseApiSelectionRender.tsx b/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DatabaseApiSelectionRender.tsx new file mode 100644 index 00000000000..77dce2e1979 --- /dev/null +++ b/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DatabaseApiSelectionRender.tsx @@ -0,0 +1,72 @@ +import React from 'react' +import { LOGS_TAILWIND_CLASSES } from '../Logs.constants' +import LogsDivider from '../Logs.Divider' +import { jsonSyntaxHighlight, ResponseCodeFormatter } from '../LogsFormatters' + +const DatabaseApiSelectionRender = ({ log }: any) => { + const request = log?.metadata[0]?.request[0] + const response = log?.metadata[0]?.response[0] + const method = log?.metadata[0]?.request[0]?.method + const status = log?.metadata[0]?.response[0].status_code + const ipAddress = log?.metadata[0]?.request[0].headers[0]?.cf_connecting_ip + const countryOrigin = log?.metadata[0]?.request[0].headers[0]?.cf_ipcountry + const clientInfo = log?.metadata[0]?.request[0].headers[0]?.x_client_info + const referer = log?.metadata[0]?.request[0].headers[0]?.referer + + const DetailedRow = ({ label, value }: { label: string; value: string | React.ReactNode }) => { + return ( +
+ {label} + {value} +
+ ) + } + + return ( + <> +
+ } /> + + + + + {clientInfo && } + {referer && } +
+ +
+

Request body

+
+          
+
+
+ +
+

+ Response{method ? ` ${method}` : null} body +

+
+          
+
+
+ + ) +} + +export const DatabaseApiSelectionHeaderRender = (log: any) => { + const method = log?.metadata[0]?.request[0]?.method + const path = log?.metadata[0]?.request[0]?.path + + return `${method} ${path}` +} + +export default DatabaseApiSelectionRender diff --git a/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DatabasePostgresSelectionRender.tsx b/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DatabasePostgresSelectionRender.tsx new file mode 100644 index 00000000000..55594056a29 --- /dev/null +++ b/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DatabasePostgresSelectionRender.tsx @@ -0,0 +1,67 @@ +import { Alert } from '@supabase/ui' +import React from 'react' +import { LOGS_TAILWIND_CLASSES } from '../Logs.constants' +import LogsDivider from '../Logs.Divider' +import { jsonSyntaxHighlight, SeverityFormatter } from '../LogsFormatters' + +const DatabasePostgresSelectionRender = ({ log }: any) => { + const postgresUsername = log?.metadata[0]?.parsed[0]?.user_name + const sessionId = log?.metadata[0]?.parsed[0]?.session_id + const hint = log?.metadata[0]?.parsed[0]?.hint + + const DetailedRow = ({ label, value }: { label: string; value: string | React.ReactNode }) => { + return ( +
+ {label} + {value} +
+ ) + } + + return ( + <> +
+ Event message + +
+
+ +
+ } /> + + +
+ {hint && ( +
+ +
+ )} + +
+

Metadata

+
+          
+
+
+ + ) +} + +export const DatabaseApiSelectionHeaderRender = (log: any) => { + const method = log?.metadata[0]?.request[0]?.method + const path = log?.metadata[0]?.request[0]?.path + + return `${method} ${path}` +} + +export default DatabasePostgresSelectionRender diff --git a/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DefaultSelectionRenderer.tsx b/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DefaultSelectionRenderer.tsx new file mode 100644 index 00000000000..9377bde687e --- /dev/null +++ b/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DefaultSelectionRenderer.tsx @@ -0,0 +1,64 @@ +import { LOGS_TAILWIND_CLASSES } from '../Logs.constants' +import { jsonSyntaxHighlight } from '../LogsFormatters' + +const DefaultSelectionRenderer = ({ log }: any) => { + const DetailedRow = ({ + label, + value, + code, + }: { + label: string + value: string | React.ReactNode + code?: boolean + }) => { + return ( +
+ {label} + + {value} + +
+ ) + } + + const DetailedJsonRow = ({ + label, + value, + code, + }: { + label: string + value: Object + code?: boolean + }) => { + return ( +
+ {label} + +
+        
+      
+ ) + } + + return ( +
+ {Object.entries(log).map(([key, value], index) => { + return ( +
+ {value && typeof value === 'object' ? ( + + ) : ( + + )} +
+ ) + })} +
+ ) +} + +export default DefaultSelectionRenderer diff --git a/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/FunctionInvocationSelectionRender.tsx b/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/FunctionInvocationSelectionRender.tsx new file mode 100644 index 00000000000..087efa95153 --- /dev/null +++ b/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/FunctionInvocationSelectionRender.tsx @@ -0,0 +1,79 @@ +import dayjs from 'dayjs' +import { LOGS_TAILWIND_CLASSES } from '../Logs.constants' +import { jsonSyntaxHighlight, ResponseCodeFormatter } from '../LogsFormatters' + +const FunctionInvocationSelectionRender = ({ log }: any) => { + const functionId = log.metadata + const request = log?.request + const response = log?.response + const method = log?.method + const status = log?.status_code + const requestUrl = request?.url + const host = request?.host + + const DetailedRow = ({ + label, + value, + code, + }: { + label: string + value: string | React.ReactNode + code?: boolean + }) => { + return ( +
+ {label} + + {value} + +
+ ) + } + + return ( + <> +
+ } /> + + + + + + + +
+
+

Request body

+
+          
+
+
+
+

+ Response{method ? ` ${method}` : null} body +

+
+          
+
+
+ + ) +} + +export const FunctionInvocationHeaderRender = (log: any) => { + const method = log?.method + const path = log?.request?.url + + return `${method} ${path}` +} + +export default FunctionInvocationSelectionRender diff --git a/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/FunctionLogsSelectionRender.tsx b/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/FunctionLogsSelectionRender.tsx new file mode 100644 index 00000000000..325005b83f8 --- /dev/null +++ b/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/FunctionLogsSelectionRender.tsx @@ -0,0 +1,63 @@ +import dayjs from 'dayjs' +import { LOGS_TAILWIND_CLASSES } from '../Logs.constants' +import { jsonSyntaxHighlight, ResponseCodeFormatter, SeverityFormatter } from '../LogsFormatters' + +const FunctionLogsSelectionRender = ({ log }: any) => { + const request = log?.request + + const DetailedRow = ({ + label, + value, + code, + }: { + label: string + value: string | React.ReactNode + code?: boolean + }) => { + return ( +
+ {label} + + {value} + +
+ ) + } + + return ( + <> +
+ Event message +
+
+
+
+ } /> + + + + + + +
+
+

Metadata

+
+          
+
+
+ + ) +} + +export default FunctionLogsSelectionRender diff --git a/studio/components/interfaces/Settings/Logs/LogTable.tsx b/studio/components/interfaces/Settings/Logs/LogTable.tsx index 495cf253833..77881e18329 100644 --- a/studio/components/interfaces/Settings/Logs/LogTable.tsx +++ b/studio/components/interfaces/Settings/Logs/LogTable.tsx @@ -1,15 +1,34 @@ import dayjs from 'dayjs' import { useEffect, useState, useMemo } from 'react' -import { Typography } from '@supabase/ui' +import { + Alert, + Button, + IconDownloadCloud, + IconEye, + IconEyeOff, + IconLoader, + Input, + Typography, +} from '@supabase/ui' import DataGrid from '@supabase/react-data-grid' import LogSelection from './LogSelection' -import { LogData } from './Logs.types' -import { isNil } from 'lodash' +import { LogData, QueryType } from './Logs.types' +import { SeverityFormatter, ResponseCodeFormatter, HeaderFormmater } from './LogsFormatters' + +// column renders +import DatabaseApiColumnRender from './LogColumnRenderers/DatabaseApiColumnRender' +import DatabasePostgresColumnRender from './LogColumnRenderers/DatabasePostgresColumnRender' +import CSVButton from 'components/ui/CSVButton' interface Props { - isCustomQuery: boolean data?: Array + queryType?: QueryType + onHistogramToggle?: () => void + isHistogramShowing?: boolean + isLoading?: boolean + error?: any + showDownload?: boolean } type LogMap = { [id: string]: LogData } @@ -18,108 +37,347 @@ type LogMap = { [id: string]: LogData } * * When in custom data display mode, the side panel will not open when focusing on logs. */ -const LogTable = ({ isCustomQuery, data = [] }: Props) => { +const LogTable = ({ + data = [], + queryType, + onHistogramToggle, + isHistogramShowing, + isLoading, + showDownload, + error, +}: Props) => { const [focusedLog, setFocusedLog] = useState(null) const columnNames = Object.keys(data[0] || {}) - // whether the data structure is LogData format. - const hasLogDataFormat = - columnNames.includes('timestamp') && - columnNames.includes('event_message') && - columnNames.length === 4 + const hasId = columnNames.includes('id') + const hasTimestamp = columnNames.includes('timestamp') + + const DEFAULT_COLUMNS = columnNames.map((v) => { + let formatter = undefined + const firstRow: any = data[0] + const value = firstRow?.[v] + if (typeof value === 'object') { + formatter = () => `[Object]` + } + return { key: v, name: v, resizable: true, formatter, header: v, minWidth: 128 } + }) + + let columns + if (!queryType) { + columns = DEFAULT_COLUMNS + } else { + switch (queryType) { + case 'api': + columns = DatabaseApiColumnRender + break + + case 'database': + columns = DatabasePostgresColumnRender + break + + case 'fn_edge': + columns = [ + { + key: 'timestamp', + headerRenderer: () => ( +
+ +
+ ), + name: 'timestamp', + formatter: (data: any) => ( + + + {dayjs(data?.row?.timestamp / 1000).format('DD MMM')} + + + {dayjs(data?.row?.timestamp / 1000).format('HH:mm:ss')} + + + ), + width: 128, + }, + { + key: 'status_code', + headerRenderer: () => , + name: 'status_code', + formatter: (data: any) => ( + + ), + width: 0, + resizable: true, + }, + { + key: 'method', + headerRenderer: () => , + width: 0, + resizable: true, + }, + { + key: 'id', + headerRenderer: () => , + name: 'id', + resizable: true, + }, + ] + break + case 'functions': + columns = [ + { + key: 'timestamp', + headerRenderer: () => ( +
+ +
+ ), + name: 'timestamp', + formatter: (data: any) => ( + + + {dayjs(data?.row?.timestamp / 1000).format('DD MMM')} + + + {dayjs(data?.row?.timestamp / 1000).format('HH:mm:ss')} + + + ), + width: 128, + resizable: true, + }, + { + key: 'level', + headerRenderer: () => , + name: 'level', + formatter: (data: any) => , + width: 24, + resizable: true, + }, + { + key: 'event_message', + headerRenderer: () => , + resizable: true, + }, + ] + break + + default: + columns = DEFAULT_COLUMNS + break + } + } - const columns = (hasLogDataFormat ? ['timestamp', 'event_message'] : columnNames).map((v) => ({ - key: v, - name: v, - width: hasLogDataFormat && v === 'timestamp' ? 210 : undefined, - resizable: true, - headerRenderer: () => { - return
{v}
- }, - formatter: ({ row }: any) => { - let value = row[v] - if (hasLogDataFormat && v === 'timestamp') { - value = dayjs(Number(row['timestamp']) / 1000).toISOString() - } - return ( -

- {value} -

- ) - }, - })) - const stringData = JSON.stringify(data) - const logMap = useMemo(() => { - if (!hasLogDataFormat) return {} as LogMap - const logData = data as LogData[] - return logData.reduce((acc: LogMap, d: LogData) => { + const [dedupedData, logMap] = useMemo<[LogData[], LogMap]>(() => { + const deduped = [...new Set(data)] as LogData[] + + if (!hasId) { + return [deduped, {} as LogMap] + } + + const map = deduped.reduce((acc: LogMap, d: LogData) => { acc[d.id] = d return acc }, {}) as LogMap + + return [deduped, map] }, [stringData]) useEffect(() => { - if (isNil(data)) return + if (!hasId || data === null) return if (focusedLog && !(focusedLog.id in logMap)) { setFocusedLog(null) - } + } }, [stringData]) if (!data) return null // [Joshen] Hmm quite hacky now, but will do - const maxHeight = isCustomQuery ? 'calc(100vh - 42px - 10rem)' : 'calc(100vh - 42px - 3rem)' + const maxHeight = !queryType ? 'calc(100vh - 42px - 10rem)' : 'calc(100vh - 42px - 3rem)' const logDataRows = useMemo(() => { - if (!hasLogDataFormat) return data - return Object.values(logMap).sort((a, b) => b.timestamp - a.timestamp) + if (hasId && hasTimestamp) { + return Object.values(logMap).sort((a, b) => b.timestamp - a.timestamp) + } else { + return dedupedData + } }, [stringData]) + return ( -
- { - if (!hasLogDataFormat) return - setFocusedLog(data[rowIdx] as LogData) - }} - noRowsFallback={ -
- - No data returned from query - + <> +
+ {!queryType && ( +
+
+
+ {data && data.length ? ( + <> + Query results + {data && data.length} + + ) : ( + Results will be shown below + )} +
+
+ {onHistogramToggle && ( + + )} + {showDownload && Download} +
+
- } - columns={columns as any} - rowClass={(r) => { - if (!hasLogDataFormat) return 'cursor-pointer' - const row = r as LogData - return `${row.id === focusedLog?.id ? 'bg-green-800' : 'cursor-pointer'}` - }} - rows={logDataRows} - rowKeyGetter={(r) => { - if (!hasLogDataFormat) return Object.keys(r)[0] - const row = r as LogData - return row.id - }} - onRowClick={(r) => { - if (!hasLogDataFormat) return - const row = r as LogData - setFocusedLog(logMap[row.id]) - }} - /> - {focusedLog && ( -
- setFocusedLog(null)} log={focusedLog} /> + )} +
+ { + if (!hasId) return + setFocusedLog(data[rowIdx] as LogData) + }} + noRowsFallback={ + !isLoading ? ( + <> +
+
+
+
+
+
+ + + +
+
+
+
+

No results

+

+ Try another search, or adjusting the filters +

+
+
+ {error && ( +
+ + + +
+ )} +
+ + ) : null + } + columns={columns as any} + rowClass={(r) => { + const row = r as LogData + + let classes = [] + classes.push( + `${ + row.id === focusedLog?.id ? '!bg-scale-400 rdg-row--focussed' : 'cursor-pointer' + }` + ) + + return classes.join(' ') + }} + rows={logDataRows} + rowKeyGetter={(r) => { + if (!hasId) return Object.keys(r)[0] + const row = r as LogData + return row.id + }} + onRowClick={(r) => setFocusedLog(r)} + /> + {logDataRows.length > 0 ? ( +
+ setFocusedLog(null)} + log={focusedLog} + queryType={queryType} + /> +
+ ) : null}
- )} -
+
+ ) } export default LogTable diff --git a/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx b/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx new file mode 100644 index 00000000000..4683deb94f9 --- /dev/null +++ b/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx @@ -0,0 +1,77 @@ +import { Button, Dropdown, IconClock } from '@supabase/ui' +import { DatePicker } from 'components/ui/DatePicker' +import { useState, useEffect } from 'react' + +type ToFrom = { to: string; from: string } +interface Props { + to?: string + from?: string + onChange: ({ to, from }: ToFrom) => void +} + +const HELPERS = [ + { text: 'Last hour', value: '1_hour' }, + { text: 'Last 3 hours', value: '3_hour' }, + { text: 'Last day', value: '1_day' }, +] +const DEFAULT_HELPER_VALUE = HELPERS[0].value + +const DatePickers: React.FC = ({ to, from, onChange }) => { + const [helperValue, setHelperValue] = useState('') + + const handleHelperChange = (newValue: string) => { + setHelperValue(newValue) + const toDate = new Date() + const fromDate = { + '1_hour': new Date(toDate.getTime() - 60 * 60 * 1000), + '3_hour': new Date(toDate.getTime() - 3 * 60 * 60 * 1000), + '1_day': new Date(toDate.getTime() - 24 * 60 * 60 * 1000), + }[newValue] + if (onChange) onChange({ to: toDate.toISOString(), from: fromDate?.toISOString() as string }) + } + const selectedHelper = HELPERS.find((h) => h.value === (helperValue || DEFAULT_HELPER_VALUE)) + return ( +
+ + + {HELPERS.map((helper) => ( + + {helper.text} + + ))} + + + } + > + + + { + setHelperValue('') + if (onChange) onChange(value) + }} + to={!helperValue ? to : undefined} + from={!helperValue ? from : undefined} + /> +
+ ) +} + +export default DatePickers diff --git a/studio/components/interfaces/Settings/Logs/Logs.Divider.tsx b/studio/components/interfaces/Settings/Logs/Logs.Divider.tsx new file mode 100644 index 00000000000..ab984fd5a4b --- /dev/null +++ b/studio/components/interfaces/Settings/Logs/Logs.Divider.tsx @@ -0,0 +1,7 @@ +const LogsDivider = () => { + return ( +
+ ) +} + +export default LogsDivider diff --git a/studio/components/interfaces/Settings/Logs/Logs.SavedQueriesItem.tsx b/studio/components/interfaces/Settings/Logs/Logs.SavedQueriesItem.tsx new file mode 100644 index 00000000000..d991b57ef2c --- /dev/null +++ b/studio/components/interfaces/Settings/Logs/Logs.SavedQueriesItem.tsx @@ -0,0 +1,73 @@ +import dayjs from 'dayjs' +import { FC, useState } from 'react' +import { Button, IconChevronRight, IconMaximize2, IconPlay } from '@supabase/ui' +import Table from 'components/to-be-cleaned/Table' +import CodeEditor from 'components/ui/CodeEditor' +import { useRouter } from 'next/router' + +interface Props { + item: any +} + +const SavedQueriesItem: FC = ({ item }: Props) => { + const [expand, setExpand] = useState(false) + + const router = useRouter() + const { ref } = router.query + + console.log('item in component', item) + return ( + <> + + +
+ + {item.name} +
+
+ + {item.description} + + + + {dayjs(item.inserted_at).format('DD MMM, YYYY HH:mm')} + + + + + {dayjs(item.updated_at).format('DD MMM, YYYY HH:mm')} + + + + + +
+ <> + + {/* */} + {expand &&
{item.content.sql}
} + + + + ) +} + +export default SavedQueriesItem diff --git a/studio/components/interfaces/Settings/Logs/Logs.constants.ts b/studio/components/interfaces/Settings/Logs/Logs.constants.ts index f1aafe8c6cf..b346c5e9d80 100644 --- a/studio/components/interfaces/Settings/Logs/Logs.constants.ts +++ b/studio/components/interfaces/Settings/Logs/Logs.constants.ts @@ -15,185 +15,290 @@ export const TEMPLATES: LogTemplate[] = [ }, { label: 'Commits By User', + description: 'Count of commits made by users on the database', mode: 'custom', - searchString: `SELECT + searchString: `select p.user_name, count(*) as count -FROM postgres_logs - LEFT JOIN UNNEST(metadata) as m ON TRUE - LEFT JOIN UNNEST(m.parsed) AS p ON TRUE -WHERE - REGEXP_CONTAINS(event_message, 'COMMIT') -GROUP BY +from postgres_logs + left join unnest(metadata) as m on true + left join unnest(m.parsed) as p on true +where + regexp_contains(event_message, 'COMMIT') +group by p.user_name `, for: ['database'], }, { label: 'Metadata IP', + description: 'List all IP addresses that used the Supabase API', mode: 'custom', - searchString: `SELECT timestamp, h.x_real_ip -FROM edge_logs - LEFT JOIN UNNEST(metadata) as m ON TRUE - LEFT JOIN UNNEST(m.request) AS r ON TRUE - LEFT JOIN UNNEST(r.headers) AS h ON TRUE -WHERE h.x_real_ip IS NOT NULL + searchString: `select timestamp, h.x_real_ip +from edge_logs + left join unnest(metadata) as m on true + left join unnest(m.request) as r on true + left join unnest(r.headers) as h on true +where h.x_real_ip is not null `, for: ['api'], }, { label: 'Requests by Country', + description: 'List all ISO 3166-1 alpha-2 country codes that used the Supabase API', mode: 'custom', - searchString: `SELECT + searchString: `select cf.country, count(*) as count -FROM edge_logs - LEFT JOIN UNNEST(metadata) as m ON TRUE - LEFT JOIN UNNEST(m.request) AS r ON TRUE - LEFT JOIN UNNEST(r.cf) AS cf ON TRUE -GROUP BY +from edge_logs + left join unnest(metadata) as m on true + left join unnest(m.request) as r on true + left join unnest(r.cf) as cf on true +group by cf.country -ORDER BY - count DESC +order by + count desc `, for: ['api'], }, { label: 'Slow Response Time', mode: 'custom', + description: 'List all Supabase API requests that are slow', searchString: `select timestamp, event_message, r.origin_time -FROM edge_logs - CROSS JOIN unnest(metadata) as m - CROSS JOIN unnest(m.response) as r -WHERE +from edge_logs + cross join unnest(metadata) as m + cross join unnest(m.response) as r +where r.origin_time > 1000 -ORDER BY - timestamp DESC -LIMIT 100 +order by + timestamp desc +limit 100 `, for: ['api'], }, { label: '500 Request Codes', + description: 'List all Supabase API requests that responded witha 5XX status code', mode: 'custom', searchString: `SELECT timestamp, event_message, r.status_code -FROM edge_logs +from edge_logs cross join unnest(metadata) as m cross join unnest(m.response) as r -WHERE +where r.status_code >= 500 -ORDER BY +order by timestamp desc -LIMIT 100 +limit 100 `, for: ['api'], }, { label: 'Top Paths', + description: 'List the most requested Supabase API paths', mode: 'custom', searchString: `SELECT r.path as path, r.search as params, count(timestamp) as c -FROM edge_logs +from edge_logs cross join unnest(metadata) as m cross join unnest(m.request) as r -GROUP BY +group by path, params -ORDER BY +order by c desc -LIMIT 100 +limit 100 `, for: ['api'], }, { label: 'REST Requests', + description: 'List all PostgREST requests', mode: 'custom', searchString: `SELECT timestamp, event_message -FROM edge_logs +from edge_logs cross join unnest(metadata) as m cross join unnest(m.request) as r -WHERE +where path like '%rest/v1%' -ORDER BY +order by timestamp desc -LIMIT 100 +limit 100 `, for: ['api'], }, { label: 'Errors', + description: 'List all Postgres error messages with ERROR, FATAL, or PANIC severity', mode: 'custom', searchString: `SELECT t.timestamp, p.error_severity, event_message -FROM +from postgres_logs as t cross join unnest(metadata) as m cross join unnest(m.parsed) as p -WHERE +where p.error_severity in ('ERROR', 'FATAL', 'PANIC') -ORDER BY +order by timestamp desc -LIMIT 100 +limit 100 `, for: ['database'], }, { label: 'Error Count by User', mode: 'custom', - searchString: `SELECT + searchString: `select count(t.timestamp) as count, p.user_name, p.error_severity -FROM +from postgres_logs as t cross join unnest(metadata) as m cross join unnest(m.parsed) as p -WHERE +where p.error_severity in ('ERROR', 'FATAL', 'PANIC') -GROUP BY +group by p.user_name, p.error_severity -ORDER BY +order by count desc -LIMIT 100 +limit 100 `, for: ['database'], }, ] export const LOG_TYPE_LABEL_MAPPING: { [k: string]: string } = { - explorer: "Explorer", + explorer: 'Explorer', api: 'API', database: 'Database', } -export const genDefaultQuery = (table: string, where: string = ''): string => `SELECT - id, timestamp, event_message, metadata -FROM - ${table}${where ? ' WHERE\n ' + where : ''} -LIMIT 100 +export const genDefaultQuery = (table: LogsTableName, where: string | undefined) => { + switch (table) { + case 'edge_logs': + return `select id, timestamp, event_message, metadata, request, response, request.method, request.path, response.status_code +from ${table} +cross join unnest(metadata) as m +cross join unnest(m.request) as request +cross join unnest(m.response) as response +${where} +limit 100 ` + break + + case 'postgres_logs': + return `select postgres_logs.timestamp, id, event_message, metadata, metadataparsed.error_severity from ${table} +cross join unnest(metadata) as m +cross join unnest(m.parsed) as metadataparsed +${where} +limit 100 +` + break + + case 'function_logs': + return `select id, ${table}.timestamp, event_message, metadata.event_type, metadata.function_id, metadata.level, metadata from ${table} +cross join unnest(metadata) as metadata +${where} +limit 100 + ` + break + + case 'function_edge_logs': + return `select id, ${table}.timestamp, event_message, response.status_code, response, request, request.method, m.function_id, m.execution_time_ms, m.deployment_id, m.version from ${table} +cross join unnest(metadata) as m +cross join unnest(m.response) as response +cross join unnest(m.request) as request +${where} +limit 100 +` + + default: + return "" + break + } +} + +export const SQL_FILTER_TEMPLATES: any = { + postgres_logs: { + 'severity.error': `metadataParsed.error_severity in ('ERROR', 'FATAL', 'PANIC')`, + 'severity.noError': `metadataParsed.error_severity not in ('ERROR', 'FATAL', 'PANIC')`, + 'severity.log': `metadataParsed.error_severity = 'LOG'`, + }, + edge_logs: { + 'status_code.error': `response.status_code between 500 and 599`, + 'status_code.success': `response.status_code between 200 and 299`, + 'status_code.warning': `response.status_code between 400 and 499`, + + 'product.database': `request.path like '/rest/%' or request.path like '/graphql/%'`, + 'product.storage': `request.path like '/storage/%'`, + 'product.auth': `request.path like '/auth/%'`, + 'product.realtime': `request.path like '/realtime/%'`, + + 'method.get': `request.method = 'GET'`, + 'method.post': `request.method = 'POST'`, + 'method.del': `request.method = 'DEL'`, + 'method.options': `request.method = 'OPTIONS'`, + }, + function_edge_logs: { + 'status_code.error': `response.status_code between 500 and 599`, + 'status_code.success': `response.status_code between 200 and 299`, + 'status_code.warning': `response.status_code between 400 and 499`, + }, + function_logs: { + 'severity.error': `metadata.level = 'error'`, + 'severity.notError': `metadata.level != 'error'`, + 'severity.log': `metadata.level = 'log'`, + 'severity.info': `metadata.level = 'info'`, + 'severity.debug': `metadata.level = 'debug'`, + }, +} + +// export const genDefaultQuery = (table: string, where: string = ''): string => `SELECT +// id, timestamp, event_message, metadata +// FROM +// ${table}${where ? ' WHERE\n ' + where : ''} +// LIMIT 100 +// ` +// export const cleanQuery = (str: string) => str.replaceAll(/\n/g, ' ') export const cleanQuery = (str: string) => str.replace(/\n/g, ' ') // .replace(/\n.*\-\-.*(\n)?$?/, "") - export enum LogsTableName { EDGE = 'edge_logs', POSTGRES = 'postgres_logs', + FUNCTIONS = 'function_logs', + FN_EDGE = 'function_edge_logs', } + +export const LOGS_TABLES = { + api: LogsTableName.EDGE, + database: LogsTableName.POSTGRES, + functions: LogsTableName.FUNCTIONS, + fn_edge: LogsTableName.FN_EDGE, +} + +export const LOGS_SOURCE_DESCRIPTION = { + [LogsTableName.EDGE]: 'Logs obtained from the network edge, containing all API requests.', + [LogsTableName.POSTGRES]: 'Database logs obtained directly from Postgres.', + [LogsTableName.FUNCTIONS]: 'Function logs generated from runtime execution.', + [LogsTableName.FN_EDGE]: 'Function call logs, containing the request and response.', +} + export const genCountQuery = (table: string): string => `SELECT count(*) as count FROM ${table}` -export const genQueryParams = (params: { [k: string ]: string }) => { +export const genQueryParams = (params: { [k: string]: string }) => { // remove keys which are empty strings, null, or undefined for (const k in params) { const v = params[k] @@ -204,3 +309,175 @@ export const genQueryParams = (params: { [k: string ]: string }) => { const qs = new URLSearchParams(params).toString() return qs } + +interface Filter { + label: string + key: string + options: { + key: string + label: string + description?: string + }[] +} +type FilterOptions = { + [table: string]: { + [filterName: string]: Filter + } +} + +export const FILTER_OPTIONS: FilterOptions = { + // Postgres logs + postgres_logs: { + severity: { + label: 'Severity', + key: 'severity', + options: [ + { + key: 'error', + label: 'Error', + description: 'Show all events with ERROR, PANIC, or FATAL', + }, + { + key: 'noError', + label: 'No Error', + description: 'Show all non-error events', + }, + { + key: 'log', + label: 'Log', + description: 'Show all events that are log severity', + }, + ], + }, + }, + + // Edge logs + edge_logs: { + status_code: { + label: 'Status', + key: 'status_code', + options: [ + { + key: 'error', + label: 'Error', + description: '500 error codes', + }, + { + key: 'success', + label: 'Success', + description: '200 codes', + }, + { + key: 'warning', + label: 'Warning', + description: '400 codes', + }, + ], + }, + product: { + label: 'Product', + key: 'product', + options: [ + { + key: 'database', + label: 'Database', + description: '', + }, + { + key: 'auth', + label: 'Auth', + description: '', + }, + { + key: 'storage', + label: 'Storage', + description: '', + }, + { + key: 'realtime', + label: 'Realtime', + description: '', + }, + ], + }, + method: { + label: 'Method', + key: 'method', + options: [ + { + key: 'get', + label: 'GET', + description: '', + }, + { + key: 'options', + label: 'OPTIONS', + description: '', + }, + { + key: 'post', + label: 'POST', + description: '', + }, + ], + }, + }, + // function_edge_logs + function_edge_logs: { + status_code: { + label: 'Status', + key: 'status_code', + options: [ + { + key: 'error', + label: 'Error', + description: '500 error codes', + }, + { + key: 'success', + label: 'Success', + description: '200 codes', + }, + { + key: 'warning', + label: 'Warning', + description: '400 codes', + }, + ], + }, + }, + // function_logs + function_logs: { + severity: { + label: 'Severity', + key: 'severity', + options: [ + { + key: 'error', + label: 'Error', + description: 'Show all events that have error severity', + }, + { + key: 'info', + label: 'Info', + description: 'Show all events that have error severity', + }, + { + key: 'debug', + label: 'Debug', + description: 'Show all events that have error severity', + }, + { + key: 'log', + label: 'Log', + description: 'Show all events that are log severity', + }, + ], + }, + }, +} + +export const LOGS_TAILWIND_CLASSES = { + log_selection_x_padding: 'px-8', + space_y: 'px-6', +} diff --git a/studio/components/interfaces/Settings/Logs/Logs.filter.tsx b/studio/components/interfaces/Settings/Logs/Logs.filter.tsx new file mode 100644 index 00000000000..1339850c555 --- /dev/null +++ b/studio/components/interfaces/Settings/Logs/Logs.filter.tsx @@ -0,0 +1,141 @@ +import { Button, Checkbox, Form, Popover } from '@supabase/ui' +import { pluckObjectFields } from 'lib/helpers' +import { useState } from 'react' + +export const LogsFilter = ({ options, filtersState, dispatchFilters, classes }: any) => { + const [open, setOpen] = useState(false) + + function handleReset() { + // console.log('handleReset ran!') + const empty = { + [options.key]: [], + } + + dispatchFilters({ ...empty }) + setOpen(!open) + } + + // console.log('filters', filters) + + // console.log('item in filter', options) + // console.log('options.key', options.key) + + const filteredKey = filtersState[options.key] + + // loop through options in saved filters + // and check if any options are active + function checkIsActive() { + let active = false + options.options.map((x: any) => { + if (filteredKey && filteredKey.includes(x.key)) { + active = true + } + }) + return active + } + + const isActive = checkIsActive() + + function initialValues() { + let compiledOptions = {} + + options.options.map((x: any) => { + compiledOptions = { + ...compiledOptions, + [x.key]: filteredKey && filteredKey.includes(x.key) ? true : false, + } + }) + + return compiledOptions + } + + return ( +
+ setOpen(!open)} + overlay={ + <> +
{ + setSubmitting(true) + setOpen(false) + + // console.log('values in Logs.filter submit', values) + + const payload: any = { + [options.key]: [], + } + + Object.keys(values).map((filterKey) => { + if (values[filterKey]) { + payload[options.key].push(filterKey) + } + }) + + console.log('payload', payload) + // console.log('submit form ran') + dispatchFilters({ ...payload }) + // handleReset() + }} + > + {({ isSubmitting }: { isSubmitting: boolean }) => ( + <> +
+ {options.options.map((x: any, i: number) => { + return ( + <> + + {i !== options.options.length - 1 && } + + ) + })} +
+
+ + +
+ + )} +
+ + } + portalled + showClose + side="bottom" + > + +
+
+ ) +} diff --git a/studio/components/interfaces/Settings/Logs/Logs.helpers.ts b/studio/components/interfaces/Settings/Logs/Logs.helpers.ts new file mode 100644 index 00000000000..244c19cd5e1 --- /dev/null +++ b/studio/components/interfaces/Settings/Logs/Logs.helpers.ts @@ -0,0 +1,78 @@ +import { LogsTableName, SQL_FILTER_TEMPLATES } from './Logs.constants' +import { FilterObject, Override } from './Logs.types' + +export function filterSqlWhereBuilder( + filters: FilterObject | any, + table: LogsTableName, + override?: Override, + search?: string +) { + // remove any filter arrays that are empty + const filtersSanitized: any = Object.values(filters).filter((x: any) => x && x.length > 0) + + const keys = Object.keys(filters) + + const hasOverride = override && override.value && override.key + // build array of where statements + // first item is `where` if there are any filters + let whereArray: string[] = + (filtersSanitized.length > 0 || hasOverride) && !search ? ['where'] : search ? ['and'] : [] + + if (filtersSanitized.length === 0 && !hasOverride) return whereArray + + if (hasOverride) { + // count = +1 + const sql = `(${override?.key}='${override?.value}')` + whereArray.push(sql) + if (filtersSanitized.length > 0) whereArray.push('and') + } + + if (filtersSanitized.length === 0) return whereArray + + keys.map((x: string, i) => { + let count = 0 + // do not parse empty key + if (!x) return + + const filterKeyArray: string[] = [] + + filters[x].map((value: string, i: number) => { + // first line should be WHERE + const last = i === filters[x].length - 1 + + if (count === 0) { + count = +1 + // the first argument begins with a `(` + // if this is also the last argument, it is closed with a `)` + const sql = `( ${SQL_FILTER_TEMPLATES[table][`${x}.${value}`]}${last ? ' ) ' : ''}` + filterKeyArray.push(sql) + return sql + } else { + count = +1 + // remaining lines use `or` as standard + // if this is also the last argument, it is closed with a `)` + const sql = ` or ${SQL_FILTER_TEMPLATES[table][`${x}.${value}`]}${last ? ' ) ' : ''}` + filterKeyArray.push(sql) + return sql + } + }) + + whereArray.push(...filterKeyArray) + + // if there are multiple filters in an 'and' sequence + // then `and` is inserted between them + if (filtersSanitized.length > 0 && i < filtersSanitized.length - 1) { + whereArray.push(' and ') + } + + return filterKeyArray + }) + return whereArray +} + +export function filterReducer(state: FilterObject, action: any) { + const oldState = { ...state } + const newState = { ...oldState, ...action } + + return newState +} diff --git a/studio/components/interfaces/Settings/Logs/Logs.types.ts b/studio/components/interfaces/Settings/Logs/Logs.types.ts index 06b868a0ff1..7112ce59153 100644 --- a/studio/components/interfaces/Settings/Logs/Logs.types.ts +++ b/studio/components/interfaces/Settings/Logs/Logs.types.ts @@ -21,10 +21,12 @@ export interface LogData { timestamp: number event_message: string metadata: Metadata + [other: string]: unknown } export interface LogTemplate { label?: string + description?: string mode: 'custom' | 'simple' for?: string[] searchString: string @@ -44,3 +46,26 @@ type LFResponse = { export type Count = LFResponse export type Logs = LFResponse + +export type QueryType = 'api' | 'database' | 'functions' | 'fn_edge' + +export type Mode = 'simple' | 'custom' + +export type Table = 'edge_logs' | 'postgres_logs' + +export interface FilterObject { + // severity?: string[] + // status_code?: string[] + + // `q` for the editor query. + q?: string + // `s` for search query. + s?: string + // `te` for timestamp start value. + te?: string +} + +export type Override = { + key: string + value: string | string[] | undefined +} diff --git a/studio/components/interfaces/Settings/Logs/LogsFormatters.tsx b/studio/components/interfaces/Settings/Logs/LogsFormatters.tsx new file mode 100644 index 00000000000..04304e51975 --- /dev/null +++ b/studio/components/interfaces/Settings/Logs/LogsFormatters.tsx @@ -0,0 +1,215 @@ +/* + * Response Code + * + * for http response codes + */ + +import { IconAlertCircle, IconInfo } from '@supabase/ui' + +export const ResponseCodeFormatter = ({ value }: any) => { + if (!value) { + return ( +
+ +
+ ) + } + + const split = value.toString().split('')[0] + + // console.log(split) + + switch (split) { + // 2XX || 1XX responses + case '1': + case '2': + return ( +
+
+ +
+
+ ) + break + // 5XX responses + case '5': + return ( +
+
+ +
+
+ ) + break + // 4XX || 3XX responses + case '4': + case '3': + return ( +
+
+ +
+
+ ) + break + // All other responses + default: + return ( +
+
+ +
+
+ ) + break + } +} + +/* + * Response Code + * + * for http response codes + */ + +export const SeverityFormatter = ({ value }: { value: string }) => { + if (!value) { + return ( +
+ +
+ ) + } + + value = value.toUpperCase() + + switch (value) { + case 'PANIC': + case 'FATAL': + case 'ERROR': + return ( +
+
+ +
+ {value} +
+ ) + break + + case 'INFO': + + case 'DEBUG': + return ( +
+
+ +
+ {value} +
+ ) + break + + case 'LOG': + return ( +
+
+ +
+ {value} +
+ ) + break + + case 'WARNING': + return ( +
+
+ +
+ {value} +
+ ) + break + + // All other responses + default: + return ( +
+
+ +
+
+ ) + break + } +} + +/* + * Header Formatter + * + * for http response codes + */ + +export const HeaderFormmater = ({ value }: any) => { + return
{value}
+} + +/* + * JSON Syntax Highlighter + * + * for http response codes + */ + +export function jsonSyntaxHighlight(input: Object) { + let json: string = JSON.stringify(input, null, 2) + json = json.replace(/&/g, '&').replace(//g, '>') + + // console.log('json', json) + const newJson = json.replace( + /("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, + + function (match) { + var cls = 'number text-tomato-900' + if (/^"/.test(match)) { + if (/:$/.test(match)) { + cls = 'key text-scale-1200' + } else { + cls = 'string text-brand-1100' + } + } else if (/true|false/.test(match)) { + cls = 'boolean text-blue-900' + } else if (/null/.test(match)) { + cls = 'null text-amber-1100' + } + return '' + match + '' + } + ) + + const jsonWithLineWraps = newJson.split(`\n`).map((x) => { + return `${x}` + }) + + return jsonWithLineWraps.join('\n') +} diff --git a/studio/components/interfaces/Settings/Logs/LogsNavigation.tsx b/studio/components/interfaces/Settings/Logs/LogsNavigation.tsx new file mode 100644 index 00000000000..1f8430495e9 --- /dev/null +++ b/studio/components/interfaces/Settings/Logs/LogsNavigation.tsx @@ -0,0 +1,32 @@ +import { Tabs } from '@supabase/ui' +import { useFlag } from 'hooks' +import { useRouter } from 'next/router' + +const LogsNavigation = () => { + const router = useRouter() + const activeRoute = router.pathname.split('/')[4] + const { ref } = router.query + + const indexRoute = 'query' + const logsSourcesPage = useFlag('logsSourcesPage') + const logsSavedQueries = useFlag('logsSavedQueries') + return ( + { + router.push(`/project/${ref}/logs-explorer/${e === indexRoute ? '' : e}`) + }} + > + + {logsSourcesPage && } + {/* */} + {logsSavedQueries && } + + + ) +} + +export default LogsNavigation diff --git a/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx b/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx new file mode 100644 index 00000000000..731c2a973b6 --- /dev/null +++ b/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx @@ -0,0 +1,232 @@ +import React, { useEffect, useReducer, useState } from 'react' +import { useRouter } from 'next/router' +import { + Typography, + IconLoader, + IconAlertCircle, + IconRewind, + Button, + Card, + Input, +} from '@supabase/ui' + +import { + LogTable, + LogTemplate, + TEMPLATES, + LogSearchCallback, + LogsTableName, + filterSqlWhereBuilder, + filterReducer, + QueryType, + LogEventChart, +} from 'components/interfaces/Settings/Logs' +import dayjs from 'dayjs' +import useLogsPreview from 'hooks/analytics/useLogsPreview' +import PreviewFilterPanel from 'components/interfaces/Settings/Logs/PreviewFilterPanel' + +import { LOGS_TABLES } from './Logs.constants' +import { Override } from './Logs.types' +import ShimmeringLoader from 'components/ui/ShimmeringLoader' +import ShimmerLine from 'components/ui/ShimmerLine' +import LoadingOpacity from 'components/ui/LoadingOpacity' + +/** + * Acts as a container component for the entire log display + * + * ## Query Params Syncing + * Query params are synced on query submission. + * + * params used are: + * - `s` for search query. + * - `te` for timestamp start value. + */ +interface Props { + projectRef: string + queryType: QueryType + override?: Override + condensedLayout?: boolean + tableName?: LogsTableName +} +export const LogsPreviewer: React.FC = ({ + projectRef, + queryType, + override, + condensedLayout = false, + tableName, +}) => { + const router = useRouter() + const { s, te, ts } = router.query + const [showChart, setShowChart] = useState(true) + + const [whereFilters, dispatchWhereFilters] = useReducer(filterReducer, {}) + + const table = !tableName ? LOGS_TABLES[queryType] : tableName + + const [ + { error, logData, params, newCount, filters, isLoading, oldestTimestamp }, + { loadOlder, setFilters, refresh, setTo, setFrom }, + ] = useLogsPreview(projectRef as string, table, { + initialFilters: { search_query: s as string }, + whereStatementFactory: (filterObj) => `${ + filterObj.search_query + ? `where REGEXP_CONTAINS(event_message, '${filterObj.search_query}')` + : '' + } + ${filterSqlWhereBuilder(whereFilters, table, override, filterObj.search_query).join('\n')}`, + }) + + useEffect(() => { + setFilters((prev) => ({ ...prev, search_query: s as string })) + if (te) { + setTo(te as string) + } else { + setTo('') + } + if (ts) { + setFrom(ts as string) + } else { + setFrom('') + } + }, [s, te, ts]) + + const onSelectTemplate = (template: LogTemplate) => { + setFilters((prev) => ({ ...prev, search_query: template.searchString })) + } + + useEffect(() => { + // runs when any of the filters change + handleRefresh() + }, [whereFilters]) + + const handleRefresh = () => { + refresh() + router.push({ + pathname: router.pathname, + query: { + ...router.query, + te: undefined, + ts: undefined, + // ...whereFilters, + }, + }) + } + + const handleSearch: LogSearchCallback = async ({ query, to, from, fromMicro, toMicro }) => { + let toValue, fromValue + if (to || toMicro) { + toValue = toMicro ? toMicro : dayjs(to).valueOf() * 1000 + + setTo(String(toValue)) + } + if (from || fromMicro) { + fromValue = fromMicro ? fromMicro : dayjs(from).valueOf() * 1000 + setFrom(String(fromValue)) + } + setFilters((prev) => ({ ...prev, search_query: query || '' })) + + router.push({ + pathname: router.pathname, + query: { + ...router.query, + s: query || '', + ts: fromValue, + te: toValue, + }, + }) + } + + return ( +
+ queryType && template.for?.includes(queryType) && template.mode === 'simple' + )} + onRefresh={handleRefresh} + onSearch={handleSearch} + defaultSearchValue={filters.search_query} + defaultToValue={ + params.timestamp_end ? dayjs(Number(params.timestamp_end) / 1000).toISOString() : '' + } + defaultFromValue={ + params.timestamp_start + ? dayjs(Number(params.timestamp_start) / 1000).toISOString() + : oldestTimestamp + ? dayjs(Number(oldestTimestamp) / 1000).toISOString() + : '' + } + onExploreClick={() => { + router.push(`/project/${projectRef}/logs-explorer?q=${params.rawSql}`) + }} + onSelectTemplate={onSelectTemplate} + dispatchWhereFilters={dispatchWhereFilters} + whereFilters={whereFilters} + table={table} + condensedLayout={condensedLayout} + isShowingEventChart={showChart} + onToggleEventChart={() => setShowChart(!showChart)} + /> +
0 + ? 'opacity-100 h-24 pt-4 mb-4' + : 'opacity-0 h-0') + } + > +
+ {showChart && ( + { + handleSearch({ query: filters.search_query, toMicro: timestampMicro }) + }} + /> + )} +
+
+
+ + + setShowChart(!showChart)} + /> + + {!error && ( +
+ +
+ )} + {error && ( +
+ +
+ + + Sorry! An error occured when fetching data. + +
+ +
+
+ )} +
+
+ ) +} + +export default LogsPreviewer diff --git a/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx b/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx new file mode 100644 index 00000000000..367814868fe --- /dev/null +++ b/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx @@ -0,0 +1,95 @@ +import { Button, Dropdown, Typography, IconChevronDown, IconPlay } from '@supabase/ui' +import { LogsTableName, LOGS_SOURCE_DESCRIPTION, LogTemplate } from '.' +interface Props { + templates?: LogTemplate[] + onSelectTemplate: (template: LogTemplate) => void + onSelectSource: (source: LogsTableName) => void + onRun: () => void + onClear: () => void + onSave?: () => void + hasEditorValue: boolean + isLoading: boolean +} + +const LogsQueryPanel: React.FC = ({ + templates = [], + onSelectTemplate, + hasEditorValue, + onRun, + onClear, + onSave, + onSelectSource, + isLoading, +}) => ( +
+
+
+
+ ( + onSelectSource(source)}> +
+ {source} + {LOGS_SOURCE_DESCRIPTION[source]} +
+
+ ))} + > + +
+ + ( + onSelectTemplate(template)}> + {template.label} + + ))} + > + + +
+
+
+
+ + {onSave && ( + + )} +
+ + +
+
+
+
+
+) + +export default LogsQueryPanel diff --git a/studio/components/interfaces/Settings/Logs/PreviewFilterPanel.tsx b/studio/components/interfaces/Settings/Logs/PreviewFilterPanel.tsx new file mode 100644 index 00000000000..c5cbfd3f1fa --- /dev/null +++ b/studio/components/interfaces/Settings/Logs/PreviewFilterPanel.tsx @@ -0,0 +1,206 @@ +import React, { FC, useEffect, useState } from 'react' +import { + Button, + Input, + Typography, + IconRefreshCw, + IconSearch, + IconExternalLink, + IconEye, + IconEyeOff, +} from '@supabase/ui' +import { LogSearchCallback, LogTemplate } from '.' +import dayjs from 'dayjs' +import utc from 'dayjs/plugin/utc' +import { FILTER_OPTIONS, LogsTableName } from './Logs.constants' +import { LogsFilter } from './Logs.filter' +import DatePickers from './Logs.DatePickers' +import CSVButton from 'components/ui/CSVButton' + +interface Props { + defaultSearchValue?: string + defaultToValue?: string + defaultFromValue?: string + templates?: any + isLoading: boolean + newCount: number + onRefresh?: () => void + onSearch?: LogSearchCallback + onExploreClick?: () => void + onSelectTemplate: (template: LogTemplate) => void + dispatchWhereFilters: (x: any) => void + whereFilters: any + table: LogsTableName + condensedLayout: Boolean + isShowingEventChart: boolean + onToggleEventChart: () => void + csvData?: unknown[] +} + +dayjs.extend(utc) + +/** + * Logs control panel header + wrapper + */ +const PreviewFilterPanel: FC = ({ + isLoading, + newCount, + onRefresh, + onSearch = () => {}, + defaultSearchValue = '', + defaultToValue = '', + defaultFromValue = '', + onExploreClick, + dispatchWhereFilters, + whereFilters: filters, + table, + condensedLayout, + isShowingEventChart, + onToggleEventChart, + csvData, +}) => { + const [search, setSearch] = useState('') + + const hasEdits = search !== defaultSearchValue + + // Sync local state with provided default value + useEffect(() => { + if (search !== defaultSearchValue) { + setSearch(defaultSearchValue) + } + }, [defaultSearchValue]) + + const RefreshButton = () => ( +
+ } + loading={isLoading} + disabled={isLoading} + onClick={onRefresh} + > + Refresh + + ) + + const handleSearch = (partial: Partial<{ query: string; to: string; from: string }>) => + onSearch({ query: search, ...partial }) + + return ( +
+
+
+ + +
+ ) +} + +export default PreviewFilterPanel diff --git a/studio/components/interfaces/Settings/Logs/index.ts b/studio/components/interfaces/Settings/Logs/index.ts index 2b7b9268f24..f0c24f1ec94 100644 --- a/studio/components/interfaces/Settings/Logs/index.ts +++ b/studio/components/interfaces/Settings/Logs/index.ts @@ -1,5 +1,8 @@ export * from './Logs.types' export * from './Logs.constants' -export {default as LogPanel} from "./LogPanel" -export {default as LogTable} from "./LogTable" -export {default as LogEventChart} from "./LogEventChart" +export * from './Logs.helpers' +export * from './Logs.filter' +export { default as LogsSavedQueriesItem } from './Logs.SavedQueriesItem' +export { default as LogsQueryPanel } from './LogsQueryPanel' +export { default as LogTable } from './LogTable' +export { default as LogEventChart } from './LogEventChart' diff --git a/studio/components/interfaces/functions/CommandRender.tsx b/studio/components/interfaces/functions/CommandRender.tsx new file mode 100644 index 00000000000..18af5eb91ee --- /dev/null +++ b/studio/components/interfaces/functions/CommandRender.tsx @@ -0,0 +1,55 @@ +import { IconCheck, IconClipboard } from '@supabase/ui' +import { useState } from 'react' + +const CommandRender = ({ commands }: any) => { + console.log('commands', commands) + return ( +
+ {commands.map((item: any) => { + const [isCopied, setIsCopied] = useState(false) + return ( +
+ {`> ${item.comment}`} +
+
+ $ + + {item.jsx ? item.jsx() : null} + + +
+
+ {item.description} +
+ ) + })} +
+ ) +} + +export default CommandRender diff --git a/studio/components/interfaces/functions/FunctionLayout.tsx b/studio/components/interfaces/functions/FunctionLayout.tsx new file mode 100644 index 00000000000..f176767046a --- /dev/null +++ b/studio/components/interfaces/functions/FunctionLayout.tsx @@ -0,0 +1,118 @@ +import BaseLayout from 'components/layouts' +import { observer } from 'mobx-react-lite' +import { useStore, withAuth } from 'hooks' +import { IconAlertCircle, IconCode, IconSlash, Loading, Tabs } from '@supabase/ui' +import { Tab } from '@headlessui/react' +import FunctionsNav from './FunctionsNav' +import React, { useEffect } from 'react' +import { useRouter } from 'next/router' +import Link from 'next/link' +import WarningBanner from 'components/ui/WarningBanner' +import { WARNING_MESSAGE } from './Functions.Constants' + +const PageLayout = ({ children }: { children?: React.ReactNode }) => { + const { functions, ui } = useStore() + + const router = useRouter() + const { id, ref } = router.query + + useEffect(() => { + functions.load() + }, [ui.selectedProject]) + + if (!functions.list()) { + return ( + + loading + + ) + } + + const item = functions.byId(id) + + const name = item?.name || '' + + return ( + + + +
+
+
+
+ +
+ +

+ Functions +

+ + + + + + + +
{name}
+
+ +
+
+ {children} +
+
+
+ ) +} + +export default withAuth(observer(PageLayout)) diff --git a/studio/components/interfaces/functions/FunctionLogsItem.tsx b/studio/components/interfaces/functions/FunctionLogsItem.tsx new file mode 100644 index 00000000000..f1b6d964b16 --- /dev/null +++ b/studio/components/interfaces/functions/FunctionLogsItem.tsx @@ -0,0 +1,44 @@ +import dayjs from 'dayjs' +import { FC } from 'react' +import { observer } from 'mobx-react-lite' +import { Badge, IconClipboard, IconExternalLink, IconGlobe, Typography } from '@supabase/ui' +import Table from 'components/to-be-cleaned/Table' + +import SimpleCodeBlock from 'components/to-be-cleaned/SimpleCodeBlock' +import router, { useRouter } from 'next/router' +// import logDropdown from './logDropdown' + +interface Props { + log: any + onClick: () => void +} + +const FunctionLogsItem: FC = ({ log, onClick }) => { + const islogConfirmed = log.email_confirmed_at || log.phone_confirmed_at + + const router = useRouter() + const { ref } = router.query + + return ( + + +
+
{log.function_status}
+
+
+ + {log.method} + + + {log.url} + + + + {dayjs(log.created_at).format('DD MMM, YYYY HH:mm')} + + +
+ ) +} + +export default observer(FunctionLogsItem) diff --git a/studio/components/interfaces/functions/Functions.Constants.ts b/studio/components/interfaces/functions/Functions.Constants.ts new file mode 100644 index 00000000000..5f8be838045 --- /dev/null +++ b/studio/components/interfaces/functions/Functions.Constants.ts @@ -0,0 +1,4 @@ +export const WARNING_MESSAGE = { + title: ' Supabase Functions is pre release', + description: 'Functions are currently experimental, and will be prone to breaking changes', +} diff --git a/studio/components/interfaces/functions/Functions.types.ts b/studio/components/interfaces/functions/Functions.types.ts new file mode 100644 index 00000000000..ed54073082f --- /dev/null +++ b/studio/components/interfaces/functions/Functions.types.ts @@ -0,0 +1,9 @@ +export interface Function { + id: string + slug: string + name: string + version: 9 + status: 'ACTIVE' | 'INACTIVE' | 'THROTTLE' + created_at: number + updated_at: number +} diff --git a/studio/components/interfaces/functions/FunctionsLayout.tsx b/studio/components/interfaces/functions/FunctionsLayout.tsx new file mode 100644 index 00000000000..9e3b0c9e4e3 --- /dev/null +++ b/studio/components/interfaces/functions/FunctionsLayout.tsx @@ -0,0 +1,86 @@ +import BaseLayout from 'components/layouts' +import { observer } from 'mobx-react-lite' +import { useStore, withAuth } from 'hooks' +import { IconCode, IconSlash, Loading, Tabs } from '@supabase/ui' +import { Tab } from '@headlessui/react' +import FunctionsNav from './FunctionsNav' +import React, { useEffect } from 'react' +import { useRouter } from 'next/router' +import WarningBanner from 'components/ui/WarningBanner' +import { WARNING_MESSAGE } from './Functions.Constants' + +const PageLayout = ({ children, centered }: { children?: React.ReactNode; centered?: boolean }) => { + const { functions, ui } = useStore() + + const router = useRouter() + const { id } = router.query + + useEffect(() => { + functions.load() + }, [ui.selectedProject]) + + if (!functions.isInitialized) { + return ( + + loading + + ) + } + + return ( + + + {centered ? ( + <> +
+
+
+ +
+

Functions

+
+ + {children} +
+ + ) : ( +
+
+
+ +
+

Functions

+
+ + {children} +
+ )} +
+ ) +} + +export default withAuth(observer(PageLayout)) diff --git a/studio/components/interfaces/functions/FunctionsListItem.tsx b/studio/components/interfaces/functions/FunctionsListItem.tsx new file mode 100644 index 00000000000..27d4d31398d --- /dev/null +++ b/studio/components/interfaces/functions/FunctionsListItem.tsx @@ -0,0 +1,69 @@ +import dayjs from 'dayjs' +import { FC } from 'react' +import { observer } from 'mobx-react-lite' +import { IconClipboard, IconGlobe } from '@supabase/ui' +import Table from 'components/to-be-cleaned/Table' + +import { useRouter } from 'next/router' +import { useStore } from 'hooks' +// import _functionDropdown from './_functionDropdown' + +interface Props { + function: any +} + +const FunctionsListItem: FC = ({ function: item }) => { + const { ui } = useStore() + + console.log('_function', item) + const is_functionConfirmed = item.email_confirmed_at || item.phone_confirmed_at + + const router = useRouter() + const ref = ui?.selectedProject?.ref + + return ( + { + router.push(`/project/${ref}/functions/${item.id}`) + }} + > + +
+ {item.name} +
+
+ + + + HTTP Request + + + {`https://${ref}.supabase.com/functions/${item.name}`} + + + + + {item.status} + + + + {dayjs(item.created_at).format('DD MMM, YYYY HH:mm')} + + + + + {dayjs(item.updated_at).format('DD MMM, YYYY HH:mm')} + + + + Version {item.version} + +
+ ) +} + +export default observer(FunctionsListItem) diff --git a/studio/components/interfaces/functions/FunctionsNav.tsx b/studio/components/interfaces/functions/FunctionsNav.tsx new file mode 100644 index 00000000000..01dd022b961 --- /dev/null +++ b/studio/components/interfaces/functions/FunctionsNav.tsx @@ -0,0 +1,29 @@ +import { Tabs } from '@supabase/ui' +import { useRouter } from 'next/router' + +// @ts-ignore +const FunctionsNav = ({ item }) => { + const router = useRouter() + const activeRoute = router.pathname.split('/')[5] + const { ref } = router.query + + return ( + + router.push(`/project/${ref}/functions/${item.id}/${e === 'metrics' ? '' : e}`) + } + > + + + {/* */} + + + + ) +} + +export default FunctionsNav diff --git a/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.ts b/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.ts index 2958897123d..a052084cad0 100644 --- a/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.ts +++ b/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.ts @@ -26,60 +26,82 @@ export const generateDatabaseMenu = (project?: Project): ProductMenuGroup[] => { url: `/project/${ref}/database/replication`, items: [], }, + ], }, ...(IS_PLATFORM ? [ - { - title: 'Settings', - items: [ - { - name: 'Backups', - key: 'backups', - url: `/project/${ref}/database/backups`, - items: [], - }, - { - name: 'Connection Pooling', - key: 'pooling', - url: `/project/${ref}/database/pooling`, - items: [], - }, - ], - }, - ] + { + title: 'Logs', + items: [ + { + name: 'API logs', + key: 'api-logs', + url: `/project/${ref}/database/api-logs`, + items: [], + }, + { + name: 'Postgres logs', + key: 'postgres-logs', + url: `/project/${ref}/database/postgres-logs`, + items: [], + }, + ], + }, + ] : []), ...(IS_PLATFORM ? [ - { - title: 'Alpha Preview', - isPreview: true, - items: [ - { - name: 'Triggers', - key: 'triggers', - url: `/project/${ref}/database/triggers`, - items: [], - }, - { - name: 'Functions', - key: 'functions', - url: `/project/${ref}/database/functions`, - items: [], - }, - ...(showHooksRoute - ? [ - { - name: 'Function Hooks', - key: 'hooks', - url: `/project/${ref}/database/hooks`, - items: [], - }, - ] - : []), - ], - }, - ] + { + title: 'Settings', + items: [ + { + name: 'Backups', + key: 'backups', + url: `/project/${ref}/database/backups`, + items: [], + }, + { + name: 'Connection Pooling', + key: 'pooling', + url: `/project/${ref}/database/pooling`, + items: [], + }, + ], + }, + ] + : []), + ...(IS_PLATFORM + ? [ + { + title: 'Alpha Preview', + isPreview: true, + items: [ + { + name: 'Triggers', + key: 'triggers', + url: `/project/${ref}/database/triggers`, + items: [], + }, + { + name: 'Functions', + key: 'functions', + url: `/project/${ref}/database/functions`, + items: [], + }, + ...(showHooksRoute + ? [ + { + name: 'Function Hooks', + key: 'hooks', + url: `/project/${ref}/database/hooks`, + items: [], + }, + ] + : []), + ], + }, + ] : []), ] } diff --git a/studio/components/layouts/LogsExplorerLayout/LogsExplorerLayout.tsx b/studio/components/layouts/LogsExplorerLayout/LogsExplorerLayout.tsx new file mode 100644 index 00000000000..9c3e96e4bb2 --- /dev/null +++ b/studio/components/layouts/LogsExplorerLayout/LogsExplorerLayout.tsx @@ -0,0 +1,83 @@ +import React, { useEffect } from 'react' +import BaseLayout from 'components/layouts' +import { observer } from 'mobx-react-lite' +import { useStore, withAuth } from 'hooks' +import { Badge, IconList, Loading } from '@supabase/ui' +import LogsNavigation from 'components/interfaces/Settings/Logs/LogsNavigation' + +const PageLayout = ({ + subtitle, + children, +}: { + subtitle?: React.ReactNode + children?: React.ReactNode +}) => { + const { content, ui } = useStore() + + useEffect(() => { + content.load() + }, [ui.selectedProject]) + + if (!content.isInitialized) { + return ( + + loading + + ) + } + + return ( + +
+
+
+
+ +
+

Logs Explorer

+ {subtitle && {subtitle}} +
+ +
+
+ {children} +
+
+
+ ) +} + +export default withAuth(observer(PageLayout)) diff --git a/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx b/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx index ef988c39e1a..83c21273b9f 100644 --- a/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx +++ b/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx @@ -5,13 +5,19 @@ import { IconSettings, IconDatabase, IconBarChart, + IconList, + IconCode, } from '@supabase/ui' import SVG from 'react-inlinesvg' import { IS_PLATFORM } from 'lib/constants' import { Route } from 'components/ui/ui.types' +import { useFlag } from 'hooks' + export const generateProductRoutes = (ref: string): Route[] => { + const functionsUi = useFlag('functionsUi') + return [ { key: 'editor', @@ -59,11 +65,32 @@ export const generateProductRoutes = (ref: string): Route[] => { icon: , link: `/project/${ref}/database/tables`, }, + ...(IS_PLATFORM && functionsUi + ? [ + { + key: 'functions', + label: 'Functions', + icon: , + link: `/project/${ref}/functions`, + }, + ] + : []), ] } export const generateOtherRoutes = (ref: string) => { + const logsExplorer = useFlag('logsExplorer') return [ + ...(IS_PLATFORM && logsExplorer + ? [ + { + key: 'logsExplorer', + label: 'Logs Explorer', + icon: , + link: `/project/${ref}/logs-explorer`, + }, + ] + : []), ...(IS_PLATFORM ? [ { diff --git a/studio/components/layouts/SettingsLayout/SettingsLayout.tsx b/studio/components/layouts/SettingsLayout/SettingsLayout.tsx index bab5d9ef241..c314e89f8c7 100644 --- a/studio/components/layouts/SettingsLayout/SettingsLayout.tsx +++ b/studio/components/layouts/SettingsLayout/SettingsLayout.tsx @@ -19,15 +19,11 @@ const SettingsLayout: FC = ({ title, children }) => { const router = useRouter() const page = router.pathname.split('/')[4] - const logType = router.query.type - - const pageKey = !isUndefined(logType) ? `logs-${logType}` : page - return ( } + productMenu={} >
{children} diff --git a/studio/components/layouts/SettingsLayout/SettingsMenu.utils.ts b/studio/components/layouts/SettingsLayout/SettingsMenu.utils.ts index 7fb445e80a8..a865e223768 100644 --- a/studio/components/layouts/SettingsLayout/SettingsMenu.utils.ts +++ b/studio/components/layouts/SettingsLayout/SettingsMenu.utils.ts @@ -2,7 +2,6 @@ import { LOG_TYPE_LABEL_MAPPING } from 'components/interfaces/Settings/Logs' import { ProductMenuGroup } from 'components/ui/ProductMenu/ProductMenu.types' export const generateSettingsMenu = (ref: string): ProductMenuGroup[] => { - const logTypes: string[] = ['explorer', 'database', 'api'] return [ { @@ -27,14 +26,5 @@ export const generateSettingsMenu = (ref: string): ProductMenuGroup[] => { }, ], }, - { - title: 'Logs', - items: logTypes.map((type: string) => ({ - name: LOG_TYPE_LABEL_MAPPING[type], - key: `logs-${type}`, - url: `/project/${ref}/settings/logs/${type}`, - items: [], - })), - }, ] } diff --git a/studio/components/layouts/index.ts b/studio/components/layouts/index.ts index a69ec6327bb..b00d71769d9 100644 --- a/studio/components/layouts/index.ts +++ b/studio/components/layouts/index.ts @@ -11,7 +11,7 @@ import AccountLayout from './AccountLayout/AccountLayout' import WizardLayout from './WizardLayout' import VercelIntegrationLayout from './VercelIntegrationLayout' import BillingLayout from './BillingLayout' - +import LogsExplorerLayout from './LogsExplorerLayout/LogsExplorerLayout' export { AuthLayout, DatabaseLayout, @@ -24,5 +24,7 @@ export { WizardLayout, VercelIntegrationLayout, BillingLayout, + LogsExplorerLayout + } export default ProjectLayout diff --git a/studio/components/to-be-cleaned/Charts/ChartHandler.tsx b/studio/components/to-be-cleaned/Charts/ChartHandler.tsx index f6f76315dba..cd29ff6f485 100644 --- a/studio/components/to-be-cleaned/Charts/ChartHandler.tsx +++ b/studio/components/to-be-cleaned/Charts/ChartHandler.tsx @@ -31,6 +31,8 @@ interface Props { hideChartType?: boolean data?: ChartData isLoading?: boolean + format?: string + highlightedValue?: string | number onBarClick?: (v: any) => void } @@ -57,6 +59,8 @@ const ChartHandler: FC = ({ hideChartType = false, data, isLoading, + format, + highlightedValue, onBarClick, }) => { const router = useRouter() @@ -114,12 +118,13 @@ const ChartHandler: FC = ({ } }, [startDate]) - const highlightedValue = - provider === 'daily-stats' - ? chartData?.total - : provider === 'log-stats' - ? chartData?.totalGrouped?.[attribute] - : chartData?.totalAverage + highlightedValue = highlightedValue + ? highlightedValue + : provider === 'daily-stats' + ? chartData?.total + : provider === 'log-stats' + ? chartData?.totalGrouped?.[attribute] + : chartData?.totalAverage if (loading) { return ( @@ -131,6 +136,7 @@ const ChartHandler: FC = ({ } if (isUndefined(chartData)) { + console.log('i am undefined chart data') return (
@@ -160,7 +166,7 @@ const ChartHandler: FC = ({ data={chartData?.data ?? []} attribute={attribute} yAxisLimit={chartData?.yAxisLimit} - format={chartData?.format} + format={format || chartData?.format} highlightedValue={highlightedValue} label={label} customDateFormat={customDateFormat} @@ -169,7 +175,7 @@ const ChartHandler: FC = ({ ) : ( ( +const NoData = ({ title = 'No data to show', message = 'May take 24 hours for data to show' }) => (
-

No data to show

-

May take 24 hours for data to show

+

{title}

+

{message}

) @@ -175,8 +175,10 @@ export function BarChart({ label, onBarClick, minimalHeader, - minimalChart, + chartSize = 'normal', className = '', + noDataTitle, + noDataMessage, }: any) { const hasData = data ? dataCheck(data, attribute) : true @@ -201,7 +203,11 @@ export function BarChart({ const day = (value: number | string) => (displayDateInUtc ? dayjs(value).utc() : dayjs(value)) // For future reference: https://github.com/supabase/supabase/pull/5311#discussion_r800852828 - const chartHeight = minimalChart ? 96 : 160 + const chartHeight = { + tiny: 76, + small: 96, + normal: 160, + }[chartSize as string] as number return ( @@ -291,7 +297,7 @@ export function BarChart({ )} ) : ( - + )}
diff --git a/studio/components/to-be-cleaned/Table.tsx b/studio/components/to-be-cleaned/Table.tsx index 2ff1ad464f7..f5c33fbb7a7 100644 --- a/studio/components/to-be-cleaned/Table.tsx +++ b/studio/components/to-be-cleaned/Table.tsx @@ -7,9 +7,17 @@ type TableProps = { className?: string containerClassName?: string borderless?: boolean + headTrClasses?: string } -function Table({ body, head, className, containerClassName, borderless }: TableProps) { +function Table({ + body, + head, + className, + containerClassName, + borderless, + headTrClasses, +}: TableProps) { let containerClasses = ['table-container'] if (containerClassName) containerClasses.push(containerClassName) if (borderless) containerClasses.push('table-container--borderless') @@ -21,7 +29,7 @@ function Table({ body, head, className, containerClassName, borderless }: TableP
- {head} + {head}{body}
@@ -68,8 +76,8 @@ type TdProps = { colSpan?: number className?: string style?: React.CSSProperties -} & React.HTMLProps -const Td: React.FC = ({ children, colSpan, className, style , ...rest}) => { +} & React.HTMLProps +const Td: React.FC = ({ children, colSpan, className, style, ...rest }) => { return ( {children} diff --git a/studio/components/ui/CSVButton.tsx b/studio/components/ui/CSVButton.tsx new file mode 100644 index 00000000000..edc65394bd1 --- /dev/null +++ b/studio/components/ui/CSVButton.tsx @@ -0,0 +1,70 @@ +import { Button, IconDownloadCloud } from '@supabase/ui' +import { ButtonProps } from '@supabase/ui/dist/cjs/components/Button/Button' +import { flattenDeep } from 'lodash' +import React, { useMemo, useRef } from 'react' +import { CSVLink } from 'react-csv' + +interface Props { + buttonType?: ButtonProps['type'] + onClick?: ButtonProps['onClick'] + disabled?: ButtonProps['disabled'] + icon?: React.ReactNode + data?: unknown[] + title?: string +} + +const CSVButton: React.FC = ({ + onClick, + buttonType = 'default', + icon, + children, + disabled, + data, + title, +}) => { + const csvRef = useRef(null) + const handleDownload = () => { + ;(csvRef.current as any)?.link.click() + } + const formattedData = useMemo(() => { + const first = data?.[0] + if (!first || !data) return + const keys = Object.keys(first as any) + return data.map((datum: any) => { + return keys.reduce((acc: any, key) => { + if (typeof datum[key] === 'object') { + acc[key] = JSON.stringify(datum[key]) as string + } else { + acc[key] = String(datum[key]) + } + return acc + }, {}) + }) + // retrieve dot notation of all keys + }, [JSON.stringify(data)]) + + return ( + <> + + + + ) +} + +export default CSVButton diff --git a/studio/components/ui/CardButton.tsx b/studio/components/ui/CardButton.tsx index 5b33bfeb093..11d7c480f13 100644 --- a/studio/components/ui/CardButton.tsx +++ b/studio/components/ui/CardButton.tsx @@ -13,6 +13,7 @@ interface Props { imgAlt?: string onClick?: () => void icon?: React.ReactNode + containerHeightClassName?: string } const CardButton: FC = ({ @@ -26,6 +27,7 @@ const CardButton: FC = ({ imgAlt, onClick, icon, + containerHeightClassName="h-32" }) => { const LinkContainer = ({ children }: { children: React.ReactNode }) => ( {children} @@ -42,8 +44,9 @@ const CardButton: FC = ({ 'group relative text-left', 'bg-panel-header-light dark:bg-panel-header-dark', 'border border-panel-border-light dark:border-panel-border-dark', - 'rounded-md py-4 px-6 flex flex-row h-32', + 'rounded-md py-4 px-6 flex flex-row', 'transition ease-in-out duration-150', + containerHeightClassName ] if (isLink) { diff --git a/studio/components/ui/DatePicker/DatePicker.stories.tsx b/studio/components/ui/DatePicker/DatePicker.stories.tsx new file mode 100644 index 00000000000..c88c49a8b9e --- /dev/null +++ b/studio/components/ui/DatePicker/DatePicker.stories.tsx @@ -0,0 +1,35 @@ +import React, { useState } from 'react' + +import { DatePicker } from '.' +import ReactDatePicker from 'react-datepicker' + +export default { + title: 'Data/DatePicker', + component: DatePicker, +} + +export const Default = (args: any) => ( +
+ +
+) + +export const Simple = () => { + const [startDate, setStartDate] = useState(new Date()) + const [endDate, setEndDate] = useState(null) + const onChange = (dates: any) => { + const [start, end] = dates + setStartDate(start) + setEndDate(end) + } + return ( + + ) +} diff --git a/studio/components/ui/DatePicker/DatePicker.tsx b/studio/components/ui/DatePicker/DatePicker.tsx new file mode 100644 index 00000000000..3b53c88c482 --- /dev/null +++ b/studio/components/ui/DatePicker/DatePicker.tsx @@ -0,0 +1,362 @@ +import React, { useState, forwardRef, useEffect } from 'react' + +import DatePicker from 'react-datepicker' + +// import dayjs from 'dayjs' + +import { + Button, + Popover, + IconChevronLeft, + IconChevronRight, + IconArrowRight, + IconCalendar, + IconGlobe, + Input, + Toggle, + Select, +} from '@supabase/ui' + +import { format } from 'date-fns' + +import TimeSplitInput from './TimeSplitInput' +// import DateSplitInput from './DateSplitInput' +import DividerSlash from './DividerSlash' + +import dayjs from 'dayjs' +import { ButtonProps } from '@supabase/ui/dist/cjs/components/Button/Button' + +var utc = require('dayjs/plugin/utc') +var timezone = require('dayjs/plugin/timezone') // dependent on utc plugin + +dayjs.extend(utc) +dayjs.extend(timezone) + +interface RootProps { + onChange?: ({ to, from }: { to: string; from: string }) => void + to?: string + from?: string + triggerButtonType?: ButtonProps['type'] + triggerButtonClassName?: string +} + +const START_DATE_DEFAULT = new Date() +const END_DATE_DEFAULT = new Date() + +// console.log('START_DATE_DEFAULT', START_DATE_DEFAULT) + +const START_TIME_DEFAULT = { HH: '00', mm: '00', ss: '00' } +const END_TIME_DEFAULT = { HH: '23', mm: '59', ss: '59' } + +function _DatePicker({ + to, + from, + onChange, + triggerButtonType = 'default', + triggerButtonClassName = '', +}: RootProps) { + const [open, setOpen] = useState(false) + + const [showTime, setShowTime] = useState(true) + + const [appliedStartDate, setAppliedStartDate] = useState(null) + const [appliedEndDate, setAppliedEndDate] = useState(null) + + const [startDate, setStartDate] = useState(START_DATE_DEFAULT) + const [endDate, setEndDate] = useState(END_DATE_DEFAULT) + + const [startTime, setStartTime] = useState(START_TIME_DEFAULT) + const [endTime, setEndTime] = useState(END_TIME_DEFAULT) + + useEffect(() => { + if (!to || !from) { + setAppliedStartDate(null) + setAppliedEndDate(null) + } + }, [to, from]) + + function handleDatePickerChange(dates: [from: Date | null, to: Date | null]) { + const [from, to] = dates + setStartDate(from) + setEndDate(to) + } + + function handleSubmit() { + setOpen(false) + + setAppliedStartDate(startDate) + setAppliedEndDate(endDate) + + const payload = { + from: dayjs + .utc(startDate) + .second(startTime.ss) + .minute(startTime.mm) + .hour(startTime.HH) + .toISOString(), + to: dayjs + .utc(endDate || startDate) + .second(endTime.ss) + .minute(endTime.mm) + .hour(endTime.HH) + .toISOString(), + } + if (onChange) onChange(payload) + } + + function handleClear() { + setOpen(false) + setStartDate(START_DATE_DEFAULT) + setEndDate(END_DATE_DEFAULT) + + setStartTime(START_TIME_DEFAULT) + setEndTime(END_TIME_DEFAULT) + + setAppliedStartDate(null) + setAppliedEndDate(null) + } + + const DEFAULT_DATE_FORMAT = 'ddd MMM YYYY HH:mm:ss' + + return ( + <> + setOpen(e)} + size="small" + align="center" + side="bottom" + header={ + <> +
+
+ +
+
+ +
+
+ +
+
+ + } + overlay={ + <> +
+ { + handleDatePickerChange(dates) + }} + dateFormat="MMMM d, yyyy h:mm aa" + startDate={startDate} + endDate={endDate} + selectsRange + // showTimeSelect + // nextMonthButtonLabel=">" + // previousMonthButtonLabel="<" + // popperClassName="react-datepicker-left" + // customInput={} + // dateFormat={DEFAULT_DATE_FORMAT} + // dateFormat={(locale, date) => dayjs(date).format('MM-YYYY')} + inline + dayClassName={() => 'cursor-pointer'} + renderCustomHeader={({ + date, + decreaseMonth, + increaseMonth, + prevMonthButtonDisabled, + nextMonthButtonDisabled, + }) => ( +
+
+ + {format(date, 'MMMM yyyy')} + +
+
+ )} + /> +
+ + +
+ + +
+ + } + > + +
+ + {/*
+ setEndDate(date)} + selectsEnd + startDate={startDate} + endDate={endDate} + nextMonthButtonLabel=">" + previousMonthButtonLabel="<" + popperClassName="react-datepicker-right" + customInput={} + renderCustomHeader={({ + date, + decreaseMonth, + increaseMonth, + prevMonthButtonDisabled, + nextMonthButtonDisabled, + }) => ( + <> +
+
+ + + {format(date, 'MMMM yyyy')} + + +
+
+ + )} + /> +
+
+
+ Default Components: +
+ setStartDate(date)} + selectsStart + startDate={startDate} + endDate={endDate} + nextMonthButtonLabel=">" + previousMonthButtonLabel="<" + popperClassName="react-datepicker-left" + /> +
+
+ setEndDate(date)} + selectsEnd + startDate={startDate} + endDate={endDate} + nextMonthButtonLabel=">" + previousMonthButtonLabel="<" + popperClassName="react-datepicker-right" + /> +
*/} + + ) +} + +const ButtonInput = forwardRef(({ value, onClick }: any, ref): any => ( + +)) + +export default _DatePicker diff --git a/studio/components/ui/DatePicker/DatePicker.types.ts b/studio/components/ui/DatePicker/DatePicker.types.ts new file mode 100644 index 00000000000..feeb68dfd74 --- /dev/null +++ b/studio/components/ui/DatePicker/DatePicker.types.ts @@ -0,0 +1,27 @@ +export type Time = { + HH: string + mm: string + ss: string +} + +export type TimeType = 'HH' | 'mm' | 'ss' + +export interface TimeSplitInputProps { + time: Time + setTime: (x: Time) => void + type: 'start' | 'end' + setStartTime: (x: Time) => void + setEndTime: (x: Time) => void + startTime: Time + endTime: Time + startDate: any + endDate: any +} + +export type Date = { + YYYY: string + MM: string + DD: string +} + +export type DateType = 'YYYY' | 'MM' | 'DD' diff --git a/studio/components/ui/DatePicker/DividerSlash.tsx b/studio/components/ui/DatePicker/DividerSlash.tsx new file mode 100644 index 00000000000..8071f95ba06 --- /dev/null +++ b/studio/components/ui/DatePicker/DividerSlash.tsx @@ -0,0 +1,19 @@ +export default function DividerSlash() { + return ( + + + + + + ) +} diff --git a/studio/components/ui/DatePicker/TimeSplitInput.tsx b/studio/components/ui/DatePicker/TimeSplitInput.tsx new file mode 100644 index 00000000000..a6c31c56e5d --- /dev/null +++ b/studio/components/ui/DatePicker/TimeSplitInput.tsx @@ -0,0 +1,254 @@ +import React, { useEffect, useState } from 'react' +import { IconClock } from '@supabase/ui' + +import { format } from 'date-fns' + +import { TimeType, Time, TimeSplitInputProps } from './DatePicker.types' + +const TimeSplitInput = ({ + type, + time, + setTime, + setStartTime, + setEndTime, + startTime, + endTime, + startDate, + endDate, +}: TimeSplitInputProps) => { + const [focus, setFocus] = useState(false) + + function handleOnBlur() { + const _time = time + + if (_time.HH.length === 1) _time.HH = '0' + _time.HH + if (_time.mm.length === 1) _time.mm = '0' + _time.mm + if (_time.ss.length === 1) _time.ss = '0' + _time.ss + + if (!_time.HH) _time.HH = '00' + if (!_time.mm) _time.mm = '00' + if (!_time.ss) _time.ss = '00' + + let endTimeChanges = false + let endTimePayload = endTime + + let startTimeChanges = false + let startTimePayload = startTime + + // Only run time conflicts if + // startDate and endDate are the same date + + // console.log(startDate) + // console.log(endDate) + if (format(new Date(startDate), 'dd/mm/yyyy') == format(new Date(endDate), 'dd/mm/yyyy')) { + // checks if start time is ahead of end time + + if (type === 'start') { + // console.log('HH in start switch') + if (_time.HH && Number(_time.HH) > Number(endTime.HH)) { + endTimePayload.HH = _time.HH + endTimeChanges = true + } + + if ( + // also check the hour + _time.HH && + Number(_time.HH) >= Number(endTime.HH) && + // check the minutes + _time.mm && + Number(_time.mm) > Number(endTime.mm) + ) { + endTimePayload.mm = _time.mm + endTimeChanges = true + } + + if ( + // also check the hour + _time.HH && + Number(_time.HH) >= Number(endTime.HH) && + // check the minutes + _time.mm && + Number(_time.mm) >= Number(endTime.mm) && + // check the seconds + _time.ss && + Number(_time.ss) > Number(endTime.ss) + ) { + endTimePayload.ss = _time.ss + endTimeChanges = true + } + } + + if (type === 'end') { + // console.log('HH in start switch') + + if (_time.HH && Number(_time.HH) < Number(startTime.HH)) { + startTimePayload.HH = _time.HH + startTimeChanges = true + } + + if ( + // also check the hour + _time.HH && + Number(_time.HH) <= Number(startTime.HH) && + // check the minutes + _time.mm && + Number(_time.mm) < Number(startTime.mm) + ) { + startTimePayload.mm = _time.mm + startTimeChanges = true + } + + if ( + // also check the hour + _time.HH && + Number(_time.HH) <= Number(startTime.HH) && + // check the minutes + _time.mm && + Number(_time.mm) <= Number(startTime.mm) && + // check the seconds + _time.ss && + Number(_time.ss) < Number(startTime.ss) + ) { + startTimePayload.ss = _time.ss + startTimeChanges = true + } + } + } + + setTime({ ..._time }) + + if (endTimeChanges) { + setEndTime({ ...endTimePayload }) + } + if (startTimeChanges) { + setStartTime({ ...startTimePayload }) + } + + setFocus(false) + } + + function handleOnChange(value: string, valueType: TimeType) { + // console.log('handleOnChange') + + const payload = { + HH: time.HH, + mm: time.mm, + ss: time.ss, + } + if (value.length > 2) return + + switch (valueType) { + case 'HH': + if (value && Number(value) > 23) return + break + case 'mm': + if (value && Number(value) > 59) return + break + case 'ss': + if (value && Number(value) > 59) return + break + default: + break + } + + // console.log('got here') + + payload[valueType] = value + setTime({ ...payload }) + + // if (endTimeChanges) { + // setEndTime(endTimePayload) + // } + } + + const handleFocus = (event: React.FocusEvent) => { + event.target.select() + setFocus(true) + } + + useEffect(() => { + handleOnBlur() + }, [startDate, endDate]) + + return ( +
+
+ +
+ + handleOnBlur()} + onFocus={handleFocus} + pattern="[0-23]*" + placeholder="00" + onChange={(e) => handleOnChange(e.target.value, 'HH')} + aria-label="Hours" + className=" + w-4 + text-center + text-xs + p-0 + text-scale-1200 bg-transparent outline-none + border-none + ring-none + ring-0 + focus:ring-0 + " + value={time.HH} + /> + : + handleOnBlur()} + onFocus={handleFocus} + pattern="[0-12]*" + placeholder="00" + onChange={(e) => handleOnChange(e.target.value, 'mm')} + aria-label="Minutes" + className=" + w-4 + text-center + text-xs + p-0 + text-scale-1200 bg-transparent outline-none + border-none + ring-none + ring-0 + focus:ring-0 + " + value={time.mm} + /> + : + handleOnBlur()} + onFocus={handleFocus} + pattern="[0-59]*" + placeholder="00" + onChange={(e) => handleOnChange(e.target.value, 'ss')} + aria-label="Seconds" + className=" + w-4 + text-center + text-xs + p-0 + text-scale-1200 bg-transparent outline-none + border-none + ring-none + ring-0 + focus:ring-0 + " + value={time.ss} + /> +
+ ) +} + +export default TimeSplitInput diff --git a/studio/components/ui/DatePicker/index.tsx b/studio/components/ui/DatePicker/index.tsx new file mode 100644 index 00000000000..25865361516 --- /dev/null +++ b/studio/components/ui/DatePicker/index.tsx @@ -0,0 +1 @@ +export { default as DatePicker } from './DatePicker' diff --git a/studio/components/ui/LoadingOpacity.tsx b/studio/components/ui/LoadingOpacity.tsx new file mode 100644 index 00000000000..ff15c8b38b3 --- /dev/null +++ b/studio/components/ui/LoadingOpacity.tsx @@ -0,0 +1,15 @@ +import React from 'react' + +const LoadingOpacity = ({ children, active }: { children?: React.ReactNode; active: boolean }) => { + return ( +
+ {children} +
+ ) +} + +export default LoadingOpacity diff --git a/studio/components/ui/ShimmerLine.tsx b/studio/components/ui/ShimmerLine.tsx new file mode 100644 index 00000000000..be6512f237b --- /dev/null +++ b/studio/components/ui/ShimmerLine.tsx @@ -0,0 +1,5 @@ +const ShimmerLine = ({ active }: { active: boolean }) => { + return active ?
: null +} + +export default ShimmerLine diff --git a/studio/components/ui/WarningBanner.tsx b/studio/components/ui/WarningBanner.tsx new file mode 100644 index 00000000000..fe5ff13fa9d --- /dev/null +++ b/studio/components/ui/WarningBanner.tsx @@ -0,0 +1,19 @@ +import { IconAlertCircle } from '@supabase/ui' + +const WarningBanner = ({ title, description }: { title: string; description: string }) => { + return ( +
+
+
+ +
+
+

{title}

+

{description}

+
+
+
+ ) +} + +export default WarningBanner diff --git a/studio/hooks/analytics/useLogsPreview.tsx b/studio/hooks/analytics/useLogsPreview.tsx index 9dac008861e..cfca0195216 100644 --- a/studio/hooks/analytics/useLogsPreview.tsx +++ b/studio/hooks/analytics/useLogsPreview.tsx @@ -53,22 +53,21 @@ function useLogsPreview( const [latestRefresh, setLatestRefresh] = useState(new Date().toISOString()) + const [filters, setFilters] = useState(options.initialFilters) + const [params, setParams] = useState({ project: projectRef, - sql: cleanQuery(genDefaultQuery(table)), - rawSql: genDefaultQuery(table), + sql: cleanQuery(genDefaultQuery(table, options.whereStatementFactory(filters))), + rawSql: genDefaultQuery(table, options.whereStatementFactory(filters)), period_start: '', period_end: '', timestamp_start: '', timestamp_end: '', }) - const [filters, setFilters] = useState(options.initialFilters) useEffect(() => { if (filters !== {}) { - const generatedSql = genDefaultQuery(table, options.whereStatementFactory(filters)) - - setParams((prev) => ({ ...prev, sql: cleanQuery(generatedSql), rawSql: generatedSql })) + refresh() } }, [JSON.stringify(filters)]) @@ -111,9 +110,11 @@ function useLogsPreview( const { data: countData } = useSWR(countUrl, get, { refreshInterval: 5000 }) const newCount = countData?.result?.[0]?.count ?? 0 - const refresh = () => { + const refresh = async () => { + const generatedSql = genDefaultQuery(table, options.whereStatementFactory(filters)) + setParams((prev) => ({ ...prev, sql: cleanQuery(generatedSql), rawSql: generatedSql })) setLatestRefresh(new Date().toISOString()) - setParams({ ...params, timestamp_end: '' }) + setSize(1) } let error: null | string | object = swrError ? swrError.message : null diff --git a/studio/hooks/analytics/useLogsQuery.tsx b/studio/hooks/analytics/useLogsQuery.tsx index 159825c05a9..a9523b3951c 100644 --- a/studio/hooks/analytics/useLogsQuery.tsx +++ b/studio/hooks/analytics/useLogsQuery.tsx @@ -36,7 +36,7 @@ const useLogsQuery = ( isValidating: isLoading, mutate, } = useSWR( - `${API_URL}/projects/${projectRef}/analytics/endpoints/logs.all?${queryParams}`, + params.sql ? `${API_URL}/projects/${projectRef}/analytics/endpoints/logs.all?${queryParams}` : null, get, { revalidateOnFocus: false } ) diff --git a/studio/package-lock.json b/studio/package-lock.json index 89228c8e529..b4c45cdc268 100644 --- a/studio/package-lock.json +++ b/studio/package-lock.json @@ -3057,8 +3057,7 @@ "@popperjs/core": { "version": "2.11.4", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.4.tgz", - "integrity": "sha512-q/ytXxO5NKvyT37pmisQAItCFqA7FD/vNb8dgaJy3/630Fsc+Mz9/9f2SziBoIZ30TJooXyTwZmhi1zjXmObYg==", - "dev": true + "integrity": "sha512-q/ytXxO5NKvyT37pmisQAItCFqA7FD/vNb8dgaJy3/630Fsc+Mz9/9f2SziBoIZ30TJooXyTwZmhi1zjXmObYg==" }, "@radix-ui/colors": { "version": "0.1.8", @@ -6618,6 +6617,18 @@ "@types/react": "*" } }, + "@types/react-datepicker": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@types/react-datepicker/-/react-datepicker-4.3.4.tgz", + "integrity": "sha512-5nTTz37KdTUgMZ1AAxztMWNtEnIMVRo8oCAEhIv0a6uUqDjvSKaMyPRpBV+8chi6f/A8wlTKJIpojpXca2dx3A==", + "dev": true, + "requires": { + "@popperjs/core": "^2.9.2", + "@types/react": "*", + "date-fns": "^2.0.1", + "react-popper": "^2.2.5" + } + }, "@types/react-dom": { "version": "17.0.11", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.11.tgz", @@ -9827,6 +9838,11 @@ } } }, + "date-fns": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.28.0.tgz", + "integrity": "sha512-8d35hViGYx/QH0icHYCeLmsLmMUheMmTyV9Fcm6gvNwdw31yXXH+O85sOBJ+OLnLQMKZowvpKb6FgMIQjcpvQw==" + }, "dayjs": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.0.tgz", @@ -17663,6 +17679,19 @@ "resolved": "https://registry.npmjs.org/react-csv/-/react-csv-2.0.3.tgz", "integrity": "sha512-exyAdFLAxtuM4wNwLYrlKyPYLiJ7e0mv9tqPAd3kq+k1CiJFtznevR3yP0icv5q/y200w+lzNgi7TQn1Wrhu0w==" }, + "react-datepicker": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-4.7.0.tgz", + "integrity": "sha512-FS8KgbwqpxmJBv/bUdA42MYqYZa+fEYcpc746DZiHvVE2nhjrW/dg7c5B5fIUuI8gZET6FOzuDgezNcj568Czw==", + "requires": { + "@popperjs/core": "^2.9.2", + "classnames": "^2.2.6", + "date-fns": "^2.24.0", + "prop-types": "^15.7.2", + "react-onclickoutside": "^6.12.0", + "react-popper": "^2.2.5" + } + }, "react-dnd": { "version": "14.0.5", "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-14.0.5.tgz", @@ -17862,11 +17891,15 @@ "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" }, + "react-onclickoutside": { + "version": "6.12.1", + "resolved": "https://registry.npmjs.org/react-onclickoutside/-/react-onclickoutside-6.12.1.tgz", + "integrity": "sha512-a5Q7CkWznBRUWPmocCvE8b6lEYw1s6+opp/60dCunhO+G6E4tDTO2Sd2jKE+leEnnrLAE2Wj5DlDHNqj5wPv1Q==" + }, "react-popper": { "version": "2.2.5", "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-2.2.5.tgz", "integrity": "sha512-kxGkS80eQGtLl18+uig1UIf9MKixFSyPxglsgLBxlYnyDf65BiY9B3nZSc6C9XUNDgStROB0fMQlTEz1KxGddw==", - "dev": true, "requires": { "react-fast-compare": "^3.0.1", "warning": "^4.0.2" @@ -17875,8 +17908,7 @@ "react-fast-compare": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz", - "integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==", - "dev": true + "integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==" } } }, diff --git a/studio/package.json b/studio/package.json index cbddbf1e3a3..c60c675f5bd 100644 --- a/studio/package.json +++ b/studio/package.json @@ -48,6 +48,7 @@ "react": "^17.0.2", "react-beautiful-dnd": "^13.1.0", "react-csv": "^2.0.3", + "react-datepicker": "^4.6.0", "react-dom": "^17.0.2", "react-grid-layout": "^1.3.0", "react-hot-toast": "^2.1.1", @@ -73,7 +74,6 @@ "@storybook/addon-links": "^6.4.19", "@storybook/react": "^6.4.19", "@storybook/testing-library": "^0.0.9", - "storybook-dark-mode": "^1.0.4", "@tailwindcss/typography": "^0.5.2", "@testing-library/dom": "^8.11.1", "@testing-library/react": "^12.1.2", @@ -85,6 +85,7 @@ "@types/react": "17.0.11", "@types/react-beautiful-dnd": "^13.1.2", "@types/react-csv": "^1.1.2", + "@types/react-datepicker": "^4.3.4", "@types/react-dom": "^17.0.11", "@types/react-grid-layout": "^1.3.0", "@types/react-virtualized-auto-sizer": "^1.0.1", @@ -98,6 +99,7 @@ "jest": "^27.4.0", "jest-canvas-mock": "^2.3.1", "postcss": "^8.4.6", + "storybook-dark-mode": "^1.0.4", "tailwindcss": "^3.0.22", "ts-jest": "^27.0.7", "typescript": "4.3.4" diff --git a/studio/pages/_app.tsx b/studio/pages/_app.tsx index 44600af84cc..1ebb8cf841d 100644 --- a/studio/pages/_app.tsx +++ b/studio/pages/_app.tsx @@ -7,6 +7,8 @@ import 'styles/toast.scss' import 'styles/code.scss' import 'styles/monaco.scss' import 'styles/contextMenu.scss' +import 'styles/react-data-grid-logs.scss' +import 'styles/date-picker.scss' import Head from 'next/head' import type { AppProps } from 'next/app' diff --git a/studio/pages/project/[ref]/database/api-logs.tsx b/studio/pages/project/[ref]/database/api-logs.tsx new file mode 100644 index 00000000000..9178054c7a7 --- /dev/null +++ b/studio/pages/project/[ref]/database/api-logs.tsx @@ -0,0 +1,27 @@ +import React from 'react' +import { NextPage } from 'next' +import { useRouter } from 'next/router' +import { observer } from 'mobx-react-lite' +import { withAuth } from 'hooks' +import { DatabaseLayout } from 'components/layouts/' +import { LogsTableName } from 'components/interfaces/Settings/Logs' +import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer' + +export const LogPage: NextPage = () => { + const router = useRouter() + const { ref } = router.query + + return ( + + + + ) +} + +export default withAuth(observer(LogPage)) diff --git a/studio/pages/project/[ref]/database/postgres-logs.tsx b/studio/pages/project/[ref]/database/postgres-logs.tsx new file mode 100644 index 00000000000..291cd5751d1 --- /dev/null +++ b/studio/pages/project/[ref]/database/postgres-logs.tsx @@ -0,0 +1,27 @@ +import React from 'react' +import { NextPage } from 'next' +import { useRouter } from 'next/router' +import { observer } from 'mobx-react-lite' +import { withAuth } from 'hooks' +import { DatabaseLayout } from 'components/layouts/' +import { LogsTableName } from 'components/interfaces/Settings/Logs' +import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer' + +export const LogPage: NextPage = () => { + const router = useRouter() + const { ref } = router.query + + return ( + + + + ) +} + +export default withAuth(observer(LogPage)) diff --git a/studio/pages/project/[ref]/functions/[id]/details.tsx b/studio/pages/project/[ref]/functions/[id]/details.tsx new file mode 100644 index 00000000000..81544ceda9d --- /dev/null +++ b/studio/pages/project/[ref]/functions/[id]/details.tsx @@ -0,0 +1,210 @@ +import { observer } from 'mobx-react-lite' +import { useStore, withAuth } from 'hooks' + +import FunctionLayout from '../../../../../components/interfaces/functions/FunctionLayout' +import CommandRender from '../../../../../components/interfaces/functions/CommandRender' +import { useRouter } from 'next/router' +import { useEffect, useState } from 'react' +import { toJS } from 'mobx' +import { IconExternalLink, IconGlobe, IconTerminal, Loading } from '@supabase/ui' +import Link from 'next/link' +import dayjs from 'dayjs' + +const PageLayout = () => { + const router = useRouter() + const { ref, id } = router.query + + const { functions, ui } = useStore() + + const [selectedFunction, setSelectedFunction] = useState(null) + + useEffect(() => { + setSelectedFunction(functions.byId(id)) + }, [functions.isLoaded, ui.selectedProject]) + + // if (!functions.isLoading) return <> + + console.log('selected', functions.byId(id)) + + const managementCommands: any = [ + { + command: `supabase functions deploy ${selectedFunction?.name}`, + description: 'This will overwrite the deployed function with your new function', + jsx: () => { + return ( + <> + supabase functions deploy{' '} + {selectedFunction?.name} + + ) + }, + comment: 'Deploy a new version', + }, + { + command: `supabase functions deploy ${selectedFunction?.name}`, + description: 'This will remove the function and all the logs associated with it', + jsx: () => { + return ( + <> + supabase functions delete{' '} + {selectedFunction?.name} + + ) + }, + comment: 'Delete the function', + }, + ] + + const secretCommands: any = [ + { + command: `supabase secrets list`, + description: 'This will list all the secrets for your project', + jsx: () => { + return ( + <> + supabase secrets list + + ) + }, + comment: 'View all secrets', + }, + { + command: `supabase secrets set NAME1=VALUE NAME2=VALUE`, + description: 'This will set secrets for your project', + jsx: () => { + return ( + <> + supabase secrets set NAME=VALUE NAME=VALUE + + ) + }, + comment: 'Set secrets for your project', + }, + { + command: `supabase secrets unset NAME1 NAME2 `, + description: 'This will deleet secrets for your project', + jsx: () => { + return ( + <> + supabase secrets unset NAME1 NAME2 + + ) + }, + comment: 'Set secrets for your project', + }, + ] + + return ( + +
+
+
+ {console.log('inside the render', functions.byId(id))} +
{selectedFunction?.createdAt}
+ +
+
+ Function name +
{selectedFunction?.name}
+
+ +
+ Status +
+
+ {selectedFunction?.status} +
+ + + + +
+
+ This function is running correctly +
+
+ + + +
+ Created at +
+ {selectedFunction?.created_at && + dayjs(selectedFunction.created_at).format('dddd, MMMM D, YYYY h:mm A')} +
+
+ +
+ Updated at +
+ {selectedFunction?.updated_at && + dayjs(selectedFunction.updated_at).format('dddd, MMMM D, YYYY h:mm A')} +
+
+ +
+ Version +
v {selectedFunction?.version}
+
+ +
+ Regions +
+
+ + Earth +
+ Supabase functions run on the edge +
+
+
+
+
+
+
+
+
+ +
+

Command line access

+
+
Deployment management
+ +
Secrets management
+ +
+
+
+
+ ) +} + +export default withAuth(observer(PageLayout)) diff --git a/studio/pages/project/[ref]/functions/[id]/index.tsx b/studio/pages/project/[ref]/functions/[id]/index.tsx new file mode 100644 index 00000000000..1ad2353474e --- /dev/null +++ b/studio/pages/project/[ref]/functions/[id]/index.tsx @@ -0,0 +1,213 @@ +import { observer } from 'mobx-react-lite' +import { withAuth } from 'hooks' + +import useSWR from 'swr' +import dayjs from 'dayjs' + +import { useState } from 'react' +import { useRouter } from 'next/router' +import { Button } from '@supabase/ui' + +import Panel from 'components/to-be-cleaned/Panel' +import ChartHandler from 'components/to-be-cleaned/Charts/ChartHandler' + +import { get } from 'lib/common/fetch' +import { API_URL, DATE_FORMAT } from 'lib/constants' + +import FunctionLayout from '../../../../../components/interfaces/functions/FunctionLayout' + +const CHART_INTERVALS = [ + { + key: '5min', + label: '5 min', + startValue: 1, + startUnit: 'hour', + format: 'MMM D, h:mma', + }, + { key: '15min', label: '15 min', startValue: 24, startUnit: 'hour', format: 'MMM D, ha' }, + { key: '1hr', label: '1 hour', startValue: 7, startUnit: 'day', format: 'MMM D' }, + { key: '1day', label: '1 day', startValue: 7, startUnit: 'day', format: 'MMM D' }, + { key: '7day', label: '7 days', startValue: 7, startUnit: 'day', format: 'MMM D' }, +] + +function calculateHighlightedValue(array: any, attribute: string, options?: { sum: boolean }) { + if (!array) return '' + + var total = 0 + var count = 0 + + array.forEach(function (item: any, index: number) { + const _item = item[attribute] + total += _item + count++ + }) + + if (options?.sum) { + return total + } else { + return total / count + } +} + +const PageLayout = () => { + const [interval, setInterval] = useState('15min') + const router = useRouter() + const { ref, id } = router.query + + const url = `${API_URL}/projects/${ref}/analytics/endpoints/functions.inv-stats` + + const selectedInterval = CHART_INTERVALS.find((i) => i.key === interval) || CHART_INTERVALS[1] + + const { data, error }: any = useSWR( + `${url}?interval=${selectedInterval.key}&function_id=${id}`, + get + ) + + const startDate = dayjs() + .subtract(selectedInterval.startValue, selectedInterval.startUnit) + .format(DATE_FORMAT) + + const endDate = dayjs().format(DATE_FORMAT) + + const charts: any = {} + + charts.data = data?.result + + const datetimeFormat = selectedInterval.format || 'MMM D, ha' + + const handleBarClick = (v: any, search: string) => { + if (!v || !v.activePayload?.[0]?.payload) return + // returns rechart internal tooltip data type + const payload = v.activePayload[0].payload + const timestamp = payload.timestamp + const timestampDigits = String(timestamp).length + if (timestampDigits < 16) { + // pad unix timestamp with additional 0 and then forward + const paddedTimestamp = String(timestamp) + '0'.repeat(16 - timestampDigits) + router.push(`/project/${ref}/functions/${id}/logs?te=${paddedTimestamp}`) + } else { + router.push(`/project/${ref}/functions/${id}/logs?te=${timestamp}`) + } + } + + return ( + +
+
+
+ {CHART_INTERVALS.map((item, i) => { + const classes = [] + + if (i === 0) { + classes.push('rounded-tr-none rounded-br-none') + } else if (i === CHART_INTERVALS.length - 1) { + classes.push('rounded-tl-none rounded-bl-none') + } else { + classes.push('rounded-none') + } + + return ( + + ) + })} +
+ + + Statistics for past {selectedInterval.label} + +
+
+ {startDate && endDate && ( + <> +
+ + + {/* + // @ts-ignore */} + handleBarClick(v, '/rest')} + /> + + + + + {/* + // @ts-ignore */} + handleBarClick(v, '/auth')} + /> + + +
+ + + {/* + // @ts-ignore */} + handleBarClick(v, '/auth')} + /> + + + + )} +
+
+
+ ) +} + +export default withAuth(observer(PageLayout)) diff --git a/studio/pages/project/[ref]/functions/[id]/invocations.tsx b/studio/pages/project/[ref]/functions/[id]/invocations.tsx new file mode 100644 index 00000000000..b3603421de8 --- /dev/null +++ b/studio/pages/project/[ref]/functions/[id]/invocations.tsx @@ -0,0 +1,32 @@ +import React from 'react' +import { NextPage } from 'next' +import { useRouter } from 'next/router' +import { observer } from 'mobx-react-lite' +import { withAuth } from 'hooks' +import { LOG_TYPE_LABEL_MAPPING } from 'components/interfaces/Settings/Logs' +import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer' +import FunctionLayout from '../../../../../components/interfaces/functions/FunctionLayout' + +/** + * Placeholder page for logs previewers until we figure out where to slot them + */ +export const LogPage: NextPage = () => { + const router = useRouter() + const { ref, type, id } = router.query + + const title = `Invocation Logs - ${ + LOG_TYPE_LABEL_MAPPING[type as keyof typeof LOG_TYPE_LABEL_MAPPING] + }` + + return ( + + + + ) +} + +export default withAuth(observer(LogPage)) diff --git a/studio/pages/project/[ref]/functions/[id]/logs.tsx b/studio/pages/project/[ref]/functions/[id]/logs.tsx new file mode 100644 index 00000000000..b553e520703 --- /dev/null +++ b/studio/pages/project/[ref]/functions/[id]/logs.tsx @@ -0,0 +1,32 @@ +import React from 'react' +import { NextPage } from 'next' +import { useRouter } from 'next/router' +import { observer } from 'mobx-react-lite' +import { withAuth } from 'hooks' +import { LOG_TYPE_LABEL_MAPPING } from 'components/interfaces/Settings/Logs' +import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer' +import FunctionLayout from '../../../../../components/interfaces/functions/FunctionLayout' + +/** + * Placeholder page for logs previewers until we figure out where to slot them + */ +export const LogPage: NextPage = () => { + const router = useRouter() + const { ref, type, id } = router.query + + const title = `Function Logs - ${ + LOG_TYPE_LABEL_MAPPING[type as keyof typeof LOG_TYPE_LABEL_MAPPING] + }` + + return ( + + + + ) +} + +export default withAuth(observer(LogPage)) diff --git a/studio/pages/project/[ref]/functions/[id]/triggers.tsx b/studio/pages/project/[ref]/functions/[id]/triggers.tsx new file mode 100644 index 00000000000..feb8d9299c8 --- /dev/null +++ b/studio/pages/project/[ref]/functions/[id]/triggers.tsx @@ -0,0 +1,10 @@ +import { observer } from 'mobx-react-lite' +import { withAuth } from 'hooks' + +import FunctionLayout from '../../../../../components/interfaces/functions/FunctionLayout' + +const PageLayout = () => { + return Triggers +} + +export default withAuth(observer(PageLayout)) diff --git a/studio/pages/project/[ref]/functions/index.tsx b/studio/pages/project/[ref]/functions/index.tsx new file mode 100644 index 00000000000..6312801a979 --- /dev/null +++ b/studio/pages/project/[ref]/functions/index.tsx @@ -0,0 +1,295 @@ +import { observer } from 'mobx-react-lite' +import { useStore, withAuth } from 'hooks' + +import FunctionsLayout from '../../../../components/interfaces/functions/FunctionsLayout' +import { Button, IconBookOpen, IconSearch, IconTerminal, Input, Loading } from '@supabase/ui' +import { useEffect } from 'react' +import Table from 'components/to-be-cleaned/Table' + +import FunctionsListItem from '../../../../components/interfaces/functions/FunctionsListItem' +import { useRouter } from 'next/router' + +import { post } from 'lib/common/fetch' +import { API_URL, PROJECT_STATUS } from 'lib/constants' + +import CommandRender from '../../../../components/interfaces/functions/CommandRender' +import Link from 'next/link' + +import { Function } from '../../../../components/interfaces/functions/Functions.types' + +const EmptyFunctions = () => { + const router = useRouter() + const { ref } = router.query + + interface Commands { + command: string + description: string + jsx?: () => void + comment?: string + } + const commands: Commands[] = [ + { + command: 'supabase init ', + description: "Needed if the project hasen't been inited before", + jsx: () => { + return ( + <> + supabase init + + ) + }, + comment: 'Initialize Supabase locally', + }, + { + command: 'supabase login', + description: "Writes the user's API token to disk", + jsx: () => { + return ( + <> + supabase login + + ) + }, + comment: 'Login using your access token', + }, + { + command: `supabase link --ref ${ref}`, + description: `Sets the Supabase project to be associated with your project ${ref}`, + jsx: () => { + return ( + <> + supabase link --ref {ref} + + ) + }, + comment: 'Link this project', + }, + { + command: 'supabase functions new hello', + description: ' creates a function stub at ./functions/hello.ts', + jsx: () => { + return ( + <> + supabase functions new hello + + ) + }, + comment: 'Create a function', + }, + { + command: 'supabase functions deploy hello', + description: 'Deploys function at ./functions/hello.ts', + jsx: () => { + return ( + <> + supabase functions deploy hello + + ) + }, + comment: 'Deploy your function', + }, + { + command: `curl -L -X POST 'https://${ref}.functions.supabase.co/hello' -H 'Authorization: Bearer [YOUR ANON KEY]'`, + description: 'Deploys function at ./functions/hello.ts', + jsx: () => { + return ( + <> + curl -L -X POST 'https://{ref} + .functions.supabase.co/hello' -H 'Authorization: Bearer [YOUR ANON KEY]' + + ) + }, + comment: 'Deploy your function', + }, + ] + return ( + <> +
+
+

+ Scalable pay-as-you-go functions as a service (FaaS) to run your code with zero server + management. +

+

+ No servers to provision, manage, or upgrade Automatically scale based on the load + Integrated monitoring, logging, and debugging capability Built-in security at role and + per function level based on the principle of least privilege Key networking capabilities + for hybrid and multi-cloud scenarios +

+
+ + + +
+
+
+
+
+ +
+

Terminal instructions

+
+
+ +
+
+
+ + ) +} + +const FunctionsList = ({ functions }: { functions: Function[] }) => { + return ( + <> +
+
+ {`${functions.length} function${ + functions.length > 1 ? 's' : '' + } deployed`} + } size="tiny" /> +
+
+ + Name + Trigger + Status + Created + Last updated + Version + + } + body={ + <> + {functions.length > 0 && + functions.map((item: any) => )} + + } + /> + + + + {/*
+

Function examples

+
+
+
+ +
+
+

Stripe payments

+

+ Charge a payment credit card when a database event occurs +

+
+
+
+
+ +
+
+

Stripe payments

+

+ Charge a payment credit card when a database event occurs +

+
+
+
+
+ +
+
+

Stripe payments

+

+ Charge a payment credit card when a database event occurs +

+
+
+
+
*/} + + ) +} + +const PageLayout = () => { + const router = useRouter() + const { ui, functions } = useStore() + + const project = ui.selectedProject + + useEffect(() => { + if (project && project.status === PROJECT_STATUS.INACTIVE) { + post(`${API_URL}/projects/${ui.selectedProject}/restore`, {}) + } + }, [project]) + + useEffect(() => { + functions.load() + }, [ui.selectedProject]) + + if (functions.isLoading) + return ( + + loading + + ) + + if (functions.list().length <= 0) { + // if (true) { + return ( + + + + ) + } else { + return ( + + + + ) + } +} + +export default withAuth(observer(PageLayout)) diff --git a/studio/pages/project/[ref]/logs-explorer/index.tsx b/studio/pages/project/[ref]/logs-explorer/index.tsx new file mode 100644 index 00000000000..c8d52f8f36c --- /dev/null +++ b/studio/pages/project/[ref]/logs-explorer/index.tsx @@ -0,0 +1,190 @@ +import React, { useEffect, useState } from 'react' +import { NextPage } from 'next' +import { useRouter } from 'next/router' +import { observer } from 'mobx-react-lite' +import { Typography, IconAlertCircle, Card, Input, Alert, Modal, Form, Button } from '@supabase/ui' +import { useStore, withAuth } from 'hooks' +import CodeEditor from 'components/ui/CodeEditor' +import { + LogsQueryPanel, + LogsTableName, + LogTable, + LogTemplate, + TEMPLATES, +} from 'components/interfaces/Settings/Logs' +import { uuidv4 } from 'lib/helpers' +import useLogsQuery from 'hooks/analytics/useLogsQuery' +import { LogsExplorerLayout } from 'components/layouts' +import ShimmerLine from 'components/ui/ShimmerLine' +import LoadingOpacity from 'components/ui/LoadingOpacity' +import { LogSqlSnippets, UserContent } from 'types' +import toast from 'react-hot-toast' + +export const LogsExplorerPage: NextPage = () => { + const router = useRouter() + const { ref, q } = router.query + const [editorId, setEditorId] = useState(uuidv4()) + const [editorValue, setEditorValue] = useState('') + const [saveModalOpen, setSaveModalOpen] = useState(false) + + const { content } = useStore() + + const [{ logData, error, isLoading }, { changeQuery, runQuery }] = useLogsQuery(ref as string) + + useEffect(() => { + // on mount, set initial values + if (q !== undefined && q !== '') { + changeQuery(q as string) + runQuery() + onSelectTemplate({ + mode: 'custom', + searchString: q as string, + }) + } + }, [q]) + + const onSelectTemplate = (template: LogTemplate) => { + setEditorValue(template.searchString) + changeQuery(template.searchString) + setEditorId(uuidv4()) + runQuery() + } + + const handleRun = () => { + changeQuery(editorValue) + runQuery() + router.push({ + pathname: router.pathname, + query: { ...router.query, q: editorValue }, + }) + } + + const handleClear = () => { + setEditorValue('') + setEditorId(uuidv4()) + changeQuery('') + } + + const handleInsertSource = (source: LogsTableName) => { + setEditorValue((prev) => prev + source) + setEditorId(uuidv4()) + } + + function handleOnSave() { + setSaveModalOpen(!saveModalOpen) + } + + return ( + +
+
+ template.mode === 'custom')} + onSelectTemplate={onSelectTemplate} + onSave={handleOnSave} + isLoading={isLoading} + /> + +
+ + setEditorValue(v || '')} + onInputRun={handleRun} + /> +
+
+
+ +
+ +
+
+
+
+ setSaveModalOpen(!saveModalOpen)} + header="Save log query" + visible={saveModalOpen} + hideFooter + > +
{ + setSubmitting(true) + + const payload: UserContent = { + name: values.name, + description: values.name, + type: 'log_sql', + content: { + content_id: editorId, + sql: editorValue, + schema_version: '1', + favorite: false, + }, + visibility: 'user', + } + + try { + const { data: query, error } = await content.create(payload) + if (error) throw error + console.log(query) + setSubmitting(false) + setSaveModalOpen(false) + toast.success(`Saved "${values.name}" log query`) + } catch (error: any) { + console.error(error) + setSubmitting(false) + setSaveModalOpen(false) + toast.error(error.message) + } + }} + > + {({ isSubmitting }: any) => ( + <> +
+ +
+ + +
+
+
+
+ +
+ + +
+
+
+ + )} + +
+
+ ) +} + +export default withAuth(observer(LogsExplorerPage)) diff --git a/studio/pages/project/[ref]/logs-explorer/saved.tsx b/studio/pages/project/[ref]/logs-explorer/saved.tsx new file mode 100644 index 00000000000..074b4779e37 --- /dev/null +++ b/studio/pages/project/[ref]/logs-explorer/saved.tsx @@ -0,0 +1,73 @@ +import React, { useEffect } from 'react' +import { NextPage } from 'next' +import Link from 'next/link' +import { observer } from 'mobx-react-lite' +import { IconSave, Loading } from '@supabase/ui' +import { useStore, withAuth } from 'hooks' +import { LogsSavedQueriesItem } from 'components/interfaces/Settings/Logs' +import LogsExplorerLayout from 'components/layouts/LogsExplorerLayout/LogsExplorerLayout' + +import Table from 'components/to-be-cleaned/Table' +import { useRouter } from 'next/router' + +export const LogsExplorerPage: NextPage = () => { + const { content, ui } = useStore() + const router = useRouter() + const { ref } = router.query + + useEffect(() => { + content.load() + }, [ui.selectedProject]) + + if (content.isLoaded) + return ( + + loading + + ) + + const saved = content.list() + + console.log('saved in parent', saved.length) + return ( + +
+ {saved[0].length > 0 && ( +
+ Name + Description + Created + Last updated + + + } + body={ + saved.length > 0 && + saved[0].map((item: any) => ) + } + /> + )} + + {saved[0].length === 0 && ( + <> +
+ +

No Saved Queries Yet

+

+ Saved queries will appear here. Queries can be saved from the{' '} + + Query + {' '} + tab. +

+
+ + )} + + ) +} + +export default withAuth(observer(LogsExplorerPage)) diff --git a/studio/pages/project/[ref]/logs-explorer/sources.tsx b/studio/pages/project/[ref]/logs-explorer/sources.tsx new file mode 100644 index 00000000000..a65cac6f0a4 --- /dev/null +++ b/studio/pages/project/[ref]/logs-explorer/sources.tsx @@ -0,0 +1,49 @@ +import React from 'react' +import { NextPage } from 'next' +import { useRouter } from 'next/router' +import { observer } from 'mobx-react-lite' +import { IconCode, Badge, IconDatabase, Icon, IconCloud } from '@supabase/ui' +import { withAuth } from 'hooks' +import { LogsTableName, LOGS_SOURCE_DESCRIPTION } from 'components/interfaces/Settings/Logs' +import LogsExplorerLayout from 'components/layouts/LogsExplorerLayout/LogsExplorerLayout' +import CardButton from 'components/ui/CardButton' + +export const LogsExplorerPage: NextPage = () => { + const router = useRouter() + const { ref } = router.query + // @ts-ignore + const sources = Object.values(LogsTableName).sort((a, b) => a - b) + return ( + +
+ {sources.map((source) => ( + +
+ {source === LogsTableName.POSTGRES && } + {source === LogsTableName.EDGE && } + {source === LogsTableName.FUNCTIONS && } + {source === LogsTableName.FN_EDGE && } +
+
+ } + linkHref={`/project/${ref}/logs-explorer/sources/${source}`} + description={LOGS_SOURCE_DESCRIPTION[source]} + /> + ))} + +
+ ) +} + +export default withAuth(observer(LogsExplorerPage)) diff --git a/studio/pages/project/[ref]/logs-explorer/sources/[source].tsx b/studio/pages/project/[ref]/logs-explorer/sources/[source].tsx new file mode 100644 index 00000000000..6ef025b4251 --- /dev/null +++ b/studio/pages/project/[ref]/logs-explorer/sources/[source].tsx @@ -0,0 +1,35 @@ +import React from 'react' +import { NextPage } from 'next' +import { useRouter } from 'next/router' +import { observer } from 'mobx-react-lite' +import { withAuth } from 'hooks' +import { LogsExplorerLayout } from 'components/layouts/' +import { LogsTableName } from 'components/interfaces/Settings/Logs' +import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer' + +export const LogPage: NextPage = () => { + const router = useRouter() + const { ref, source } = router.query + + // quick fix to get correct queryType + const queryType = { + edge_logs: 'api', + postgres_logs: 'database', + function_logs: 'functions', + function_edge_logs: 'fn_edge', + } + + return ( + {source}}> + + + ) +} + +export default withAuth(observer(LogPage)) diff --git a/studio/pages/project/[ref]/logs-explorer/templates.tsx b/studio/pages/project/[ref]/logs-explorer/templates.tsx new file mode 100644 index 00000000000..2801b2e38d3 --- /dev/null +++ b/studio/pages/project/[ref]/logs-explorer/templates.tsx @@ -0,0 +1,77 @@ +import React, { useState } from 'react' +import { NextPage } from 'next' +import { useRouter } from 'next/router' +import { observer, useStaticRendering } from 'mobx-react-lite' +import { IconCode, Badge, Collapsible, Button, Popover } from '@supabase/ui' +import { withAuth } from 'hooks' +import { TEMPLATES } from 'components/interfaces/Settings/Logs' +import LogsExplorerLayout from 'components/layouts/LogsExplorerLayout/LogsExplorerLayout' +import CardButton from 'components/ui/CardButton' + +export const LogsExplorerPage: NextPage = () => { + const router = useRouter() + const { ref } = router.query + + return ( + +
+
+ {TEMPLATES.filter((template) => template.mode === 'custom').map((template) => { + const [showPreview, setShowPreview] = useState(false) + return ( + +
+ +
+
+ } + containerHeightClassName="h-40" + linkHref={`/project/${ref}/logs-explorer?q=${encodeURI(template.searchString)}`} + description={template.description} + footer={ +
+ + {template.searchString} + + } + > + + +
+ } + /> + ) + })} +
+ +
+ ) +} + +export default withAuth(observer(LogsExplorerPage)) diff --git a/studio/pages/project/[ref]/settings/logs/[type].tsx b/studio/pages/project/[ref]/settings/logs/[type].tsx deleted file mode 100644 index 73320beb67d..00000000000 --- a/studio/pages/project/[ref]/settings/logs/[type].tsx +++ /dev/null @@ -1,209 +0,0 @@ -import useSWR from 'swr' -import React, { useEffect, useState } from 'react' -import { NextPage } from 'next' -import { useRouter } from 'next/router' -import { observer } from 'mobx-react-lite' -import { - Typography, - IconLoader, - IconAlertCircle, - IconRewind, - Button, - IconInfo, - Card, - Input, -} from '@supabase/ui' - -import { withAuth } from 'hooks' -import { get } from 'lib/common/fetch' -import { API_URL } from 'lib/constants' -import { SettingsLayout } from 'components/layouts/' -import CodeEditor from 'components/ui/CodeEditor' -import { - LogPanel, - LogTable, - LogEventChart, - Count, - Logs, - LogTemplate, - TEMPLATES, - LogData, - LogSearchCallback, - LOG_TYPE_LABEL_MAPPING, - genDefaultQuery, - genCountQuery, - LogsTableName, -} from 'components/interfaces/Settings/Logs' -import { uuidv4 } from 'lib/helpers' -import useSWRInfinite, { SWRInfiniteKeyLoader } from 'swr/infinite' -import { isUndefined } from 'lodash' -import dayjs from 'dayjs' -import InformationBox from 'components/ui/InformationBox' -import useLogsPreview from 'hooks/analytics/useLogsPreview' - -/** - * Acts as a container component for the entire log display - * - * ## Query Params Syncing - * Query params are synced on query submission. - * - * params used are: - * - `q` for the editor query. - * - `s` for search query. - * - `te` for timestamp start value. - */ -export const LogPage: NextPage = () => { - const router = useRouter() - const { ref, type, s, te, ts } = router.query - const [showChart, setShowChart] = useState(true) - const table = type === 'api' ? LogsTableName.EDGE : LogsTableName.POSTGRES - - const [ - { error, logData, params, newCount, filters, isLoading, oldestTimestamp }, - { loadOlder, setFilters, refresh, setTo, setFrom }, - ] = useLogsPreview(ref as string, table, { - initialFilters: { search_query: s as string }, - whereStatementFactory: (filterObj) => - `${ - filterObj.search_query ? `REGEXP_CONTAINS(event_message, '${filterObj.search_query}')` : '' - }`, - }) - - const title = `Logs - ${LOG_TYPE_LABEL_MAPPING[type as keyof typeof LOG_TYPE_LABEL_MAPPING]}` - - useEffect(() => { - if (filters.search_query !== s) { - setFilters((prev) => ({ ...prev, search_query: s as string })) - } - if (te !== params.timestamp_end) { - setTo(te as string) - } - if (ts !== params.timestamp_start) { - setFrom(ts as string) - } - }, [s, te, ts]) - - const onSelectTemplate = (template: LogTemplate) => { - setFilters((prev) => ({ ...prev, search_query: template.searchString })) - } - - const handleRefresh = () => { - refresh() - router.push({ - pathname: router.pathname, - query: { - ...router.query, - te: undefined, - ts: undefined, - }, - }) - } - - const handleSearch: LogSearchCallback = ({ query, to, from, fromMicro, toMicro }) => { - let toValue, fromValue - if (to || toMicro) { - toValue = toMicro ? toMicro : dayjs(to).valueOf() * 1000 - - setTo(String(toValue)) - } - if (from || fromMicro) { - fromValue = fromMicro ? fromMicro : dayjs(from).valueOf() * 1000 - setFrom(String(fromValue)) - } - setFilters((prev) => ({ ...prev, search_query: query || '' })) - - router.push({ - pathname: router.pathname, - query: { - ...router.query, - s: query || '', - ts: fromValue, - te: toValue, - }, - }) - } - - return ( - -
- setShowChart(!showChart)} - isCustomQuery={false} - isLoading={isLoading} - newCount={newCount} - templates={TEMPLATES.filter( - (template) => template.for?.includes(type as string) && template.mode === 'simple' - )} - onRefresh={handleRefresh} - onSearch={handleSearch} - defaultSearchValue={filters.search_query} - defaultToValue={ - params.timestamp_end ? dayjs(Number(params.timestamp_end) / 1000).toISOString() : '' - } - defaultFromValue={ - params.timestamp_start - ? dayjs(Number(params.timestamp_start) / 1000).toISOString() - : oldestTimestamp - ? dayjs(Number(oldestTimestamp) / 1000).toISOString() - : '' - } - onCustomClick={() => { - router.push(`/project/${ref}/settings/logs/explorer?q=${params.rawSql}`) - }} - onSelectTemplate={onSelectTemplate} - /> - {showChart && ( -
- { - handleSearch({ query: filters.search_query, toMicro: timestampMicro }) - }} - /> -
- )} -
- {isLoading && ( -
- -
- )} - - -
- -
- - {error && ( -
- -
- - - Sorry! An error occured when fetching data. - -
- -
-
- )} -
-
-
- ) -} - -export default withAuth(observer(LogPage)) diff --git a/studio/pages/project/[ref]/settings/logs/explorer.tsx b/studio/pages/project/[ref]/settings/logs/explorer.tsx deleted file mode 100644 index e4f030d86a9..00000000000 --- a/studio/pages/project/[ref]/settings/logs/explorer.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import useSWR from 'swr' -import React, { useEffect, useState } from 'react' -import { NextPage } from 'next' -import { useRouter } from 'next/router' -import { observer } from 'mobx-react-lite' -import { - Typography, - IconLoader, - IconAlertCircle, - IconRewind, - Button, - IconInfo, - Card, - Input, -} from '@supabase/ui' - -import { withAuth } from 'hooks' -import { get } from 'lib/common/fetch' -import { API_URL } from 'lib/constants' -import { SettingsLayout } from 'components/layouts/' -import CodeEditor from 'components/ui/CodeEditor' -import { - LogPanel, - LogTable, - LogEventChart, - Count, - Logs, - LogTemplate, - TEMPLATES, - LogData, - LogSearchCallback, - LOG_TYPE_LABEL_MAPPING, - genDefaultQuery, - genCountQuery, - LogsTableName, -} from 'components/interfaces/Settings/Logs' -import { uuidv4 } from 'lib/helpers' -import useSWRInfinite, { SWRInfiniteKeyLoader } from 'swr/infinite' -import { isUndefined } from 'lodash' -import dayjs from 'dayjs' -import InformationBox from 'components/ui/InformationBox' -import useLogsPreview from 'hooks/analytics/useLogsPreview' -import useLogsQuery from 'hooks/analytics/useLogsQuery' - -/** - * Acts as a container component for the entire log display - * - * ## Query Params Syncing - * Query params are synced on query submission. - * - * params used are: - * - `q` for the editor query. - * - `s` for search query. - * - `te` for timestamp start value. - */ -export const LogsExplorerPage: NextPage = () => { - const router = useRouter() - const { ref, type, q } = router.query - const [editorId, setEditorId] = useState(uuidv4()) - const [editorValue, setEditorValue] = useState('') - const title = `Logs - All` - const [{ logData, params, error, isLoading }, { changeQuery, runQuery }] = useLogsQuery( - ref as string - ) - useEffect(() => { - // on mount, set initial values - if (q !== undefined && q !== '') { - changeQuery(q as string) - runQuery() - onSelectTemplate({ - mode: 'custom', - searchString: q as string, - }) - } - }, [q]) - - const onSelectTemplate = (template: LogTemplate) => { - setEditorValue(template.searchString) - changeQuery(template.searchString) - setEditorId(uuidv4()) - runQuery() - } - - const handleEditorSubmit = () => { - changeQuery(editorValue) - runQuery() - router.push({ - pathname: router.pathname, - query: { ...router.query, q: editorValue }, - }) - } - - return ( - -
- null} - isCustomQuery={true} - isLoading={isLoading} - newCount={0} - templates={TEMPLATES.filter((template) => template.mode === 'custom')} - onRefresh={() => null} - onSearch={() => null} - defaultSearchValue={''} - defaultToValue={ - params.timestamp_end - ? dayjs(Number(params.timestamp_end) / 1000).toISOString() - : params.timestamp_end - } - onCustomClick={() => null} - onSelectTemplate={onSelectTemplate} - /> - -
- setEditorValue(v || '')} - onInputRun={handleEditorSubmit} - /> -
-
-
- - -
-
-
-
- {isLoading && ( -
- -
- )} - - - - {error && ( -
- -
- - - Sorry! An error occured when fetching data. - -
- -
-
- )} -
-
-
- ) -} - -export default withAuth(observer(LogsExplorerPage)) diff --git a/studio/stores/RootStore.ts b/studio/stores/RootStore.ts index 57cc645b3af..3b9386fab4d 100644 --- a/studio/stores/RootStore.ts +++ b/studio/stores/RootStore.ts @@ -3,6 +3,7 @@ import AppStore, { IAppStore } from './app/AppStore' import MetaStore, { IMetaStore } from './pgmeta/MetaStore' import UiStore, { IUiStore } from './UiStore' import ProjectContentStore, { IProjectContentStore } from './content/ProjectContentStore' +import ProjectFunctionsStore, { IProjectFunctionsStore } from './functions/ProjectFunctionsStore' // Temporary disable mobx warnings // TODO: need to remove this after refactoring old stores. @@ -13,6 +14,7 @@ configure({ export interface IRootStore { ui: IUiStore content: IProjectContentStore + functions: IProjectFunctionsStore meta: IMetaStore app: IAppStore setProjectRef: (value?: string) => void @@ -21,12 +23,15 @@ export interface IRootStore { export class RootStore implements IRootStore { ui: IUiStore content: IProjectContentStore + functions: IProjectFunctionsStore meta: IMetaStore app: IAppStore constructor() { this.ui = new UiStore(this) + // @ts-ignore this.content = new ProjectContentStore(this, { projectRef: '' }) + this.functions = new ProjectFunctionsStore(this, { projectRef: '' }) this.meta = new MetaStore(this, { projectRef: '', connectionString: '', @@ -45,13 +50,17 @@ export class RootStore implements IRootStore { () => this.ui.selectedProject, (selectedProject) => { if (selectedProject) { + // @ts-ignore this.content = new ProjectContentStore(this, { projectRef: selectedProject.ref }) + this.functions = new ProjectFunctionsStore(this, { projectRef: selectedProject.ref }) this.meta = new MetaStore(this, { projectRef: selectedProject.ref, connectionString: selectedProject.connectionString ?? '', }) } else { + // @ts-ignore this.content = new ProjectContentStore(this, { projectRef: '' }) + this.functions = new ProjectFunctionsStore(this, { projectRef: '' }) this.meta = new MetaStore(this, { projectRef: '', connectionString: '', diff --git a/studio/stores/content/ProjectContentStore.ts b/studio/stores/content/ProjectContentStore.ts index 4dd3e2c00ea..05318375f6e 100644 --- a/studio/stores/content/ProjectContentStore.ts +++ b/studio/stores/content/ProjectContentStore.ts @@ -12,9 +12,11 @@ import { API_URL } from 'lib/constants' export interface IProjectContentStore { isLoading: boolean isInitialized: boolean + isLoaded: boolean error: any load: () => void + create: (x: UserContent) => { data: UserContent; error: { error: { message: string } } } list: (filter?: any) => any[] reports: (filter?: any) => any[] sqlSnippets: (filter?: any) => any[] @@ -51,6 +53,10 @@ export default class ProjectContentStore implements IProjectContentStore { return this.state !== this.STATES.INITIAL } + get isLoaded() { + return this.state !== this.STATES.LOADED + } + async fetchData() { const headers = { 'Content-Type': 'application/json', @@ -110,6 +116,7 @@ export default class ProjectContentStore implements IProjectContentStore { } sqlSnippets(filter?: any) { + console.log('i ran') let arr = Object.values(this.data) if (!!filter) { const arr_filtered = arr @@ -127,6 +134,7 @@ export default class ProjectContentStore implements IProjectContentStore { } } + // @ts-ignore async create(payload: any) { try { const headers = { diff --git a/studio/stores/functions/ProjectFunctionsStore.ts b/studio/stores/functions/ProjectFunctionsStore.ts new file mode 100644 index 00000000000..d848c7ce111 --- /dev/null +++ b/studio/stores/functions/ProjectFunctionsStore.ts @@ -0,0 +1,192 @@ +import { makeAutoObservable, toJS } from 'mobx' +import { keyBy } from 'lodash' + +import { get, post, patch, delete_ } from 'lib/common/fetch' +import { UserContent, UserContentMap } from 'types' +import { IRootStore } from '../RootStore' + +// [Joshen] This will be the new ProjectContentStore +// but use the one under the stores folder projectContentStore first while we transition + +export interface IProjectFunctionsStore { + isLoading: boolean + isInitialized: boolean + isLoaded: boolean + error: any + + load: () => void + list: (filter?: any) => any[] + byId: (id?: any) => any + reports: (filter?: any) => any[] + sqlSnippets: (filter?: any) => any[] +} + +export default class ProjectFunctionsStore implements IProjectFunctionsStore { + rootStore: IRootStore + + STATES = { + INITIAL: 'initial', + LOADING: 'loading', + ERROR: 'error', + LOADED: 'loaded', + } + + baseUrl: string + // @ts-ignore + data: UserContentMap = [] + + state = this.STATES.INITIAL + error = null + + constructor(rootStore: IRootStore, options: { projectRef: string }) { + const { projectRef } = options + this.rootStore = rootStore + this.baseUrl = `${process.env.NEXT_PUBLIC_API_ADMIN_URL}/projects/${projectRef}/functions` + makeAutoObservable(this) + } + + get isLoading() { + return this.state === this.STATES.INITIAL || this.state === this.STATES.LOADING + } + + get isInitialized() { + return this.state !== this.STATES.INITIAL + } + + get isLoaded() { + return this.state !== this.STATES.LOADED + } + + async fetchData() { + const headers = { + 'Content-Type': 'application/json', + } + const response = await get(this.baseUrl, { headers }) + if (response.error) { + throw response.error + } + this.data = keyBy(response, 'id') + return response + } + + async load() { + let { LOADING, ERROR, LOADED } = this.STATES + try { + this.error = null + this.state = LOADING + await this.fetchData() + this.state = LOADED + return this.data + } catch (e: any) { + console.error('Failed to load project content', e.message) + this.error = e + this.state = ERROR + } + } + + async loadPersistentData() {} + + async loadRemotePersistentData(userId: any) { + const sqlSnippets = this.sqlSnippets((x: any) => x.owner_id === userId) + } + + list(filter?: any) { + const arr = Object.values(this.data) + if (!!filter) { + return arr.filter(filter).sort((a, b) => a.name.localeCompare(b.name)) + } else { + return arr.sort((a, b) => a.name.localeCompare(b.name)) + } + } + + byId(id: any) { + return toJS(this.data)[id] + } + + reports(filter?: any) { + let arr = Object.values(this.data) + if (!!filter) { + return arr + .filter((x) => x.type == 'report') + .filter(filter) + .sort((a, b) => a.name.localeCompare(b.name)) + } else { + return arr.filter((x) => x.type == 'report').sort((a, b) => a.name.localeCompare(b.name)) + } + } + + sqlSnippets(filter?: any) { + let arr = Object.values(this.data) + if (!!filter) { + const arr_filtered = arr + .filter((x) => x.type == 'sql') + .filter(filter) + .sort((a, b) => a.name.localeCompare(b.name)) + + return arr_filtered + } else { + const arr_filtered = arr + .filter((x) => x.type == 'sql') + .sort((a, b) => a.name.localeCompare(b.name)) + + return arr_filtered + } + } + + async create(payload: any) { + try { + const headers = { + 'Content-Type': 'application/json', + } + const created = await post(this.baseUrl, payload, { headers }) + if (created.error) throw created.error + this.data[created['id']] = created + return { data: created, error: null } + } catch (error) { + return { data: null, error } + } + } + + async update(id: any, updates: any, type: UserContent['type']) { + try { + const headers = { + 'Content-Type': 'application/json', + } + let payload = { + ...updates, + type, + id, + } + const url = `${this.baseUrl}?id=${id}` + const updated = await patch(url, payload, { headers }) + if (updated.error) throw updated.error + this.data[id] = updated + return { data: updated, error: null } + } catch (error) { + return { data: null, error } + } + } + + async updateSql(id: any, updates: any) { + return this.update(id, updates, 'sql') + } + + async updateReport(id: any, updates: any) { + return this.update(id, updates, 'report') + } + + async del(id: any) { + try { + const headers = { + 'Content-Type': 'application/json', + } + const url = `${this.baseUrl}?id=${id}` + const deleted = await delete_(url, {}, { headers }) + if (deleted.error) throw deleted.error + delete this.data[id] + return { data: true, error: null } + } catch (error) { + return { data: false, error } + } + } +} diff --git a/studio/styles/date-picker.scss b/studio/styles/date-picker.scss new file mode 100644 index 00000000000..90f96730ae2 --- /dev/null +++ b/studio/styles/date-picker.scss @@ -0,0 +1,101 @@ +/* // date picker */ + +.react-datepicker__input-container input { + @apply block w-full text-base md:text-sm bg-white border border-scale-300 rounded shadow-sm; +} + +.react-datepicker-popper { + @apply z-40 w-72 text-sm bg-scale-100 dark:bg-scale-300 shadow px-3 py-2 border-2 border-scale-200 rounded; +} + +.react-datepicker-left { + @apply absolute left-0 right-auto top-11 transform-none #{!important}; +} + +.react-datepicker-right { + @apply absolute right-0 left-auto top-11 transform-none #{!important}; +} + +.react-datepicker__portal { + @apply absolute z-10 w-72 text-sm transform-none bg-white shadow px-3 py-2 top-12 right-0 border-2 border-scale-200 rounded; +} + +.react-datepicker__month-container { + @apply flex flex-col; +} + +.react-datepicker__month { + @apply flex flex-col; + @apply -space-y-px; +} + +.react-datepicker__current-month { + @apply ml-2.5 text-lg font-semibold text-scale-800; +} + +.react-datepicker__week { + @apply flex -space-x-px; +} + +.react-datepicker__day-names { + @apply flex -space-x-px text-scale-900 font-normal text-center text-xs; +} + +.react-datepicker__day-name { + @apply w-10 h-10 flex items-center justify-center py-1 rounded-full; +} + +.react-datepicker__navigation { + @apply absolute top-2; +} + +.react-datepicker__navigation--previous { + @apply right-12 w-10 h-10 rounded transition flex items-center justify-center hover:bg-scale-200; +} + +.react-datepicker__navigation--next { + @apply right-4 w-10 h-10 rounded transition flex items-center justify-center hover:bg-scale-200; +} + +.react-datepicker__day { + @apply w-10 h-10 flex items-center font-normal justify-center text-sm leading-loose transition text-scale-1100; + /* @apply mb-1 py-1; */ + @apply -space-y-px; + @apply border border-scale-500; +} + +.react-datepicker__day--disabled { + @apply cursor-not-allowed opacity-40 hover:bg-transparent; +} + +.react-datepicker__day--outside-month { + @apply text-scale-800; +} + +.react-datepicker__day--in-range { + @apply bg-brand-800 text-scale-fixed-100 border-brand-800; +} + +.react-datepicker__day--in-selecting-range { + @apply bg-brand-700 text-scale-fixed-100 border-brand-700; +} + +.react-datepicker__day--selecting-range-start { + @apply bg-scale-200; +} + +.react-datepicker__day--selecting-range-end { + @apply bg-scale-200; +} + +.react-datepicker__day--selected { + @apply bg-brand-800 text-white; +} + +.react-datepicker__day--range-start { + @apply bg-brand-900 text-white hover:text-scale-700 hover:bg-white; +} + +.react-datepicker__day--range-end { + @apply bg-brand-900 text-white hover:text-scale-700 hover:bg-white; +} diff --git a/studio/styles/main.scss b/studio/styles/main.scss index 88d9a1aaa7d..51f2c32137d 100644 --- a/studio/styles/main.scss +++ b/studio/styles/main.scss @@ -188,3 +188,18 @@ div[data-radix-portal]:not(.portal--toast) { div.fixed.inset-0 { z-index: 11 !important; } + +.recharts-tooltip-cursor { + @apply fill-transparent; +} + +.expandable-tr { + display: grid; + grid-template-columns: repeat(5, 1fr); +} + +.expanded-row-content { + display: grid; + grid-column: 1/-1; + justify-content: flex-start; +} diff --git a/studio/styles/react-data-grid-logs.scss b/studio/styles/react-data-grid-logs.scss new file mode 100644 index 00000000000..be6c4e5354a --- /dev/null +++ b/studio/styles/react-data-grid-logs.scss @@ -0,0 +1,95 @@ +.data-grid--simple-logs { + .rdg-cell, + .rdg-cell span { + @apply text-scale-1100 font-normal #{!important}; + border-left: none; + border-right: none; + } + + .rdg-cell:first-child { + @apply pl-5; + } + .rdg-cell:last-child { + @apply pr-5; + } + + .rdg-row { + &.rdg-row--focussed { + @apply border-r border-brand-900; + border-right-width: 4px; + } + + &.rdg-row--focussed .rdg-cell, + &.rdg-row--focussed .rdg-cell span { + @apply text-scale-1200 font-semibold #{!important}; + } + } +} + +.data-grid--logs-explorer { + @apply transition-all overflow-hidden overflow-x-scroll; + + .rdg-cell, + .rdg-cell span { + @apply text-scale-1100 font-normal #{!important}; + } + + .rdg-cell:first-child { + @apply pl-5; + } + .rdg-cell:last-child { + @apply pr-5; + } + + .rdg-row { + &.rdg-row--focussed { + @apply border-r border-brand-900; + border-right-width: 4px; + } + + &.rdg-row--focussed .rdg-cell, + &.rdg-row--focussed .rdg-cell span { + @apply text-scale-1200 font-semibold #{!important}; + } + } +} + +.syntax-highlight { + counter-reset: line-numbering; + font-family: Menlo, Monaco, monospace; + color: var(--colors-scale10); + word-break: break-word; +} + +.syntax-highlight .line::before { + content: counter(line-numbering); + counter-increment: line-numbering; + padding-right: 0px; + display: inline-block; + color: var(--colors-scale8); + /* space after numbers */ + width: 2rem; + text-align: left; +} + +.logs-shimmering-loader { + animation: shimmer 1.5s infinite linear; + background: linear-gradient( + to right, + var(--colors-scale2) 0%, + var(--colors-brand9) 25%, + var(--colors-brand4) 35%, + var(--colors-scale2) 45%, + var(--colors-scale3) 75% + ); + background-size: 3000px 100%; +} + +@keyframes logsShimmer { + 0% { + background-position: -1000px 0; + } + 50% { + background-position: 1000px 0; + } +} diff --git a/studio/tailwind.config.js b/studio/tailwind.config.js index d7ffd8567f9..41fcac0dfa1 100644 --- a/studio/tailwind.config.js +++ b/studio/tailwind.config.js @@ -77,7 +77,7 @@ module.exports = ui({ }), divideColor: (theme) => ({ ...theme('colors'), - DEFAULT: 'var(--colors-scale3)', + DEFAULT: 'var(--colors-scale6)', dark: 'var(--colors-scale2)', }), extend: { @@ -222,7 +222,7 @@ module.exports = ui({ dark: 'var(--colors-scale4)', // '#1a1a1a', }, 'table-border': { - light: 'var(--colors-scale4)', // gray[100], + light: 'var(--colors-scale5)', // gray[100], dark: 'var(--colors-scale4)', // '#1a1a1a', }, @@ -240,11 +240,11 @@ module.exports = ui({ dark: 'var(--colors-scale3)', }, 'panel-border': { - light: 'var(--colors-scale4)', + light: 'var(--colors-scale5)', dark: 'var(--colors-scale4)', }, 'panel-border-interior': { - light: 'var(--colors-scale2)', + light: 'var(--colors-scale4)', dark: 'var(--colors-scale4)', }, 'panel-border-hover': { diff --git a/studio/tests/pages/projects/LogPanel.test.js b/studio/tests/pages/projects/LogPanel.test.js deleted file mode 100644 index 251a0fc8678..00000000000 --- a/studio/tests/pages/projects/LogPanel.test.js +++ /dev/null @@ -1,140 +0,0 @@ -import LogPanel from 'components/interfaces/Settings/Logs/LogPanel' -import { render, waitFor, screen } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { getToggleByText } from 'tests/helpers' - -jest.mock('components/ui/Flag/Flag') -import Flag from 'components/ui/Flag/Flag' -Flag.mockImplementation(({ children }) => <>{children}) -jest.mock('hooks') -import { useFlag } from 'hooks' -useFlag.mockReturnValue(true) - -test('templates', async () => { - const mockFn = jest.fn() - render() - const search = screen.getByPlaceholderText(/Search/) - userEvent.type(search, '12345') - - // TODO templates dropdown interaction currently cannot be tested - // https://github.com/supabase/ui/issues/299 - // userEvent.click(screen.getByText("Templates")) - // screen.debug() - - // await waitFor(() => screen.getByText("Some option")) - // userEvent.click(screen.getByText("Some option")) - // expect(mockFn).toBeCalled() -}) -test('custom mode', async () => { - render() - await waitFor(() => { - expect(() => screen.getByPlaceholderText(/Search/)).toThrow() - }) -}) - -test('toggle event chart', async () => { - const mockFn = jest.fn() - const { rerender } = render() - const toggle = getToggleByText(/Show event chart/) - userEvent.click(toggle) - expect(mockFn).toBeCalled() - rerender() -}) - -test('filter input change and submit', async () => { - const mockFn = jest.fn() - render() - const search = screen.getByPlaceholderText(/Search/) - userEvent.type(search, '12345') - expect(mockFn).not.toBeCalled() - userEvent.click(screen.getByTitle('Go')) - expect(mockFn).toBeCalled() -}) - -test('filter input value', async () => { - render() - screen.getByDisplayValue('1234') -}) - -test('Manual refresh', async () => { - const mockFn = jest.fn() - render() - let btn - await waitFor(() => { - btn = screen.getByText(/Refresh/) - }) - userEvent.click(btn) - expect(mockFn).toBeCalled() -}) - -test('reset search filter', async () => { - const { rerender } = render() - expect(() => screen.getByTitle(/Clear search/)).toThrow() - - rerender() - await waitFor(() => screen.getByDisplayValue(/something123/)) - userEvent.click(screen.getByTitle(/Clear search/)) - expect(() => screen.getByTitle(/Clear search/)).toThrow() - expect(() => screen.getByDisplayValue(/something123/)).toThrow() -}) - -test('timestamp to/from filter default value', async () => { - render() - userEvent.click(await screen.findByText('Custom')) - await screen.findByDisplayValue('2022-01-18T10:43:39+0000') - // TODO: use screen.findByLabelText when https://github.com/supabase/ui/issues/310 is resolved - await screen.findByText('To') - await screen.findByText('From') -}) - -test('timestamp to/from filter error handling', async () => { - const mockFn = jest.fn() - render() - userEvent.click(await screen.findByText(/Now/)) - - // display iso timestamp - const year = new Date().getFullYear() - const inputs = await screen.findAllByDisplayValue(RegExp(year)) - expect(inputs.length).toBe(2) - const input = inputs[0] - userEvent.clear(input) - userEvent.type(input, '123456') - await screen.findByText(/[iI]nvalid ISO 8601 timestamp/) -}) - -test('timestamp to/from filter value change', async () => { - const mockFn = jest.fn() - render() - userEvent.click(await screen.findByText(/Now/)) - // display iso timestamp - const year = new Date().getFullYear() - const inputs = await screen.findAllByDisplayValue(RegExp(year)) - - for (const input of inputs) { - // replace the input's value - userEvent.clear(input) - - // get time 20 mins before - const newDate = new Date() - newDate.setMinutes(new Date().getMinutes() - 20) - userEvent.type(input, newDate.toISOString()) - } - - // input actions - const set = await screen.findByRole('button', { name: 'Set' }) - - userEvent.click(set) - expect(mockFn).toBeCalled() - await screen.findByText('Custom') - await screen.findByTitle(/Clear timestamp filter/) -}) - -test('custom query mode hides elements', async () => { - const { rerender } = render() - await screen.findByPlaceholderText(/Search/) - await screen.findByText('Now') - rerender() - await expect(screen.findByPlaceholderText(/Search/)).rejects.toThrow() - await expect(screen.findByText('Now')).rejects.toThrow() - await expect(screen.findByText('Custom')).rejects.toThrow() -}) diff --git a/studio/tests/pages/projects/LogTable.test.js b/studio/tests/pages/projects/LogTable.test.js index 02b4a52d681..56b596396fc 100644 --- a/studio/tests/pages/projects/LogTable.test.js +++ b/studio/tests/pages/projects/LogTable.test.js @@ -1,5 +1,6 @@ import LogTable from 'components/interfaces/Settings/Logs/LogTable' -import { render, fireEvent, waitFor, screen } from '@testing-library/react' +import { render, waitFor, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' test('can display log data', async () => { render( { /> ) - await waitFor(() => screen.getByText(/happened/)) - const row = screen.getByText(/happened/) - fireEvent.click(row) + const row = await screen.findByText(/some-uuid/) + userEvent.click(row) await waitFor(() => screen.getByText(/my_key/)) await waitFor(() => screen.getByText(/something_value/)) }) @@ -32,20 +32,20 @@ test('dedupes log lines with exact id', async () => { id: 'some-uuid', timestamp: 1621323232312, event_message: 'some event happened', - metadata: {} + metadata: {}, }, { id: 'some-uuid', timestamp: 1621323232312, event_message: 'some event happened', - metadata: {} + metadata: {}, }, ]} /> ) // should only have one element, this line will fail if there are >1 element - await waitFor(() => screen.getByText(/happened/)) + await screen.findByText(/some-uuid/) }) test('can display custom columns and headers based on data input', async () => { @@ -55,3 +55,11 @@ test('can display custom columns and headers based on data input', async () => { await waitFor(() => screen.getByText(/kinda/)) await waitFor(() => screen.getByText(/123456/)) }) + +test('toggle histogram', async () => { + const mockFn = jest.fn() + render() + const toggle = await screen.getByText(/Histogram/) + userEvent.click(toggle) + expect(mockFn).toBeCalled() +}) diff --git a/studio/tests/pages/projects/LogsPreviewer.test.js b/studio/tests/pages/projects/LogsPreviewer.test.js new file mode 100644 index 00000000000..582f623a5c1 --- /dev/null +++ b/studio/tests/pages/projects/LogsPreviewer.test.js @@ -0,0 +1,285 @@ +// mock the fetch function +jest.mock('lib/common/fetch') +import { get } from 'lib/common/fetch' + +// mock mobx +jest.mock('mobx-react-lite') +import { observer } from 'mobx-react-lite' +observer.mockImplementation((v) => v) + +// mock the router +jest.mock('next/router') +import { useRouter } from 'next/router' +const defaultRouterMock = () => { + const router = jest.fn() + router.query = {} + router.push = jest.fn() + router.pathname = 'logs/path' + return router +} +useRouter.mockReturnValue(defaultRouterMock()) + +// mock monaco editor +jest.mock('@monaco-editor/react') +import Editor, { useMonaco } from '@monaco-editor/react' +Editor = jest.fn() +Editor.mockImplementation((props) => { + return ( + + ) +}) +useMonaco.mockImplementation((v) => v) + +// mock usage flags +jest.mock('components/ui/Flag/Flag') +import Flag from 'components/ui/Flag/Flag' +Flag.mockImplementation(({ children }) => <>{children}) +jest.mock('hooks') +import { useFlag } from 'hooks' +useFlag.mockReturnValue(true) + +import { SWRConfig } from 'swr' +jest.mock('components/interfaces/Settings/Logs/LogsPreviewer') +import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer' +LogsPreviewer.mockImplementation((props) => { + const Comp = jest.requireActual('components/interfaces/Settings/Logs/LogsPreviewer').default + // wrap with SWR to reset the cache each time + return ( + new Map(), + shouldRetryOnError: false, + }} + > + + + ) +}) + +import { render, fireEvent, waitFor, screen, act } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { getToggleByText } from '../../helpers' +import { wait } from '@testing-library/user-event/dist/utils' +import { logDataFixture } from '../../fixtures' +import { LogsTableName } from 'components/interfaces/Settings/Logs' +beforeEach(() => { + // reset mocks between tests + get.mockReset() + useRouter.mockReset() + useRouter.mockReturnValue(defaultRouterMock()) +}) +test('can display log data and metadata', async () => { + get.mockResolvedValue({ + result: [ + logDataFixture({ + id: 'some-event-happened-id', + metadata: { + my_key: 'something_value', + }, + }), + ], + }) + render() + fireEvent.click(await screen.findByText(/some-event-happened-id/)) + await screen.findByText(/my_key/) + await screen.findByText(/something_value/) +}) + +test('Refresh page', async () => { + get.mockImplementation((url) => { + if (url.includes('count')) return { result: { count: 0 } } + return { + result: [ + logDataFixture({ + id: 'some-event-id', + metadata: { my_key: 'something_value' }, + }), + ], + } + }) + render() + + const row = await screen.findByText(/some-event-id/) + fireEvent.click(row) + await screen.findByText(/my_key/) +}) + +test('Search will trigger a log refresh', async () => { + get.mockImplementation((url) => { + if (url.includes('something')) { + return { + result: [logDataFixture({ id: 'some-event-id' })], + } + } + return { result: [] } + }) + render() + + userEvent.type(screen.getByPlaceholderText(/Search events/), 'something{enter}') + + await waitFor( + () => { + expect(get).toHaveBeenCalledWith(expect.stringContaining('something')) + + // updates router query params + const router = useRouter() + expect(router.push).toHaveBeenCalledWith( + expect.objectContaining({ + pathname: expect.any(String), + query: expect.objectContaining({ + s: expect.stringContaining('something'), + }), + }) + ) + }, + { timeout: 1500 } + ) + await screen.findByText(/some-event-id/) +}) + +test('poll count for new messages', async () => { + get.mockImplementation((url) => { + if (url.includes('count')) { + return { result: [{ count: 125 }] } + } + return { + result: [logDataFixture({ id: 'some-uuid123' })], + } + }) + render() + await waitFor(() => screen.queryByText(/some-uuid123/) === null) + // should display new logs count + await waitFor(() => screen.getByText(/125/)) + + userEvent.click(screen.getByText(/Refresh/)) + await waitFor(() => screen.queryByText(/125/) === null) + await screen.findByText(/some-uuid123/) +}) + +test('s= query param will populate the search bar', async () => { + const router = defaultRouterMock() + router.query = { ...router.query, s: 'someSearch' } + useRouter.mockReturnValue(router) + render() + // should populate search input with the search param + await screen.findByDisplayValue('someSearch') + expect(get).toHaveBeenCalledWith(expect.stringContaining('someSearch')) +}) + +test('te= query param will populate the timestamp to input', async () => { + // get time 20 mins before + const newDate = new Date() + newDate.setMinutes(new Date().getMinutes() - 20) + const isoString = newDate.toISOString() + const unixMicro = newDate.getTime() * 1000 //microseconds + const router = defaultRouterMock() + router.query = { ...router.query, te: unixMicro } + useRouter.mockReturnValue(router) + render() + + await waitFor(() => { + expect(get).toHaveBeenCalledWith( + expect.stringContaining(`timestamp_end=${encodeURIComponent(unixMicro)}`) + ) + }) + userEvent.click(await screen.findByText('Custom')) + expect(get).toHaveBeenCalledWith(expect.stringContaining('timestamp_end=' + unixMicro)) +}) +test('ts= query param will populate the timestamp from input', async () => { + // get time 20 mins before + const newDate = new Date() + newDate.setMinutes(new Date().getMinutes() - 20) + const unixMicro = newDate.getTime() * 1000 //microseconds + const router = defaultRouterMock() + router.query = { ...router.query, ts: unixMicro } + useRouter.mockReturnValue(router) + render() + + await waitFor(() => { + expect(get).toHaveBeenCalledWith( + expect.stringContaining(`timestamp_start=${encodeURIComponent(unixMicro)}`) + ) + }) + userEvent.click(await screen.findByText('Custom')) + await screen.findByText(new RegExp(newDate.getFullYear())) + await screen.findByText(/Apply/) + expect(get).toHaveBeenCalledWith(expect.stringContaining('timestamp_start=' + unixMicro)) +}) + +test('load older btn will fetch older logs', async () => { + get.mockImplementation((url) => { + if (url.includes('count')) { + return {} + } + return { + result: [logDataFixture({ id: 'first event' })], + } + }) + render() + // should display first log but not second + await waitFor(() => screen.getByText('first event')) + await expect(screen.findByText('second event')).rejects.toThrow() + + get.mockResolvedValueOnce({ + result: [logDataFixture({ id: 'second event' })], + }) + // should display first and second log + userEvent.click(await screen.findByText('Load older')) + await screen.findByText('first event') + await screen.findByText('second event') + expect(get).toHaveBeenCalledWith(expect.stringContaining('timestamp_end=')) +}) + +test('bug: load older btn does not error out when previous page is empty', async () => { + // bugfix for https://sentry.io/organizations/supabase/issues/2903331460/?project=5459134&referrer=slack + get.mockImplementation((url) => { + if (url.includes('count')) { + return {} + } + return { result: [] } + }) + render() + + userEvent.click(await screen.findByText('Load older')) + // NOTE: potential race condition, since we are asserting that something DOES NOT EXIST + // wait for 500s to make sure all ui logic is complete + // need to wrap in act because internal react state is changing during this time. + await act(async () => await wait(100)) + + // clicking load older multiple times should not give error + await waitFor(() => { + expect(screen.queryByText(/Sorry/)).toBeNull() + expect(screen.queryByText(/An error occured/)).toBeNull() + expect(screen.queryByText(/undefined/)).toBeNull() + }) +}) + +test('log event chart hide', async () => { + render() + await screen.findByText('Events') + const toggle = await screen.findByText(/Chart/) + userEvent.click(toggle) + await expect(screen.findByText('Events')).rejects.toThrow() +}) + +test('bug: nav backwards with params change results in ui changing', async () => { + // bugfix for https://sentry.io/organizations/supabase/issues/2903331460/?project=5459134&referrer=slack + get.mockImplementation((url) => { + if (url.includes('count')) { + return {} + } + return { data: [] } + }) + const { container, rerender } = render( + + ) + + await expect(screen.findByDisplayValue('simple-query')).rejects.toThrow() + + const router = defaultRouterMock() + router.query = { ...router.query, s: 'simple-query' } + useRouter.mockReturnValue(router) + rerender() + + await screen.findByDisplayValue('simple-query') +}) diff --git a/studio/tests/pages/projects/LogsQueryPanel.test.js b/studio/tests/pages/projects/LogsQueryPanel.test.js new file mode 100644 index 00000000000..0eeb11152c5 --- /dev/null +++ b/studio/tests/pages/projects/LogsQueryPanel.test.js @@ -0,0 +1,26 @@ +import LogsQueryPanel from 'components/interfaces/Settings/Logs/LogsQueryPanel' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +jest.mock('components/ui/Flag/Flag') +import Flag from 'components/ui/Flag/Flag' +Flag.mockImplementation(({ children }) => <>{children}) +jest.mock('hooks') +import { useFlag } from 'hooks' +useFlag.mockReturnValue(true) + +test.todo('templates') + +test('run and clear', async () => { + const mockRun = jest.fn() + const mockClear = jest.fn() + render() + await expect(screen.findByPlaceholderText(/Search/)).rejects.toThrow() + const run = await screen.findByText(/Run/) + userEvent.click(run) + expect(mockRun).toBeCalled() + const clear = await screen.findByText(/Clear/) + userEvent.click(clear) + expect(mockClear).toBeCalled() + +}) diff --git a/studio/tests/pages/projects/PreviewFilterPanel.test.js b/studio/tests/pages/projects/PreviewFilterPanel.test.js new file mode 100644 index 00000000000..2165d4b7261 --- /dev/null +++ b/studio/tests/pages/projects/PreviewFilterPanel.test.js @@ -0,0 +1,95 @@ +import PreviewFilterPanel from 'components/interfaces/Settings/Logs/PreviewFilterPanel' +import { render, waitFor, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +jest.mock('components/ui/Flag/Flag') +import Flag from 'components/ui/Flag/Flag' +Flag.mockImplementation(({ children }) => <>{children}) +jest.mock('hooks') +import { useFlag } from 'hooks' +import { clickDropdown } from 'tests/helpers' +useFlag.mockReturnValue(true) + +test.todo('templates') +// , async () => { +// const mockFn = jest.fn() +// render() +// const search = screen.getByPlaceholderText(/Search/) +// userEvent.type(search, '12345') + +// // TODO templates dropdown interaction currently cannot be tested +// // https://github.com/supabase/ui/issues/299 +// // userEvent.click(screen.getByText("Templates")) +// // screen.debug() + +// // await waitFor(() => screen.getByText("Some option")) +// // userEvent.click(screen.getByText("Some option")) +// // expect(mockFn).toBeCalled() +// }) + +test('filter input change and submit', async () => { + const mockFn = jest.fn() + render() + expect(mockFn).not.toBeCalled() + const search = screen.getByPlaceholderText(/Search/) + userEvent.type(search, '12345{enter}') + expect(mockFn).toBeCalled() +}) + +test('filter input value', async () => { + render() + await screen.findByDisplayValue('1234') +}) + +test('Manual refresh', async () => { + const mockFn = jest.fn() + render() + const btn = await screen.findByText(/Refresh/) + userEvent.click(btn) + expect(mockFn).toBeCalled() +}) +test('Datepicker dropdown', async () => { + render() + clickDropdown(await screen.findByText(/Last hour/)) + userEvent.click(await screen.findByText(/Last 3 hours/)) + await screen.findByText(/Last 3 hours/) + await expect(screen.findByText(/Last hour/)).rejects.toThrow() +}) + +test.todo('timestamp to/from filter default value') +// , async () => { +// render() +// userEvent.click(await screen.findByText('Custom')) +// await screen.findByDisplayValue('2022-01-18T10:43:39+0000') +// // TODO: use screen.findByLabelText when https://github.com/supabase/ui/issues/310 is resolved +// await screen.findByText('To') +// await screen.findByText('From') +// }) + +test.todo('timestamp to/from filter value change') +// , async () => { +// const mockFn = jest.fn() +// render() +// userEvent.click(await screen.findByText(/Now/)) +// // display iso timestamp +// const year = new Date().getFullYear() +// const inputs = await screen.findAllByDisplayValue(RegExp(year)) + +// for (const input of inputs) { +// // replace the input's value +// userEvent.clear(input) + +// // get time 20 mins before +// const newDate = new Date() +// newDate.setMinutes(new Date().getMinutes() - 20) +// userEvent.type(input, newDate.toISOString()) +// } + +// // input actions +// const set = await screen.findByRole('button', { name: 'Set' }) + +// userEvent.click(set) +// expect(mockFn).toBeCalled() +// await screen.findByText('Custom') +// await screen.findByTitle(/Clear timestamp filter/) +// }) diff --git a/studio/tests/pages/projects/logs-preview.test.js b/studio/tests/pages/projects/logs-preview.test.js index 2b1dfb19960..50c21941b17 100644 --- a/studio/tests/pages/projects/logs-preview.test.js +++ b/studio/tests/pages/projects/logs-preview.test.js @@ -2,11 +2,6 @@ jest.mock('lib/common/fetch') import { get } from 'lib/common/fetch' -// mock the settings layout -jest.mock('components/layouts', () => ({ - SettingsLayout: jest.fn().mockImplementation(({ children }) =>
{children}
), -})) - // mock mobx jest.mock('mobx-react-lite') import { observer } from 'mobx-react-lite' @@ -17,7 +12,7 @@ jest.mock('next/router') import { useRouter } from 'next/router' const defaultRouterMock = () => { const router = jest.fn() - router.query = { ref: '123', type: 'auth' } + router.query = {} router.push = jest.fn() router.pathname = 'logs/path' return router @@ -44,10 +39,10 @@ import { useFlag } from 'hooks' useFlag.mockReturnValue(true) import { SWRConfig } from 'swr' -jest.mock('pages/project/[ref]/settings/logs/[type]') -import { LogPage } from 'pages/project/[ref]/settings/logs/[type]' -LogPage.mockImplementation((props) => { - const Page = jest.requireActual('pages/project/[ref]/settings/logs/[type]').LogPage +jest.mock('components/interfaces/Settings/Logs/LogsPreviewer') +import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer' +LogsPreviewer.mockImplementation((props) => { + const Comp = jest.requireActual('components/interfaces/Settings/Logs/LogsPreviewer').default // wrap with SWR to reset the cache each time return ( { shouldRetryOnError: false, }} > - + ) }) @@ -66,6 +61,8 @@ import userEvent from '@testing-library/user-event' import { getToggleByText } from '../../helpers' import { wait } from '@testing-library/user-event/dist/utils' import { logDataFixture } from '../../fixtures' +import { LogsTableName } from 'components/interfaces/Settings/Logs' +import { find } from 'lodash' beforeEach(() => { // reset mocks between tests get.mockReset() @@ -76,15 +73,15 @@ test('can display log data and metadata', async () => { get.mockResolvedValue({ result: [ logDataFixture({ - event_message: 'some event happened', + id: 'some-event-happened-id', metadata: { my_key: 'something_value', }, }), ], }) - render() - fireEvent.click(await screen.findByText(/happened/)) + render() + fireEvent.click(await screen.findByText(/some-event-happened-id/)) await screen.findByText(/my_key/) await screen.findByText(/something_value/) }) @@ -95,39 +92,31 @@ test('Refresh page', async () => { return { result: [ logDataFixture({ - event_message: 'some event happened', + id: 'some-event-id', metadata: { my_key: 'something_value' }, }), ], } }) - render() + render() - const row = await screen.findByText(/happened/) - get.mockResolvedValueOnce({ result: [] }) + const row = await screen.findByText(/some-event-id/) fireEvent.click(row) - await waitFor(() => screen.getByText(/my_key/)) - - // simulate refresh - userEvent.click(screen.getByText(/Refresh/)) - // when log line unmounts and it was focused, should close focus panel - await waitFor(() => screen.queryByText(/my_key/) === null, { timeout: 1000 }) - await waitFor(() => screen.queryByText(/happened/) === null, { timeout: 1000 }) + await screen.findByText(/my_key/) }) test('Search will trigger a log refresh', async () => { get.mockImplementation((url) => { if (url.includes('something')) { return { - result: [logDataFixture({ event_message: 'some event happened' })], + result: [logDataFixture({ id: 'some-event-id' })], } } return { result: [] } }) - render() + render() - userEvent.type(screen.getByPlaceholderText(/Search/), 'something') - userEvent.click(screen.getByTitle('Go')) + userEvent.type(screen.getByPlaceholderText(/Search events/), 'something{enter}') await waitFor( () => { @@ -146,7 +135,7 @@ test('Search will trigger a log refresh', async () => { }, { timeout: 1500 } ) - await screen.findByText(/happened/) + await screen.findByText(/some-event-id/) }) test('poll count for new messages', async () => { @@ -155,24 +144,24 @@ test('poll count for new messages', async () => { return { result: [{ count: 125 }] } } return { - result: [logDataFixture({ event_message: 'something happened' })], + result: [logDataFixture({ id: 'some-uuid123' })], } }) - render() - await waitFor(() => screen.queryByText(/happened/) === null) + render() + await waitFor(() => screen.queryByText(/some-uuid123/) === null) // should display new logs count await waitFor(() => screen.getByText(/125/)) userEvent.click(screen.getByText(/Refresh/)) await waitFor(() => screen.queryByText(/125/) === null) - await waitFor(() => screen.getByText(/happened/)) + await screen.findByText(/some-uuid123/) }) test('s= query param will populate the search bar', async () => { const router = defaultRouterMock() - router.query = { ...router.query, type: 'api', s: 'someSearch' } + router.query = { ...router.query, s: 'someSearch' } useRouter.mockReturnValue(router) - render() + render() // should populate search input with the search param await screen.findByDisplayValue('someSearch') expect(get).toHaveBeenCalledWith(expect.stringContaining('someSearch')) @@ -187,7 +176,7 @@ test('te= query param will populate the timestamp to input', async () => { const router = defaultRouterMock() router.query = { ...router.query, te: unixMicro } useRouter.mockReturnValue(router) - render() + render() await waitFor(() => { expect(get).toHaveBeenCalledWith( @@ -195,7 +184,7 @@ test('te= query param will populate the timestamp to input', async () => { ) }) userEvent.click(await screen.findByText('Custom')) - await screen.findByDisplayValue(isoString) + expect(get).toHaveBeenCalledWith(expect.stringContaining('timestamp_end=' + unixMicro)) }) test('ts= query param will populate the timestamp from input', async () => { // get time 20 mins before @@ -206,7 +195,7 @@ test('ts= query param will populate the timestamp from input', async () => { const router = defaultRouterMock() router.query = { ...router.query, ts: unixMicro } useRouter.mockReturnValue(router) - render() + render() await waitFor(() => { expect(get).toHaveBeenCalledWith( @@ -214,26 +203,27 @@ test('ts= query param will populate the timestamp from input', async () => { ) }) userEvent.click(await screen.findByText('Custom')) - await screen.findByDisplayValue(isoString) + await screen.findByText(new RegExp(newDate.getFullYear())) + await screen.findByText(/Apply/) + expect(get).toHaveBeenCalledWith(expect.stringContaining('timestamp_start=' + unixMicro)) }) - test('load older btn will fetch older logs', async () => { get.mockImplementation((url) => { if (url.includes('count')) { return {} } return { - result: [logDataFixture({ event_message: 'first event' })], + result: [logDataFixture({ id: 'first event' })], } }) - render() + render() // should display first log but not second await waitFor(() => screen.getByText('first event')) await expect(screen.findByText('second event')).rejects.toThrow() get.mockResolvedValueOnce({ - result: [logDataFixture({ event_message: 'second event' })], + result: [logDataFixture({ id: 'second event' })], }) // should display first and second log userEvent.click(await screen.findByText('Load older')) @@ -250,7 +240,7 @@ test('bug: load older btn does not error out when previous page is empty', async } return { result: [] } }) - render() + render() userEvent.click(await screen.findByText('Load older')) // NOTE: potential race condition, since we are asserting that something DOES NOT EXIST @@ -267,9 +257,9 @@ test('bug: load older btn does not error out when previous page is empty', async }) test('log event chart hide', async () => { - render() + render() await screen.findByText('Events') - const toggle = getToggleByText(/Show event chart/) + const toggle = await screen.findByText(/Chart/) userEvent.click(toggle) await expect(screen.findByText('Events')).rejects.toThrow() }) @@ -282,14 +272,16 @@ test('bug: nav backwards with params change results in ui changing', async () => } return { data: [] } }) - const { container, rerender } = render() + const { container, rerender } = render( + + ) await expect(screen.findByDisplayValue('simple-query')).rejects.toThrow() const router = defaultRouterMock() router.query = { ...router.query, s: 'simple-query' } useRouter.mockReturnValue(router) - rerender() + rerender() await screen.findByDisplayValue('simple-query') }) diff --git a/studio/tests/pages/projects/logs-explorer.test.js b/studio/tests/pages/projects/logs-query.test.js similarity index 74% rename from studio/tests/pages/projects/logs-explorer.test.js rename to studio/tests/pages/projects/logs-query.test.js index 81265af2772..104d1c080e4 100644 --- a/studio/tests/pages/projects/logs-explorer.test.js +++ b/studio/tests/pages/projects/logs-query.test.js @@ -4,7 +4,7 @@ import { get } from 'lib/common/fetch' // mock the settings layout jest.mock('components/layouts', () => ({ - SettingsLayout: jest.fn().mockImplementation(({ children }) =>
{children}
), + LogsExplorerLayout: jest.fn().mockImplementation(({ children }) =>
{children}
), })) // mock mobx @@ -17,7 +17,7 @@ jest.mock('next/router') import { useRouter } from 'next/router' const defaultRouterMock = () => { const router = jest.fn() - router.query = { ref: '123', type: 'auth' } + router.query = { ref: '123' } router.push = jest.fn() router.pathname = 'logs/path' return router @@ -40,67 +40,71 @@ jest.mock('components/ui/Flag/Flag') import Flag from 'components/ui/Flag/Flag' Flag.mockImplementation(({ children }) => <>{children}) jest.mock('hooks') -import { useFlag } from 'hooks' +import { useStore, useFlag } from 'hooks' useFlag.mockReturnValue(true) +useStore.mockImplementation(() => ({ + content: jest.fn(), +})) import { SWRConfig } from 'swr' -jest.mock('pages/project/[ref]/settings/logs/explorer') -import { LogsExplorerPage } from 'pages/project/[ref]/settings/logs/explorer' -LogsExplorerPage.mockImplementation((props) => { - const Page = jest.requireActual('pages/project/[ref]/settings/logs/explorer').LogsExplorerPage - // wrap with SWR to reset the cache each time - return ( - new Map(), - shouldRetryOnError: false, - }} - > - - - ) -}) +import { LogsExplorerPage as Page } from 'pages/project/[ref]/logs-explorer/index' +const LogsExplorerPage = (props) => ( + new Map(), + shouldRetryOnError: false, + }} + > + + +) -import { render, fireEvent, waitFor, screen, act } from '@testing-library/react' +import { render, fireEvent, waitFor, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { getToggleByText } from '../../helpers' -import { wait } from '@testing-library/user-event/dist/utils' import { logDataFixture } from '../../fixtures' + beforeEach(() => { // reset mocks between tests get.mockReset() useRouter.mockReset() useRouter.mockReturnValue(defaultRouterMock()) }) -test('can display log data and metadata', async () => { +test('can display log data', async () => { get.mockResolvedValue({ result: [ logDataFixture({ - event_message: 'some event happened', + id: 'some-event-happened', metadata: { my_key: 'something_value', }, }), ], }) - render() - fireEvent.click(await screen.findByText(/happened/)) - await screen.findByText(/my_key/) + const {container} = render() + let editor = container.querySelector('.monaco-editor') + await waitFor(() => { + editor = container.querySelector('.monaco-editor') + expect(editor).toBeTruthy() + }) + // type new query + userEvent.type(editor, 'select \ncount(*) as my_count \nfrom edge_logs') + + userEvent.click(await screen.findByText(/Run/)) + const row = await screen.findByText("some-event-happened") + screen.debug() + userEvent.click(row) await screen.findByText(/something_value/) }) test('q= query param will populate the query input', async () => { const router = defaultRouterMock() - router.query = { ...router.query, type: 'api', q: 'some_query', s: 'someSearch' } + router.query = { ...router.query, type: 'api', q: 'some_query' } useRouter.mockReturnValue(router) render() // should populate editor with the query param await waitFor(() => { expect(get).toHaveBeenCalledWith(expect.stringContaining('sql=some_query')) }) - - // query takes precedence of search queryparam - expect(() => !screen.queryByDisplayValue(/someSearch/)) }) test('custom sql querying', async () => { diff --git a/studio/tsconfig.json b/studio/tsconfig.json index 379a3b4c863..71436a2fdd4 100644 --- a/studio/tsconfig.json +++ b/studio/tsconfig.json @@ -1,11 +1,7 @@ { "compilerOptions": { "target": "es5", - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], + "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -17,16 +13,9 @@ "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", - "baseUrl": "." + "baseUrl": ".", + "downlevelIteration": true }, - "include": [ - "next-env.d.ts", - "**/*.ts", - "**/*.tsx", - "**/*.js", - "**/*.jsx" - ], - "exclude": [ - "node_modules" - ] + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"], + "exclude": ["node_modules"] } diff --git a/studio/types/userContent.ts b/studio/types/userContent.ts index 35c79fac7d3..1219ea809b3 100644 --- a/studio/types/userContent.ts +++ b/studio/types/userContent.ts @@ -2,9 +2,9 @@ export interface UserContent { id?: string name: string description?: string - type: 'sql' | 'report' + type: 'sql' | 'report' | 'log_sql' visibility: 'user' | 'project' | 'org' | 'public' - content: Dashboards.Content | SqlSnippets.Content + content: Dashboards.Content | SqlSnippets.Content | LogSqlSnippets.Content owner_id?: number // user id last_updated_by?: number // user id inserted_at?: string // '2021-08-26T08:24:52.040695+00:00' @@ -120,3 +120,25 @@ export namespace SqlSnippets { favorite: boolean } } + +export namespace LogSqlSnippets { + /** + * To be stored in the database: public.user_content.content + * In this case there is only one thing to store, but it's good to + * nest it in an object for future expansion. + */ + export interface Content { + // unique id of the sql snippet, possibly to used so snippets can support versioning + content_id: string + + // A full SQL query - this will be hashed on the /content endpoint + sql: string + + // we can add some versioning to this schema in case we need to change the format. + schema_version: string + + // show sql snippet as a favorite. + // this could be problematic if sql snippets have visibility that is != 'user' + favorite: boolean + } +} diff --git a/web/docs/guides/api.mdx b/web/docs/guides/api.mdx index a7fb8dfc992..8faedf6a8d1 100644 --- a/web/docs/guides/api.mdx +++ b/web/docs/guides/api.mdx @@ -13,18 +13,18 @@ Supabase generates three types of API directly from your database schema. - REST - interact with your database through a restful interface. - Realtime - listen to database changes. -- GraphQL - [coming soon](https://supabase.com/blog/2021/12/03/pg-graphql). +- GraphQL - [in beta](/blog/2021/12/03/pg-graphql). The APIs are: -- Instant and auto-generated: as you update your database the changes are immediately accessible through your API. -- Self documenting: Supabase generates documentation in the Dashboard which updates as you make database changes. -- Secure: the API is configured to work with PostgreSQL's Row Level Security, provisioned behind an API gateway with key-auth enabled. -- Fast: our benchmarks for basic reads are more than 300% faster than Firebase. The API is a very thin layer on top of Postgres, which does most of the heavy lifting. -- Scalable: the API can serve thousands of simultaneous requests, and works well for Serverless workloads. +- **Instant and auto-generated.**
As you update your database the changes are immediately accessible through your API. +- **Self documenting.**
Supabase generates documentation in the Dashboard which updates as you make database changes. +- **Secure.**
The API is configured to work with PostgreSQL's Row Level Security, provisioned behind an API gateway with key-auth enabled. +- **Fast.**
Our benchmarks for basic reads are more than 300% faster than Firebase. The API is a very thin layer on top of Postgres, which does most of the heavy lifting. +- **Scalable.**
The API can serve thousands of simultaneous requests, and works well for Serverless workloads. -### RESTful API +### REST API Supabase provides a RESTful API using [PostgREST](https://postgrest.org/). This is a very thin API layer on top of Postgres. It provides everything you need from a CRUD API: @@ -45,20 +45,29 @@ It provides everything you need from a CRUD API: allowFullScreen > +### GraphQL API + +:::note + +GraphQL is in Beta, and may have breaking changes. It is only available on self-hosted setups and Supabase projects created after 28th March 2022. + +::: + +GraphQL in Supabase works through [pg_graphql](/blog/2021/12/03/pg-graphql), an open source PostgreSQL extension for GraphQL. +To get started with GraphQL you must enable the `pg_graphql` extension in your project. + + + ### Realtime API Supabase provides a Realtime API using [Realtime](https://github.com/supabase/realtime). You can use this to listen to database changes over websockets. Realtime leverages PostgreSQL's built-in logical replication. You can manage your Realtime API simply by managing Postgres publications. -### GraphQL API - -[Coming soon](https://supabase.com/blog/2021/12/03/pg-graphql). We are developing `pg_graphql`, an open source PostgreSQL extension for GraphQL. -`pg_graphql` inspects an existing PostgreSQL schema and reflects a GraphQL schema with resolvers. We will expose `pg_graphql` via the existing REST interface. - - ## Getting started -After you have added tables or functions to your database, you can use the API. +All APIs are auto-created from Database tables. After you have added tables or functions to your database, you can use the APIs provided. ### Creating API Routes @@ -105,19 +114,10 @@ create table todos ( - ### API URL and Keys Every Supabase project has a unique API URL. Your API is secured behind an API gateway which requires an API Key for every request. -You can find the Keys inside the Dashboard. - - - ```sh 1. Go to the "Settings" section. @@ -130,23 +130,30 @@ values={[ - - +The REST API and the GraphQL API are both accessible through this URL: + +- REST: `https://.supabase.com/rest/v1` +- GraphQL: `https://.supabase.com/rest/v1` + +Both of these routes require the `anon` key to be passed through an `apikey` header. + +#### API Keys -#### Details and links: +You can find the Keys inside the Dashboard in the same location as the URL above. -You are provided with two keys initially: +You are provided with two keys: - an `anon` key, which is safe to be used in a browser context. -- a `service_role` key, which should only be used on a server. This key can bypass Row Level Security. +- a `service_role` key, which should only be used on a server. This key can bypass Row Level Security. NEVER use this key in a browser. ### Accessing the Docs -Supabase generates documentation in the Dashboard which updates as you make database changes. +#### REST API -Let's view the documentation for the `todos` table which we created in the first step. +Supabase generates documentation in the Dashboard which updates as you make database changes. +Let's view the documentation for a `countries` table which we created in our database. + + + +#### GraphQL -### Using the API +The GraphQL Endpoint that we provide (`https://.supabase.com/rest/v1`) is compatible with any GraphiQL implementation that can pass an `apikey` header. +Some suggested applications: + +- [paw.cloud](https://paw.cloud) +- [insomnia.rest](https://insomnia.rest) +- [postman.com/graphql](https://www.postman.com/graphql/) +- Self-hosted GraphiQL: GraphiQL can be served through a simple HTML file. See [this discussion](https://github.com/supabase/supabase/discussions/6144) for more details. + +## Using the API + + +### REST API You can interact with your API directly via HTTP requests, or you can use the client libraries which we provide. Let's see how to make a request to the `todos` table which we created in the first step, -using the API URL (`[SUPABASE_URL]`) and Key (`[SUPABASE_ANON_KEY]`) we provided: +using the API URL (`SUPABASE_URL`) and Key (`SUPABASE_ANON_KEY`) we provided: /rest/v1/todos' \ +-H "apikey: " \ +-H "Authorization: Bearer " ``` -#### Details and links: - -- When you create a table in Postgres, Row Level Security is disabled by default. Make sure you secure it by [enabling RLS](/docs/guides/api#securing-your-routes). -- Never expose the `service_role` key in a browser or anywhere where a user can see it. -- JS Reference: [select()](/docs/reference/javascript/select), +JS Reference: [select()](/docs/reference/javascript/select), [insert()](/docs/reference/javascript/insert), [update()](/docs/reference/javascript/update), [upsert()](/docs/reference/javascript/upsert), [delete()](/docs/reference/javascript/delete), [rpc()](/docs/reference/javascript/rpc) (call Postgres functions). -### Safeguards towards accidental deletes and updates -For new projects, by default, the Postgres extension [safeupdate](https://github.com/eradman/pg-safeupdate) is enabled for all queries coming from the API. -This ensures that any `delete()` or `update()` would fail if there are no accompanying filters provided. -This can be disabled by running the following query on the SQL editor on the dashboard: +### GraphQL API -```sql -alter role "authenticator" -set "session_preload_libraries" to default; +:::note + +While pg_graphql is in beta, we have disabled GraphQL auto-refreshing. If you want to refresh your GraphQL introspection, call `select graphql.rebuild_schema();` to rebuild your GraphQL schema. + +::: + +You can use any GraphQL client with the Supabase GraphQL API. For our GraphQL example we will use [urlql](https://formidable.com/open-source/urql/docs/). + + + + +```javascript +import { createClient, useQuery } from 'urql' + +// Prepare API key and Authorization header +const headers = { + apikey: , + authorization: `Bearer: ${ +} + +// Create GraphQL client +// See: https://formidable.com/open-source/urql/docs/basics/react-preact/#setting-up-the-client +const client = createClient({ + url: '/grapqhl/v1', + fetchOptions: function createFetchOptions() { + return { headers } + }, +}) + +// Prepare our GraphQL query +const TodosQuery = ` + query { + todos { + id + title + } + } +` + +// Query for the data (React) +const [result, reexecuteQuery] = useQuery({ + query: TodosQuery, +}) + +// Read the result +const { data, fetching, error } = result ``` -To reenable it : -```sql -alter role "authenticator" -set "session_preload_libraries" = 'safeupdate'; + + + +```bash +# Append /grapqhl/v1/ to your URL, and then use the table name as the route +curl --request POST '/grapqhl/v1' \ +-H 'apikey: ' \ +-H 'Authorization: Bearer ' \ +-d '{ "query":"{ todos(first: 3) { edges { node { id } } } }" }' ``` -The above can also be run to enable it for existing projects. Settings are applied immediately and as such no restarts are needed after running any of the two. -### Managing Realtime + + -The Realtime API works through PostgreSQL's replication functionality. Postgres sends database changes to a "publication" -called `supabase_realtime`, and by managing this publication you can control which data is broadcast. +### Realtime API By default Realtime is disabled on your database. Let's turn on Realtime for the `todos` table. +:::note + +Realtime only works for tables in the public schema. + +::: + -#### Details and links: +Now we can listen to any new data that is inserted into the `todo` table: -- Realtime only works for tables in the public schema. -- [JS Reference](/docs/reference/javascript/subscribe): Subscribe to database changes using the realtime client + + + +```javascript +// Initialize the JS client +import { createClient } from '@supabase/supabase-js' +const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY) + +// Create a function to handle inserts +const handleInserts = (payload) => { + console.log('Change received!', payload) +} + +// Listen to unserts +const { data: todos, error } = await supabase + .from('todos') + .in('INSERT', handleUpdates) + .subscribe() +``` + + + + +Use [subscribe()](/docs/reference/javascript/subscribe) to listen to database changes. +The Realtime API works through PostgreSQL's replication functionality. Postgres sends database changes to a [publication](/docs/guides/database/replication#publications) +called `supabase_realtime`, and by managing this publication you can control which data is broadcast. + +## API Security ### Securing your Routes + +Your API is designed to work with Postgres Row Level Security. If you use Supabase [Auth](/docs/guides/auth), you can restrict data based on the logged-in user. +To control access to your data, you can use [Policies](/docs/guides/auth#policies). +When you create a table in Postgres, Row Level Security is disabled by default. Make sure you secure it by [enabling RLS](/docs/guides/api#securing-your-routes). + +### The `service_role` key -#### Details and links: -- Your API is designed to work with Postgres Row Level Security. If you use Supabase [Auth](/docs/guides/auth), you can restrict data based on the logged-in user. -- To control access to your data, you can use [Policies](/docs/guides/auth#policies). +Never expose the `service_role` key in a browser or anywhere where a user can see it. This Key can is designed to bypass Row Level Security - so it should only be used on a private server. + +We have [partnered with GitHub](/blog/2022/03/28/community-day#supabase-is-now-a-github-secret-scanning-partner) to scan for Supabase `service_role` keys pushed to public repositories. +If they detect any keys with service_role privileges being pushed to GitHub, they will forward the API key to us, so that we can automatically revoke the detected secrets and notify you, protecting your data against malicious actors. + +### Safeguards towards accidental deletes and updates + +For new projects, by default, the Postgres extension [safeupdate](https://github.com/eradman/pg-safeupdate) is enabled for all queries coming from the API. +This ensures that any `delete()` or `update()` would fail if there are no accompanying filters provided. +This can be disabled by running the following query on the SQL editor on the dashboard: + +```sql +alter role "authenticator" +set "session_preload_libraries" to default; +``` + +To reenable it : +```sql +alter role "authenticator" +set "session_preload_libraries" = 'safeupdate'; +``` +The above can also be run to enable it for existing projects. Settings are applied immediately and as such no restarts are needed after running any of the two. + diff --git a/web/docusaurus.config.js b/web/docusaurus.config.js index 422937a2783..778739c1d1d 100755 --- a/web/docusaurus.config.js +++ b/web/docusaurus.config.js @@ -40,8 +40,8 @@ module.exports = { }, }, algolia: { - apiKey: '766d56f13dd1e82f43253559b7c86636', - appId: 'supabase', + appId: 'W4BIZ4FKU9', + apiKey: 'ac408c5c0a323ee4d7952cee149d1474', indexName: 'supabase', }, image: '/img/supabase-og-image.png', // used for meta tag, in particular og:image and twitter:image diff --git a/web/static/lawyers.txt b/web/static/lawyers.txt index e248d52b8b7..4aa599ea4d1 100644 --- a/web/static/lawyers.txt +++ b/web/static/lawyers.txt @@ -9,7 +9,9 @@ Supabase™ is a trademark of Supabase Inc. If you're a creator/contributor to one of these packages, thanks! -Last updated: 2022-03-24 +Last updated: 2022-03-29 + +JavaScript/TypeScript -- ------------------------------------ ---------------------- -------------------------------------------------------------------- --------------------------------------------------------------------------------------- ------------------------- 0 lodash@4.17.21 MIT https://github.com/lodash/lodash https://github.com/lodash/lodash/raw/master/LICENSE @supabase/ui @@ -110,3 +112,298 @@ Last updated: 2022-03-24 95 stripe@8.145.0 MIT https://github.com/stripe/stripe-node https://github.com/stripe/stripe-node/raw/master/LICENSE stripe-sync-engine 96 yesql@5.0.0 ISC https://github.com/pihvi/yesql https://github.com/pihvi/yesql stripe-sync-engine -- ------------------------------------ ---------------------- -------------------------------------------------------------------- --------------------------------------------------------------------------------------- ------------------------- + + +Elixir + +Id Name Version License +-- --------------------------------- ------- ---------- + 0 castore 0.1.11 Apache 2.0 + 1 certifi 2.5.3 BSD + 2 combine 0.10.0 MIT + 3 connection 1.1.0 Apache 2.0 + 4 cowboy 2.8.0 ISC + 5 cowboy_telemetry 0.3.1 Apache 2.0 + 6 cowlib 2.9.1 ISC + 7 db_connection 2.4.1 Apache 2.0 + 8 decimal 2.0.0 Apache 2.0 + 9 ecto 3.7.1 Apache 2.0 +10 ecto_sql 3.7.1 Apache 2.0 +11 epgsql 4.6.0 BSD +12 file_system 0.2.10 WTFPL +13 finch 0.7.0 MIT +14 gettext 0.18.2 Apache 2.0 +15 hackney 1.17.0 Apache 2.0 +16 httpoison 1.8.0 MIT +17 idna 6.1.1 MIT +18 jason 1.2.2 Apache 2.0 +19 joken 2.3.0 Apache 2.0 +20 jose 1.11.1 MIT +21 metrics 1.0.1 BSD +22 mime 1.5.0 Apache 2.0 +23 mimerl 1.2.0 MIT +24 mint 1.3.0 Apache 2.0 +25 nimble_options 0.3.5 Apache 2.0 +26 nimble_pool 0.2.4 Apache 2.0 +27 parse_trans 3.3.1 Apache 2.0 +28 phoenix 1.5.8 MIT +29 phoenix_html 2.14.3 MIT +30 phoenix_live_reload 1.3.0 MIT +31 phoenix_pubsub 2.0.0 MIT +32 plug 1.11.0 Apache 2.0 +33 plug_cowboy 2.4.1 Apache 2.0 +34 plug_crypto 1.2.1 Apache 2.0 +35 postgrex 0.15.13 Apache 2.0 +36 prom_ex 1.3.0 MIT +37 ranch 1.7.1 ISC +38 retry 0.14.1 Apache 2.0 +39 ssl_verify_fun 1.1.6 MIT +40 telemetry 0.4.2 Apache 2.0 +41 telemetry_metrics 0.6.1 Apache 2.0 +42 telemetry_metrics_prometheus_core 1.0.1 Apache 2.0 +43 telemetry_poller 0.5.1 Apache 2.0 +44 timex 3.6.3 MIT +45 tzdata 1.0.5 MIT +46 unicode_util_compat 0.7.0 Apache 2.0 +47 bunt 0.2.0 MIT +48 credo 1.5.5 MIT +49 dialyxir 1.1.0 Apache 2.0 +50 earmark_parser 1.4.12 Apache 2.0 +51 erlex 0.2.6 Apache 2.0 +52 ex_doc 0.24.1 Apache 2.0 +53 file_system 0.2.10 WTFPL +54 jason 1.2.2 Apache 2.0 +55 makeup 1.0.5 BSD +56 makeup_elixir 0.15.1 BSD +57 makeup_erlang 0.1.1 BSD +58 nimble_parsec 1.1.0 Apache 2.0 +59 warpath 0.6.0 MIT +-- --------------------------------- ------- ---------- + +Go + +ID Package Link Version +--- ----------------------------------------------------------- ------------------------------------- +License Type +------------------------------------------------------ + 0 github.com/BurntSushi/toml v1.0.0 MIT + 1 github.com/adrg/xdg v0.4.0 MIT + 2 github.com/alecthomas/chroma v0.10.0 MIT + 3 github.com/aymerick/douceur v0.2.0 MIT + 4 github.com/charmbracelet/bubbles v0.10.3 MIT + 5 github.com/charmbracelet/bubbletea v0.20.0 MIT + 6 github.com/charmbracelet/glamour v0.5.0 MIT + 7 github.com/charmbracelet/harmonica v0.1.0 MIT + 8 github.com/charmbracelet/lipgloss v0.5.0 MIT + 9 github.com/containerd/console v1.0.3 Apache 2.0 + 10 github.com/containerd/containerd v1.6.2 Apache 2.0 + 11 github.com/dlclark/regexp2 v1.4.0 MIT + 12 github.com/docker/distribution v2.8.1 Apache 2.0 + 13 github.com/docker/docker v20.10.14 Apache 2.0,MIT + 14 github.com/docker/go-connections v0.4.0 Apache 2.0 + 15 github.com/docker/go-units v0.4.0 Apache 2.0 + 16 github.com/fsnotify/fsnotify v1.5.1 New BSD + 17 github.com/gogo/protobuf v1.3.2 New BSD + 18 github.com/golang/protobuf v1.5.2 New BSD + 19 github.com/gorilla/css v1.0.0 New BSD + 20 github.com/hashicorp/hcl v1.0.0 Mozilla Public License 2.0 + 21 github.com/jackc/chunkreader/v2 v2.0.1 MIT + 22 github.com/jackc/pgconn v1.11.0 MIT + 23 github.com/jackc/pgio v1.0.0 MIT + 24 github.com/jackc/pgpassfile v1.0.0 MIT + 25 github.com/jackc/pgproto3/v2 v2.2.0 MIT + 26 github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b MIT + 27 github.com/jackc/pgtype v1.10.0 MIT + 28 github.com/jackc/pgx/v4 v4.15.0 MIT + 29 github.com/joho/godotenv v1.4.0 MIT + 30 github.com/lucasb-eyer/go-colorful v1.2.0 MIT + 31 github.com/magiconair/properties v1.8.6 Simplified BSD + 32 github.com/mattn/go-isatty v0.0.14 MIT + 33 github.com/mattn/go-runewidth v0.0.13 MIT + 34 github.com/microcosm-cc/bluemonday v1.0.18 New BSD + 35 github.com/mitchellh/mapstructure v1.4.3 MIT + 36 github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 Apache 2.0 + 37 github.com/morikuni/aec v1.0.0 MIT + 38 github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 MIT + 39 github.com/muesli/reflow v0.3.0 MIT + 40 github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 MIT + 41 github.com/olekukonko/tablewriter v0.0.5 MIT + 42 github.com/opencontainers/go-digest v1.0.0 Apache 2.0 + 43 github.com/opencontainers/image-spec v1.0.2 Apache 2.0 + 44 github.com/pelletier/go-toml v1.9.4 Apache 2.0 + 45 github.com/pkg/errors v0.9.1 Simplified BSD + 46 github.com/rivo/uniseg v0.2.0 MIT + 47 github.com/sirupsen/logrus v1.8.1 MIT + 48 github.com/spf13/afero v1.8.2 Apache 2.0 + 49 github.com/spf13/cast v1.4.1 MIT + 50 github.com/spf13/cobra v1.4.0 Apache 2.0 + 51 github.com/spf13/jwalterweatherman v1.1.0 MIT + 52 github.com/spf13/pflag v1.0.5 New BSD + 53 github.com/spf13/viper v1.10.1 MIT + 54 github.com/subosito/gotenv v1.2.0 MIT + 55 github.com/withfig/autocomplete-tools/packages/cobra v1.1.3 MIT + 56 github.com/yuin/goldmark v1.4.11 MIT + 57 github.com/yuin/goldmark-emoji v1.0.1 MIT + 58 golang.org/x/crypto v0.0.0-20220321153916-2c7772ba3064 New BSD + 59 golang.org/x/net v0.0.0-20220225172249-27dd8689420f New BSD + 60 golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8 New BSD + 61 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 New BSD + 62 golang.org/x/text v0.3.7 New BSD + 63 google.golang.org/genproto v0.0.0-20220323144105-ec3c684e5b14 Apache 2.0 + 64 google.golang.org/grpc v1.45.0 Apache 2.0 + 65 google.golang.org/protobuf v1.28.0 New BSD + 66 gopkg.in/ini.v1 v1.66.4 Apache 2.0 + 67 gopkg.in/yaml.v2 v2.4.0 Apache 2.0,MIT + 68 github.com/aws/aws-sdk-go-v2 v1.8.0 Apache 2.0,New BSD + 69 github.com/aws/aws-sdk-go-v2/config v1.6.0 Apache 2.0 + 70 github.com/aws/aws-sdk-go-v2/credentials v1.3.2 Apache 2.0 + 71 github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.4.0 Apache 2.0 + 72 github.com/aws/aws-sdk-go-v2/internal/ini v1.2.0 Apache 2.0 + 73 github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.2.2 Apache 2.0 + 74 github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.5.0 Apache 2.0 + 75 github.com/aws/aws-sdk-go-v2/service/sso v1.3.2 Apache 2.0 + 76 github.com/aws/aws-sdk-go-v2/service/sts v1.6.1 Apache 2.0 + 77 github.com/aws/smithy-go v1.7.0 Apache 2.0 + 78 github.com/coreos/go-semver v0.3.0 Apache 2.0 + 79 github.com/coreos/go-systemd/v22 v22.3.2 Apache 2.0 + 80 github.com/fsnotify/fsnotify v1.4.9 New BSD + 81 github.com/gogo/protobuf v1.3.2 New BSD + 82 github.com/golang/protobuf v1.5.2 New BSD + 83 github.com/hashicorp/hcl v1.0.0 Mozilla Public License 2.0 + 84 github.com/magiconair/properties v1.8.5 Simplified BSD + 85 github.com/mitchellh/mapstructure v1.4.1 MIT + 86 github.com/pelletier/go-toml v1.9.3 Apache 2.0 + 87 github.com/pkg/errors v0.9.1 Simplified BSD + 88 github.com/spf13/afero v1.6.0 Apache 2.0 + 89 github.com/spf13/cast v1.3.1 MIT + 90 github.com/spf13/cobra v1.2.1 Apache 2.0,GPLv2,GPLv3,LGPL,MIT,New BSD,Simplified BSD + 91 github.com/spf13/jwalterweatherman v1.1.0 MIT + 92 github.com/spf13/pflag v1.0.5 New BSD + 93 github.com/spf13/viper v1.8.1 MIT + 94 github.com/subosito/gotenv v1.2.0 MIT + 95 go.etcd.io/etcd/api/v3 v3.5.0 Apache 2.0 + 96 go.etcd.io/etcd/client/pkg/v3 v3.5.0 Apache 2.0 + 97 go.etcd.io/etcd/client/v3 v3.5.0 Apache 2.0 + 98 go.uber.org/atomic v1.9.0 MIT + 99 go.uber.org/multierr v1.7.0 MIT +100 go.uber.org/zap v1.19.0 MIT +101 golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4 New BSD +102 golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40 New BSD +103 golang.org/x/text v0.3.5 New BSD +104 google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c Apache 2.0 +105 google.golang.org/grpc v1.38.0 Apache 2.0 +106 google.golang.org/protobuf v1.26.0 New BSD +107 gopkg.in/ini.v1 v1.62.0 Apache 2.0 +108 gopkg.in/yaml.v2 v2.4.0 Apache 2.0,MIT +109 github.com/coreos/go-oidc v2.2.1 Apache 2.0 +110 github.com/kelseyhightower/envconfig v1.4.0 MIT +111 github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 Apache 2.0 +112 golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899 New BSD +113 golang.org/x/net v0.0.0-20200707034311-ab3426394381 New BSD +114 golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d New BSD +115 gopkg.in/square/go-jose.v2 v2.5.1 Apache 2.0,New BSD +116 cloud.google.com/go v0.67.0 Apache 2.0,New BSD +117 github.com/GoogleCloudPlatform/cloudsql-proxy v0.0.0-20170623214735-571947b0f240 Apache 2.0 +118 github.com/Masterminds/semver/v3 v3.1.1 MIT +119 github.com/aymerick/douceur v0.2.0 MIT +120 github.com/badoux/checkmail v0.0.0-20170203135005-d0a759655d62 MIT +121 github.com/beevik/etree v1.1.0 Simplified BSD +122 github.com/coreos/go-oidc/v3 v3.0.0 Apache 2.0 +123 github.com/didip/tollbooth/v5 v5.1.1 MIT +124 github.com/fatih/color v1.10.0 MIT +125 github.com/fatih/structs v1.1.0 MIT +126 github.com/go-chi/chi v4.0.2 MIT +127 github.com/go-sql-driver/mysql v1.5.0 Mozilla Public License 2.0 +128 github.com/gobuffalo/envy v1.9.0 MIT +129 github.com/gobuffalo/fizz v1.13.0 MIT +130 github.com/gobuffalo/flect v0.2.2 MIT +131 github.com/gobuffalo/github_flavored_markdown v1.1.0 MIT,Simplified BSD +132 github.com/gobuffalo/helpers v0.6.1 MIT +133 github.com/gobuffalo/nulls v0.4.0 MIT +134 github.com/gobuffalo/packd v1.0.0 MIT +135 github.com/gobuffalo/plush/v4 v4.1.0 MIT +136 github.com/gobuffalo/pop/v5 v5.3.3 MIT +137 github.com/gobuffalo/tags/v3 v3.1.0 MIT +138 github.com/gobuffalo/validate/v3 v3.3.0 MIT +139 github.com/gofrs/uuid v4.0.0 MIT +140 github.com/golang-jwt/jwt v3.2.1 MIT +141 github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e Apache 2.0 +142 github.com/golang/protobuf v1.4.2 New BSD +143 github.com/googleapis/gax-go/v2 v2.0.5 New BSD +144 github.com/gorilla/context v1.1.1 New BSD +145 github.com/gorilla/css v1.0.0 New BSD +146 github.com/gorilla/securecookie v1.1.1 New BSD +147 github.com/gorilla/sessions v1.1.1 New BSD +148 github.com/imdario/mergo v0.0.0-20160216103600-3e95a51e0639 New BSD +149 github.com/jackc/chunkreader/v2 v2.0.1 MIT +150 github.com/jackc/pgconn v1.8.0 MIT +151 github.com/jackc/pgio v1.0.0 MIT +152 github.com/jackc/pgpassfile v1.0.0 MIT +153 github.com/jackc/pgproto3/v2 v2.0.7 MIT +154 github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b MIT +155 github.com/jackc/pgtype v1.6.2 MIT +156 github.com/jackc/pgx/v4 v4.10.1 MIT +157 github.com/jmoiron/sqlx v1.3.1 MIT +158 github.com/joho/godotenv v1.3.0 MIT +159 github.com/jonboulle/clockwork v0.2.2 Apache 2.0 +160 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 MIT +161 github.com/kelseyhightower/envconfig v1.4.0 MIT +162 github.com/lestrrat-go/jwx v0.9.0 MIT +163 github.com/luna-duclos/instrumentedsql v1.1.3 MIT +164 github.com/mattermost/xml-roundtrip-validator v0.1.0 Apache 2.0 +165 github.com/mattn/go-colorable v0.1.8 MIT +166 github.com/mattn/go-isatty v0.0.12 MIT +167 github.com/microcosm-cc/bluemonday v1.0.16 New BSD +168 github.com/mitchellh/mapstructure v1.1.2 MIT +169 github.com/mrjones/oauth v0.0.0-20190623134757-126b35219450 MIT +170 github.com/netlify/mailme v1.1.1 MIT +171 github.com/netlify/netlify-commons v0.32.0 MIT +172 github.com/opentracing/opentracing-go v1.1.0 Apache 2.0 +173 github.com/patrickmn/go-cache v2.1.0 MIT +174 github.com/philhofer/fwd v1.0.0 MIT +175 github.com/pkg/errors v0.9.1 Simplified BSD +176 github.com/rogpeppe/go-internal v1.8.0 New BSD +177 github.com/rs/cors v1.6.0 MIT +178 github.com/russellhaering/gosaml2 v0.6.1-0.20210916051624-757d23f1bc28 Apache 2.0 +179 github.com/russellhaering/goxmldsig v1.1.1 Apache 2.0 +180 github.com/sebest/xff v0.0.0-20160910043805-6c115e0ffa35 MIT +181 github.com/sergi/go-diff v1.1.0 Apache 2.0,MIT +182 github.com/sethvargo/go-password v0.2.0 MIT +183 github.com/sirupsen/logrus v1.7.0 MIT +184 github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d New BSD +185 github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e New BSD +186 github.com/spf13/cobra v1.1.3 Apache 2.0,GPLv2,GPLv3,LGPL,MIT,New BSD,Simplified BSD +187 github.com/spf13/pflag v1.0.5 New BSD +188 github.com/tinylib/msgp v1.1.0 MIT +189 go.opencensus.io v0.22.4 Apache 2.0 +190 golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad New BSD +191 golang.org/x/net v0.0.0-20220121210141-e204ce36a2ba New BSD +192 golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43 New BSD +193 golang.org/x/sync v0.0.0-20201207232520-09787c993a3a New BSD +194 golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e New BSD +195 golang.org/x/text v0.3.7 New BSD +196 golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1 New BSD +197 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 New BSD +198 google.golang.org/api v0.32.0 New BSD +199 google.golang.org/genproto v0.0.0-20200929141702-51c3e5b607fe Apache 2.0 +200 google.golang.org/grpc v1.32.0 Apache 2.0 +201 google.golang.org/protobuf v1.25.0 New BSD +202 gopkg.in/DataDog/dd-trace-go.v1 v1.12.1 New BSD +203 gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df MIT +204 gopkg.in/square/go-jose.v2 v2.5.1 Apache 2.0,New BSD +205 gopkg.in/yaml.v2 v2.4.0 Apache 2.0,MIT +206 github.com/aws/aws-sdk-go v1.36.20 Apache 2.0,New BSD +207 github.com/beorn7/perks v1.0.1 MIT +208 github.com/cespare/xxhash/v2 v2.1.1 MIT +209 github.com/golang/protobuf v1.4.3 New BSD +210 github.com/jmespath/go-jmespath v0.4.0 Apache 2.0 +211 github.com/matttproud/golang_protobuf_extensions v1.0.1 Apache 2.0 +212 github.com/prometheus/client_golang v1.9.0 Apache 2.0 +213 github.com/prometheus/client_model v0.2.0 Apache 2.0 +214 github.com/prometheus/common v0.15.0 Apache 2.0,New BSD +215 github.com/prometheus/procfs v0.2.0 Apache 2.0 +216 github.com/sirupsen/logrus v1.6.0 MIT +217 golang.org/x/sys v0.0.0-20201214210602-f9fddec55a1e New BSD +218 google.golang.org/protobuf v1.23.0 New BSD +219 gopkg.in/yaml.v2 v2.3.0 Apache 2.0,MIT +--- ----------------------------------------------------------- ------------------------------------- ------------------------------------------------------ diff --git a/web/static/videos/api/api-docs.mp4 b/web/static/videos/api/api-docs.mp4 new file mode 100644 index 00000000000..7fe41d76a17 Binary files /dev/null and b/web/static/videos/api/api-docs.mp4 differ diff --git a/web/static/videos/api/api-enable-graphql.mp4 b/web/static/videos/api/api-enable-graphql.mp4 new file mode 100644 index 00000000000..f563d278f21 Binary files /dev/null and b/web/static/videos/api/api-enable-graphql.mp4 differ diff --git a/www/_blog/2022-03-25-supabase-launch-week-four.mdx b/www/_blog/2022-03-25-supabase-launch-week-four.mdx index 89db9bd88f9..b8cd220c35f 100644 --- a/www/_blog/2022-03-25-supabase-launch-week-four.mdx +++ b/www/_blog/2022-03-25-supabase-launch-week-four.mdx @@ -72,8 +72,10 @@ Monday 28 March 2022: 9am PT ### What? -If Postgres could talk it would sing “I am the [WALRUS](https://supabase.com/blog/2021/09/10/supabase-beta-august-2021#realtime-security-codename-walrus)”. -Since last Launch Week we’ve been busy shipping. Tune in on Tuesday to learn about a brand new way to fetch your data. +~If Postgres could talk it would sing “I am the [WALRUS](https://supabase.com/blog/2021/09/10/supabase-beta-august-2021#realtime-security-codename-walrus)”.~ +~Since last Launch Week we've been busy shipping. Tune in on Tuesday to learn about a brand new way to fetch your data.~ + +[GraphQL is now available in Supabase!](/blog/2022/03/29/graphql-now-available) ### When? @@ -81,8 +83,9 @@ Tuesday 29 March 2022: 9am PT ### Where? -Blog post coming soon. Follow our [Twitter](https://twitter.com/supabase) to get notified immediately, -and set a reminder for our [YouTube live stream](https://www.youtube.com/watch?v=fApxu_U_hdw). +- Read the [blog post](/blog/2022/03/29/graphql-now-available). +- Tune in live on [Twitter Spaces](https://twitter.com/i/spaces/1dRKZlAQPaVJB). +- Watch live on [YouTube](https://youtu.be/fApxu_U_hdw). ## Wednesday: Something for the Enterprise @@ -107,7 +110,7 @@ Blog post coming soon. Follow our [Twitter](https://twitter.com/supabase) to get ### What? -This one’s gonna be hotter than the Human Torch 🔥, more scalable than Mister Fantastic, more rock solid than The Thing, +This one's gonna be hotter than the Human Torch 🔥, more scalable than Mister Fantastic, more rock solid than The Thing, and the latency will be harder to find than Invisible Woman. ### When? diff --git a/www/_blog/2022-03-29-graphql-now-available.mdx b/www/_blog/2022-03-29-graphql-now-available.mdx new file mode 100644 index 00000000000..dc2e53fa8ff --- /dev/null +++ b/www/_blog/2022-03-29-graphql-now-available.mdx @@ -0,0 +1,324 @@ +--- +title: 'GraphQL is now available in Supabase' +description: GraphQL support is now in general availability on the Supabase platform via our open source PostgreSQL extension, pg_graphql (beta). +author: oli_rice,dthyresson +image: launch-week-4/tuesday-graphql/graphql-thumb-og.png +thumb: launch-week-4/tuesday-graphql/graphql-thumb.png +tags: + - launch-week + - graphql +date: '2022-03-29' +toc_depth: 3 +--- + +GraphQL support is now in general availability on the Supabase platform via our [open source](https://github.com/supabase/pg_graphql/) PostgreSQL extension, +[`pg_graphql](https://supabase.github.io/pg_graphql/) (beta)`. + +![supameme.png](/images/blog/launch-week-4/tuesday-graphql/supameme.png) + +`pg_graphql` enables you to query existing PostgreSQL databases using GraphQL, either from within SQL or over HTTP: + +From SQL: + +```sql +select graphql.resolve($$ + { + accountCollection(first: 1) { + edges { + node { + id + firstName + address { + countryCode + } + } + } + } + } +$$); +``` + +or over HTTP: + +```bash +curl -X POST https://.supabase.co/graphql/v1 \ + -H 'apiKey: '\ + -H 'Content-Type: application/json' \ + --data-raw ' + { + "query":"{ accountCollection(first: 3) { edges { node { id } } } }" + }' +``` + +## Schema Reflection + +GraphQL types and fields are reflected from the SQL schema: + +- Tables become types +- Columns become fields +- Foreign keys become relations + +For example: + +```sql +create table "Account"( + "id" serial primary key, + "email" varchar(255) not null, + "createdAt" timestamp not null, + "updatedAt" timestamp not null +); + +-- Rebuild the GraphQL Schema Cache +select graphql.rebuild_schema(); +``` + +Translates to the GraphQL base type + +```graphql +type Account { + id: Int! + email: String! + createdAt: DateTime! + updatedAt: DateTime! +} +``` + +And exposes bulk CRUD operations on the `Query` and `Mutation` types, complete with relay style keyset pagination, filters, and ordering and (optional) name inflection. + +```graphql +type Query { + accountCollection( + first: Int + last: Int + before: Cursor + after: Cursor + filter: AccountFilter + orderBy: [AccountOrderBy!] + ): AccountConnection +} + +type Mutation { + insertIntoAccountCollection( + objects: [AccountInsertInput!]! + ): AccountInsertResponse + + updateAccountCollection( + set: AccountUpdateInput! + filter: AccountFilter + atMost: Int! = 1 + ): AccountUpdateResponse! + + deleteFromAccountCollection( + filter: AccountFilter + atMost: Int! = 1 + ): AccountDeleteResponse! +``` + +For a complete example with relationships, check out the [reflection docs](https://supabase.github.io/pg_graphql/reflection/). + +## Security + +An advantage to embedding GraphQL directly in the database is that we can lean on PostgreSQL's built-in primitives for authentication and authorization. + +### Authentication + +The GraphQL types exposed by `pg_graphql` are filtered according to the SQL role's [INSERT/UPDATE/DELETE permissions](https://www.postgresql.org/docs/current/sql-grant.html). +At Supabase, each API request is resolved in the database using the role in the request's JWT. + +Anonymous users receive the `anon` role, and logged in users get the `authenticated` role. In either case, pg_graphql resolves requests according to the SQL permissions. +The [introspection schema](https://graphql.org/learn/introspection/) is similarly filtered to limit exposed types and fields to those that the user has permission to access. +That means we can serve multiple GraphQL schemas for users of differing privilege levels from a single endpoint! + +### Authorization + +Another nice side effect of making PostgreSQL do the heavy lifting is that GraphQL queries respect your existing +[row level security policies](https://www.postgresql.org/docs/current/ddl-rowsecurity.html) right out-of-the-box. No additional configuration required. + +## Performance + +Each [free tier database](https://supabase.com/pricing) on the Supabase platform runs on a dedicated AWS t4g.micro instance with 2 vCPUs and 1 GB of memory. +To squeeze the most out of that limited hardware we had to make a few significant optimizations: + +**GraphQL queries are always transpiled into exactly one SQL query** + +The SQL queries select and aggregate requested data into the shape of the GraphQL JSON response. +In addition to solving the [N+1 query problem](https://medium.com/the-marcy-lab-school/what-is-the-n-1-problem-in-graphql-dd4921cb3c1a), a common issue with GraphQL resolvers, +GraphQL queries requiring multiple joins typically produce significantly less IO due to reduced data duplication. + +For example, when selecting all comments for a blog post: + +```sql +select + blog_posts.title, + comments.body as comment_body +from + blog_posts +join + comments on blog_posts.id = comments.blog_post_id +``` + +a SQL response would duplicate all data from the `blog_posts` table (title). + +```markdown +| title | comment_body | +| ---------- | ------------------------------ | +| F1sRt P0$T | this guy gets it! | +| F1sRt P0$T | you should re-write it in rust | +| F1sRt P0$T | 10% off daily vitamin http:... | +``` + +Compared to the equivalent GraphQL response. + +```json +{ + "blogPostCollection": { + "edges": { + "node": + "title": "F1sRt P0$T" + "commentCollection": { + "edges": [ + "node": { + "body": "this guy gets it!" + }, + "node": { + "body": "you should re-write it in rust" + }, + "node": { + "body": "10% off daily vitamin http:..." + } + ] + } + } + } + } +} +``` + +Which has no duplication of data. + +The difference in payload size is negligible in this case, but as the number of 1-to-many joins grows, data duplication in the SQL response grows geometrically. + +**Queries are cached as [prepared statements](https://www.postgresql.org/docs/current/sql-prepare.html)** + +After a GraphQL query is transpiled to SQL, it is added to the prepared statement cache so subsequent requests with the same structure (think pagination) can skip the transpilation step. + +Using prepared statements also allows PostgreSQL to skip the overhead of computing a query plan. For small, on-index, queries, +the query planning step can take several times as long as the query's execution time, so the saving is significant at scale. + +**All operations are bulk** + +Finally, all reflected query and mutation fields support bulk operations to nudge users towards consuming the API efficiently. +Batching similar operations reduces network round-trips and time spent in the database. + +**Result** + +As a result of these optimizations, the throughput of a “hello world” equivalent query on Supabase free-tier hardware is: + +- 377.4 requests/second through the API (mean) +- 656.2 queries/second through SQL (single connection, mean) + +## Getting Started + + + +To enable GraphQL in your Supabase instance, enable `pg_graphql` from the dashboard. + + + +Or create the extension in your database + +```sql +create extension pg_graphql; +``` + +And we're done! + +The GraphQL endpoint is available at: `https://.supabase.co/graphql/v1` + +## Example app: Build a HN clone with Postgres and GraphQL + +![graph-ql-example-app.png](/images/blog/launch-week-4/tuesday-graphql/graph-ql-example-app.png) + +We're excited to have worked with [The Guild](https://www.the-guild.dev) to show you [how to use](https://supabase-graphql-example.vercel.app/about) `pg_graphql` +and their tools to build a [HackerNews clone](https://supabase-graphql-example.vercel.app/). + +The [demo application](https://supabase-graphql-example.vercel.app/) showcases: + +- **CRUD (Query + Mutation Operations).** + Data is fetched from the GraphQL layer auto-generated via `pg_graphql`. +- **Cursor Based Pagination.** + `pg_graphql` generates standardized pagination types and fields as defined by the GraphQL Cursor Connections Specification. +- **Authorization / RLS.** + GraphQL requests made include Supabase authorization headers so that Row Level Security on the Postgres layer ensures that viewers can only access what they are allowed to — and authenticated users can only update what they should. +- **Code generation.** + Introspect your GraphQL schema and operations to generates the types for full backend to frontend type-safety. +- **Postgres Triggers and Functions.** + Recalculate the feed scoring each time someone votes. +- **Supabase UI.** + Use Auth widget straight out the box to handle logins and access tokens. + +![graph-ql-example-app-2.png](/images/blog/launch-week-4/tuesday-graphql/graph-ql-example-app-2.png) + +Now instead of using the Supabase PostgREST API to query your database ... + +```jsx +// using Supabase PostgREST + +const { data, error } = await supabase + .from('profile') + .select('id, username, bio, avatarUrl, website') +``` + +... all data fetching and updates are done using the same GraphQL operations you know and love! 🤯 + +``` +// using GraphQL + +query ProfilesQuery { + profileCollection { + edges { + node { + id + username + bio + avatarUrl + website + } + } + } +} +``` + +🎁  Get the code on GitHub here: [github.com/supabase-community/supabase-graphql-example](https://github.com/supabase-community/supabase-graphql-example) + +## Supabase + The Guild + +![graphql-supabase-guild.png](/images/blog/launch-week-4/tuesday-graphql/graphql-supabase-guild.png) + +This is just the start of what we hope to be a close collaboration with the Guild, whose expertise of the GraphQL ecosystem will guide the development of Supabase's GraphQL features. +The Guild and Supabase share a similar approach to open source - we both favor collaboration and composability, making collaboration easy and productive. + +Be sure to visit [The Guild](https://www.the-guild.dev) and [follow them](https://twitter.com/TheGuildDev) to stay informed of the latest developments in GraphQL. + +## Limitations & Roadmap + +Our first general availability release of `pg_graphql` supports: + +- Full CRUD on table columns with scalar types +- Read only support for array types +- Extending types with [computed fields](https://supabase.github.io/pg_graphql/computed_fields/) +- Configuration with [SQL comments](https://supabase.github.io/pg_graphql/configuration/) + +In the near term, we plan to fully support array and json/b types. Longer term, we intend to support views and custom mutations from user defined functions. + +Didn't see the feature you're interested in? [Let us know](https://github.com/supabase/pg_graphql/issues) diff --git a/www/lib/authors.json b/www/lib/authors.json index edaa5a794da..e884a8cead3 100644 --- a/www/lib/authors.json +++ b/www/lib/authors.json @@ -112,5 +112,13 @@ "position": "Engineering", "author_url": "https://github.com/w3b6x9", "author_image_url": "https://github.com/w3b6x9.png" + }, + { + "author_id": "dthyresson", + "author": "David Thyresson", + "username": "dthyresson", + "position": "Engineering", + "author_url": "https://github.com/dthyresson", + "author_image_url": "https://github.com/dthyresson.png" } ] diff --git a/www/pages/blog.tsx b/www/pages/blog.tsx index f11fc03261a..6f844a88c52 100644 --- a/www/pages/blog.tsx +++ b/www/pages/blog.tsx @@ -168,26 +168,28 @@ function FeaturedThumb(blog: PostTypes) {

{blog.description}

- {author.map((author: any) => { - return ( -
- {author.author_image_url && ( -
- +
+ {author.map((author: any) => { + return ( +
+ {author.author_image_url && ( +
+ +
+ )} +
+ {author.author} + {author.position}
- )} -
- {author.author} - {author.position}
-
- ) - })} + ) + })} +
diff --git a/www/pages/blog/[year]/[month]/[day]/[slug].tsx b/www/pages/blog/[year]/[month]/[day]/[slug].tsx index bacfd356040..e5070c4ab0f 100644 --- a/www/pages/blog/[year]/[month]/[day]/[slug].tsx +++ b/www/pages/blog/[year]/[month]/[day]/[slug].tsx @@ -215,7 +215,7 @@ function BlogPostPage(props: any) {

•

{generateReadingTime(props.blog.content.renderedOutput)}

-
+
{author.map((author: any) => { return (
diff --git a/www/public/images/blog/launch-week-4/tuesday-graphql/graph-ql-example-app-2.png b/www/public/images/blog/launch-week-4/tuesday-graphql/graph-ql-example-app-2.png new file mode 100644 index 00000000000..48135bd5ad3 Binary files /dev/null and b/www/public/images/blog/launch-week-4/tuesday-graphql/graph-ql-example-app-2.png differ diff --git a/www/public/images/blog/launch-week-4/tuesday-graphql/graph-ql-example-app.png b/www/public/images/blog/launch-week-4/tuesday-graphql/graph-ql-example-app.png new file mode 100644 index 00000000000..a2b4123bbdb Binary files /dev/null and b/www/public/images/blog/launch-week-4/tuesday-graphql/graph-ql-example-app.png differ diff --git a/www/public/images/blog/launch-week-4/tuesday-graphql/graphql-supabase-guild.png b/www/public/images/blog/launch-week-4/tuesday-graphql/graphql-supabase-guild.png new file mode 100644 index 00000000000..68639a1180a Binary files /dev/null and b/www/public/images/blog/launch-week-4/tuesday-graphql/graphql-supabase-guild.png differ diff --git a/www/public/images/blog/launch-week-4/tuesday-graphql/graphql-thumb-og.png b/www/public/images/blog/launch-week-4/tuesday-graphql/graphql-thumb-og.png new file mode 100644 index 00000000000..e132c772882 Binary files /dev/null and b/www/public/images/blog/launch-week-4/tuesday-graphql/graphql-thumb-og.png differ diff --git a/www/public/images/blog/launch-week-4/tuesday-graphql/graphql-thumb.png b/www/public/images/blog/launch-week-4/tuesday-graphql/graphql-thumb.png new file mode 100644 index 00000000000..12ef9f5bc25 Binary files /dev/null and b/www/public/images/blog/launch-week-4/tuesday-graphql/graphql-thumb.png differ diff --git a/www/public/images/blog/launch-week-4/tuesday-graphql/supameme.png b/www/public/images/blog/launch-week-4/tuesday-graphql/supameme.png new file mode 100644 index 00000000000..2d912cbccc3 Binary files /dev/null and b/www/public/images/blog/launch-week-4/tuesday-graphql/supameme.png differ diff --git a/www/public/rss.xml b/www/public/rss.xml index 41057c7bce5..67966f0f188 100644 --- a/www/public/rss.xml +++ b/www/public/rss.xml @@ -5,9 +5,17 @@ https://supabase.com Latest news from Supabase en - Sun, 27 Mar 2022 22:00:00 GMT + Mon, 28 Mar 2022 22:00:00 GMT + + https://supabase.com/blog/2022/03/29/graphql-now-available + GraphQL is now available in Supabase + https://supabase.com/blog/2022/03/29/graphql-now-available + GraphQL support is now in general availability on the Supabase platform via our open source PostgreSQL extension, pg_graphql (beta). + Mon, 28 Mar 2022 22:00:00 GMT + + https://supabase.com/blog/2022/03/28/community-day Community Day diff --git a/www/public/videos/blog/launch-week-4/api-enable-graphql.mp4 b/www/public/videos/blog/launch-week-4/api-enable-graphql.mp4 new file mode 100644 index 00000000000..f563d278f21 Binary files /dev/null and b/www/public/videos/blog/launch-week-4/api-enable-graphql.mp4 differ diff --git a/www/tailwind.config.js b/www/tailwind.config.js index a17c3249073..1806eb442ee 100644 --- a/www/tailwind.config.js +++ b/www/tailwind.config.js @@ -82,6 +82,10 @@ module.exports = ui({ padding: 0, marginBottom: '32px', }, + img: { + borderRadius: '4px', + overflow: 'hidden', + }, }, },