diff --git a/.gitignore b/.gitignore index e5840f62fad..6364efb6add 100644 --- a/.gitignore +++ b/.gitignore @@ -106,3 +106,7 @@ typings/ !docker/.env !docker/supabase/.env !docker/supabase-traefik/.env + +# Supabase +**/supabase/.branches +**/supabase/.temp diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 46699f6c553..d418484fd8b 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -151,7 +151,7 @@ services: db: container_name: supabase-db - image: supabase/postgres:14.1.0.15 + image: supabase/postgres:14.1.0.19 command: postgres -c config_file=/etc/postgresql/postgresql.conf restart: unless-stopped ports: 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/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/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.data.ts b/studio/components/interfaces/Functions/Functions.data.ts new file mode 100644 index 00000000000..88d1b1e44fb --- /dev/null +++ b/studio/components/interfaces/Functions/Functions.data.ts @@ -0,0 +1,326 @@ +export const LOGS_DATA = [ + { + id: 1, + url: '/readededeaded/functions/hello-world', + method: 'POST', + runtime: 'Node14', + memory: 256, + created_at: 1646524256, + user_agent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36', + memory_used: '113', + init_duration: '431.44', + duration: '1394.57 ms', + edge_status: '200', + function_status: '200', + }, + { + id: 2, + url: '/readededeaded/functions/hello-world', + method: 'POST', + runtime: 'Node14', + memory: 256, + created_at: 1646524256, + user_agent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36', + memory_used: '113', + init_duration: '431.44', + duration: '1394.57 ms', + edge_status: '200', + function_status: '200', + }, + { + id: 3, + url: '/readededeaded/functions/hello-world', + method: 'POST', + runtime: 'Node14', + memory: 512, + created_at: 1646524256, + user_agent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36', + memory_used: '113', + init_duration: '431.44', + duration: '1394.57 ms', + edge_status: '200', + function_status: '200', + }, + { + id: 3, + url: '/readededeaded/functions/hello-world', + method: 'POST', + runtime: 'Node14', + memory: 512, + created_at: 1646524256, + user_agent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36', + memory_used: '113', + init_duration: '431.44', + duration: '1394.57 ms', + edge_status: '200', + function_status: '200', + }, + { + id: 3, + url: '/readededeaded/functions/hello-world', + method: 'POST', + runtime: 'Node14', + memory: 512, + created_at: 1646524256, + user_agent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36', + memory_used: '113', + init_duration: '431.44', + duration: '1394.57 ms', + edge_status: '200', + function_status: '200', + }, + { + id: 3, + url: '/readededeaded/functions/hello-world', + method: 'POST', + runtime: 'Node14', + memory: 512, + created_at: 1646524256, + user_agent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36', + memory_used: '113', + init_duration: '431.44', + duration: '1394.57 ms', + edge_status: '200', + function_status: '200', + }, + { + id: 3, + url: '/readededeaded/functions/hello-world', + method: 'POST', + runtime: 'Node14', + memory: 512, + created_at: 1646524256, + user_agent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36', + memory_used: '113', + init_duration: '431.44', + duration: '1394.57 ms', + edge_status: '200', + function_status: '200', + }, + { + id: 3, + url: '/readededeaded/functions/hello-world', + method: 'POST', + runtime: 'Node14', + memory: 512, + created_at: 1646524256, + user_agent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36', + memory_used: '113', + init_duration: '431.44', + duration: '1394.57 ms', + edge_status: '200', + function_status: '200', + }, + { + id: 3, + url: '/readededeaded/functions/hello-world', + method: 'POST', + runtime: 'Node14', + memory: 512, + created_at: 1646524256, + user_agent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36', + memory_used: '113', + init_duration: '431.44', + duration: '1394.57 ms', + edge_status: '200', + function_status: '200', + }, +] + +export const REQUEST_SAMPLE = { + custom_fields: { + '0': { + name: 'Project name', + value: '1a1a1a1a1a1a1a1a1a1a1a', + }, + }, +} + +export const RESPONSE_SAMPLE = [ + { + id: 'in_1a1a1a1a1a1a1a1a1a1a1a', + object: '1a1a1a1a1a1a1a1a1a1a1a', + account_country: 'US', + account_name: '1a1a1a1a1a1a1a1a1a1a1a', + account_tax_ids: null, + amount_due: 0, + amount_paid: 0, + amount_remaining: 0, + application_fee_amount: null, + attempt_count: 0, + attempted: false, + auto_advance: true, + automatic_tax: { + enabled: false, + status: null, + }, + billing_reason: '1a1a1a1a1a1a1a1a1a1a1a', + charge: null, + collection_method: 'charge_automatically', + created: 1646526032, + currency: 'usd', + custom_fields: [ + { + name: 'Project name', + value: '1a1a1a1a1a1a1a1a1a1a1a', + }, + ], + customer: 'cus_in_1a1a1a1a1a1a1a1a1a1a1a', + customer_address: null, + customer_email: 'in_1a1a1a1a1a1a1a1a1a1a1a@gmail.com', + customer_name: null, + customer_phone: null, + customer_shipping: null, + customer_tax_exempt: 'none', + customer_tax_ids: [], + default_payment_method: null, + default_source: null, + default_tax_rates: [], + description: null, + discount: null, + discounts: [], + due_date: null, + ending_balance: null, + footer: null, + hosted_invoice_url: null, + invoice_pdf: null, + last_finalization_error: null, + lines: { + object: 'list', + data: [ + { + id: 'il_in_1a1a1a1a1a1a1a1a1a1a1a', + object: 'line_item', + amount: 0, + currency: 'usd', + description: '1 × Free tier (at $0.00 / month)', + discount_amounts: [], + discountable: true, + discounts: [], + livemode: true, + metadata: { + project_id: '29167', + project_ref: '1a1a1a1a1a1a1a1a1a1a1a', + project_name: '1a1a1a1a1a1a1a1a1a1a1a', + organization_id: '1a1a1a1a1a1a1a1a1a1a1a', + organization_slug: + '1a1a1a1a1a1a1a1a1a1a1a-1a1a1a1a1a1a1a1a1a1a1a-1a1a1a1a1a1a1a1a1a1a1a', + }, + period: { + end: 1649204401, + start: 1646526001, + }, + plan: { + id: 'price_1a1a1a1a1a1a1a1a1a1a1a', + object: 'plan', + active: true, + aggregate_usage: null, + amount: 0, + amount_decimal: '0', + billing_scheme: 'per_unit', + created: 1615645622, + currency: 'usd', + interval: 'month', + interval_count: 1, + livemode: true, + metadata: { + price: '0000', + }, + nickname: null, + product: 'prod_1a1a1a1a1a1a1a1a1a1a1a', + tiers_mode: null, + transform_usage: null, + trial_period_days: null, + usage_type: 'licensed', + }, + price: { + id: 'price_1a1a1a1a1a1a1a1a1a1a1a', + object: 'price', + active: true, + billing_scheme: 'per_unit', + created: 1615645622, + currency: 'usd', + livemode: true, + lookup_key: null, + metadata: { + price: '0000', + }, + nickname: null, + product: 'prod_1a1a1a1a1a1a1a1a1a1a1a', + recurring: { + aggregate_usage: null, + interval: 'month', + interval_count: 1, + trial_period_days: null, + usage_type: 'licensed', + }, + tax_behavior: 'unspecified', + tiers_mode: null, + transform_quantity: null, + type: 'recurring', + unit_amount: 0, + unit_amount_decimal: '0', + }, + proration: false, + proration_details: { + credited_items: null, + }, + quantity: 1, + subscription: 'sub_1a1a1a1a1a1a1a1a1a1a1a', + subscription_item: 'si_1a1a1a1a1a1a1a1a1a1a1a', + tax_amounts: [], + tax_rates: [], + type: 'subscription', + }, + ], + has_more: false, + total_count: 1, + url: '/v1/invoices/in_1a1a1a1a1a1a1a1a1a1a1a/lines', + }, + livemode: true, + metadata: {}, + next_payment_attempt: 1646529632, + number: null, + on_behalf_of: null, + paid: false, + paid_out_of_band: false, + payment_intent: null, + payment_settings: { + payment_method_options: null, + payment_method_types: null, + }, + period_end: 1646526001, + period_start: 1644106801, + post_payment_credit_notes_amount: 0, + pre_payment_credit_notes_amount: 0, + quote: null, + receipt_number: null, + starting_balance: 0, + statement_descriptor: null, + status: 'draft', + status_transitions: { + finalized_at: null, + marked_uncollectible_at: null, + paid_at: null, + voided_at: null, + }, + subscription: 'sub_1a1a1a1a1a1a1a1a1a1a1a', + subtotal: 0, + tax: null, + test_clock: null, + total: 0, + total_discount_amounts: [], + total_tax_amounts: [], + transfer_data: null, + webhooks_delivered_at: null, + }, +] 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..a539500390c --- /dev/null +++ b/studio/components/interfaces/Functions/FunctionsLayout.tsx @@ -0,0 +1,141 @@ +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, centered }: { children?: React.ReactNode; centered?: boolean }) => { + const { functions, ui } = useStore() + const router = useRouter() + + const { id, ref } = router.query + + useEffect(() => { + if (ui.selectedProjectRef) { + functions.load() + } + }, [ui.selectedProjectRef]) + + if (!functions.isLoaded) { + return ( + + loading + + ) + } + + const item = id ? functions.byId(id) : null + const name = item?.name || '' + + return ( + + + {centered ? ( + <> +
+
+
+ +
+

