diff --git a/apps/docs/content/guides/functions/websockets.mdx b/apps/docs/content/guides/functions/websockets.mdx index b9741bb7c60be..e4d15d7ae9dfa 100644 --- a/apps/docs/content/guides/functions/websockets.mdx +++ b/apps/docs/content/guides/functions/websockets.mdx @@ -32,7 +32,7 @@ Here are some basic examples of setting up WebSocket servers using Deno and Node Deno.serve((req) => { const upgrade = req.headers.get('upgrade') || '' - if (upgrade.toLowerCase() != 'WebSocket') { + if (upgrade.toLowerCase() != 'websocket') { return new Response("request isn't trying to upgrade to WebSocket.", { status: 400 }) } diff --git a/apps/studio/components/interfaces/Database/Publications/PublicationSkeleton.tsx b/apps/studio/components/interfaces/Database/Publications/PublicationSkeleton.tsx index c6774d0803c5c..70def772d885d 100644 --- a/apps/studio/components/interfaces/Database/Publications/PublicationSkeleton.tsx +++ b/apps/studio/components/interfaces/Database/Publications/PublicationSkeleton.tsx @@ -1,32 +1,29 @@ -import Table from 'components/to-be-cleaned/Table' import ShimmeringLoader from 'components/ui/ShimmeringLoader' -import { Toggle } from 'ui' +import { Switch, TableCell, TableRow } from 'ui' export interface PublicationSkeletonProps { index?: number } -const PublicationSkeleton = ({ index }: PublicationSkeletonProps) => { +export const PublicationSkeleton = ({ index }: PublicationSkeletonProps) => { return ( - - + + - - + + - + {Array.from({ length: 4 }).map((_, i) => ( - - - + + + ))} - +
-
-
+ + ) } - -export default PublicationSkeleton diff --git a/apps/studio/components/interfaces/Database/Publications/PublicationsList.tsx b/apps/studio/components/interfaces/Database/Publications/PublicationsList.tsx index f24078292b20b..deb947dd863c9 100644 --- a/apps/studio/components/interfaces/Database/Publications/PublicationsList.tsx +++ b/apps/studio/components/interfaces/Database/Publications/PublicationsList.tsx @@ -1,34 +1,52 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { noop } from 'lodash' -import { AlertCircle, Search } from 'lucide-react' +import { AlertCircle, Info, Search } from 'lucide-react' +import Link from 'next/link' import { useState } from 'react' import { toast } from 'sonner' -import { Button, Input, Toggle } from 'ui' -import Table from 'components/to-be-cleaned/Table' +import { useParams } from 'common' +import AlertError from 'components/ui/AlertError' import InformationBox from 'components/ui/InformationBox' import NoSearchResults from 'components/ui/NoSearchResults' import { useDatabasePublicationsQuery } from 'data/database-publications/database-publications-query' import { useDatabasePublicationUpdateMutation } from 'data/database-publications/database-publications-update-mutation' import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { + Button, + Card, + Switch, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Tooltip, + TooltipContent, + TooltipTrigger, +} from 'ui' +import { Input } from 'ui-patterns/DataInputs/Input' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' -import PublicationSkeleton from './PublicationSkeleton' +import { PublicationSkeleton } from './PublicationSkeleton' interface PublicationEvent { event: string key: string } -interface PublicationsListProps { - onSelectPublication: (id: number) => void -} - -export const PublicationsList = ({ onSelectPublication = noop }: PublicationsListProps) => { +export const PublicationsList = () => { + const { ref } = useParams() const { data: project } = useSelectedProjectQuery() const [filterString, setFilterString] = useState('') - const { data, isLoading } = useDatabasePublicationsQuery({ + const { + data = [], + error, + isLoading, + isSuccess, + isError, + } = useDatabasePublicationsQuery({ projectRef: project?.ref, connectionString: project?.connectionString, }) @@ -48,10 +66,11 @@ export const PublicationsList = ({ onSelectPublication = noop }: PublicationsLis { event: 'Delete', key: 'publish_delete' }, { event: 'Truncate', key: 'publish_truncate' }, ] - const publications = + const publications = ( filterString.length === 0 - ? data ?? [] - : (data ?? []).filter((publication) => publication.name.includes(filterString)) + ? data + : data.filter((publication) => publication.name.includes(filterString)) + ).sort((a, b) => a.id - b.id) const [toggleListenEventValue, setToggleListenEventValue] = useState<{ publication: any @@ -79,8 +98,9 @@ export const PublicationsList = ({ onSelectPublication = noop }: PublicationsLis
} - placeholder={'Filter'} + icon={} + className="w-48 pl-8" + placeholder="Search for a publication" value={filterString} onChange={(e) => setFilterString(e.target.value)} /> @@ -97,32 +117,62 @@ export const PublicationsList = ({ onSelectPublication = noop }: PublicationsLis
- Name, - System ID, - Insert, - Update, - Delete, - Truncate, - - Source - , - ]} - body={ - isLoading - ? Array.from({ length: 5 }).map((_, i) => ) - : publications.map((x) => ( - - {x.name} - {x.id} + +
+ + + Name + System ID + Insert + Update + Delete + Truncate + + + + + {isLoading && + Array.from({ length: 2 }).map((_, i) => )} + + {isError && ( + + + + + + )} + + {isSuccess && + publications.map((x) => ( + + + {x.name} + {/* [Joshen] Making this tooltip very specific for these 2 publications */} + {['supabase_realtime', 'supabase_realtime_messages_publication'].includes( + x.name + ) && ( + + + + + + {x.name === 'supabase_realtime' + ? 'This publication is managed by Supabase and handles Postgres changes' + : x.name === 'supabase_realtime_messages_publication' + ? 'This publication is managed by Supabase and handles broadcasts from the database' + : undefined} + + + )} + + {x.id} {publicationEvents.map((event) => ( - - + { + onClick={() => { setToggleListenEventValue({ publication: x, event, @@ -130,27 +180,24 @@ export const PublicationsList = ({ onSelectPublication = noop }: PublicationsLis }) }} /> - + ))} - -
-
-
- - )) - } - /> + +
+ ))} +
+
+
{!isLoading && publications.length === 0 && ( diff --git a/apps/studio/components/interfaces/Database/Publications/PublicationsTableItem.tsx b/apps/studio/components/interfaces/Database/Publications/PublicationsTableItem.tsx index 0ae7b75c7f993..ad6f35e7d96c9 100644 --- a/apps/studio/components/interfaces/Database/Publications/PublicationsTableItem.tsx +++ b/apps/studio/components/interfaces/Database/Publications/PublicationsTableItem.tsx @@ -3,11 +3,11 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useState } from 'react' import { toast } from 'sonner' -import Table from 'components/to-be-cleaned/Table' import { useDatabasePublicationUpdateMutation } from 'data/database-publications/database-publications-update-mutation' import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { Badge, Toggle } from 'ui' +import { useProtectedSchemas } from 'hooks/useProtectedSchemas' +import { Badge, Switch, TableCell, TableRow, Tooltip, TooltipContent, TooltipTrigger } from 'ui' interface PublicationsTableItemProps { table: PostgresTable @@ -16,8 +16,11 @@ interface PublicationsTableItemProps { const PublicationsTableItem = ({ table, selectedPublication }: PublicationsTableItemProps) => { const { data: project } = useSelectedProjectQuery() + const { data: protectedSchemas } = useProtectedSchemas() const enabledForAllTables = selectedPublication.tables == null + const isProtected = protectedSchemas.map((x) => x.name).includes(table.schema) + const [checked, setChecked] = useState( selectedPublication.tables?.find((x: any) => x.id == table.id) != undefined ) @@ -70,13 +73,13 @@ const PublicationsTableItem = ({ table, selectedPublication }: PublicationsTable } return ( - - {table.name} - {table.schema} - + + {table.name} + {table.schema} + {table.comment} - - + +
{enabledForAllTables ? ( @@ -84,18 +87,25 @@ const PublicationsTableItem = ({ table, selectedPublication }: PublicationsTable  for all tables ) : ( - toggleReplicationForTable(table, selectedPublication)} - /> + + + toggleReplicationForTable(table, selectedPublication)} + /> + + {isProtected && ( + + This table belongs to a protected schema, and its publication cannot be toggled + + )} + )}
-
-
+ + ) } diff --git a/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx b/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx index ec191ac0ce781..5200c4e3a0140 100644 --- a/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx +++ b/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx @@ -1,39 +1,38 @@ -import type { PostgresPublication } from '@supabase/postgres-meta' import { PermissionAction } from '@supabase/shared-types/out/constants' import { ChevronLeft, Search } from 'lucide-react' +import Link from 'next/link' import { useMemo, useState } from 'react' +import { useParams } from 'common' import NoSearchResults from 'components/to-be-cleaned/NoSearchResults' -import Table from 'components/to-be-cleaned/Table' import AlertError from 'components/ui/AlertError' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { Loading } from 'components/ui/Loading' +import { useDatabasePublicationsQuery } from 'data/database-publications/database-publications-query' import { useTablesQuery } from 'data/tables/tables-query' import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { useProtectedSchemas } from 'hooks/useProtectedSchemas' -import { Button, Input } from 'ui' +import { Card, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui' import { Admonition } from 'ui-patterns' +import { Input } from 'ui-patterns/DataInputs/Input' import PublicationsTableItem from './PublicationsTableItem' -interface PublicationsTablesProps { - selectedPublication: PostgresPublication - onSelectBack: () => void -} - -export const PublicationsTables = ({ - selectedPublication, - onSelectBack, -}: PublicationsTablesProps) => { +export const PublicationsTables = () => { + const { ref, id } = useParams() const { data: project } = useSelectedProjectQuery() const [filterString, setFilterString] = useState('') const { can: canUpdatePublications, isLoading: isLoadingPermissions } = useAsyncCheckProjectPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'publications') - const { data: protectedSchemas } = useProtectedSchemas() + const { data: publications = [] } = useDatabasePublicationsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + const selectedPublication = publications.find((pub) => pub.id === Number(id)) const { - data: tablesData, + data: tablesData = [], isLoading, isSuccess, isError, @@ -42,33 +41,35 @@ export const PublicationsTables = ({ projectRef: project?.ref, connectionString: project?.connectionString, }) + const tables = useMemo(() => { - return (tablesData || []).filter((table) => - filterString.length === 0 - ? !protectedSchemas.find((s) => s.name === table.schema) - : !protectedSchemas.find((s) => s.name === table.schema) && - table.name.includes(filterString) + return tablesData.filter((table) => + filterString.length === 0 ? table : table.name.includes(filterString) ) - }, [tablesData, protectedSchemas, filterString]) + }, [tablesData, filterString]) return ( <>
-
@@ -95,38 +96,42 @@ export const PublicationsTables = ({ ) : (
- Name, - Schema, - - Description - , - - {/* Temporarily disable All tables toggle for publications. See https://github.com/supabase/supabase/pull/7233. -
-
- All Tables -
- toggleReplicationForAllTables(publication, enabledForAllTables)} - /> -
*/} -
, - ]} - body={tables.map((table) => ( - - ))} - /> + +
+ + + Name + Schema + Description + {/* + We've disabled All tables toggle for publications. + See https://github.com/supabase/supabase/pull/7233. + */} + + + + + {!!selectedPublication ? ( + tables.map((table) => ( + + )) + ) : ( + + +

The selected publication with ID {id} cannot be found

+

+ Head back to the list of publications to select one from there +

+
+
+ )} +
+
+
))} diff --git a/apps/studio/components/interfaces/Realtime/Inspector/ChooseChannelPopover/index.tsx b/apps/studio/components/interfaces/Realtime/Inspector/ChooseChannelPopover/index.tsx index e45ead06f1037..4851d41d6ea17 100644 --- a/apps/studio/components/interfaces/Realtime/Inspector/ChooseChannelPopover/index.tsx +++ b/apps/studio/components/interfaces/Realtime/Inspector/ChooseChannelPopover/index.tsx @@ -6,6 +6,7 @@ import { useForm } from 'react-hook-form' import * as z from 'zod' import { DocsButton } from 'components/ui/DocsButton' +import { getTemporaryAPIKey } from 'data/api-keys/temp-api-keys-query' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { @@ -52,7 +53,7 @@ export const ChooseChannelPopover = ({ config, onChangeConfig }: ChooseChannelPo setOpen(v) } - const onSubmit = () => { + const onSubmit = async () => { setOpen(false) sendEvent({ action: 'realtime_inspector_listen_channel_clicked', @@ -61,8 +62,17 @@ export const ChooseChannelPopover = ({ config, onChangeConfig }: ChooseChannelPo organization: org?.slug ?? 'Unknown', }, }) + + let token = config.token + + // [Joshen] Refresh if starting to listen + using temp API key, since it has a low refresh rate + if (token.startsWith('sb_temp')) { + const data = await getTemporaryAPIKey({ projectRef: config.projectRef, expiry: 3600 }) + token = data.api_key + } onChangeConfig({ ...config, + token, channelName: form.getValues('channel'), isChannelPrivate: form.getValues('isPrivate'), enabled: true, diff --git a/apps/studio/components/interfaces/Realtime/Inspector/Header.tsx b/apps/studio/components/interfaces/Realtime/Inspector/Header.tsx index 867438923c1d7..08f008921b18e 100644 --- a/apps/studio/components/interfaces/Realtime/Inspector/Header.tsx +++ b/apps/studio/components/interfaces/Realtime/Inspector/Header.tsx @@ -4,6 +4,7 @@ import { Dispatch, SetStateAction } from 'react' import { useParams } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { getTemporaryAPIKey } from 'data/api-keys/temp-api-keys-query' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' @@ -38,8 +39,16 @@ export const Header = ({ config, onChangeConfig }: HeaderProps) => { className="rounded-l-none border-l-0" disabled={!canReadAPIKeys || config.channelName.length === 0} icon={config.enabled ? : } - onClick={() => { - onChangeConfig({ ...config, enabled: !config.enabled }) + onClick={async () => { + // [Joshen] Refresh if starting to listen + using temp API key, since it has a low refresh rate + if (!config.enabled && config.token.startsWith('sb_temp')) { + const data = await getTemporaryAPIKey({ projectRef: config.projectRef, expiry: 3600 }) + const token = data.api_key + onChangeConfig({ ...config, token, enabled: !config.enabled }) + } else { + onChangeConfig({ ...config, enabled: !config.enabled }) + } + if (!config.enabled) { // the user has clicked to start listening sendEvent({ diff --git a/apps/studio/components/interfaces/Realtime/Inspector/RealtimeTokensPopover/index.tsx b/apps/studio/components/interfaces/Realtime/Inspector/RealtimeTokensPopover/index.tsx index ca059755665fe..2ed1e7515e776 100644 --- a/apps/studio/components/interfaces/Realtime/Inspector/RealtimeTokensPopover/index.tsx +++ b/apps/studio/components/interfaces/Realtime/Inspector/RealtimeTokensPopover/index.tsx @@ -69,7 +69,7 @@ export const RealtimeTokensPopover = ({ config, onChangeConfig }: RealtimeTokens .catch((err) => toast.error(`Failed to get JWT for role: ${err.message}`)) } else { try { - const data = await getTemporaryAPIKey({ projectRef: config.projectRef }) + const data = await getTemporaryAPIKey({ projectRef: config.projectRef, expiry: 3600 }) token = data.api_key } catch (error) { token = publishableKey?.api_key diff --git a/apps/studio/data/api-keys/temp-api-keys-query.ts b/apps/studio/data/api-keys/temp-api-keys-query.ts index 4939727c23620..5a15846d269c6 100644 --- a/apps/studio/data/api-keys/temp-api-keys-query.ts +++ b/apps/studio/data/api-keys/temp-api-keys-query.ts @@ -2,12 +2,14 @@ import { handleError, post } from 'data/fetchers' interface getTemporaryAPIKeyVariables { projectRef?: string + /** In seconds, max: 3600 (an hour) */ + expiry?: number } // [Joshen] This one specifically shouldn't need a useQuery hook since the expiry is meant to be short lived // Used in storage explorer and realtime inspector. export async function getTemporaryAPIKey( - { projectRef }: getTemporaryAPIKeyVariables, + { projectRef, expiry = 300 }: getTemporaryAPIKeyVariables, signal?: AbortSignal ) { if (!projectRef) throw new Error('projectRef is required') @@ -15,7 +17,10 @@ export async function getTemporaryAPIKey( const { data, error } = await post('/platform/projects/{ref}/api-keys/temporary', { params: { path: { ref: projectRef }, - query: { authorization_exp: '300', claims: JSON.stringify({ role: 'service_role' }) }, + query: { + authorization_exp: expiry.toString(), + claims: JSON.stringify({ role: 'service_role' }), + }, }, signal, }) diff --git a/apps/studio/data/tables/tables-query.ts b/apps/studio/data/tables/tables-query.ts index b7da827a5bb16..af715ea5b60ae 100644 --- a/apps/studio/data/tables/tables-query.ts +++ b/apps/studio/data/tables/tables-query.ts @@ -3,10 +3,10 @@ import { useQuery, useQueryClient, type UseQueryOptions } from '@tanstack/react- import { sortBy } from 'lodash' import { useCallback } from 'react' +import { DEFAULT_PLATFORM_APPLICATION_NAME } from '@supabase/pg-meta/src/constants' import { get, handleError } from 'data/fetchers' import type { ResponseError } from 'types' import { tableKeys } from './keys' -import { DEFAULT_PLATFORM_APPLICATION_NAME } from '@supabase/pg-meta/src/constants' export type TablesVariables = { projectRef?: string diff --git a/apps/studio/instrumentation-client.ts b/apps/studio/instrumentation-client.ts index b1268e0a5345b..06eca90031d77 100644 --- a/apps/studio/instrumentation-client.ts +++ b/apps/studio/instrumentation-client.ts @@ -63,8 +63,12 @@ Sentry.init({ const isInvalidUrlEvent = (hint.originalException as any)?.message?.includes( `Failed to construct 'URL': Invalid URL` ) + // [Joshen] Similar behaviour for this error from SessionTimeoutModal to control the quota usage + const isSessionTimeoutEvent = (hint.originalException as any)?.message.includes( + 'Session error detected' + ) - if (isInvalidUrlEvent && Math.random() > 0.01) { + if ((isInvalidUrlEvent || isSessionTimeoutEvent) && Math.random() > 0.01) { return null } diff --git a/apps/studio/pages/project/[ref]/database/publications.tsx b/apps/studio/pages/project/[ref]/database/publications.tsx deleted file mode 100644 index fb3d57b306042..0000000000000 --- a/apps/studio/pages/project/[ref]/database/publications.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useState } from 'react' - -import { PublicationsList } from 'components/interfaces/Database/Publications/PublicationsList' -import { PublicationsTables } from 'components/interfaces/Database/Publications/PublicationsTables' -import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout' -import DefaultLayout from 'components/layouts/DefaultLayout' -import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' -import { FormHeader } from 'components/ui/Forms/FormHeader' -import NoPermission from 'components/ui/NoPermission' -import { useDatabasePublicationsQuery } from 'data/database-publications/database-publications-query' -import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' -import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import type { NextPageWithLayout } from 'types' - -// [Joshen] Technically, best that we have these as separate URLs -// makes it easier to manage state, but foresee that this page might -// be consolidated somewhere else eventually for better UX - -const DatabasePublications: NextPageWithLayout = () => { - const { data: project } = useSelectedProjectQuery() - - const { data } = useDatabasePublicationsQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - }) - const publications = data ?? [] - - const { can: canViewPublications, isSuccess: isPermissionsLoaded } = - useAsyncCheckProjectPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'publications') - - const [selectedPublicationId, setSelectedPublicationId] = useState() - const selectedPublication = publications.find((pub) => pub.id === selectedPublicationId) - - if (isPermissionsLoaded && !canViewPublications) { - return - } - - return ( - - -
- - {selectedPublication === undefined ? ( - - ) : ( - setSelectedPublicationId(undefined)} - /> - )} -
-
-
- ) -} - -DatabasePublications.getLayout = (page) => ( - - {page} - -) - -export default DatabasePublications diff --git a/apps/studio/pages/project/[ref]/database/publications/[id].tsx b/apps/studio/pages/project/[ref]/database/publications/[id].tsx new file mode 100644 index 0000000000000..81fec4d623a87 --- /dev/null +++ b/apps/studio/pages/project/[ref]/database/publications/[id].tsx @@ -0,0 +1,37 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' + +import { PublicationsTables } from 'components/interfaces/Database/Publications/PublicationsTables' +import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout' +import DefaultLayout from 'components/layouts/DefaultLayout' +import { PageLayout } from 'components/layouts/PageLayout/PageLayout' +import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' +import NoPermission from 'components/ui/NoPermission' +import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import type { NextPageWithLayout } from 'types' + +const DatabasePublications: NextPageWithLayout = () => { + const { can: canViewPublications, isSuccess: isPermissionsLoaded } = + useAsyncCheckProjectPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'publications') + + if (isPermissionsLoaded && !canViewPublications) { + return + } + + return ( + + + + + + ) +} + +DatabasePublications.getLayout = (page) => ( + + + {page} + + +) + +export default DatabasePublications diff --git a/apps/studio/pages/project/[ref]/database/publications/index.tsx b/apps/studio/pages/project/[ref]/database/publications/index.tsx new file mode 100644 index 0000000000000..b5ee05c33dcd2 --- /dev/null +++ b/apps/studio/pages/project/[ref]/database/publications/index.tsx @@ -0,0 +1,37 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' + +import { PublicationsList } from 'components/interfaces/Database/Publications/PublicationsList' +import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout' +import DefaultLayout from 'components/layouts/DefaultLayout' +import { PageLayout } from 'components/layouts/PageLayout/PageLayout' +import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' +import NoPermission from 'components/ui/NoPermission' +import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' +import type { NextPageWithLayout } from 'types' + +const DatabasePublications: NextPageWithLayout = () => { + const { can: canViewPublications, isSuccess: isPermissionsLoaded } = + useAsyncCheckProjectPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'publications') + + if (isPermissionsLoaded && !canViewPublications) { + return + } + + return ( + + + + + + ) +} + +DatabasePublications.getLayout = (page) => ( + + + {page} + + +) + +export default DatabasePublications diff --git a/apps/www/app/api-v2/luma-events/route.tsx b/apps/www/app/api-v2/luma-events/route.tsx index 9d7fc28ddc632..ffd30e14daa8c 100644 --- a/apps/www/app/api-v2/luma-events/route.tsx +++ b/apps/www/app/api-v2/luma-events/route.tsx @@ -1,3 +1,4 @@ +import * as Sentry from '@sentry/nextjs' import { NextRequest, NextResponse } from 'next/server' export interface LumaGeoAddressJson { @@ -115,6 +116,7 @@ export async function GET(request: NextRequest) { }, }) } catch (error) { + Sentry.captureException(error) console.error('Error fetching meetups from Luma:', error) return NextResponse.json( { diff --git a/apps/www/app/api-v2/opt-out/[ref]/route.ts b/apps/www/app/api-v2/opt-out/[ref]/route.ts index 9028a6934b29a..8cc41ee481dde 100644 --- a/apps/www/app/api-v2/opt-out/[ref]/route.ts +++ b/apps/www/app/api-v2/opt-out/[ref]/route.ts @@ -1,5 +1,6 @@ -import { NextRequest, NextResponse } from 'next/server' +import * as Sentry from '@sentry/nextjs' import { createClient } from '@supabase/supabase-js' +import { NextRequest, NextResponse } from 'next/server' const supabaseUrl = process.env.NEXT_PUBLIC_EMAIL_ABUSE_URL as string const supabaseServiceKey = process.env.EMAIL_ABUSE_SERVICE_KEY as string @@ -78,7 +79,9 @@ export async function POST(req: NextRequest, props: { params: Promise<{ ref: str .from('manual_reports') .insert([{ project_ref: ref, reason, email }]) - if (supabaseError) throw new Error(`Supabase error: ${supabaseError.message}`) + if (supabaseError) { + throw new Error(`Supabase error: ${supabaseError.message}`) + } const response = await fetch(process.env.EMAIL_REPORT_SLACK_WEBHOOK as string, { method: 'POST', @@ -93,6 +96,7 @@ export async function POST(req: NextRequest, props: { params: Promise<{ ref: str { status: 200 } ) } catch (error) { + Sentry.captureException(error) const errorMessage = (error as Error).message return NextResponse.json( { error: `Failure: Could not send post to Slack. Error: ${errorMessage}` }, diff --git a/apps/www/app/api-v2/submit-form-contact-sales/route.tsx b/apps/www/app/api-v2/submit-form-contact-sales/route.tsx index 4f2e1aed08eb9..a8e2ef53720dd 100644 --- a/apps/www/app/api-v2/submit-form-contact-sales/route.tsx +++ b/apps/www/app/api-v2/submit-form-contact-sales/route.tsx @@ -1,3 +1,5 @@ +import * as Sentry from '@sentry/nextjs' + const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', @@ -92,6 +94,7 @@ export async function POST(req: Request) { if (!response.ok) { const errorData = await response.json() + Sentry.captureException(errorData) return new Response(JSON.stringify({ message: errorData.message }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: response.status, @@ -103,6 +106,7 @@ export async function POST(req: Request) { status: 200, }) } catch (error: any) { + Sentry.captureException(error) return new Response(JSON.stringify({ error: error.message }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 500, diff --git a/apps/www/app/api-v2/submit-form-sos2025-newsletter/route.tsx b/apps/www/app/api-v2/submit-form-sos2025-newsletter/route.tsx index 856845ad620f7..ed9c6a668ebe3 100644 --- a/apps/www/app/api-v2/submit-form-sos2025-newsletter/route.tsx +++ b/apps/www/app/api-v2/submit-form-sos2025-newsletter/route.tsx @@ -1,3 +1,5 @@ +import * as Sentry from '@sentry/nextjs' + const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', @@ -56,6 +58,7 @@ export async function POST(req: Request) { if (!response.ok) { const errorData = await response.json() + Sentry.captureException(errorData) return new Response(JSON.stringify({ message: errorData.message }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: response.status, @@ -67,6 +70,7 @@ export async function POST(req: Request) { status: 200, }) } catch (error: any) { + Sentry.captureException(error) return new Response(JSON.stringify({ error: error.message }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 500, diff --git a/apps/www/app/api-v2/submit-form-talk-to-partnership/route.tsx b/apps/www/app/api-v2/submit-form-talk-to-partnership/route.tsx index b41b906430061..a4d6561928055 100644 --- a/apps/www/app/api-v2/submit-form-talk-to-partnership/route.tsx +++ b/apps/www/app/api-v2/submit-form-talk-to-partnership/route.tsx @@ -1,3 +1,5 @@ +import * as Sentry from '@sentry/nextjs' + const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', @@ -91,6 +93,7 @@ export async function POST(req: Request) { if (!response.ok) { const errorData = await response.json() + Sentry.captureException(errorData) return new Response(JSON.stringify({ message: errorData.message }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: response.status, @@ -102,6 +105,7 @@ export async function POST(req: Request) { status: 200, }) } catch (error: any) { + Sentry.captureException(error) return new Response(JSON.stringify({ error: error.message }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 500, diff --git a/apps/www/app/api-v2/ticket-og/route.tsx b/apps/www/app/api-v2/ticket-og/route.tsx index 61f4cc0fdeb3e..6c9ba7807a3db 100644 --- a/apps/www/app/api-v2/ticket-og/route.tsx +++ b/apps/www/app/api-v2/ticket-og/route.tsx @@ -1,6 +1,6 @@ -import React from 'react' -import { ImageResponse } from '@vercel/og' +import * as Sentry from '@sentry/nextjs' import { createClient } from '@supabase/supabase-js' +import { ImageResponse } from '@vercel/og' import useTicketBg from 'components/LaunchWeek/15/hooks/use-ticket-bg' export const runtime = 'edge' // 'nodejs' is the default @@ -501,6 +501,9 @@ export async function GET(req: Request, res: Response) { return await fetch(`${STORAGE_URL}/og/${ticketType}/${username}.png?t=${NEW_TIMESTAMP}`) } catch (error: any) { + Sentry.captureException(error) + await Sentry.flush(2000) + return new Response(JSON.stringify({ error: error.message }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 400, diff --git a/apps/www/instrumentation.ts b/apps/www/instrumentation.ts new file mode 100644 index 0000000000000..3063091e693ee --- /dev/null +++ b/apps/www/instrumentation.ts @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/nextjs' + +export async function register() { + if (process.env.NEXT_RUNTIME === 'nodejs') { + await import('./sentry.server.config') + } + + if (process.env.NEXT_RUNTIME === 'edge') { + await import('./sentry.edge.config') + } +} + +export const onRequestError = Sentry.captureRequestError diff --git a/apps/www/next.config.mjs b/apps/www/next.config.mjs index a04689a8a98e1..88390c9889d9b 100644 --- a/apps/www/next.config.mjs +++ b/apps/www/next.config.mjs @@ -1,5 +1,6 @@ import bundleAnalyzer from '@next/bundle-analyzer' import nextMdx from '@next/mdx' +import { withSentryConfig } from '@sentry/nextjs' import rehypeSlug from 'rehype-slug' import remarkGfm from 'remark-gfm' @@ -124,11 +125,37 @@ const nextConfig = { } // next.config.js. -export default () => { +const configExport = () => { const plugins = [withContentlayer, withMDX, withBundleAnalyzer] return plugins.reduce((acc, next) => next(acc), nextConfig) } +export default withSentryConfig(configExport, { + // For all available options, see: + // https://www.npmjs.com/package/@sentry/webpack-plugin#options + + org: 'supabase', + project: 'www', + + // Only print logs for uploading source maps in CI + silent: !process.env.CI, + + // For all available options, see: + // https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/ + + // Upload a larger set of source maps for prettier stack traces (increases build time) + widenClientFileUpload: true, + + // Uncomment to route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers. + // This can increase your server load as well as your hosting bill. + // Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client- + // side errors will fail. + // tunnelRoute: "/monitoring", + + // Automatically tree-shake Sentry logger statements to reduce bundle size + disableLogger: true, +}) + function getAssetPrefix() { // If not force enabled, but not production env, disable CDN if (process.env.FORCE_ASSET_CDN !== '1' && process.env.VERCEL_ENV !== 'production') { diff --git a/apps/www/package.json b/apps/www/package.json index a35b815912b6b..6f63357f1c526 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -30,6 +30,7 @@ "@octokit/plugin-paginate-graphql": "^4.0.0", "@octokit/rest": "^21.0.0", "@radix-ui/react-dialog": "^1.0.5", + "@sentry/nextjs": "^10", "@supabase/supabase-js": "catalog:", "@vercel/og": "^0.6.2", "ai-commands": "workspace:*", diff --git a/apps/www/sentry.edge.config.ts b/apps/www/sentry.edge.config.ts new file mode 100644 index 0000000000000..9bc4d3e1e64d9 --- /dev/null +++ b/apps/www/sentry.edge.config.ts @@ -0,0 +1,16 @@ +// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on). +// The config you add here will be used whenever one of the edge features is loaded. +// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally. +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ + +import * as Sentry from '@sentry/nextjs' + +Sentry.init({ + dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, + + // Enable logs to be sent to Sentry + enableLogs: true, + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, +}) diff --git a/apps/www/sentry.server.config.ts b/apps/www/sentry.server.config.ts new file mode 100644 index 0000000000000..89dc5a230bfeb --- /dev/null +++ b/apps/www/sentry.server.config.ts @@ -0,0 +1,15 @@ +// This file configures the initialization of Sentry on the server. +// The config you add here will be used whenever the server handles a request. +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ + +import * as Sentry from '@sentry/nextjs' + +Sentry.init({ + dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, + + // Enable logs to be sent to Sentry + enableLogs: true, + + // Setting this option to true will print useful information to the console while you're setting up Sentry. + debug: false, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 383f72de40be8..4214a59b26f3c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1553,6 +1553,9 @@ importers: '@radix-ui/react-dialog': specifier: ^1.0.5 version: 1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@sentry/nextjs': + specifier: ^10 + version: 10.3.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.3.3(@opentelemetry/api@1.9.0)(@playwright/test@1.53.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0(esbuild@0.25.2)) '@supabase/supabase-js': specifier: 'catalog:' version: 2.49.3 @@ -24785,6 +24788,32 @@ snapshots: '@sentry/core@10.3.0': {} + '@sentry/nextjs@10.3.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.3.3(@opentelemetry/api@1.9.0)(@playwright/test@1.53.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0(esbuild@0.25.2))': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/semantic-conventions': 1.36.0 + '@rollup/plugin-commonjs': 28.0.1(rollup@4.38.0) + '@sentry-internal/browser-utils': 10.3.0 + '@sentry/core': 10.3.0 + '@sentry/node': 10.3.0(supports-color@8.1.1) + '@sentry/opentelemetry': 10.3.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.36.0) + '@sentry/react': 10.3.0(react@18.3.1) + '@sentry/vercel-edge': 10.3.0 + '@sentry/webpack-plugin': 4.0.2(encoding@0.1.13)(supports-color@8.1.1)(webpack@5.94.0(esbuild@0.25.2)) + chalk: 3.0.0 + next: 15.3.3(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.53.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) + resolve: 1.22.8 + rollup: 4.38.0 + stacktrace-parser: 0.1.10 + transitivePeerDependencies: + - '@opentelemetry/context-async-hooks' + - '@opentelemetry/core' + - '@opentelemetry/sdk-trace-base' + - encoding + - react + - supports-color + - webpack + '@sentry/nextjs@10.3.0(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.3.3(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.53.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.94.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -24864,6 +24893,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@sentry/opentelemetry@10.3.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.36.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/context-async-hooks': 2.0.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.36.0 + '@sentry/core': 10.3.0 + '@sentry/opentelemetry@10.3.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.36.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -24886,6 +24924,16 @@ snapshots: '@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0) '@sentry/core': 10.3.0 + '@sentry/webpack-plugin@4.0.2(encoding@0.1.13)(supports-color@8.1.1)(webpack@5.94.0(esbuild@0.25.2))': + dependencies: + '@sentry/bundler-plugin-core': 4.0.2(encoding@0.1.13)(supports-color@8.1.1) + unplugin: 1.0.1 + uuid: 9.0.1 + webpack: 5.94.0(esbuild@0.25.2) + transitivePeerDependencies: + - encoding + - supports-color + '@sentry/webpack-plugin@4.0.2(encoding@0.1.13)(supports-color@8.1.1)(webpack@5.94.0)': dependencies: '@sentry/bundler-plugin-core': 4.0.2(encoding@0.1.13)(supports-color@8.1.1) @@ -26786,7 +26834,7 @@ snapshots: '@types/pg@8.15.4': dependencies: '@types/node': 22.13.14 - pg-protocol: 1.7.1 + pg-protocol: 1.10.3 pg-types: 2.2.0 '@types/phoenix@1.6.4': {} @@ -37040,7 +37088,6 @@ snapshots: webpack: 5.94.0(esbuild@0.25.2) optionalDependencies: esbuild: 0.25.2 - optional: true terser-webpack-plugin@5.3.14(webpack@5.94.0): dependencies: @@ -38409,7 +38456,6 @@ snapshots: - '@swc/core' - esbuild - uglify-js - optional: true whatwg-encoding@2.0.0: dependencies: diff --git a/turbo.json b/turbo.json index 6d010af23b408..0409cabc4c9ae 100644 --- a/turbo.json +++ b/turbo.json @@ -124,6 +124,7 @@ "NEXT_PUBLIC_HCAPTCHA_SITE_KEY", "HCAPTCHA_SECRET_KEY", "NODE_ENV", + "NEXT_PUBLIC_SENTRY_DSN", // These envs are used in the packages "NEXT_PUBLIC_STORAGE_KEY", "NEXT_PUBLIC_AUTH_DEBUG_KEY",