Functions

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

+ Functions +

+ + + {name && ( + <> + + + + + +
{name}
+ + )} +
+ {item && } +
+
+ {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/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..8432c76b892 --- /dev/null +++ b/studio/components/interfaces/Settings/Logs/LogsNavigation.tsx @@ -0,0 +1,39 @@ +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') + + const tabs = [ + { id: 'query', label: 'Query' }, + logsSourcesPage ? { id: 'sources', label: 'Sources' } : null, + logsSavedQueries ? { id: 'saved', label: 'Saved' } : null, + { id: 'templates', label: 'Templates' }, + ].filter(Boolean) + + return ( + { + router.push(`/project/${ref}/logs-explorer/${e === indexRoute ? '' : e}`) + }} + > + {/* TODO: type properly */} + {(tabs as any[]).map(({ id, label }) => ( + + ))} + + ) +} + +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..91d60213a50 --- /dev/null +++ b/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx @@ -0,0 +1,98 @@ +import { Button, Dropdown, Typography, IconChevronDown, IconPlay } from '@supabase/ui' +import Flag from 'components/ui/Flag/Flag' +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/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..cbf99c9a405 --- /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.isLoaded) { + 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..1825abd5fc6 --- /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 FunctionsLayout from 'components/interfaces/Functions/FunctionsLayout' +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?.slug}`, + description: 'This will overwrite the deployed function with your new function', + jsx: () => { + return ( + <> + supabase functions deploy{' '} + {selectedFunction?.slug} + + ) + }, + comment: 'Deploy a new version', + }, + { + command: `supabase functions delete ${selectedFunction?.slug}`, + description: 'This will remove the function and all the logs associated with it', + jsx: () => { + return ( + <> + supabase functions delete{' '} + {selectedFunction?.slug} + + ) + }, + 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=VALUE1 NAME2=VALUE2`, + description: 'This will set secrets for your project', + jsx: () => { + return ( + <> + supabase secrets set NAME1=VALUE1 NAME2=VALUE2 + + ) + }, + 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..88090571608 --- /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 FunctionsLayout from 'components/interfaces/Functions/FunctionsLayout' + +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..5217ba872bc --- /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 FunctionsLayout from 'components/interfaces/Functions/FunctionsLayout' + +/** + * 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-old.tsx b/studio/pages/project/[ref]/functions/[id]/logs-old.tsx new file mode 100644 index 00000000000..928f3fc59c6 --- /dev/null +++ b/studio/pages/project/[ref]/functions/[id]/logs-old.tsx @@ -0,0 +1,334 @@ +import dayjs from 'dayjs' +import { useRouter } from 'next/router' +import { useState } from 'react' +import { observer } from 'mobx-react-lite' + +import { + Button, + Checkbox, + IconCalendar, + IconChevronRight, + IconExternalLink, + IconGlobe, + IconSearch, + Input, + Popover, + SidePanel, +} from '@supabase/ui' + +import { withAuth } from 'hooks' +import Table from 'components/to-be-cleaned/Table' +import FunctionsLayout from 'components/interfaces/Functions/FunctionsLayout' +import FunctionLogsItem from 'components/interfaces/Functions/FunctionLogsItem' +import { LOGS_DATA } from 'components/interfaces/Functions/Functions.data' + +const EmptyFunctions = () => { + 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 +

+
+ + +
+
+

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 FunctionsList = () => { + const [detailVisible, setDetailVisible] = useState(false) + + const router = useRouter() + const { ref } = router.query + + const PageState = { + functions: [ + { + name: 'hello-world', + id: 1, + runtime: 'Node14', + memory: 256, + created_at: 1646524256, + }, + { + name: 'charge', + id: 2, + runtime: 'Node14', + memory: 256, + created_at: 1646524256, + }, + { + name: 'projects', + id: 3, + runtime: 'Node14', + memory: 512, + created_at: 1646524256, + }, + ], + } + + const logDetails = ( +
+ Status +
+
+
200 OK
+
+
+ +
ID
+
+ {LOGS_DATA[0].id} +
+ +
Time
+
+ {dayjs(LOGS_DATA[0].created_at).format('DD MMM, YYYY HH:mm')} +
+ +
IP Address
+
+ {/* + // @ts-ignore */} + {LOGS_DATA[0].ip_address} +
+ +
API Version
+
+ v0.1 +
+ +
Source
+
+ Something +
+ + {/*
User agent
+
+ {LOGS_DATA[0].user_agent} +
*/} +
+ ) + + return ( +
+
+
+ } placeholder="Filter by anything.." size="tiny" /> +
+ + +
+ + +
Status
+ +
+ } + onOpenChange={function noRefCheck() {}} + overlay={[ + <> +
+ + + +
+ , + ]} + > + + +
+ +
+
+
+ + Response + Method + URL + Timestamp + + } + body={ + <> + {LOGS_DATA.length > 0 && + LOGS_DATA.map((x: any) => ( + { + setDetailVisible(true) + }} + /> + ))} + + } + /> + +
+

+ POST /readededeaded/functions/hello-world +

+ {logDetails} + +

Response body

+
+ + setDetailVisible(false)} + > +
+ {logDetails} + + +

Response body

+
+
+
+ + ) +} + +const PageLayout = () => { + const [pageState, setPageState] = useState('list') + + const Selection = () => ( +
+ + +
+ ) + + return ( + + + + ) +} + +export default withAuth(observer(PageLayout)) 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..4ac048890ea --- /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 FunctionsLayout from 'components/interfaces/Functions/FunctionsLayout' + +/** + * 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..6a1bb91493c --- /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 FunctionsLayout from 'components/interfaces/Functions/FunctionsLayout' + +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..c07a15bd1f8 --- /dev/null +++ b/studio/pages/project/[ref]/functions/index.tsx @@ -0,0 +1,263 @@ +import Link from 'next/link' +import { useEffect } from 'react' +import { useRouter } from 'next/router' +import { observer } from 'mobx-react-lite' +import { Button, IconBookOpen, IconSearch, IconTerminal, Input, Loading } from '@supabase/ui' + +import { useStore, withAuth } from 'hooks' +import { post } from 'lib/common/fetch' +import { API_URL, PROJECT_STATUS } from 'lib/constants' +import Table from 'components/to-be-cleaned/Table' +import FunctionsLayout from 'components/interfaces/Functions/FunctionsLayout' +import FunctionsListItem from 'components/interfaces/Functions/FunctionsListItem' +import CommandRender from 'components/interfaces/Functions/CommandRender' +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: `Associate the current folder with your Supabase 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/hello.ts', + jsx: () => { + return ( + <> + supabase functions new hello + + ) + }, + comment: 'Create a function', + }, + { + command: 'supabase functions deploy hello', + description: 'Deploys function at ./functions/hello/index.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: 'Invokes the hello function', + jsx: () => { + return ( + <> + curl -L -X POST 'https://{ref} + .functions.supabase.co/hello' -H 'Authorization: Bearer [YOUR ANON KEY]' + + ) + }, + comment: 'Invoke 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 { functions } = useStore() + const hasFunctions = functions.list().length > 0 + + return ( + + {hasFunctions ? : } + + ) +} + +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..6117098a77e 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,12 +50,14 @@ export class RootStore implements IRootStore { () => this.ui.selectedProject, (selectedProject) => { if (selectedProject) { + // @ts-ignore this.content = new ProjectContentStore(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.meta = new MetaStore(this, { projectRef: '', @@ -64,6 +71,7 @@ export class RootStore implements IRootStore { setProjectRef(value?: string) { if (this.ui.selectedProject?.ref === value) return this.ui.setProjectRef(value) + this.functions.setProjectRef(value) } setOrganizationSlug(value?: string) { diff --git a/studio/stores/UiStore.ts b/studio/stores/UiStore.ts index 55c59c0b381..312e19bc336 100644 --- a/studio/stores/UiStore.ts +++ b/studio/stores/UiStore.ts @@ -9,6 +9,7 @@ export interface IUiStore { theme: 'dark' | 'light' themeOption: 'dark' | 'light' | 'system' + selectedProjectRef?: string isDarkTheme: boolean selectedProject?: Project selectedOrganization?: Organization diff --git a/studio/stores/content/ProjectContentStore.ts b/studio/stores/content/ProjectContentStore.ts index 4dd3e2c00ea..1c8fddff0ad 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..b8d9e564884 --- /dev/null +++ b/studio/stores/functions/ProjectFunctionsStore.ts @@ -0,0 +1,205 @@ +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 + + baseUrl: string + projectRef: string + + load: () => void + list: (filter?: any) => any[] + byId: (id?: any) => any + reports: (filter?: any) => any[] + sqlSnippets: (filter?: any) => any[] + setProjectRef: (ref?: string) => void +} + +export default class ProjectFunctionsStore implements IProjectFunctionsStore { + rootStore: IRootStore + + STATES = { + INITIAL: 'initial', + LOADING: 'loading', + ERROR: 'error', + LOADED: 'loaded', + } + + baseUrl: string + projectRef: string + // @ts-ignore + data: UserContentMap = [] + + state = this.STATES.INITIAL + error = null + + constructor(rootStore: IRootStore, options: { projectRef: string }) { + const { projectRef } = options + this.projectRef = projectRef + this.rootStore = rootStore + this.baseUrl = `` + 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 } + } + } + + setProjectRef(ref?: string) { + if (ref) { + this.projectRef = ref + this.baseUrl = `${process.env.NEXT_PUBLIC_API_ADMIN_URL}/projects/${ref}/functions` + } + } +} 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/platform/logs.mdx b/web/docs/guides/platform/logs.mdx index e40ac2a8a8d..c1be8d25568 100644 --- a/web/docs/guides/platform/logs.mdx +++ b/web/docs/guides/platform/logs.mdx @@ -1,20 +1,39 @@ --- id: logs -title: Log Browser +title: Logging description: Getting started with Supabase Platform Log Browser --- The Supabase Platform provides a log browser that allows log tracing and debugging. Currently, PostgreSQL and Cloudflare edge logs are available. -The features discussed in this article are only available through the Supabase platform and are not available on self-hosted. +:::note -The log browser can be accessed through: +The features discussed in this article are only available through the Supabase Platform and are not available on self-hosted. -- `Project Overview > Settings > API` -- `Project Overview > Settings > Database` +::: + +## Log Explorer ![Sidebar navigation steps](/img/guides/platform/logs/sidebar-navigation.png) +The log browser can be accessed in the sidebar under `Log Explorer`. The Log Explorer is for exploring all the logs within your project and is not specific to any product. + +## Product Logs + +As well as a Log Explorer, Supabase provides a logging interface specific to each product. + +### API Logs + +![API Logs](/img/guides/platform/logs/logs-api.png) + +The API Logs can be found under `Database > API Logs`. These show all network requests for the REST and GraphQL [API](/docs/guides/api). + +### Postgres Logs + +![Postgres Logs](/img/guides/platform/logs/logs-database.png) + +The Postgres Logs can be found under `Database > Postgres Logs`. These show all queries and activity for your [Database](/docs/guides/database). + ## Basic Querying Features - Simple regex-based search on the event message. @@ -28,9 +47,7 @@ To access advanced querying features, you will need to enable **custom queries** ![Enabling query mode](/img/guides/platform/logs/query-mode.png) - - -`SELECT` queries allow complex and powerful aggregated result sets, letting you to perform in-depth analysis of your log data. +`select` queries allow complex and powerful aggregated result sets, letting you to perform in-depth analysis of your log data. For example, you may enter the following into the API logs dashboard to query for each user's IP address: @@ -55,14 +72,14 @@ The table references are as follows: Note that cross-table joining is currently not supported. -#### Unnesting Arrays +### Unnesting Arrays In the above example, in order to query the metadata field, we need to unnest the field and add it as a join. Wehn we click on an individual log, we can see that log metadata is stored as an array of objects. In order to query any value that is an array, we would need to `UNNEST()` that field and add it to the query as a join, thereby allowing us to reference the nested fields within the array. -#### Functions +### Functions You may have also noticed from the above examples that we are able to use certain SQL functions within our queries. The querying engine currently supported is BigQuery, hence you may use [any valid BigQuery function](https://cloud.google.com/bigquery/docs/reference/standard-sql/functions-and-operators) within your query. @@ -71,7 +88,7 @@ You may have also noticed from the above examples that we are able to use certai Each log timestamp is stored as a `TIMESTAMP` data type. In order to utilize the `timestamp` field in a query, you would need to use the appropriate [timestamp functions](https://cloud.google.com/bigquery/docs/reference/standard-sql/timestamp_functions#timestamp). -### Template Queries +## Template Queries Templates are available to help shorten the time needed to craft these queries. There are examples for each complexity level, which can serve as a foundation for your custom queries. diff --git a/web/static/img/guides/platform/logs/logs-api.png b/web/static/img/guides/platform/logs/logs-api.png new file mode 100644 index 00000000000..a5051f4ca2a Binary files /dev/null and b/web/static/img/guides/platform/logs/logs-api.png differ diff --git a/web/static/img/guides/platform/logs/logs-database.png b/web/static/img/guides/platform/logs/logs-database.png new file mode 100644 index 00000000000..2b67b16a2a8 Binary files /dev/null and b/web/static/img/guides/platform/logs/logs-database.png differ diff --git a/web/static/img/guides/platform/logs/sidebar-navigation.png b/web/static/img/guides/platform/logs/sidebar-navigation.png index 1514460dd79..41cf3ebdfe4 100644 Binary files a/web/static/img/guides/platform/logs/sidebar-navigation.png and b/web/static/img/guides/platform/logs/sidebar-navigation.png differ 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/www/_blog/2022-03-30-supabase-enterprise.mdx b/www/_blog/2022-03-30-supabase-enterprise.mdx new file mode 100644 index 00000000000..0a73edfad40 --- /dev/null +++ b/www/_blog/2022-03-30-supabase-enterprise.mdx @@ -0,0 +1,278 @@ +--- +title: 'Introducing Supabase Enterprise' +description: 'Today we are releasing Supabase Enterprise, a suite of features to scale your project.' +author: rory_wilding,paul_copplestone +image: launch-week-4/enterprise-day/enterprise-thumb.png +thumb: launch-week-4/enterprise-day/enterprise-thumb.png +tags: + - launch-week + - enterprise +date: '2022-03-30' +toc_depth: 3 +--- + +As our platform continues its rapid adoption within the developer community, we're seeing a growing segment of users building business-critical applications that require enterprise-grade resilience. + + + +## Enterprise features for everybody + +Some features are too good to limit to large customers, so today we're introducing a few enterprise features into the Pro tier. + +### Spend caps + + + +To simplify pricing, we've merged the “Pro” and “Pay as you go” tiers and introduced monthly spend caps to avoid nasty billing surprises. +These changes are to keep Supabase pricing [predictable, transparent, and developer friendly](/blog/2021/03/29/pricing). + +When you upgrade to the Pro Tier, spend caps are turned on by default, limiting your per-project costs to $25 per month. +We're also retaining our soft limits while we manage the transition to granular spend-caps, so your service will continue to run even if your usage exceeds $25 (we'll contact you directly when you go over the limit). Right now there is a global project spend-cap, and in the future you'll have full control with configurable spend-caps on a “per-feature” basis. + +### Database Add-ons + + + +Today we're releasing self-serve Database Add-ons. + +What do you do when your project hits production and your userbase is sky-rocketing? +Many developers have been asking to scale their projects on-demand. Database Add-ons give everyone this control. + +Today, Database Add-ons are available for a small set of customers. We will progressively release this for everyone by the end of next week (Fridy 8th April). + +### New Log Explorer + + + + +Today we're releasing a brand new [Log Explorer](/docs/guides/platform/logs) in the Supabase Dashboard. +As big advocates of SQL, we've done what any good Postgres fanatics would do - we're giving you the ability to query your logs using SQL. +And if you're new to SQL, we have plenty of templates included. +This is just one of the exciting features we are releasing through our [Logflare acquisition](/blog/2021/12/02/supabase-acquires-logflare). +Log history is available to every Supabase project: + +- Free Tier: 7 days of log history +- Pro Tier: 30 days of log history +- Enterprise Tier: 90 days of log history + +### Elixir Livebooks + +Today we're releasing [Elixir Livebooks for Monitoring](https://github.com/supabase/livebooks), starting with built-in monitoring for PgBouncer, a Postgres connection pooler. + +![PGBoucner](/images/blog/launch-week-4/enterprise-day/pg-bouncer.png) + +While our default PgBouncer settings work for 98% of our customers, sometimes they require customization for unique load patterns. +For example, Supabase is popular with web3 projects where traffic can be very unpredictable - especially when you partner with Snoop Dogg. +When [sound.xyz](http://sound.xyz) dropped [an NFT with Snoop](https://www.sound.xyz/snoopdogg/death-row-mix-vol-1), we customized their pooler to handle significant load. +Their database handled over 9,000 simultaneously connections from Vercel's serverless API. + + +

In our MVP, we started with a serverless stack to simplify DevOps. Supabase made it dead simple to get PgBouncer and Postgres running so we could focus on the product.

+

+ As we started hitting scale, they've been crucial to supporting our drops. When Snoop Dogg debuted on Sound, Supabase was able to help us provision our data store to handle the load. +

+

+ As we rearchitect our backend stack towards scaled microservices, we can be confident that managing Postgres won't be a bottleneck. +

+

+ Supabase takes out the mental effort from our back-end infrastructure so we can focus on our + customers needs. +

+
+ +If you want to monitor your own PgBouncer connections you can easily [spin up a Livebook](https://github.com/supabase/livebooks) on a free [Fly.io](http://fly.io) instance. + +## Enterprise Features + +With the release of our new Enterprise Tier, we're announcing a tonne of new features for Enterprise customers. + +### Point-in-Time Recovery + + + +Disaster Recovery is a critical process for any company, even for the most fault-tolerant products. What happens when you accidentally delete that database column +because of a clumsy `where` clause? Even with Supabase's daily backups, a day's worth of data can be lost if disaster strikes at the most inopportune time. + +Every Enterprise project on the Supabase platform has access to Point-in-Time Recovery (PITR), allowing projects to recover from a snapshot mere seconds after a disaster. +Supabase PITR is powered by [WAL-G](https://github.com/wal-g/wal-g), an open source archival and restoration tool. + +### Prometheus Endpoints + +![Prometheus](/images/blog/launch-week-4/enterprise-day/prometheus.png) + +If you run critical infrastructure you likely use Prometheus to monitor your metrics. We've now exposed a Prometheus compatible endpoint for our Enterprise customers. +This allows them to scrape Supabase metrics into their own metrics infrastructure for real-time monitoring and alerting. This makes monitoring Supabase as easy as building with Supabase. + +If you want access to the Prometheus Endpoint, [contact the Supabase Enterprise team](/contact/enterprise) today and we'll get you setup. + +### SLAs & Enterprise Support + + + +For our enterprise customers we know that service level agreements and support response times are also critical features. We continue to treat support as an +important priority for all tiers, though enterprise users require confidence that our response times meet their business needs. We now offer faster response times for Enterprise customers, +alongside Priority and Priority Plus support packages for those who need more comprehensive support. You can find further details around our SLAs & support +[in our documentation](/docs/company/sla). + +### Enterprise Pricing + +![enterprise-pricing](/images/blog/launch-week-4/enterprise-day/enterprise-pricing.png) + +If you need more information on our Enterprise tier, Pricing, SLAs, Support packages, or want to learn more about how Supabase can meet your scaling needs just +[contact us](/contact/enterprise). + +## Coming soon + +We have a lot more Enterprise features under development, available to early-access customers: + +### SOC2 + +We are actively working towards our SOC2 Type II certification by monitoring and patching alerts raised by our security monitoring platform. +SOC2 covers a wide spectrum of potential security areas and achieving SOC2 would also ease compliance with other major certifications like ISO-27001 and HIPAA. + +### Log ingestion + +Supabase is now ingesting over 4 billion log events every week, just from the Postgres instances we host on the platform. +Soon you'll be able to ingest logs and analytics from anywhere directly into your Supabase project. + +### Foreign Data Wrappers + +Ingesting your logs is one thing, but what about querying them directly from your PostgreSQL database? What if you can figure out how many API requests one of your users made last month? +Or how many gigabytes of video a user streamed from [Supabase Storage](/storage)? Watch this space! + +### Materialized Views + +Querying huge datasets can be time-consuming, especially when you are aggregating billions of rows of historical logs. Supabase will make this simple with auto-updating views, +saved to disk for increased performance. These views update periodically at a cadence that you decide. You'll even be able to fetch these views with your +[Supabase API](/docs/guides/api)! + +## Next steps + +Get started today with all of our Enterprise Features on [app.supabase.io](https://app.supabase.io), or [contact the Supabase Enterprise team](/contact/enterprise) +if you want to access our Enterprise features. diff --git a/www/components/EnterpriseContactForm.tsx b/www/components/EnterpriseContactForm.tsx new file mode 100644 index 00000000000..af4c80eda3a --- /dev/null +++ b/www/components/EnterpriseContactForm.tsx @@ -0,0 +1,163 @@ +import { Button, Form, Input, Select } from '@supabase/ui' +import { useState } from 'react' +import countries from '~/data/Countries.json' +import supabase from '~/lib/supabase' + +const INITIAL_VALUES = { + companyName: '', + contactName: '', + contactEmail: '', + contactPhone: '', + companySize: '', + details: '', + country: 'US', +} + +const validate = (values: any) => { + const errors: any = {} + + if (!values.companyName) { + errors.companyName = 'Required' + } + + if (!values.contactName) { + errors.contactName = 'Required' + } + + if (!values.contactEmail) { + errors.contactEmail = 'Required' + } else if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(values.contactEmail)) { + errors.contactEmail = 'Invalid email address' + } + + return errors +} + +const EnterpriseContactForm = () => { + const [formSubmitted, setFormSubmitted] = useState(false) + + const handleFormSubmit = async (values: typeof INITIAL_VALUES, { resetForm }: any) => { + const { error } = await supabase.from('enterprise_contacts').insert( + [ + { + company_name: values.companyName, + contact_name: values.contactName, + contact_email: values.contactEmail, + contact_phone: values.contactPhone, + company_size: values.companySize, + details: values.details, + country: values.country, + }, + ], + { returning: 'minimal' } + ) + + // TODO: handle error + console.log('error:', error) + if (!error) { + resetForm() + + setFormSubmitted(true) + } + } + + return ( +
+
+ {({ isSubmitting }: any) => ( +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+ )} + + + {formSubmitted && ( +

Thanks, we'll reach out to you shortly.

+ )} +
+ ) +} + +export default EnterpriseContactForm diff --git a/www/components/PricingAddOnTable.tsx b/www/components/PricingAddOnTable.tsx new file mode 100644 index 00000000000..6fa0d20e728 --- /dev/null +++ b/www/components/PricingAddOnTable.tsx @@ -0,0 +1,117 @@ +import { Collapsible, IconChevronUp } from '@supabase/ui' +import classNames from 'classnames' +import { Fragment, useMemo, useState } from 'react' +import { Check } from './PricingTableRow' + +interface PricingAddOnTableProps { + pricing: { + title: string + features: string[] + rows: { + columns: { + key: string + title: string + value: string + }[] + }[] + } + icon: JSX.Element +} + +const PricingAddOnTable = ({ icon, pricing }: PricingAddOnTableProps) => { + const [isAddOnOpen, setIsAddOnOpen] = useState(false) + + const columnNames = useMemo( + () => + pricing.rows.map((row) => + row.columns.map((column) => ({ key: column.key, title: column.title })) + )[0], + [pricing] + ) + + return ( +
+ + + + + + +
+
+ + + {columnNames.map((column) => ( + + ))} + + + + {pricing.rows.map((row, i) => ( + + {row.columns.map((column) => ( + + ))} + + ))} + +
+ {column.title} +
+ {column.value} +
+ + + + {pricing.rows.map((row, i) => ( + + {row.columns.map((column) => ( + + + + + ))} + + ))} + +
{column.title}{column.value}
+
+ + +
+ ) +} + +export default PricingAddOnTable diff --git a/www/components/PricingTableRow.tsx b/www/components/PricingTableRow.tsx index 60fd0c79a0a..0c900700a99 100644 --- a/www/components/PricingTableRow.tsx +++ b/www/components/PricingTableRow.tsx @@ -1,23 +1,26 @@ -import React from 'react' -import ReactDOM from 'react-dom' -import { IconHelpCircle, IconWatch, Space } from '@supabase/ui' -import ReactTooltip from 'react-tooltip' +import { IconHelpCircle } from '@supabase/ui' import ProductIcon from 'components/ProductIcon' +import React from 'react' +import ReactTooltip from 'react-tooltip' -const Chevron = (props: any) => ( +export const Check = () => ( + + + +) + +const IncludedCheck = (props: any) => ( <> - - - + Included in {props.tier} ) @@ -25,7 +28,7 @@ const Chevron = (props: any) => ( const Minus = (props: any) => ( <> { return ( <> -
+
@@ -56,16 +62,16 @@ export const PricingTableRowDesktop = (props: any) => {

{category.title}

- - - + + + {category.features.map((feat: any) => { return ( {feat.title} @@ -79,11 +85,11 @@ export const PricingTableRowDesktop = (props: any) => { )} - {Object.values(feat.tiers).map((tier: any) => { + {Object.values(feat.tiers).map((tier: any, i) => { return ( - + {typeof tier === 'boolean' && tier === true ? ( - + ) : typeof tier === 'boolean' && tier === false ? ( ) : ( @@ -106,11 +112,11 @@ export const PricingTableRowMobile = (props: any) => { return ( <> - -
+ + @@ -124,10 +130,10 @@ export const PricingTableRowMobile = (props: any) => { - {category.features.map((feat: any) => { + {category.features.map((feat: any, i: number) => { return ( - - +
{category.icon ? : null} - {category.title} + {category.title}
+

{feat.title}

@@ -135,7 +141,7 @@ export const PricingTableRowMobile = (props: any) => {
{typeof feat.tiers[tier] === 'boolean' && feat.tiers[tier] === true ? (
- +
) : typeof feat.tiers[tier] === 'boolean' && feat.tiers[tier] === false ? (
diff --git a/www/data/Pricing.json b/www/data/Pricing.json index 971aa7f0151..fec1a99a417 100644 --- a/www/data/Pricing.json +++ b/www/data/Pricing.json @@ -28,21 +28,12 @@ "enterprise": true } }, - { - "title": "Optimized database instances", - "tooltip": "Why? Unique workload requirements, heavy reads and writes, compliance", - "tiers": { - "free": false, - "pro": false, - "enterprise": "Starting from $50/month" - } - }, { "title": "Database space", "tiers": { "free": "Up to 500 MB", - "pro": "Up to 8 GB", - "enterprise": "$0.125 per GB" + "pro": "8 GB included, then $0.125 per GB", + "enterprise": "Unlimited" } }, { @@ -68,8 +59,8 @@ "tooltip": "Good news, only download operations are counted towards the limit", "tiers": { "free": "Up to 2GB", - "pro": "Up to 50GB", - "enterprise": "$0.09 per GB" + "pro": "50GB included, then $0.09 per GB", + "enterprise": "Unlimited" } } ] @@ -79,7 +70,16 @@ "icon": "M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4", "features": [ { - "title": "Users", + "title": "Total Users", + "tooltip": "The maximum number of users your project can have", + "tiers": { + "free": "Unlimited", + "pro": "Unlimited", + "enterprise": "Unlimited" + } + }, + { + "title": "Monthly Active Users", "tiers": { "free": "10,000 Users", "pro": "100,000 Users", @@ -125,19 +125,36 @@ "pro": "7 days", "enterprise": true } + }, + { + "title": "Custom SMTP", + "tiers": { + "free": true, + "pro": true, + "enterprise": true + } + }, + { + "title": "Supabase Auth emails", + "tooltip": "Rate limits do not apply to Custom SMTP", + "tiers": { + "free": "30 / hour", + "pro": "100 / hour", + "enterprise": "Contact Us" + } } ] }, "storage": { - "title": "Object storage", + "title": "Storage", "icon": "M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4", "features": [ { "title": "Storage total", "tiers": { "free": "Up to 1 GB", - "pro": "Up to 100 GB", - "enterprise": "$0.021 per GB" + "pro": "100 GB included, then $0.021 per GB", + "enterprise": "Unlimited" } }, { @@ -145,8 +162,8 @@ "tooltip": "Good news, only download operations are counted towards the limit", "tiers": { "free": "Up to 2 GB", - "pro": "Up to 200 GB", - "enterprise": "$0.09 per GB" + "pro": "200 GB included, then $0.09 per GB", + "enterprise": "Unlimited" } }, { @@ -156,12 +173,20 @@ "pro": true, "enterprise": true } + }, + { + "title": "Max file upload size", + "tiers": { + "free": "Up to 50MB", + "pro": "Up to 5GB", + "enterprise": "Unlimited" + } } ] }, "dashboard": { "title": "Dashboard", - "icon": "", + "icon": "M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4", "features": [ { "title": "Team members", @@ -191,7 +216,7 @@ }, "support": { "title": "Support and Compliance", - "icon": "", + "icon": "M18.364 5.636l-3.536 3.536m0 5.656l3.536 3.536M9.172 9.172L5.636 5.636m3.536 9.192l-3.536 3.536M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-5 0a4 4 0 11-8 0 4 4 0 018 0z", "features": [ { "title": "Community support", @@ -209,6 +234,14 @@ "enterprise": true } }, + { + "title": "Email support SLA", + "tiers": { + "free": false, + "pro": false, + "enterprise": true + } + }, { "title": "Designated support", "tiers": { @@ -241,6 +274,14 @@ "enterprise": "Coming soon" } }, + { + "title": "Metrics endpoint", + "tiers": { + "free": false, + "pro": false, + "enterprise": true + } + }, { "title": "SOC2", "tiers": { @@ -248,6 +289,30 @@ "pro": false, "enterprise": "Coming soon" } + }, + { + "title": "SSO", + "tiers": { + "free": false, + "pro": false, + "enterprise": "Contact Us" + } + }, + { + "title": "99.9% SLA", + "tiers": { + "free": false, + "pro": false, + "enterprise": true + } + }, + { + "title": "On Boarding Support", + "tiers": { + "free": false, + "pro": false, + "enterprise": true + } } ] } diff --git a/www/data/PricingAddOn.json b/www/data/PricingAddOn.json new file mode 100644 index 00000000000..0eaacfa10de --- /dev/null +++ b/www/data/PricingAddOn.json @@ -0,0 +1,235 @@ +{ + "database": { + "title": "Database", + "features": [ + "Fully Managed PostgreSQL", + "Dedicated Postgres Database", + "Optimized Database Instances", + "Realtime Functionality" + ], + "rows": [ + { + "columns": [ + { "key": "plan", "title": "Plan", "value": "Free (Included)" }, + { "key": "pricing", "title": "Pricing", "value": "$0" }, + { "key": "cpu", "title": "CPU", "value": "2-core ARM (shared)" }, + { "key": "memory", "title": "Memory", "value": "1 GB" }, + { "key": "diskIO", "title": "Disk IO Bandwidth", "value": "Up to 2,085 Mbps" }, + { + "key": "directConnections", + "title": "Connections: Direct", + "value": "10 (recommended)" + }, + { + "key": "poolerConnections", + "title": "Connections: Pooler", + "value": "50 (recommended)" + } + ] + }, + { + "columns": [ + { "key": "plan", "title": "Plan", "value": "Small" }, + { "key": "pricing", "title": "Pricing", "value": "$5" }, + { "key": "cpu", "title": "CPU", "value": "2-core ARM (shared)" }, + { "key": "memory", "title": "Memory", "value": "2 GB" }, + { "key": "diskIO", "title": "Disk IO Bandwidth", "value": "Up to 2,085 Mbps" }, + { + "key": "directConnections", + "title": "Connections: Direct", + "value": "10 (recommended)" + }, + { + "key": "poolerConnections", + "title": "Connections: Pooler", + "value": "50 (recommended)" + } + ] + }, + { + "columns": [ + { "key": "plan", "title": "Plan", "value": "Medium" }, + { "key": "pricing", "title": "Pricing", "value": "$50" }, + { "key": "cpu", "title": "CPU", "value": "2-core ARM (shared)" }, + { "key": "memory", "title": "Memory", "value": "4 GB" }, + { "key": "diskIO", "title": "Disk IO Bandwidth", "value": "Up to 2,085 Mbps" }, + { + "key": "directConnections", + "title": "Connections: Direct", + "value": "50 (recommended)" + }, + { + "key": "poolerConnections", + "title": "Connections: Pooler", + "value": "500 (recommended)" + } + ] + }, + { + "columns": [ + { "key": "plan", "title": "Plan", "value": "Large" }, + { "key": "pricing", "title": "Pricing", "value": "$100" }, + { "key": "cpu", "title": "CPU", "value": "2-core ARM (dedicated)" }, + { "key": "memory", "title": "Memory", "value": "8 GB" }, + { "key": "diskIO", "title": "Disk IO Bandwidth", "value": "Up to 4,750 Mbps" }, + { + "key": "directConnections", + "title": "Connections: Direct", + "value": "100 (recommended)" + }, + { + "key": "poolerConnections", + "title": "Connections: Pooler", + "value": "1000 (recommended)" + } + ] + }, + { + "columns": [ + { "key": "plan", "title": "Plan", "value": "XL" }, + { "key": "pricing", "title": "Pricing", "value": "$200" }, + { "key": "cpu", "title": "CPU", "value": "4-core ARM (dedicated)" }, + { "key": "memory", "title": "Memory", "value": "16 GB" }, + { "key": "diskIO", "title": "Disk IO Bandwidth", "value": "Up to 4,750 Mbps" }, + { + "key": "directConnections", + "title": "Connections: Direct", + "value": "200 (recommended)" + }, + { + "key": "poolerConnections", + "title": "Connections: Pooler", + "value": "2000 (recommended)" + } + ] + }, + { + "columns": [ + { "key": "plan", "title": "Plan", "value": "2XL" }, + { "key": "pricing", "title": "Pricing", "value": "$400" }, + { "key": "cpu", "title": "CPU", "value": "8-core ARM (dedicated)" }, + { "key": "memory", "title": "Memory", "value": "32 GB" }, + { "key": "diskIO", "title": "Disk IO Bandwidth", "value": "Up to 4,750 Mbps" }, + { + "key": "directConnections", + "title": "Connections: Direct", + "value": "400 (recommended)" + }, + { + "key": "poolerConnections", + "title": "Connections: Pooler", + "value": "4000 (recommended)" + } + ] + }, + { + "columns": [ + { "key": "plan", "title": "Plan", "value": "Custom (need more?)" }, + { "key": "pricing", "title": "Pricing", "value": "Contact Us" }, + { "key": "cpu", "title": "CPU", "value": "Contact Us" }, + { "key": "memory", "title": "Memory", "value": "Contact Us" }, + { "key": "diskIO", "title": "Disk IO Bandwidth", "value": "Contact Us" }, + { + "key": "directConnections", + "title": "Connections: Direct", + "value": "Contact Us" + }, + { + "key": "poolerConnections", + "title": "Connections: Pooler", + "value": "Contact Us" + } + ] + } + ] + }, + "support": { + "title": "Support", + "features": [ + "Guaranteed Response Times", + "SLAs", + "Designated Support", + "Advanced Support Scope" + ], + "rows": [ + { + "columns": [ + { "key": "plan", "title": "Plan", "value": "Standard" }, + { "key": "pricing", "title": "Pricing", "value": "Contact Us" }, + { + "key": "severityLevelUrgent", + "title": "1. Severity Level: Urgent", + "value": "1 business hour (24/7x365)" + }, + { + "key": "severityLevelHigh", + "title": "2. Severity Level: High", + "value": "4 business hours (Monday - Friday)" + }, + { + "key": "severityLevelNormal", + "title": "3. Severity Level: Normal", + "value": "1 business day (Monday - Friday)" + }, + { + "key": "severityLevelLow", + "title": "4. Severity Level: Low", + "value": "2 business days (Monday - Friday)" + } + ] + }, + { + "columns": [ + { "key": "plan", "title": "Plan", "value": "Priority" }, + { "key": "pricing", "title": "Pricing", "value": "Contact Us" }, + { + "key": "severityLevelUrgent", + "title": "1. Severity Level: Urgent", + "value": "1 business hour (24/7x365)" + }, + { + "key": "severityLevelHigh", + "title": "2. Severity Level: High", + "value": "2 business hours (Monday - Friday)" + }, + { + "key": "severityLevelNormal", + "title": "3. Severity Level: Normal", + "value": "1 business day (Monday - Friday)" + }, + { + "key": "severityLevelLow", + "title": "4. Severity Level: Low", + "value": "2 business days (Monday - Friday)" + } + ] + }, + { + "columns": [ + { "key": "plan", "title": "Plan", "value": "Priority Plus" }, + { "key": "pricing", "title": "Pricing", "value": "Contact Us" }, + { + "key": "severityLevelUrgent", + "title": "1. Severity Level: Urgent", + "value": "1 business hour (24/7x365)" + }, + { + "key": "severityLevelHigh", + "title": "2. Severity Level: High", + "value": "2 business hours (24/7x365)" + }, + { + "key": "severityLevelNormal", + "title": "3. Severity Level: Normal", + "value": "12 business hours (Monday - Friday)" + }, + { + "key": "severityLevelLow", + "title": "4. Severity Level: Low", + "value": "1 business days (Monday - Friday)" + } + ] + } + ] + } +} diff --git a/www/lib/supabase.ts b/www/lib/supabase.ts new file mode 100644 index 00000000000..304c3890488 --- /dev/null +++ b/www/lib/supabase.ts @@ -0,0 +1,9 @@ +import { createClient } from '@supabase/supabase-js' + +const SUPABASE_URL = 'https://obuldanrptloktxcffvn.supabase.co' +const SUPABASE_ANON_KEY = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlhdCI6MTYzNzY1NjAxNSwiZXhwIjoxOTUzMjMyMDE1fQ.0sfp_Njf7l4g-nOCF5a1TQE11rPqtz8Y10uctIetkBA' + +const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY) + +export default supabase diff --git a/www/next.config.js b/www/next.config.js index 291212b011d..b0be5c9eee3 100644 --- a/www/next.config.js +++ b/www/next.config.js @@ -2,7 +2,7 @@ const withMDX = require('@next/mdx')() module.exports = withMDX({ basePath: '', - pageExtensions: ['js', 'jsx', 'tsx', 'md', 'mdx'], + pageExtensions: ['js', 'jsx', 'ts', 'tsx', 'md', 'mdx'], trailingSlash: false, images: { diff --git a/www/package-lock.json b/www/package-lock.json index ae49d62314d..e0b40408c1c 100644 --- a/www/package-lock.json +++ b/www/package-lock.json @@ -1132,6 +1132,11 @@ "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-1.5.0.tgz", "integrity": "sha512-aaRnYxBb3MU2FNJf3Ut9RMTUqqU3as0aI1lQhgo2n9Fa67wRu14iOGqx93xB+uMNVfNwZ5B3y/Ndm7qZGuFeMQ==" }, + "@heroicons/react": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-1.0.6.tgz", + "integrity": "sha512-JJCXydOFWMDpCP4q13iEplA503MQO3xLoZiKum+955ZCtHINWnx26CUxVxxFQu/uLb4LW3ge15ZpzIkXKkJ8oQ==" + }, "@jest/types": { "version": "25.5.0", "resolved": "https://registry.npmjs.org/@jest/types/-/types-25.5.0.tgz", @@ -1932,6 +1937,15 @@ "integrity": "sha512-q4jlIR71hUpWTnGhXWcakgkZeHa3CCjcQcnuzU8M891BAWA2jHiziiWEPEkdS5pFsz7H9HJiy8BrK7tBRNrY7A==", "dev": true }, + "@types/nodemailer": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.4.tgz", + "integrity": "sha512-Ksw4t7iliXeYGvIQcSIgWQ5BLuC/mljIEbjf615svhZL10PE9t+ei8O9gDaD3FPCasUJn9KTLwz2JFJyiiyuqw==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", @@ -4127,6 +4141,11 @@ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.2.tgz", "integrity": "sha512-XxYDdcQ6eKqp/YjI+tb2C5WM2LgjnZrfYg4vgQt49EK268b6gYCHsBLrK2qvJo4FmCtqmKezb0WZFK4fkrZNsg==" }, + "nodemailer": { + "version": "6.7.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.7.3.tgz", + "integrity": "sha512-KUdDsspqx89sD4UUyUKzdlUOper3hRkDVkrKh/89G+d9WKsU5ox51NWS4tB1XR5dPUdR4SP0E3molyEfOvSa3g==" + }, "normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", diff --git a/www/package.json b/www/package.json index b43c01bc68e..3df5eb9d571 100644 --- a/www/package.json +++ b/www/package.json @@ -13,6 +13,7 @@ "postbuild": "node ./internals/generate-sitemap.mjs" }, "dependencies": { + "@heroicons/react": "^1.0.6", "@mdx-js/loader": "^1.6.22", "@mdx-js/react": "^1.6.22", "@next/mdx": "^10.0.7", @@ -28,6 +29,7 @@ "next": "latest", "next-mdx-remote": "^2.1.3", "next-seo": "^4.20.0", + "nodemailer": "^6.7.3", "react": "^17.0.2", "react-copy-to-clipboard": "^5.0.2", "react-dom": "^17.0.2", @@ -52,6 +54,7 @@ "@types/jest": "^25.2.2", "@types/marked": "^4.0.3", "@types/node": "^14.0.1", + "@types/nodemailer": "^6.4.4", "@types/react": "^16.9.35", "@types/react-copy-to-clipboard": "^4.3.0", "@types/react-dom": "^16.9.8", diff --git a/www/pages/contact/enterprise.tsx b/www/pages/contact/enterprise.tsx new file mode 100644 index 00000000000..1396b8c495f --- /dev/null +++ b/www/pages/contact/enterprise.tsx @@ -0,0 +1,38 @@ +import { NextSeo } from 'next-seo' +import { useRouter } from 'next/router' +import EnterpriseContactForm from '~/components/EnterpriseContactForm' +import DefaultLayout from '~/components/Layouts/Default' + +const meta_title = 'Enterprise Contact | Supabase' +const meta_description = 'Contact Supabase for enterprise sales and pricing.' + +const EnterpriseContactPage = () => { + const router = useRouter() + + return ( + + + +
+

Contact Enterprise Sales

+ + +
+
+ ) +} + +export default EnterpriseContactPage diff --git a/www/pages/partners/[slug].tsx b/www/pages/partners/[slug].tsx index bbf2ee30b4a..aed590557d2 100644 --- a/www/pages/partners/[slug].tsx +++ b/www/pages/partners/[slug].tsx @@ -1,21 +1,16 @@ -import { createClient } from '@supabase/supabase-js' import { IconChevronLeft, IconExternalLink } from '@supabase/ui' import { marked } from 'marked' import { GetStaticPaths, GetStaticProps } from 'next' import { NextSeo } from 'next-seo' +import Image from 'next/image' import Link from 'next/link' import { Swiper, SwiperSlide } from 'swiper/react' import 'swiper/swiper.min.css' import DefaultLayout from '~/components/Layouts/Default' import SectionContainer from '~/components/Layouts/SectionContainer' +import supabase from '~/lib/supabase' import { Partner } from '~/types/partners' import Error404 from '../404' -import Image from 'next/image' - -const supabase = createClient( - 'https://obuldanrptloktxcffvn.supabase.co', - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlhdCI6MTYzNzY1NjAxNSwiZXhwIjoxOTUzMjMyMDE1fQ.0sfp_Njf7l4g-nOCF5a1TQE11rPqtz8Y10uctIetkBA' -) function Partner({ partner }: { partner: Partner }) { if (!partner) return @@ -93,7 +88,7 @@ function Partner({ partner }: { partner: Partner }) { {partner.images.map((image: any, i: number) => { return ( -
+
-
+

('partners') @@ -75,7 +68,7 @@ function ExpertPartnersPage(props: Props) {
{/* Search Bar */}
-
Explore more
+
Explore more
('partners') @@ -121,7 +115,7 @@ function IntegrationPartnersPage(props: Props) {

{meta_description}

{/* Title */} -
+
{/* Horizontal link menu */}
@@ -144,7 +138,7 @@ function IntegrationPartnersPage(props: Props) { } />
-
Categories
+
Categories
{allCategories.map((category) => (
-
Explore more
+
Explore more
{ return ( -
+

{tier}

- ${price} + + {showDollarSign && '$'} + {price} +

{priceDescription}

{description}

@@ -64,325 +126,496 @@ export default function IndexPage() { ], }} /> -
-
-

Predictable pricing, no surprises

-

- Start with a hobby project, collaborate with a team, and scale to millions of users. -

-
-
-
- {/* */} -
- {/* Free - Mobile */} - - - - - - - - - - {/* Pro - Mobile */} - - - - - - - - - {/* Enterprise - Mobile */} - - - - - - - +
+
+
+
+

+ Pricing +

+

Predictable pricing, no surprises

+

+ Start building for free, collaborate with a team, then scale to millions of users +

+
+
- {/* */} -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Pricing plan comparison
+ {/*
*/} + +
+
+ {tiers.map((tier) => ( +
- Feature by - Plans -
-
- Free -
-
- Pro -
-
- Pay as you go -
-
- Pricing - -
- $0 -

/project /month

- -

Perfect for hobby projects and experiments

- -
-
-
- $25 -

/project /month

- -

- Everything you need to scale your project into production -

- -
-
-
- $25 -

/project /month plus usage costs

- -

- Designated support team, account manager and technical specialist -

- - - - - -
-
- Choose your plan - - - - - - - - - - - - - - - - - - -
-
-
- -
-
-

Frequently asked questions

-

- Can't find the answer to your question, ask someone in the community either on our - Discord or GitHub. -

- - - - - - - - - - -
-
- {pricingFaq.map((faq, i) => { - return ( -
- {/* @ts-ignore */} - +

- - {faq.answer} - - + {tier.name} +

+
+ {tier.priceMonthly !== undefined ? ( + <> + ${tier.priceMonthly} + /mo + + ) : ( + Contact Us + )} +
+ {tier.warning && ( + + {tier.warning} + + )} +

{tier.description}

- ) - })} +
+ {/*

Included with plan:

*/} +
    + {tier.features.map((feature) => ( +
  • + {/*
    */} +
    */} +

    + {feature} +

    +
  • + ))} +
+ +
+

{tier.scale}

+

{tier.shutdown}

+
+ + + +
+
+ ))}
+
+
+

Add-Ons

+

+ Level up your Supabase experience with add-ons. +

+
+ +
+ } + pricing={pricingAddOn.database} + /> + } + pricing={pricingAddOn.support} + /> +
+
+ +
+
+
+

Compare Plans

+

+ Start with a hobby project, collaborate with a team, and scale to millions of users. +

+
+ +
+ {/* */} + +
+ {/* Free - Mobile */} + + + + + + + + + + {/* Pro - Mobile */} + + + + + + + + {/* Enterprise - Mobile */} + + + + + + + +
+ + {/* */} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Pricing plan comparison
+ Feature by + Plans +
+
+ Free +
+
+ Pro +
+
+ Enterprise +
+
+ Pricing + +
+ $0 +

/project/month

+ +

Perfect for hobby projects and experiments

+ + + + + + +
+
+
+ $25 +

/project/month + usage costs

+ +

+ Everything you need to scale your project into production +

+ + + + + + +
+
+
+ Contact Us +

for a quote

+ +

+ Designated support team, account manager and technical specialist +

+ + + + + + +
+
+ Choose your plan + + + + + + + + + + + + + + + + + + +
+
+
+ +
+
+

Frequently asked questions

+

+ Can't find the answer to your question, ask someone in the community either on + our Discord or GitHub. +

+ + + + + + + + + + +
+ {/* @ts-ignore */} + {/* */} +
+ {pricingFaq.map((faq, i) => { + return ( +
+ {/* @ts-ignore */} + + + {faq.answer} + + +
+ ) + })} +
+ {/*
*/} +
+
+
+
+
) diff --git a/www/public/images/blog/avatars/vignesh-sound.jpeg b/www/public/images/blog/avatars/vignesh-sound.jpeg new file mode 100644 index 00000000000..e823eccdbc4 Binary files /dev/null and b/www/public/images/blog/avatars/vignesh-sound.jpeg differ diff --git a/www/public/images/blog/launch-week-4/enterprise-day/enterprise-pricing.png b/www/public/images/blog/launch-week-4/enterprise-day/enterprise-pricing.png new file mode 100644 index 00000000000..ecea555bbac Binary files /dev/null and b/www/public/images/blog/launch-week-4/enterprise-day/enterprise-pricing.png differ diff --git a/www/public/images/blog/launch-week-4/enterprise-day/enterprise-thumb.png b/www/public/images/blog/launch-week-4/enterprise-day/enterprise-thumb.png new file mode 100644 index 00000000000..677fc39c811 Binary files /dev/null and b/www/public/images/blog/launch-week-4/enterprise-day/enterprise-thumb.png differ diff --git a/www/public/images/blog/launch-week-4/enterprise-day/pg-bouncer.png b/www/public/images/blog/launch-week-4/enterprise-day/pg-bouncer.png new file mode 100644 index 00000000000..9ed74c6f3f3 Binary files /dev/null and b/www/public/images/blog/launch-week-4/enterprise-day/pg-bouncer.png differ diff --git a/www/public/images/blog/launch-week-4/enterprise-day/prometheus.png b/www/public/images/blog/launch-week-4/enterprise-day/prometheus.png new file mode 100644 index 00000000000..db80ae48b21 Binary files /dev/null and b/www/public/images/blog/launch-week-4/enterprise-day/prometheus.png differ diff --git a/www/public/rss.xml b/www/public/rss.xml index 67966f0f188..026ccab9ad1 100644 --- a/www/public/rss.xml +++ b/www/public/rss.xml @@ -5,9 +5,17 @@ https://supabase.com Latest news from Supabase en - Mon, 28 Mar 2022 22:00:00 GMT + Tue, 29 Mar 2022 22:00:00 GMT + + https://supabase.com/blog/2022/03/30/supabase-enterprise + Introducing Supabase Enterprise + https://supabase.com/blog/2022/03/30/supabase-enterprise + Today we are releasing Supabase Enterprise, a suite of features to scale your project. + Tue, 29 Mar 2022 22:00:00 GMT + + https://supabase.com/blog/2022/03/29/graphql-now-available GraphQL is now available in Supabase diff --git a/www/public/sitemap_www.xml b/www/public/sitemap_www.xml index 915bd5ba18e..c0a61ded5ed 100644 --- a/www/public/sitemap_www.xml +++ b/www/public/sitemap_www.xml @@ -48,6 +48,12 @@ 0.5 + + https://supabase.com/contact/enterprise + weekly + 0.5 + + https://supabase.com/database weekly @@ -565,4 +571,22 @@ weekly 0.5 + + + https://supabase.com/blog/2022/03/25/should-i-open-source-my-company + weekly + 0.5 + + + + https://supabase.com/blog/2022/03/25/supabase-launch-week-four + weekly + 0.5 + + + + https://supabase.com/blog/2022/03/28/community-day + weekly + 0.5 + diff --git a/www/public/videos/blog/launch-week-4/database-addons.mp4 b/www/public/videos/blog/launch-week-4/database-addons.mp4 new file mode 100644 index 00000000000..d7a79504b2d Binary files /dev/null and b/www/public/videos/blog/launch-week-4/database-addons.mp4 differ diff --git a/www/public/videos/blog/launch-week-4/logexplorer.mp4 b/www/public/videos/blog/launch-week-4/logexplorer.mp4 new file mode 100644 index 00000000000..00aaa7ae74c Binary files /dev/null and b/www/public/videos/blog/launch-week-4/logexplorer.mp4 differ diff --git a/www/public/videos/blog/launch-week-4/spend-caps.mov b/www/public/videos/blog/launch-week-4/spend-caps.mov new file mode 100644 index 00000000000..92360c7da48 Binary files /dev/null and b/www/public/videos/blog/launch-week-4/spend-caps.mov differ diff --git a/www/public/videos/blog/launch-week-4/spend-caps.mp4 b/www/public/videos/blog/launch-week-4/spend-caps.mp4 new file mode 100644 index 00000000000..13909c72cf7 Binary files /dev/null and b/www/public/videos/blog/launch-week-4/spend-caps.mp4 differ diff --git a/www/schema.sql b/www/schema.sql index 3445399df14..eb956aa59aa 100644 --- a/www/schema.sql +++ b/www/schema.sql @@ -49,3 +49,18 @@ alter table partners enable row level security; create policy "Enable public read access" on partners for select using (true); +create table enterprise_contacts ( + id bigint generated by default as identity primary key, + created_at timestamp with time zone default timezone('utc'::text, now()) not null, + company_name text not null, + contact_name text not null, + contact_email text not null, + contact_phone text, + company_size text, + details text, + country text +); +alter table enterprise_contacts enable row level security; + +create policy "Enable public insert" on enterprise_contacts for insert with check (true); + diff --git a/www/styles/index.css b/www/styles/index.css index 738024d64de..8947bb54816 100644 --- a/www/styles/index.css +++ b/www/styles/index.css @@ -165,7 +165,7 @@ h6 { */ } */ .sbui-tabs--alt div[role='tablist'] .sbui-btn-primary { - @apply bg-gray-800 text-white dark:bg-scale-300 dark:text-white; + @apply text-white bg-gray-800 dark:bg-scale-300 dark:text-white; text-shadow: none; font-weight: 400; } @@ -216,29 +216,6 @@ table { border-collapse: collapse; } -table .thead--plans th { - @apply py-4; - position: sticky; - border-collapse: separate; - top: 0; - z-index: 5; -} - -.pricing-table__product-row { - /* @apply py-4; */ - position: sticky; - top: 0; - z-index: 5; -} - -/* -th { - border-right: 5px solid red; - background: white; - background-clip: padding-box; -} -*/ - .sbui-typography-container thead { @apply text-gray-500 dark:text-gray-200; } diff --git a/www/supabase/config.toml b/www/supabase/config.toml new file mode 100644 index 00000000000..cc86c099e31 --- /dev/null +++ b/www/supabase/config.toml @@ -0,0 +1,60 @@ +# A string used to distinguish different Supabase projects on the same host. Defaults to the working +# directory name when running `supabase init`. +project_id = "obuldanrptloktxcffvn" + +[api] +# Port to use for the API URL. +port = 54321 +# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API +# endpoints. public and storage are always included. +schemas = [] +# Extra schemas to add to the search_path of every request. +extra_search_path = ["extensions"] +# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size +# for accidental or malicious requests. +max_rows = 1000 + +[db] +# Port to use for the local database URL. +port = 54322 +# The database major version to use. This has to be the same as your remote database's. Run `SHOW +# server_version;` on the remote database to check. +major_version = 14 + +[studio] +# Port to use for Supabase Studio. +port = 54323 + +# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they +# are monitored, and you can view the emails that would have been sent from the web interface. +[inbucket] +# Port to use for the email testing server web interface. +port = 54324 + +[auth] +# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used +# in emails. +site_url = "http://localhost:3000" +# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. +additional_redirect_urls = ["https://localhost:3000"] +# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 seconds (one +# week). +jwt_expiry = 3600 +# Allow/disallow new user signups to your project. +enable_signup = true + +[auth.email] +# Allow/disallow new user signups via email to your project. +enable_signup = true +# If enabled, a user will be required to confirm any email change on both the old, and new email +# addresses. If disabled, only the new email is required to confirm. +double_confirm_changes = true +# If enabled, users need to confirm their email address before signing in. +enable_confirmations = true + +# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, +# `discord`, `facebook`, `github`, `gitlab`, `google`, `twitch`, `twitter`, `slack`, `spotify`. +[auth.external.apple] +enabled = false +client_id = "" +secret = "" diff --git a/www/supabase/functions/contact-notification/index.ts b/www/supabase/functions/contact-notification/index.ts new file mode 100644 index 00000000000..4c2463496de --- /dev/null +++ b/www/supabase/functions/contact-notification/index.ts @@ -0,0 +1,62 @@ +import { serve } from 'https://deno.land/std@0.131.0/http/server.ts' +import { SmtpClient } from 'https://deno.land/x/denomailer@0.12.0/mod.ts' + +const smtp = new SmtpClient() + +serve(async (req) => { + await smtp.connect({ + hostname: Deno.env.get('SMTP_HOSTNAME')!, + port: Number(Deno.env.get('SMTP_PORT')!), + username: Deno.env.get('SMTP_USERNAME')!, + password: Deno.env.get('SMTP_PASSWORD')!, + }) + + const body = await req.json() + + const functionSecret = Deno.env.get('FUNCTION_SECRET') + const requestSecret = req.headers.get('x-function-secret') + + if (functionSecret !== requestSecret) { + return new Response('Unauthorized', { status: 401 }) + } + + const { table, record } = body + + const content = Object.entries(record).reduce( + (acc, [key, value]) => acc + `${key}: ${value || 'NOT_PROVIDED'}\n`, + '' + ) + + let to = Deno.env.get('SMTP_FALLBACK_TO')! + let form = 'Unknown' + + if (table === 'enterprise_contacts') { + to = Deno.env.get('SMTP_ENTERPRISE_TO')! + form = 'Enterprise' + } else if (table === 'partner_contacts') { + to = Deno.env.get('SMTP_PARTNER_TO')! + form = 'Partner' + } + + try { + await smtp.send({ + from: Deno.env.get('SMTP_FROM')!, + to, + subject: `New Contact Form Submission (${form})`, + content, + }) + } catch (error: any) { + return new Response(error.message, { status: 500 }) + } + + await smtp.close() + + return new Response( + JSON.stringify({ + done: true, + }), + { + headers: { 'Content-Type': 'application/json' }, + } + ) +}) diff --git a/www/tsconfig.json b/www/tsconfig.json index a123e5bfcdd..3c0beb80aa4 100644 --- a/www/tsconfig.json +++ b/www/tsconfig.json @@ -19,6 +19,6 @@ "isolatedModules": true, "incremental": true }, - "exclude": ["node_modules"], + "exclude": ["node_modules", "supabase"], "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"] }