mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
chore: send feature flags for page events (#32590)
This commit is contained in:
1 parent
83c94206c2
commit
4577389177
29 files changed
+772
-698
No files matched your search
@@ -1,9 +1,10 @@
|
||||
import { type PropsWithChildren } from 'react'
|
||||
|
||||
import { ThemeProvider } from 'common'
|
||||
import { FeatureFlagProvider, ThemeProvider } from 'common'
|
||||
import { SonnerToaster } from 'ui'
|
||||
import { CommandProvider } from 'ui-patterns/CommandMenu'
|
||||
import SiteLayout from '~/layouts/SiteLayout'
|
||||
import { API_URL } from '~/lib/constants'
|
||||
import { AuthContainer } from './auth/auth.client'
|
||||
import { DocsCommandMenu } from './command'
|
||||
import { QueryClientProvider } from './data/queryClient.client'
|
||||
@@ -18,20 +19,22 @@ function GlobalProviders({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<QueryClientProvider>
|
||||
<AuthContainer>
|
||||
<PageTelemetry />
|
||||
<ScrollRestoration />
|
||||
<ThemeProvider defaultTheme="system" enableSystem disableTransitionOnChange>
|
||||
<CommandProvider>
|
||||
<div className="flex flex-col">
|
||||
<SiteLayout>
|
||||
{children}
|
||||
<DocsCommandMenu />
|
||||
</SiteLayout>
|
||||
<ThemeSandbox />
|
||||
</div>
|
||||
</CommandProvider>
|
||||
<SonnerToaster position="top-right" />
|
||||
</ThemeProvider>
|
||||
<FeatureFlagProvider API_URL={API_URL}>
|
||||
<PageTelemetry />
|
||||
<ScrollRestoration />
|
||||
<ThemeProvider defaultTheme="system" enableSystem disableTransitionOnChange>
|
||||
<CommandProvider>
|
||||
<div className="flex flex-col">
|
||||
<SiteLayout>
|
||||
{children}
|
||||
<DocsCommandMenu />
|
||||
</SiteLayout>
|
||||
<ThemeSandbox />
|
||||
</div>
|
||||
</CommandProvider>
|
||||
<SonnerToaster position="top-right" />
|
||||
</ThemeProvider>
|
||||
</FeatureFlagProvider>
|
||||
</AuthContainer>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
@@ -1,86 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import { isBrowser, useTelemetryCookie } from 'common'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { IS_PLATFORM, PageTelemetry as PageTelemetryImpl } from 'common'
|
||||
import { useConsent } from 'ui-patterns/ConsentToast'
|
||||
import { IS_PLATFORM } from '~/lib/constants'
|
||||
import { unauthedAllowedPost } from '~/lib/fetch/fetchWrappers'
|
||||
|
||||
const useTelemetryWithConsent = () => {
|
||||
const pathname = usePathname()
|
||||
const { hasAcceptedConsent } = useConsent()
|
||||
|
||||
const sendPageTelemetry = useCallback(() => {
|
||||
if (!(IS_PLATFORM && hasAcceptedConsent)) return
|
||||
|
||||
const title = typeof document !== 'undefined' ? document?.title : ''
|
||||
const referrer = typeof document !== 'undefined' ? document?.referrer : ''
|
||||
|
||||
unauthedAllowedPost('/platform/telemetry/page', {
|
||||
body: {
|
||||
pathname,
|
||||
page_url: isBrowser ? window.location.href : '',
|
||||
page_title: title,
|
||||
ph: {
|
||||
referrer,
|
||||
language: navigator.language ?? 'en-US',
|
||||
user_agent: navigator.userAgent,
|
||||
search: isBrowser ? window.location.search : '',
|
||||
viewport_height: isBrowser ? window.innerHeight : 0,
|
||||
viewport_width: isBrowser ? window.innerWidth : 0,
|
||||
},
|
||||
},
|
||||
headers: { Version: '2' },
|
||||
credentials: 'include',
|
||||
}).catch((e) => {
|
||||
console.error('Problem sending telemetry page:', e)
|
||||
})
|
||||
}, [pathname, hasAcceptedConsent])
|
||||
|
||||
const sendPageLeaveTelemetry = useCallback(() => {
|
||||
if (!(IS_PLATFORM && hasAcceptedConsent)) return
|
||||
|
||||
const title = typeof document !== 'undefined' ? document?.title : ''
|
||||
|
||||
unauthedAllowedPost('/platform/telemetry/page-leave', {
|
||||
body: {
|
||||
pathname,
|
||||
page_url: isBrowser ? window.location.href : '',
|
||||
page_title: title,
|
||||
},
|
||||
credentials: 'include',
|
||||
}).catch((e) => {
|
||||
console.error('Problem sending telemetry page-leave:', e)
|
||||
})
|
||||
}, [pathname, hasAcceptedConsent])
|
||||
|
||||
return { sendPageTelemetry, sendPageLeaveTelemetry }
|
||||
}
|
||||
import { API_URL } from '~/lib/constants'
|
||||
|
||||
const PageTelemetry = () => {
|
||||
const pathname = usePathname()
|
||||
|
||||
const title = typeof document !== 'undefined' ? document?.title : ''
|
||||
const referrer = typeof document !== 'undefined' ? document?.referrer : ''
|
||||
const { hasAcceptedConsent } = useConsent()
|
||||
useTelemetryCookie({ hasAcceptedConsent, title, referrer })
|
||||
|
||||
const { sendPageTelemetry, sendPageLeaveTelemetry } = useTelemetryWithConsent()
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname) sendPageTelemetry()
|
||||
}, [pathname, sendPageTelemetry])
|
||||
|
||||
useEffect(() => {
|
||||
if (!pathname) return
|
||||
|
||||
const handleBeforeUnload = () => sendPageLeaveTelemetry()
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}, [pathname, sendPageLeaveTelemetry])
|
||||
|
||||
return null
|
||||
return (
|
||||
<PageTelemetryImpl
|
||||
API_URL={API_URL}
|
||||
hasAcceptedConsent={hasAcceptedConsent}
|
||||
enabled={IS_PLATFORM}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { PageTelemetry }
|
||||
@@ -1,7 +1,7 @@
|
||||
export const API_URL = (
|
||||
process.env.NODE_ENV === 'development'
|
||||
? process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080'
|
||||
: process.env.NEXT_PUBLIC_API_URL
|
||||
: process.env.NEXT_PUBLIC_API_URL!
|
||||
)?.replace(/\/platform$/, '')
|
||||
export const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH || '/docs'
|
||||
export const IS_DEV = process.env.NODE_ENV === 'development'
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { createContext } from 'react'
|
||||
|
||||
const FlagContext = createContext({ configcat: {}, posthog: {} })
|
||||
|
||||
export default FlagContext
|
||||
@@ -1,78 +0,0 @@
|
||||
import type { User } from '@supabase/supabase-js'
|
||||
import { FlagValues } from '@vercel/flags/react'
|
||||
import { PropsWithChildren, useEffect, useState } from 'react'
|
||||
|
||||
import { useUser } from 'lib/auth'
|
||||
import { getFlags } from 'lib/configcat'
|
||||
import { IS_PLATFORM } from 'lib/constants'
|
||||
import { CallFeatureFlagsResponse, getPHFeatureFlags } from 'lib/posthog'
|
||||
import FlagContext from './FlagContext'
|
||||
|
||||
var getCookies = function () {
|
||||
var pairs = document.cookie.split(';')
|
||||
var cookies: Record<string, string> = {}
|
||||
for (var i = 0; i < pairs.length; i++) {
|
||||
var [t_key, value] = pairs[i].split('=')
|
||||
const key = t_key.trim()
|
||||
|
||||
cookies[key] = unescape(value)
|
||||
}
|
||||
return cookies
|
||||
}
|
||||
|
||||
export type FlagProviderStore = {
|
||||
configcat: { [key: string]: boolean | string }
|
||||
posthog: CallFeatureFlagsResponse
|
||||
}
|
||||
|
||||
const FlagProvider = ({ children }: PropsWithChildren<{}>) => {
|
||||
const user = useUser()
|
||||
|
||||
const { Provider } = FlagContext
|
||||
const [store, setStore] = useState<FlagProviderStore>({ configcat: {}, posthog: {} })
|
||||
|
||||
const processFlags = async (user?: User) => {
|
||||
const flagStore: FlagProviderStore = { configcat: {}, posthog: {} }
|
||||
|
||||
// Load PH flags
|
||||
if (user) {
|
||||
const flags = await getPHFeatureFlags()
|
||||
if (flags) flagStore.posthog = flags
|
||||
}
|
||||
|
||||
// Load ConfigCat flags
|
||||
const flagValues = await getFlags(user)
|
||||
let overridesCookieValue: Record<string, boolean> = {}
|
||||
try {
|
||||
const cookies = getCookies()
|
||||
overridesCookieValue = JSON.parse(cookies['vercel-flag-overrides'])
|
||||
} catch {}
|
||||
|
||||
flagValues.forEach((item) => {
|
||||
flagStore['configcat'][item.settingKey] =
|
||||
overridesCookieValue[item.settingKey] ?? item.settingValue
|
||||
})
|
||||
setStore(flagStore)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// [Joshen] getFlags get triggered everytime the tab refocuses but this should be okay
|
||||
// as per https://configcat.com/docs/sdk-reference/js/#polling-modes:
|
||||
// The polling downloads the config.json at the set interval and are stored in the internal cache
|
||||
// which subsequently all getValueAsync() calls are served from there
|
||||
if (IS_PLATFORM) processFlags(user ?? undefined)
|
||||
}, [user])
|
||||
|
||||
return (
|
||||
<Provider value={store}>
|
||||
{/*
|
||||
[Joshen] Just support configcat flags in Vercel flags for now for simplicity
|
||||
although I think it should be fairly simply to support PH too
|
||||
*/}
|
||||
<FlagValues values={store.configcat} />
|
||||
{children}
|
||||
</Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export default FlagProvider
|
||||
+13
-53
@@ -1,17 +1,15 @@
|
||||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import * as Sentry from '@sentry/nextjs'
|
||||
import { useRouter } from 'next/router'
|
||||
import { PropsWithChildren, useEffect } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { useParams, useTelemetryCookie, useUser } from 'common'
|
||||
import { useSendGroupsIdentifyMutation } from 'data/telemetry/send-groups-identify-mutation'
|
||||
import { useSendGroupsResetMutation } from 'data/telemetry/send-groups-reset-mutation'
|
||||
import { useSendPageLeaveMutation } from 'data/telemetry/send-page-leave-mutation'
|
||||
import { useSendPageMutation } from 'data/telemetry/send-page-mutation'
|
||||
import { usePrevious } from 'hooks/deprecated'
|
||||
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
||||
import { IS_PLATFORM, LOCAL_STORAGE_KEYS } from 'lib/constants'
|
||||
import { useAppStateSnapshot } from 'state/app-state'
|
||||
import { useConsent } from 'ui-patterns/ConsentToast'
|
||||
|
||||
const getAnonId = async (id: string) => {
|
||||
const encoder = new TextEncoder()
|
||||
@@ -23,19 +21,19 @@ const getAnonId = async (id: string) => {
|
||||
return base64String
|
||||
}
|
||||
|
||||
const PageTelemetry = ({ children }: PropsWithChildren<{}>) => {
|
||||
const GroupsTelemetry = ({ hasAcceptedConsent }: { hasAcceptedConsent: boolean }) => {
|
||||
// Although this is "technically" breaking the rules of hooks
|
||||
// IS_PLATFORM never changes within a session, so this won't cause any issues
|
||||
if (!IS_PLATFORM) return null
|
||||
|
||||
const user = useUser()
|
||||
const router = useRouter()
|
||||
const { ref, slug } = useParams()
|
||||
const snap = useAppStateSnapshot()
|
||||
const organization = useSelectedOrganization()
|
||||
|
||||
const { consentValue, hasAcceptedConsent } = useConsent()
|
||||
const previousPathname = usePrevious(router.pathname)
|
||||
|
||||
const trackTelemetryPH = consentValue === 'true'
|
||||
const { mutate: sendPage } = useSendPageMutation()
|
||||
const { mutateAsync: sendPageLeave } = useSendPageLeaveMutation()
|
||||
const { mutate: sendGroupsIdentify } = useSendGroupsIdentifyMutation()
|
||||
const { mutate: sendGroupsReset } = useSendGroupsResetMutation()
|
||||
|
||||
@@ -44,34 +42,11 @@ const PageTelemetry = ({ children }: PropsWithChildren<{}>) => {
|
||||
useTelemetryCookie({ hasAcceptedConsent, title, referrer })
|
||||
|
||||
useEffect(() => {
|
||||
if (consentValue !== null) {
|
||||
if (hasAcceptedConsent) {
|
||||
snap.setIsOptedInTelemetry(hasAcceptedConsent)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [consentValue])
|
||||
|
||||
useEffect(() => {
|
||||
function handleRouteChange() {
|
||||
if (snap.isOptedInTelemetry) handlePageTelemetry(window.location.href)
|
||||
}
|
||||
|
||||
// Listen for page changes after a navigation or when the query changes
|
||||
router.events.on('routeChangeComplete', handleRouteChange)
|
||||
return () => {
|
||||
router.events.off('routeChangeComplete', handleRouteChange)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [router, snap.isOptedInTelemetry])
|
||||
|
||||
useEffect(() => {
|
||||
// Send page telemetry on first page load
|
||||
// Waiting for router ready before sending page_view
|
||||
// if not the path will be dynamic route instead of the browser url
|
||||
if (router.isReady && snap.isOptedInTelemetry) {
|
||||
handlePageTelemetry(window.location.href)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [router.isReady, snap.isOptedInTelemetry])
|
||||
}, [hasAcceptedConsent])
|
||||
|
||||
useEffect(() => {
|
||||
// don't set the sentry user id if the user hasn't logged in (so that Sentry errors show null user id instead of anonymous id)
|
||||
@@ -107,7 +82,7 @@ const PageTelemetry = ({ children }: PropsWithChildren<{}>) => {
|
||||
const isLeavingOrgRoute =
|
||||
(previousPathname ?? '').includes('[slug]') && !router.pathname.includes('[slug]')
|
||||
|
||||
if (trackTelemetryPH) {
|
||||
if (hasAcceptedConsent) {
|
||||
if (ref && (isLandingOnProjectRoute || isEnteringProjectRoute)) {
|
||||
sendGroupsIdentify({ organization_slug: organization?.slug, project_ref: ref as string })
|
||||
} else if (slug && (isLandingOnOrgRoute || isEnteringOrgRoute)) {
|
||||
@@ -121,24 +96,9 @@ const PageTelemetry = ({ children }: PropsWithChildren<{}>) => {
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [trackTelemetryPH, slug, ref, router.pathname])
|
||||
}, [hasAcceptedConsent, slug, ref, router.pathname])
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = async () => {
|
||||
if (snap.isOptedInTelemetry) await sendPageLeave()
|
||||
}
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handlePageTelemetry = async (route: string) => {
|
||||
if (IS_PLATFORM) sendPage({ url: route })
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
return null
|
||||
}
|
||||
|
||||
export default PageTelemetry
|
||||
export default GroupsTelemetry
|
||||
@@ -28,7 +28,7 @@ export async function getContentCount(
|
||||
if (name) query.name = name
|
||||
|
||||
const { data, error } = await get('/platform/projects/{ref}/content/count', {
|
||||
params: { path: { ref: projectRef }, query: { type, visibility, favorite, name } },
|
||||
params: { path: { ref: projectRef }, query },
|
||||
signal,
|
||||
})
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ export type SendEventVariables =
|
||||
| AssistantSqlDiffHandlerEvaluatedEvent
|
||||
| AssistantEditInSqlEditorClickedEvent
|
||||
|
||||
type SendEventPayload = components['schemas']['TelemetryEventBodyV2']
|
||||
type SendEventPayload = components['schemas']['TelemetryEventBodyV2Dto']
|
||||
|
||||
export async function sendEvent({ body }: { body: SendEventPayload }) {
|
||||
const consent =
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
|
||||
import { components } from 'api-types'
|
||||
|
||||
import { LOCAL_STORAGE_KEYS } from 'common'
|
||||
import { handleError, post } from 'data/fetchers'
|
||||
import { IS_PLATFORM } from 'lib/constants'
|
||||
import { useRouter } from 'next/router'
|
||||
import type { ResponseError } from 'types'
|
||||
|
||||
type SendPageLeaveBody = components['schemas']['TelemetryPageLeaveBody']
|
||||
|
||||
export async function sendPageLeave({ body }: { body: SendPageLeaveBody }) {
|
||||
const consent =
|
||||
(typeof window !== 'undefined'
|
||||
? localStorage.getItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT)
|
||||
: null) === 'true'
|
||||
|
||||
if (!consent || !IS_PLATFORM) return undefined
|
||||
|
||||
const { data, error } = await post(`/platform/telemetry/page-leave`, {
|
||||
body,
|
||||
credentials: 'include',
|
||||
})
|
||||
if (error) handleError(error)
|
||||
return data
|
||||
}
|
||||
|
||||
type SendPageLeaveData = Awaited<ReturnType<typeof sendPageLeave>>
|
||||
|
||||
export const useSendPageLeaveMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
...options
|
||||
}: Omit<UseMutationOptions<SendPageLeaveData, ResponseError>, 'mutationFn'> = {}) => {
|
||||
const router = useRouter()
|
||||
|
||||
const url = typeof window !== 'undefined' ? window.location.href : ''
|
||||
const title = typeof document !== 'undefined' ? document?.title : ''
|
||||
|
||||
const body = {
|
||||
page_url: url,
|
||||
page_title: title,
|
||||
pathname: router.pathname,
|
||||
} as SendPageLeaveBody
|
||||
|
||||
return useMutation<SendPageLeaveData, ResponseError>((vars) => sendPageLeave({ body }), {
|
||||
async onSuccess(data, variables, context) {
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
if (onError === undefined) {
|
||||
console.error(`Failed to send Telemetry page leave: ${data.message}`)
|
||||
} else {
|
||||
onError(data, variables, context)
|
||||
}
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
|
||||
|
||||
import { components } from 'api-types'
|
||||
import { isBrowser, LOCAL_STORAGE_KEYS } from 'common'
|
||||
import { handleError, post } from 'data/fetchers'
|
||||
import { IS_PLATFORM } from 'lib/constants'
|
||||
import { useRouter } from 'next/router'
|
||||
import type { ResponseError } from 'types'
|
||||
|
||||
type SendPage = components['schemas']['TelemetryPageBodyV2']
|
||||
|
||||
export type SendPageVariables = {
|
||||
url: string
|
||||
}
|
||||
|
||||
type SendPagePayload = any
|
||||
|
||||
export async function sendPage({ body }: { body: SendPagePayload }) {
|
||||
const consent =
|
||||
(typeof window !== 'undefined'
|
||||
? localStorage.getItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT)
|
||||
: null) === 'true'
|
||||
|
||||
if (!consent || !IS_PLATFORM) return undefined
|
||||
|
||||
const headers = { Version: '2' }
|
||||
const { data, error } = await post(`/platform/telemetry/page`, { body, headers })
|
||||
if (error) handleError(error)
|
||||
return data
|
||||
}
|
||||
|
||||
type SendPageData = Awaited<ReturnType<typeof sendPage>>
|
||||
|
||||
export const useSendPageMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
...options
|
||||
}: Omit<UseMutationOptions<SendPageData, ResponseError, SendPageVariables>, 'mutationFn'> = {}) => {
|
||||
const router = useRouter()
|
||||
|
||||
const title = typeof document !== 'undefined' ? document?.title : ''
|
||||
const referrer = typeof document !== 'undefined' ? document?.referrer : ''
|
||||
|
||||
return useMutation<SendPageData, ResponseError, SendPageVariables>(
|
||||
(vars) => {
|
||||
const { url } = vars
|
||||
const type = 'PH'
|
||||
const body: SendPage = {
|
||||
page_url: url,
|
||||
page_title: title,
|
||||
pathname: router.pathname,
|
||||
ph: {
|
||||
referrer,
|
||||
language: router?.locale ?? 'en-US',
|
||||
user_agent: navigator.userAgent,
|
||||
search: window.location.search,
|
||||
viewport_height: isBrowser ? window.innerHeight : 0,
|
||||
viewport_width: isBrowser ? window.innerWidth : 0,
|
||||
},
|
||||
}
|
||||
return sendPage({ body })
|
||||
},
|
||||
{
|
||||
async onSuccess(data, variables, context) {
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
if (onError === undefined) {
|
||||
console.error(`Failed to send Telemetry page: ${data.message}`)
|
||||
} else {
|
||||
onError(data, variables, context)
|
||||
}
|
||||
},
|
||||
...options,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useContext } from 'react'
|
||||
import * as Sentry from '@sentry/nextjs'
|
||||
|
||||
import FlagContext from 'components/ui/Flag/FlagContext'
|
||||
import { FlagProviderStore } from 'components/ui/Flag/FlagProvider'
|
||||
import { useFeatureFlags } from 'common'
|
||||
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
|
||||
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
|
||||
import { trackFeatureFlag } from 'lib/posthog'
|
||||
@@ -13,7 +11,7 @@ const isObjectEmpty = (obj: Object) => {
|
||||
|
||||
export function useFlag<T = boolean>(name: string) {
|
||||
const project = useSelectedProject()
|
||||
const flagStore = useContext(FlagContext) as FlagProviderStore
|
||||
const flagStore = useFeatureFlags()
|
||||
|
||||
const store = flagStore.configcat
|
||||
|
||||
@@ -29,8 +27,9 @@ export function useFlag<T = boolean>(name: string) {
|
||||
return store[name] as T
|
||||
}
|
||||
|
||||
// TODO(Alaister): move this to packages/common/feature-flags.tsx and rename to useFlag
|
||||
export function usePHFlag<T = string | boolean>(name: string) {
|
||||
const flagStore = useContext(FlagContext) as FlagProviderStore
|
||||
const flagStore = useFeatureFlags()
|
||||
// [Joshen] Prepend PH flags with "PH" in local storage for easier identification of PH flags
|
||||
const [trackedValue, setTrackedValue] = useLocalStorageQuery(`ph_${name}`, '')
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { User } from '@supabase/supabase-js'
|
||||
import * as configcat from 'configcat-js'
|
||||
|
||||
let client: configcat.IConfigCatClient
|
||||
@@ -17,9 +16,10 @@ function getClient() {
|
||||
return client
|
||||
}
|
||||
|
||||
export async function getFlags(user?: User) {
|
||||
if (user) {
|
||||
return getClient().getAllValuesAsync(new configcat.User(user?.email ?? ''))
|
||||
export async function getFlags(userEmail: string = '') {
|
||||
if (userEmail) {
|
||||
return getClient().getAllValuesAsync(new configcat.User(userEmail))
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
@@ -4,7 +4,7 @@ export * from './infrastructure'
|
||||
|
||||
export const IS_PLATFORM = process.env.NEXT_PUBLIC_IS_PLATFORM === 'true'
|
||||
export const DEFAULT_HOME = IS_PLATFORM ? '/projects' : '/project/default'
|
||||
export const API_URL = IS_PLATFORM ? process.env.NEXT_PUBLIC_API_URL : '/api'
|
||||
export const API_URL = IS_PLATFORM ? process.env.NEXT_PUBLIC_API_URL! : '/api'
|
||||
export const PG_META_URL = IS_PLATFORM
|
||||
? process.env.PLATFORM_PG_META_URL
|
||||
: process.env.STUDIO_PG_META_URL
|
||||
|
||||
+47
-41
@@ -22,14 +22,14 @@ import { TooltipProvider } from '@radix-ui/react-tooltip'
|
||||
import * as Sentry from '@sentry/nextjs'
|
||||
import { Hydrate, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||
import { ThemeProvider, useThemeSandbox } from 'common'
|
||||
import { FeatureFlagProvider, PageTelemetry, ThemeProvider, useThemeSandbox } from 'common'
|
||||
import dayjs from 'dayjs'
|
||||
import customParseFormat from 'dayjs/plugin/customParseFormat'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import timezone from 'dayjs/plugin/timezone'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
import Head from 'next/head'
|
||||
import { ErrorInfo, useMemo, useState } from 'react'
|
||||
import { ErrorInfo } from 'react'
|
||||
import { ErrorBoundary } from 'react-error-boundary'
|
||||
|
||||
import MetaFaviconsPagesRouter from 'common/MetaFavicons/pages-router'
|
||||
@@ -40,15 +40,16 @@ import { FeaturePreviewContextProvider } from 'components/interfaces/App/Feature
|
||||
import FeaturePreviewModal from 'components/interfaces/App/FeaturePreview/FeaturePreviewModal'
|
||||
import { GenerateSql } from 'components/interfaces/SqlGenerator/SqlGenerator'
|
||||
import { ErrorBoundaryState } from 'components/ui/ErrorBoundaryState'
|
||||
import FlagProvider from 'components/ui/Flag/FlagProvider'
|
||||
import PageTelemetry from 'components/ui/PageTelemetry'
|
||||
import GroupsTelemetry from 'components/ui/GroupsTelemetry'
|
||||
import { useRootQueryClient } from 'data/query-client'
|
||||
import { AuthProvider } from 'lib/auth'
|
||||
import { BASE_PATH, IS_PLATFORM } from 'lib/constants'
|
||||
import { getFlags as getConfigCatFlags } from 'lib/configcat'
|
||||
import { API_URL, BASE_PATH, IS_PLATFORM } from 'lib/constants'
|
||||
import { ProfileProvider } from 'lib/profile'
|
||||
import HCaptchaLoadedStore from 'stores/hcaptcha-loaded-store'
|
||||
import { AppPropsWithLayout } from 'types'
|
||||
import { SonnerToaster } from 'ui'
|
||||
import { useConsent } from 'ui-patterns'
|
||||
import { CommandProvider } from 'ui-patterns/CommandMenu'
|
||||
|
||||
dayjs.extend(customParseFormat)
|
||||
@@ -79,14 +80,6 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) {
|
||||
|
||||
const getLayout = Component.getLayout ?? ((page) => page)
|
||||
|
||||
const TelemetryContainer = useMemo(
|
||||
// eslint-disable-next-line react/display-name
|
||||
() => (props: any) => {
|
||||
return IS_PLATFORM ? <PageTelemetry>{props.children}</PageTelemetry> : <>{props.children}</>
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const errorBoundaryHandler = (error: Error, info: ErrorInfo) => {
|
||||
Sentry.withScope(function (scope) {
|
||||
scope.setTag('globalErrorBoundary', true)
|
||||
@@ -98,6 +91,11 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) {
|
||||
|
||||
useThemeSandbox()
|
||||
|
||||
// Although this is "technically" breaking the rules of hooks
|
||||
// IS_PLATFORM never changes within a session, so this won't cause any issues
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const { hasAcceptedConsent } = IS_PLATFORM ? useConsent() : { hasAcceptedConsent: true }
|
||||
|
||||
const isTestEnv = process.env.NEXT_PUBLIC_NODE_ENV === 'test'
|
||||
|
||||
return (
|
||||
@@ -105,44 +103,52 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) {
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Hydrate state={pageProps.dehydratedState}>
|
||||
<AuthProvider>
|
||||
<FlagProvider>
|
||||
<FeatureFlagProvider
|
||||
API_URL={API_URL}
|
||||
enabled={IS_PLATFORM}
|
||||
getConfigCatFlags={getConfigCatFlags}
|
||||
>
|
||||
<ProfileProvider>
|
||||
<Head>
|
||||
<title>Supabase</title>
|
||||
<meta name="viewport" content="initial-scale=1.0, width=device-width" />
|
||||
</Head>
|
||||
<MetaFaviconsPagesRouter applicationName="Supabase Studio" />
|
||||
<TelemetryContainer>
|
||||
<TooltipProvider>
|
||||
<RouteValidationWrapper>
|
||||
<ThemeProvider
|
||||
defaultTheme="system"
|
||||
themes={['dark', 'light', 'classic-dark']}
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<AppBannerContextProvider>
|
||||
<CommandProvider>
|
||||
<AppBannerWrapper>
|
||||
<FeaturePreviewContextProvider>
|
||||
{getLayout(<Component {...pageProps} />)}
|
||||
<StudioCommandMenu />
|
||||
<GenerateSql />
|
||||
<FeaturePreviewModal />
|
||||
</FeaturePreviewContextProvider>
|
||||
</AppBannerWrapper>
|
||||
<SonnerToaster position="top-right" />
|
||||
</CommandProvider>
|
||||
</AppBannerContextProvider>
|
||||
</ThemeProvider>
|
||||
</RouteValidationWrapper>
|
||||
</TooltipProvider>
|
||||
</TelemetryContainer>
|
||||
<TooltipProvider>
|
||||
<RouteValidationWrapper>
|
||||
<ThemeProvider
|
||||
defaultTheme="system"
|
||||
themes={['dark', 'light', 'classic-dark']}
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<AppBannerContextProvider>
|
||||
<CommandProvider>
|
||||
<AppBannerWrapper>
|
||||
<FeaturePreviewContextProvider>
|
||||
{getLayout(<Component {...pageProps} />)}
|
||||
<StudioCommandMenu />
|
||||
<GenerateSql />
|
||||
<FeaturePreviewModal />
|
||||
</FeaturePreviewContextProvider>
|
||||
</AppBannerWrapper>
|
||||
<SonnerToaster position="top-right" />
|
||||
</CommandProvider>
|
||||
</AppBannerContextProvider>
|
||||
</ThemeProvider>
|
||||
</RouteValidationWrapper>
|
||||
</TooltipProvider>
|
||||
|
||||
<PageTelemetry
|
||||
API_URL={API_URL}
|
||||
hasAcceptedConsent={hasAcceptedConsent}
|
||||
enabled={IS_PLATFORM}
|
||||
/>
|
||||
<GroupsTelemetry hasAcceptedConsent={hasAcceptedConsent} />
|
||||
{!isTestEnv && <HCaptchaLoadedStore />}
|
||||
{!isTestEnv && <ReactQueryDevtools initialIsOpen={false} position="bottom-right" />}
|
||||
</ProfileProvider>
|
||||
</FlagProvider>
|
||||
</FeatureFlagProvider>
|
||||
</AuthProvider>
|
||||
</Hydrate>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -19,5 +19,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
type ResponseData = extractResponse<'/platform/projects/{ref}/content/count', 'get'>
|
||||
|
||||
const handleGetAll = async (req: NextApiRequest, res: NextApiResponse<ResponseData>) => {
|
||||
return res.status(200).json({ count: 1 })
|
||||
return res.status(200).json({
|
||||
favorites: 0,
|
||||
private: 0,
|
||||
shared: 0,
|
||||
})
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { render } from '../../helpers'
|
||||
vi.mock('common', () => ({
|
||||
useParams: vi.fn().mockReturnValue({}),
|
||||
useIsLoggedIn: vi.fn(),
|
||||
isBrowser: false,
|
||||
}))
|
||||
vi.mock('lib/gotrue', () => ({
|
||||
auth: { onAuthStateChange: vi.fn() },
|
||||
|
||||
@@ -3,7 +3,7 @@ export const DEFAULT_META_DESCRIPTION =
|
||||
'Build production-grade applications with a Postgres database, Authentication, instant APIs, Realtime, Functions, Storage and Vector embeddings. Start for free.'
|
||||
export const IS_PROD = process.env.NEXT_PUBLIC_VERCEL_ENV === 'production'
|
||||
export const IS_PREVIEW = process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview'
|
||||
export const API_URL = process.env.NEXT_PUBLIC_API_URL
|
||||
export const API_URL = process.env.NEXT_PUBLIC_API_URL!
|
||||
|
||||
// Products
|
||||
|
||||
|
||||
+18
-84
@@ -4,17 +4,15 @@ import '../styles/index.css'
|
||||
|
||||
import {
|
||||
AuthProvider,
|
||||
IS_PROD,
|
||||
FeatureFlagProvider,
|
||||
PageTelemetry,
|
||||
ThemeProvider,
|
||||
useTelemetryCookie,
|
||||
useTelemetryProps,
|
||||
useThemeSandbox,
|
||||
} from 'common'
|
||||
import { DefaultSeo } from 'next-seo'
|
||||
import { AppProps } from 'next/app'
|
||||
import Head from 'next/head'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect } from 'react'
|
||||
import { SonnerToaster, themes } from 'ui'
|
||||
import { CommandProvider } from 'ui-patterns/CommandMenu'
|
||||
import { useConsent } from 'ui-patterns/ConsentToast'
|
||||
@@ -24,82 +22,15 @@ import MetaFaviconsPagesRouter, {
|
||||
DEFAULT_FAVICON_THEME_COLOR,
|
||||
} from 'common/MetaFavicons/pages-router'
|
||||
import { WwwCommandMenu } from '~/components/CommandMenu'
|
||||
import { API_URL, APP_NAME, DEFAULT_META_DESCRIPTION, IS_PREVIEW } from '~/lib/constants'
|
||||
import { post } from '~/lib/fetchWrapper'
|
||||
import { API_URL, APP_NAME, DEFAULT_META_DESCRIPTION } from '~/lib/constants'
|
||||
import useDarkLaunchWeeks from '../hooks/useDarkLaunchWeeks'
|
||||
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
const router = useRouter()
|
||||
const telemetryProps = useTelemetryProps()
|
||||
const { hasAcceptedConsent } = useConsent()
|
||||
const IS_DEV = !IS_PROD && !IS_PREVIEW
|
||||
const blockEvents = IS_DEV || !hasAcceptedConsent
|
||||
|
||||
const title = typeof document !== 'undefined' ? document?.title : ''
|
||||
const referrer = typeof document !== 'undefined' ? document?.referrer : ''
|
||||
|
||||
const { search, language, viewport_height, viewport_width } = telemetryProps
|
||||
|
||||
useThemeSandbox()
|
||||
|
||||
useTelemetryCookie({ hasAcceptedConsent, title, referrer })
|
||||
|
||||
function handlePageTelemetry(url: string) {
|
||||
return post(
|
||||
`${API_URL}/telemetry/page`,
|
||||
{
|
||||
page_url: url,
|
||||
page_title: title,
|
||||
pathname: router.pathname,
|
||||
ph: {
|
||||
referrer,
|
||||
language,
|
||||
search,
|
||||
viewport_height,
|
||||
viewport_width,
|
||||
user_agent: navigator.userAgent,
|
||||
},
|
||||
},
|
||||
{ headers: { Version: '2' }, credentials: 'include' }
|
||||
)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (blockEvents) return
|
||||
|
||||
function handleRouteChange() {
|
||||
handlePageTelemetry(window.location.href)
|
||||
}
|
||||
|
||||
// Listen for page changes after a navigation or when the query changes
|
||||
router.events.on('routeChangeComplete', handleRouteChange)
|
||||
return () => {
|
||||
router.events.off('routeChangeComplete', handleRouteChange)
|
||||
}
|
||||
}, [router.events, blockEvents])
|
||||
|
||||
useEffect(() => {
|
||||
if (!router.isReady) return
|
||||
if (blockEvents) return
|
||||
handlePageTelemetry(window.location.href)
|
||||
}, [router.isReady, blockEvents])
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = async () => {
|
||||
if (!blockEvents) {
|
||||
await post(`${API_URL}/telemetry/page-leave`, {
|
||||
page_url: window.location.href,
|
||||
page_title: title,
|
||||
pathname: router.pathname,
|
||||
})
|
||||
}
|
||||
}
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}
|
||||
}, [blockEvents, router.pathname, title])
|
||||
|
||||
const site_title = `${APP_NAME} | The Open Source Firebase Alternative`
|
||||
const { basePath } = useRouter()
|
||||
|
||||
@@ -153,18 +84,21 @@ export default function App({ Component, pageProps }: AppProps) {
|
||||
/>
|
||||
|
||||
<AuthProvider>
|
||||
<ThemeProvider
|
||||
themes={themes.map((theme) => theme.value)}
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
forcedTheme={forceDarkMode ? 'dark' : undefined}
|
||||
>
|
||||
<CommandProvider>
|
||||
<SonnerToaster position="top-right" />
|
||||
<Component {...pageProps} />
|
||||
<WwwCommandMenu />
|
||||
</CommandProvider>
|
||||
</ThemeProvider>
|
||||
<FeatureFlagProvider API_URL={API_URL}>
|
||||
<ThemeProvider
|
||||
themes={themes.map((theme) => theme.value)}
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
forcedTheme={forceDarkMode ? 'dark' : undefined}
|
||||
>
|
||||
<CommandProvider>
|
||||
<SonnerToaster position="top-right" />
|
||||
<Component {...pageProps} />
|
||||
<WwwCommandMenu />
|
||||
<PageTelemetry API_URL={API_URL} hasAcceptedConsent={hasAcceptedConsent} />
|
||||
</CommandProvider>
|
||||
</ThemeProvider>
|
||||
</FeatureFlagProvider>
|
||||
</AuthProvider>
|
||||
</>
|
||||
)
|
||||
|
||||
Vendored
+289
-127
@@ -873,8 +873,8 @@ export interface paths {
|
||||
patch: operations['ContentController_updateContent']
|
||||
}
|
||||
'/platform/projects/{ref}/content/count': {
|
||||
/** Gets the count of a user's content */
|
||||
get: operations['ContentController_getContentCount']
|
||||
/** Gets the user's content counts */
|
||||
get: operations['ContentController_getContentCountV2']
|
||||
}
|
||||
'/platform/projects/{ref}/content/folders': {
|
||||
/** Gets project's content root folder */
|
||||
@@ -1144,7 +1144,7 @@ export interface paths {
|
||||
}
|
||||
'/platform/telemetry/page-leave': {
|
||||
/** Send analytics page leave event */
|
||||
post: operations['TelemetryPageLeaveController_pageLeave']
|
||||
post: operations['TelemetryPageLeaveController_trackPageLeave']
|
||||
}
|
||||
'/platform/telemetry/reset': {
|
||||
/** Reset analytics */
|
||||
@@ -1879,8 +1879,8 @@ export interface paths {
|
||||
patch: operations['ContentController_updateContent']
|
||||
}
|
||||
'/v0/projects/{ref}/content/count': {
|
||||
/** Gets the count of a user's content */
|
||||
get: operations['ContentController_getContentCount']
|
||||
/** Gets the user's content counts */
|
||||
get: operations['ContentController_getContentCountV2']
|
||||
}
|
||||
'/v0/projects/{ref}/content/item/{id}': {
|
||||
/** Gets project's content by the given id */
|
||||
@@ -2127,6 +2127,10 @@ export interface paths {
|
||||
/** Deletes the given project */
|
||||
delete: operations['v1-delete-a-project']
|
||||
}
|
||||
'/v1/projects/{ref}/analytics/endpoints/logs.all': {
|
||||
/** Gets project's logs */
|
||||
get: operations['V1ProjectLogsController_getLogs']
|
||||
}
|
||||
'/v1/projects/{ref}/api-keys': {
|
||||
/** Get project api keys */
|
||||
get: operations['v1-get-project-api-keys']
|
||||
@@ -2334,6 +2338,16 @@ export interface paths {
|
||||
/** Disables project's readonly mode for the next 15 minutes */
|
||||
post: operations['v1-disable-readonly-mode-temporarily']
|
||||
}
|
||||
'/v1/projects/{ref}/restore': {
|
||||
/** Lists available restore versions for the given project */
|
||||
get: operations['v1-list-available-restore-versions']
|
||||
/** Restores the given project */
|
||||
post: operations['v1-restore-a-project']
|
||||
}
|
||||
'/v1/projects/{ref}/restore/cancel': {
|
||||
/** Cancels the given project restoration */
|
||||
post: operations['v1-cancel-a-project-restoration']
|
||||
}
|
||||
'/v1/projects/{ref}/secrets': {
|
||||
/**
|
||||
* List all secrets
|
||||
@@ -2784,19 +2798,19 @@ export interface components {
|
||||
release_channel: string
|
||||
/** @enum {string} */
|
||||
status:
|
||||
| 'INACTIVE'
|
||||
| 'ACTIVE_HEALTHY'
|
||||
| 'ACTIVE_UNHEALTHY'
|
||||
| 'COMING_UP'
|
||||
| 'UNKNOWN'
|
||||
| 'GOING_DOWN'
|
||||
| 'INACTIVE'
|
||||
| 'INIT_FAILED'
|
||||
| 'REMOVED'
|
||||
| 'RESTARTING'
|
||||
| 'UNKNOWN'
|
||||
| 'RESTORING'
|
||||
| 'UPGRADING'
|
||||
| 'PAUSING'
|
||||
| 'RESTORING'
|
||||
| 'RESTORE_FAILED'
|
||||
| 'RESTARTING'
|
||||
| 'PAUSE_FAILED'
|
||||
| 'RESIZING'
|
||||
}
|
||||
@@ -2840,6 +2854,19 @@ export interface components {
|
||||
result: components['schemas']['CustomHostnameDetails']
|
||||
success: boolean
|
||||
}
|
||||
CloneBackupsResponse: {
|
||||
backups: components['schemas']['Backup'][]
|
||||
physicalBackupData: {
|
||||
earliestPhysicalBackupDateUnix?: number
|
||||
latestPhysicalBackupDateUnix?: number
|
||||
}
|
||||
pitr_enabled: boolean
|
||||
region: string
|
||||
target_compute_size: string
|
||||
target_volume_size_gb: number
|
||||
tierKey: string
|
||||
walg_enabled: boolean
|
||||
}
|
||||
CloneProjectDto: {
|
||||
/** @default 0 */
|
||||
cloneBackupId?: number
|
||||
@@ -3311,6 +3338,22 @@ export interface components {
|
||||
CustomSupabaseInternalRequests: {
|
||||
ami: components['schemas']['AmiSearchOptions']
|
||||
}
|
||||
/** @enum {string} */
|
||||
database_status:
|
||||
| 'ACTIVE_HEALTHY'
|
||||
| 'ACTIVE_UNHEALTHY'
|
||||
| 'COMING_UP'
|
||||
| 'GOING_DOWN'
|
||||
| 'INIT_FAILED'
|
||||
| 'REMOVED'
|
||||
| 'RESTORING'
|
||||
| 'UNKNOWN'
|
||||
| 'INIT_READ_REPLICA'
|
||||
| 'INIT_READ_REPLICA_FAILED'
|
||||
| 'RESTARTING'
|
||||
| 'RESIZING'
|
||||
/** @enum {string} */
|
||||
database_type: 'PRIMARY' | 'READ_REPLICA'
|
||||
DatabaseDetailResponse: {
|
||||
/** @enum {string} */
|
||||
cloud_provider: 'AWS' | 'FLY'
|
||||
@@ -3334,27 +3377,11 @@ export interface components {
|
||||
| 'REMOVED'
|
||||
| 'RESTORING'
|
||||
| 'UNKNOWN'
|
||||
| 'UPGRADING'
|
||||
| 'INIT_READ_REPLICA'
|
||||
| 'INIT_READ_REPLICA_FAILED'
|
||||
| 'RESTARTING'
|
||||
| 'RESIZING'
|
||||
}
|
||||
/** @enum {string} */
|
||||
DatabaseStatus:
|
||||
| 'ACTIVE_HEALTHY'
|
||||
| 'ACTIVE_UNHEALTHY'
|
||||
| 'COMING_UP'
|
||||
| 'GOING_DOWN'
|
||||
| 'INIT_FAILED'
|
||||
| 'REMOVED'
|
||||
| 'RESTORING'
|
||||
| 'UNKNOWN'
|
||||
| 'UPGRADING'
|
||||
| 'INIT_READ_REPLICA'
|
||||
| 'INIT_READ_REPLICA_FAILED'
|
||||
| 'RESTARTING'
|
||||
| 'RESIZING'
|
||||
DatabaseStatusResponse: {
|
||||
identifier: string
|
||||
replicaInitializationStatus?: Record<string, never>
|
||||
@@ -3368,14 +3395,11 @@ export interface components {
|
||||
| 'REMOVED'
|
||||
| 'RESTORING'
|
||||
| 'UNKNOWN'
|
||||
| 'UPGRADING'
|
||||
| 'INIT_READ_REPLICA'
|
||||
| 'INIT_READ_REPLICA_FAILED'
|
||||
| 'RESTARTING'
|
||||
| 'RESIZING'
|
||||
}
|
||||
/** @enum {string} */
|
||||
DatabaseType: 'PRIMARY' | 'READ_REPLICA'
|
||||
DatabaseUpgradeStatus: {
|
||||
/** @enum {string} */
|
||||
error?:
|
||||
@@ -3661,6 +3685,11 @@ export interface components {
|
||||
GetContentCountResponse: {
|
||||
count: number
|
||||
}
|
||||
GetContentCountV2Response: {
|
||||
favorites: number
|
||||
private: number
|
||||
shared: number
|
||||
}
|
||||
GetMetricsBody: {
|
||||
/** @enum {string} */
|
||||
interval: '1d' | '3d' | '7d'
|
||||
@@ -3729,6 +3758,9 @@ export interface components {
|
||||
}
|
||||
updated_at: string
|
||||
}
|
||||
GetProjectAvailableRestoreVersionsResponse: {
|
||||
available_versions: components['schemas']['ProjectAvailableRestoreVersion'][]
|
||||
}
|
||||
GetProjectByFlyExtensionIdResponse: {
|
||||
ref: string
|
||||
}
|
||||
@@ -4387,19 +4419,19 @@ export interface components {
|
||||
* @enum {string}
|
||||
*/
|
||||
status:
|
||||
| 'INACTIVE'
|
||||
| 'ACTIVE_HEALTHY'
|
||||
| 'ACTIVE_UNHEALTHY'
|
||||
| 'COMING_UP'
|
||||
| 'UNKNOWN'
|
||||
| 'GOING_DOWN'
|
||||
| 'INACTIVE'
|
||||
| 'INIT_FAILED'
|
||||
| 'REMOVED'
|
||||
| 'RESTARTING'
|
||||
| 'UNKNOWN'
|
||||
| 'RESTORING'
|
||||
| 'UPGRADING'
|
||||
| 'PAUSING'
|
||||
| 'RESTORING'
|
||||
| 'RESTORE_FAILED'
|
||||
| 'RESTARTING'
|
||||
| 'PAUSE_FAILED'
|
||||
| 'RESIZING'
|
||||
/**
|
||||
@@ -4483,7 +4515,7 @@ export interface components {
|
||||
}
|
||||
OrganizationSlugResponse: {
|
||||
billing_email: string | null
|
||||
billing_metadata?: Record<string, never>
|
||||
billing_metadata: unknown
|
||||
has_oriole_project: boolean
|
||||
id: number
|
||||
name: string
|
||||
@@ -4900,6 +4932,23 @@ export interface components {
|
||||
username: string
|
||||
}
|
||||
/** @enum {string} */
|
||||
project_status:
|
||||
| 'INACTIVE'
|
||||
| 'ACTIVE_HEALTHY'
|
||||
| 'ACTIVE_UNHEALTHY'
|
||||
| 'COMING_UP'
|
||||
| 'UNKNOWN'
|
||||
| 'GOING_DOWN'
|
||||
| 'INIT_FAILED'
|
||||
| 'REMOVED'
|
||||
| 'RESTORING'
|
||||
| 'UPGRADING'
|
||||
| 'PAUSING'
|
||||
| 'RESTORE_FAILED'
|
||||
| 'RESTARTING'
|
||||
| 'PAUSE_FAILED'
|
||||
| 'RESIZING'
|
||||
/** @enum {string} */
|
||||
ProjectAddonPricingInterval: 'monthly' | 'hourly'
|
||||
ProjectAddonsResponse: {
|
||||
available_addons: components['schemas']['AvailableAddonResponse'][]
|
||||
@@ -4936,8 +4985,17 @@ export interface components {
|
||||
db_schema: string
|
||||
endpoint: string
|
||||
}
|
||||
ProjectAvailableRestoreVersion: {
|
||||
/** @enum {string} */
|
||||
postgres_engine: '13' | '14' | '15' | '17-oriole'
|
||||
/** @enum {string} */
|
||||
release_channel: 'internal' | 'alpha' | 'beta' | 'ga' | 'withdrawn' | 'preview'
|
||||
version: string
|
||||
}
|
||||
ProjectClonedResponse: {
|
||||
source_project_ref: string
|
||||
target_disk_size_gb: number
|
||||
target_instance_size: string
|
||||
target_project_ref: string
|
||||
}
|
||||
ProjectClonedStatusResponse: {
|
||||
@@ -4961,8 +5019,8 @@ export interface components {
|
||||
identifier: string
|
||||
infra_compute_size?: components['schemas']['DbInstanceSize']
|
||||
region: string
|
||||
status: components['schemas']['DatabaseStatus']
|
||||
type: components['schemas']['DatabaseType']
|
||||
status: components['schemas']['database_status']
|
||||
type: components['schemas']['database_type']
|
||||
}
|
||||
ProjectDetailResponse: {
|
||||
cloud_provider: string
|
||||
@@ -4984,19 +5042,19 @@ export interface components {
|
||||
restUrl: string
|
||||
/** @enum {string} */
|
||||
status:
|
||||
| 'INACTIVE'
|
||||
| 'ACTIVE_HEALTHY'
|
||||
| 'ACTIVE_UNHEALTHY'
|
||||
| 'COMING_UP'
|
||||
| 'UNKNOWN'
|
||||
| 'GOING_DOWN'
|
||||
| 'INACTIVE'
|
||||
| 'INIT_FAILED'
|
||||
| 'REMOVED'
|
||||
| 'RESTARTING'
|
||||
| 'UNKNOWN'
|
||||
| 'RESTORING'
|
||||
| 'UPGRADING'
|
||||
| 'PAUSING'
|
||||
| 'RESTORING'
|
||||
| 'RESTORE_FAILED'
|
||||
| 'RESTARTING'
|
||||
| 'PAUSE_FAILED'
|
||||
| 'RESIZING'
|
||||
subscription_id: string
|
||||
@@ -5150,26 +5208,11 @@ export interface components {
|
||||
ssl_enforced: boolean
|
||||
status: string
|
||||
}
|
||||
/** @enum {string} */
|
||||
ProjectStatus:
|
||||
| 'ACTIVE_HEALTHY'
|
||||
| 'ACTIVE_UNHEALTHY'
|
||||
| 'COMING_UP'
|
||||
| 'GOING_DOWN'
|
||||
| 'INACTIVE'
|
||||
| 'INIT_FAILED'
|
||||
| 'REMOVED'
|
||||
| 'RESTARTING'
|
||||
| 'UNKNOWN'
|
||||
| 'UPGRADING'
|
||||
| 'PAUSING'
|
||||
| 'RESTORING'
|
||||
| 'RESTORE_FAILED'
|
||||
| 'PAUSE_FAILED'
|
||||
| 'RESIZING'
|
||||
ProjectUnpauseVersionInfo: {
|
||||
postgres_engine: components['schemas']['PostgresEngine']
|
||||
release_channel: components['schemas']['ReleaseChannel']
|
||||
/** @enum {string} */
|
||||
postgres_engine: '13' | '14' | '15' | '17-oriole'
|
||||
/** @enum {string} */
|
||||
release_channel: 'internal' | 'alpha' | 'beta' | 'ga' | 'withdrawn' | 'preview'
|
||||
version: string
|
||||
}
|
||||
ProjectUpgradeEligibilityResponse: {
|
||||
@@ -5197,7 +5240,7 @@ export interface components {
|
||||
name: string
|
||||
ref: string
|
||||
region: string
|
||||
status: components['schemas']['ProjectStatus']
|
||||
status: components['schemas']['project_status']
|
||||
}
|
||||
Provider: {
|
||||
created_at?: string
|
||||
@@ -5263,7 +5306,6 @@ export interface components {
|
||||
| 'REMOVED'
|
||||
| 'RESTORING'
|
||||
| 'UNKNOWN'
|
||||
| 'UPGRADING'
|
||||
| 'INIT_READ_REPLICA'
|
||||
| 'INIT_READ_REPLICA_FAILED'
|
||||
| 'RESTARTING'
|
||||
@@ -5337,19 +5379,19 @@ export interface components {
|
||||
* @enum {string}
|
||||
*/
|
||||
status:
|
||||
| 'INACTIVE'
|
||||
| 'ACTIVE_HEALTHY'
|
||||
| 'ACTIVE_UNHEALTHY'
|
||||
| 'COMING_UP'
|
||||
| 'UNKNOWN'
|
||||
| 'GOING_DOWN'
|
||||
| 'INACTIVE'
|
||||
| 'INIT_FAILED'
|
||||
| 'REMOVED'
|
||||
| 'RESTARTING'
|
||||
| 'UNKNOWN'
|
||||
| 'RESTORING'
|
||||
| 'UPGRADING'
|
||||
| 'PAUSING'
|
||||
| 'RESTORING'
|
||||
| 'RESTORE_FAILED'
|
||||
| 'RESTARTING'
|
||||
| 'PAUSE_FAILED'
|
||||
| 'RESIZING'
|
||||
/**
|
||||
@@ -5393,6 +5435,18 @@ export interface components {
|
||||
id: number
|
||||
recovery_time_target: string
|
||||
}
|
||||
RestoreProjectBodyDto: {
|
||||
/**
|
||||
* @description Postgres engine version. If not provided, the latest version from the given release channel will be used.
|
||||
* @enum {string}
|
||||
*/
|
||||
postgres_engine?: '15' | '17-oriole'
|
||||
/**
|
||||
* @description Release channel version. If not provided, GeneralAvailability will be used.
|
||||
* @enum {string}
|
||||
*/
|
||||
release_channel?: 'internal' | 'alpha' | 'beta' | 'ga' | 'withdrawn' | 'preview'
|
||||
}
|
||||
RestrictionData: {
|
||||
grace_period_end?: string
|
||||
report_date?: string
|
||||
@@ -5631,7 +5685,7 @@ export interface components {
|
||||
data: components['schemas']['SnippetMeta'][]
|
||||
}
|
||||
SnippetMeta: {
|
||||
description?: string
|
||||
description: string | null
|
||||
id: string
|
||||
inserted_at: string
|
||||
name: string
|
||||
@@ -5651,7 +5705,7 @@ export interface components {
|
||||
}
|
||||
SnippetResponse: {
|
||||
content: components['schemas']['SnippetContent']
|
||||
description?: string
|
||||
description: string | null
|
||||
id: string
|
||||
inserted_at: string
|
||||
name: string
|
||||
@@ -5855,21 +5909,22 @@ export interface components {
|
||||
TelemetryCallFeatureFlagsResponseDto: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
TelemetryEventBodyV2: {
|
||||
TelemetryEventBodyV2Dto: {
|
||||
action: string
|
||||
custom_properties: Record<string, never>
|
||||
custom_properties: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
page_title: string
|
||||
page_url: string
|
||||
pathname: string
|
||||
ph: components['schemas']['TelemetryEventPostHog']
|
||||
}
|
||||
TelemetryEventPostHog: {
|
||||
language: string
|
||||
referrer: string
|
||||
search: string
|
||||
user_agent: string
|
||||
viewport_height: number
|
||||
viewport_width: number
|
||||
ph: {
|
||||
language: string
|
||||
referrer: string
|
||||
search: string
|
||||
user_agent: string
|
||||
viewport_height: number
|
||||
viewport_width: number
|
||||
}
|
||||
}
|
||||
TelemetryFeatureFlagBodyDto: {
|
||||
feature_flag_name: string
|
||||
@@ -5888,25 +5943,30 @@ export interface components {
|
||||
project_ref?: string
|
||||
user_id: string
|
||||
}
|
||||
TelemetryPageBodyV2: {
|
||||
TelemetryPageBodyV2Dto: {
|
||||
feature_flags?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
page_title: string
|
||||
page_url: string
|
||||
pathname: string
|
||||
ph: components['schemas']['TelemetryPagePostHog']
|
||||
ph: {
|
||||
language: string
|
||||
referrer: string
|
||||
search: string
|
||||
user_agent: string
|
||||
viewport_height: number
|
||||
viewport_width: number
|
||||
}
|
||||
}
|
||||
TelemetryPageLeaveBody: {
|
||||
TelemetryPageLeaveBodyDto: {
|
||||
feature_flags?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
page_title: string
|
||||
page_url: string
|
||||
pathname: string
|
||||
}
|
||||
TelemetryPagePostHog: {
|
||||
language: string
|
||||
referrer: string
|
||||
search: string
|
||||
user_agent: string
|
||||
viewport_height: number
|
||||
viewport_width: number
|
||||
}
|
||||
ThirdPartyAuth: {
|
||||
custom_jwks?: unknown
|
||||
id: string
|
||||
@@ -5932,11 +5992,19 @@ export interface components {
|
||||
UnpauseProjectAvailableVersionsResponse: {
|
||||
available_versions: components['schemas']['ProjectUnpauseVersionInfo'][]
|
||||
}
|
||||
UnpauseProjectBody: {
|
||||
postgres_engine?: components['schemas']['PostgresEngine']
|
||||
release_channel?: components['schemas']['ReleaseChannel']
|
||||
UnpauseProjectBodyDto: {
|
||||
/**
|
||||
* @description Postgres engine version. If not provided, the latest version from the given release channel will be used.
|
||||
* @enum {string}
|
||||
*/
|
||||
postgres_engine?: '15' | '17-oriole'
|
||||
/**
|
||||
* @description Release channel version. If not provided, GeneralAvailability will be used.
|
||||
* @enum {string}
|
||||
*/
|
||||
release_channel?: 'internal' | 'alpha' | 'beta' | 'ga' | 'withdrawn' | 'preview'
|
||||
}
|
||||
UnpauseProjectInfo: {
|
||||
UnpauseProjectResponse: {
|
||||
back_ups: components['schemas']['BackupId'][]
|
||||
cloud_provider: string
|
||||
id: number
|
||||
@@ -6710,7 +6778,8 @@ export interface components {
|
||||
updated_at?: string
|
||||
}
|
||||
UpdateVercelConnectionsBody: {
|
||||
env_sync_targets?: ('production' | 'preview' | 'development')[]
|
||||
/** @enum {array} */
|
||||
env_sync_targets?: production | preview | development
|
||||
public_env_var_prefix?: string
|
||||
}
|
||||
UpgradeDatabaseBody: {
|
||||
@@ -6822,6 +6891,26 @@ export interface components {
|
||||
total: number
|
||||
users: components['schemas']['UserBody'][]
|
||||
}
|
||||
V1AnalyticsResponse: {
|
||||
error?: OneOf<
|
||||
[
|
||||
{
|
||||
code?: number
|
||||
errors?: {
|
||||
domain?: string
|
||||
location?: string
|
||||
locationType?: string
|
||||
message?: string
|
||||
reason?: string
|
||||
}[]
|
||||
message?: string
|
||||
status?: string
|
||||
},
|
||||
string,
|
||||
]
|
||||
>
|
||||
result?: Record<string, never>[]
|
||||
}
|
||||
V1Backup: {
|
||||
inserted_at: string
|
||||
is_physical_backup: boolean
|
||||
@@ -6982,19 +7071,19 @@ export interface components {
|
||||
region: string
|
||||
/** @enum {string} */
|
||||
status:
|
||||
| 'INACTIVE'
|
||||
| 'ACTIVE_HEALTHY'
|
||||
| 'ACTIVE_UNHEALTHY'
|
||||
| 'COMING_UP'
|
||||
| 'UNKNOWN'
|
||||
| 'GOING_DOWN'
|
||||
| 'INACTIVE'
|
||||
| 'INIT_FAILED'
|
||||
| 'REMOVED'
|
||||
| 'RESTARTING'
|
||||
| 'UNKNOWN'
|
||||
| 'RESTORING'
|
||||
| 'UPGRADING'
|
||||
| 'PAUSING'
|
||||
| 'RESTORING'
|
||||
| 'RESTORE_FAILED'
|
||||
| 'RESTARTING'
|
||||
| 'PAUSE_FAILED'
|
||||
| 'RESIZING'
|
||||
}
|
||||
@@ -7018,19 +7107,19 @@ export interface components {
|
||||
region: string
|
||||
/** @enum {string} */
|
||||
status:
|
||||
| 'INACTIVE'
|
||||
| 'ACTIVE_HEALTHY'
|
||||
| 'ACTIVE_UNHEALTHY'
|
||||
| 'COMING_UP'
|
||||
| 'UNKNOWN'
|
||||
| 'GOING_DOWN'
|
||||
| 'INACTIVE'
|
||||
| 'INIT_FAILED'
|
||||
| 'REMOVED'
|
||||
| 'RESTARTING'
|
||||
| 'UNKNOWN'
|
||||
| 'RESTORING'
|
||||
| 'UPGRADING'
|
||||
| 'PAUSING'
|
||||
| 'RESTORING'
|
||||
| 'RESTORE_FAILED'
|
||||
| 'RESTARTING'
|
||||
| 'PAUSE_FAILED'
|
||||
| 'RESIZING'
|
||||
}
|
||||
@@ -8019,7 +8108,7 @@ export interface operations {
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': components['schemas']['BackupsResponse']
|
||||
'application/json': components['schemas']['CloneBackupsResponse']
|
||||
}
|
||||
}
|
||||
/** @description Failed to list available valid backups */
|
||||
@@ -11850,16 +11939,12 @@ export interface operations {
|
||||
/** Gets project's logs */
|
||||
LogsController_getApiPaths: {
|
||||
parameters: {
|
||||
query: {
|
||||
sql: string
|
||||
project: string
|
||||
iso_timestamp_start: string
|
||||
iso_timestamp_end: string
|
||||
timestamp_start: string
|
||||
timestamp_end: string
|
||||
query?: {
|
||||
iso_timestamp_end?: string
|
||||
iso_timestamp_start?: string
|
||||
sql?: string
|
||||
}
|
||||
path: {
|
||||
/** @description Project ref */
|
||||
ref: string
|
||||
}
|
||||
}
|
||||
@@ -12874,13 +12959,11 @@ export interface operations {
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Gets the count of a user's content */
|
||||
ContentController_getContentCount: {
|
||||
/** Gets the user's content counts */
|
||||
ContentController_getContentCountV2: {
|
||||
parameters: {
|
||||
query?: {
|
||||
visibility?: string
|
||||
type?: 'sql' | 'report' | 'log_sql'
|
||||
favorite?: boolean
|
||||
name?: string
|
||||
}
|
||||
path: {
|
||||
@@ -12890,10 +12973,10 @@ export interface operations {
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': components['schemas']['GetContentCountResponse']
|
||||
'application/json': components['schemas']['GetContentCountV2Response']
|
||||
}
|
||||
}
|
||||
/** @description Failed to retrieve user's content count */
|
||||
/** @description Failed to retrieve user's content counts */
|
||||
500: {
|
||||
content: never
|
||||
}
|
||||
@@ -13479,19 +13562,18 @@ export interface operations {
|
||||
UnpauseController_unpauseProject: {
|
||||
parameters: {
|
||||
path: {
|
||||
/** @description Project ref */
|
||||
ref: string
|
||||
}
|
||||
}
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': components['schemas']['UnpauseProjectBody']
|
||||
'application/json': components['schemas']['UnpauseProjectBodyDto']
|
||||
}
|
||||
}
|
||||
responses: {
|
||||
201: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': components['schemas']['UnpauseProjectInfo']
|
||||
'application/json': components['schemas']['UnpauseProjectResponse']
|
||||
}
|
||||
}
|
||||
403: {
|
||||
@@ -13507,12 +13589,11 @@ export interface operations {
|
||||
UnpauseController_cancelProjectRestoration: {
|
||||
parameters: {
|
||||
path: {
|
||||
/** @description Project ref */
|
||||
ref: string
|
||||
}
|
||||
}
|
||||
responses: {
|
||||
201: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': components['schemas']['RestoreCancellation']
|
||||
}
|
||||
@@ -13530,7 +13611,6 @@ export interface operations {
|
||||
UnpauseController_getAvailableImageVersions: {
|
||||
parameters: {
|
||||
path: {
|
||||
/** @description Project ref */
|
||||
ref: string
|
||||
}
|
||||
}
|
||||
@@ -14374,7 +14454,7 @@ export interface operations {
|
||||
TelemetryEventController_sendServerEventV2: {
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': components['schemas']['TelemetryEventBodyV2']
|
||||
'application/json': components['schemas']['TelemetryEventBodyV2Dto']
|
||||
}
|
||||
}
|
||||
responses: {
|
||||
@@ -14474,7 +14554,7 @@ export interface operations {
|
||||
TelemetryPageController_sendServerPageV2: {
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': components['schemas']['TelemetryPageBodyV2']
|
||||
'application/json': components['schemas']['TelemetryPageBodyV2Dto']
|
||||
}
|
||||
}
|
||||
responses: {
|
||||
@@ -14488,13 +14568,17 @@ export interface operations {
|
||||
}
|
||||
}
|
||||
/** Send analytics page leave event */
|
||||
TelemetryPageLeaveController_pageLeave: {
|
||||
TelemetryPageLeaveController_trackPageLeave: {
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': components['schemas']['TelemetryPageLeaveBody']
|
||||
'application/json': components['schemas']['TelemetryPageLeaveBodyDto']
|
||||
}
|
||||
}
|
||||
responses: {
|
||||
/** @description Page leave event sent */
|
||||
200: {
|
||||
content: never
|
||||
}
|
||||
201: {
|
||||
content: never
|
||||
}
|
||||
@@ -16079,6 +16163,29 @@ export interface operations {
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Gets project's logs */
|
||||
V1ProjectLogsController_getLogs: {
|
||||
parameters: {
|
||||
query?: {
|
||||
iso_timestamp_end?: string
|
||||
iso_timestamp_start?: string
|
||||
sql?: string
|
||||
}
|
||||
path: {
|
||||
ref: string
|
||||
}
|
||||
}
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': components['schemas']['V1AnalyticsResponse']
|
||||
}
|
||||
}
|
||||
403: {
|
||||
content: never
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Get project api keys */
|
||||
'v1-get-project-api-keys': {
|
||||
parameters: {
|
||||
@@ -17189,6 +17296,61 @@ export interface operations {
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Lists available restore versions for the given project */
|
||||
'v1-list-available-restore-versions': {
|
||||
parameters: {
|
||||
path: {
|
||||
ref: string
|
||||
}
|
||||
}
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': components['schemas']['GetProjectAvailableRestoreVersionsResponse']
|
||||
}
|
||||
}
|
||||
403: {
|
||||
content: never
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Restores the given project */
|
||||
'v1-restore-a-project': {
|
||||
parameters: {
|
||||
path: {
|
||||
ref: string
|
||||
}
|
||||
}
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': components['schemas']['RestoreProjectBodyDto']
|
||||
}
|
||||
}
|
||||
responses: {
|
||||
200: {
|
||||
content: never
|
||||
}
|
||||
403: {
|
||||
content: never
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Cancels the given project restoration */
|
||||
'v1-cancel-a-project-restoration': {
|
||||
parameters: {
|
||||
path: {
|
||||
ref: string
|
||||
}
|
||||
}
|
||||
responses: {
|
||||
200: {
|
||||
content: never
|
||||
}
|
||||
403: {
|
||||
content: never
|
||||
}
|
||||
}
|
||||
}
|
||||
/** [Beta] Get project's SSL enforcement configuration. */
|
||||
'v1-get-ssl-enforcement-config': {
|
||||
parameters: {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { FlagValues } from '@vercel/flags/react'
|
||||
import { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react'
|
||||
|
||||
import { components } from 'api-types'
|
||||
import { useUser } from './auth'
|
||||
import { LOCAL_STORAGE_KEYS } from './constants'
|
||||
import { get, post } from './fetchWrappers'
|
||||
import { ensurePlatformSuffix } from './helpers'
|
||||
|
||||
type TrackFeatureFlagVariables = components['schemas']['TelemetryFeatureFlagBodyDto']
|
||||
export type CallFeatureFlagsResponse = components['schemas']['TelemetryCallFeatureFlagsResponseDto']
|
||||
|
||||
export async function getFeatureFlags(API_URL: string) {
|
||||
const data = await get(`${ensurePlatformSuffix(API_URL)}/telemetry/feature-flags`)
|
||||
|
||||
return data as CallFeatureFlagsResponse
|
||||
}
|
||||
|
||||
export async function trackFeatureFlag(API_URL: string, body: TrackFeatureFlagVariables) {
|
||||
const consent =
|
||||
(typeof window !== 'undefined'
|
||||
? localStorage.getItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT)
|
||||
: null) === 'true'
|
||||
|
||||
if (!consent) return undefined
|
||||
await post(`${ensurePlatformSuffix(API_URL)}/telemetry/feature-flags/track`, { body })
|
||||
}
|
||||
|
||||
export type FeatureFlagContextType = {
|
||||
API_URL?: string
|
||||
configcat: { [key: string]: boolean | number | string | null }
|
||||
posthog: CallFeatureFlagsResponse
|
||||
hasLoaded?: boolean
|
||||
}
|
||||
|
||||
const FeatureFlagContext = createContext<FeatureFlagContextType>({
|
||||
API_URL: undefined,
|
||||
configcat: {},
|
||||
posthog: {},
|
||||
hasLoaded: false,
|
||||
})
|
||||
|
||||
function getCookies() {
|
||||
const pairs = document.cookie.split(';')
|
||||
let cookies: Record<string, string> = {}
|
||||
for (var i = 0; i < pairs.length; i++) {
|
||||
var [t_key, value] = pairs[i].split('=')
|
||||
const key = t_key.trim()
|
||||
|
||||
cookies[key] = unescape(value)
|
||||
}
|
||||
return cookies
|
||||
}
|
||||
|
||||
export const FeatureFlagProvider = ({
|
||||
API_URL,
|
||||
enabled = true,
|
||||
getConfigCatFlags,
|
||||
children,
|
||||
}: PropsWithChildren<{
|
||||
API_URL: string
|
||||
enabled?: boolean
|
||||
getConfigCatFlags?: (
|
||||
userEmail?: string
|
||||
) => Promise<{ settingKey: string; settingValue: boolean | number | string | null | undefined }[]>
|
||||
}>) => {
|
||||
const user = useUser()
|
||||
|
||||
const [store, setStore] = useState<FeatureFlagContextType>({
|
||||
API_URL,
|
||||
configcat: {},
|
||||
posthog: {},
|
||||
hasLoaded: false,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
|
||||
async function processFlags() {
|
||||
if (!enabled) return
|
||||
|
||||
let flagStore: FeatureFlagContextType = { configcat: {}, posthog: {} }
|
||||
|
||||
// Load PH flags
|
||||
const flags = await getFeatureFlags(API_URL)
|
||||
if (flags) {
|
||||
flagStore.posthog = flags
|
||||
}
|
||||
|
||||
// Load ConfigCat flags
|
||||
if (typeof getConfigCatFlags === 'function') {
|
||||
const flagValues = await getConfigCatFlags(user?.email)
|
||||
let overridesCookieValue: Record<string, boolean> = {}
|
||||
try {
|
||||
const cookies = getCookies()
|
||||
overridesCookieValue = JSON.parse(cookies['vercel-flag-overrides'])
|
||||
} catch {}
|
||||
|
||||
flagValues.forEach((item) => {
|
||||
flagStore['configcat'][item.settingKey] =
|
||||
overridesCookieValue[item.settingKey] ??
|
||||
(item.settingValue === null ? null : item.settingValue ?? false)
|
||||
})
|
||||
}
|
||||
|
||||
flagStore.hasLoaded = true
|
||||
|
||||
if (mounted) {
|
||||
setStore(flagStore)
|
||||
}
|
||||
}
|
||||
|
||||
// [Joshen] getFlags get triggered everytime the tab refocuses but this should be okay
|
||||
// as per https://configcat.com/docs/sdk-reference/js/#polling-modes:
|
||||
// The polling downloads the config.json at the set interval and are stored in the internal cache
|
||||
// which subsequently all getValueAsync() calls are served from there
|
||||
processFlags()
|
||||
|
||||
return () => {
|
||||
mounted = false
|
||||
}
|
||||
}, [enabled, user?.email])
|
||||
|
||||
return (
|
||||
<FeatureFlagContext.Provider value={store}>
|
||||
{/*
|
||||
[Joshen] Just support configcat flags in Vercel flags for now for simplicity
|
||||
although I think it should be fairly simply to support PH too
|
||||
*/}
|
||||
<FlagValues values={store.configcat} />
|
||||
{children}
|
||||
</FeatureFlagContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useFeatureFlags = () => {
|
||||
return useContext(FeatureFlagContext)
|
||||
}
|
||||
@@ -1,13 +1,43 @@
|
||||
import { getAccessToken } from './auth'
|
||||
|
||||
interface DataProps {
|
||||
[prop: string]: any
|
||||
}
|
||||
|
||||
export const post = (url: string, data: DataProps, options = {} as { [key: string]: any }) => {
|
||||
export async function get(url: string, options = {} as { [key: string]: any }) {
|
||||
const { headers: optionHeaders, ...otherOptions } = options
|
||||
|
||||
const accessToken = await getAccessToken()
|
||||
|
||||
let headers = new Headers({
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
...(accessToken && { Authorization: `Bearer ${accessToken}` }),
|
||||
...optionHeaders,
|
||||
})
|
||||
|
||||
return fetch(url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
credentials: 'include',
|
||||
referrerPolicy: 'no-referrer-when-downgrade',
|
||||
...otherOptions,
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.catch((error) => {
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
export async function post(url: string, data: DataProps, options = {} as { [key: string]: any }) {
|
||||
const { headers: optionHeaders, ...otherOptions } = options
|
||||
|
||||
const accessToken = await getAccessToken()
|
||||
|
||||
let headers = new Headers({
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
...(accessToken && { Authorization: `Bearer ${accessToken}` }),
|
||||
...optionHeaders,
|
||||
})
|
||||
|
||||
@@ -19,6 +49,6 @@ export const post = (url: string, data: DataProps, options = {} as { [key: strin
|
||||
body: JSON.stringify(data),
|
||||
...otherOptions,
|
||||
}).catch((error) => {
|
||||
console.error('Error at fetchWrapper - post:', error)
|
||||
throw error
|
||||
})
|
||||
}
|
||||
@@ -33,3 +33,7 @@ export const useReducedMotion = (): boolean => {
|
||||
() => false
|
||||
)
|
||||
}
|
||||
|
||||
export function ensurePlatformSuffix(apiUrl: string) {
|
||||
return apiUrl.endsWith('/platform') ? apiUrl : `${apiUrl}/platform`
|
||||
}
|
||||
@@ -22,7 +22,7 @@ export function useTelemetryProps(): TelemetryProps {
|
||||
return {
|
||||
screenResolution: isBrowser ? `${window.innerWidth}x${window.innerHeight}` : undefined,
|
||||
page_url: isBrowser ? window.location.href : '',
|
||||
pathname: router?.pathname ?? pathname ?? window.location.pathname,
|
||||
pathname: router?.pathname ?? pathname ?? (isBrowser ? window.location.pathname : ''),
|
||||
search: isBrowser ? window.location.search : '',
|
||||
viewport_height: isBrowser ? window.innerHeight : 0,
|
||||
viewport_width: isBrowser ? window.innerWidth : 0,
|
||||
|
||||
@@ -9,3 +9,4 @@ export * from './hooks'
|
||||
export * from './MetaFavicons/pages-router'
|
||||
export * from './Providers'
|
||||
export * from './telemetry'
|
||||
export * from './feature-flags'
|
||||
@@ -12,6 +12,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/dat.gui": "^0.7.12",
|
||||
"@vercel/flags": "^2.6.0",
|
||||
"api-types": "workspace:*",
|
||||
"config": "workspace:*",
|
||||
"dat.gui": "^0.7.9",
|
||||
"lodash": "^4.17.21",
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { post } from './fetchWrappers'
|
||||
|
||||
interface TelemetryData {
|
||||
page_url: string
|
||||
page_title: string
|
||||
pathname: string
|
||||
ph: {
|
||||
referrer: string
|
||||
language: string
|
||||
search: string
|
||||
viewport_height: number
|
||||
viewport_width: number
|
||||
user_agent: string
|
||||
}
|
||||
}
|
||||
|
||||
export function handlePageTelemetry(API_URL: string, route: string, telemetryData: TelemetryData) {
|
||||
const { page_url } = telemetryData
|
||||
|
||||
const { search, language, viewport_height, viewport_width, referrer, user_agent } =
|
||||
telemetryData.ph
|
||||
|
||||
return post(
|
||||
`${API_URL}/telemetry/page`,
|
||||
{
|
||||
page_url,
|
||||
page_title: document.title,
|
||||
pathname: route,
|
||||
ph: {
|
||||
referrer: referrer ?? document.referrer,
|
||||
language,
|
||||
search,
|
||||
viewport_height,
|
||||
viewport_width,
|
||||
user_agent: user_agent ?? navigator.userAgent,
|
||||
},
|
||||
},
|
||||
{ headers: { Version: '2' }, credentials: 'include' }
|
||||
)
|
||||
}
|
||||
|
||||
export function handleResetTelemetry(API_URL: string) {
|
||||
return post(`${API_URL}/telemetry/reset`, {})
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { components } from 'api-types'
|
||||
import { useRouter } from 'next/compat/router'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useFeatureFlags } from './feature-flags'
|
||||
import { post } from './fetchWrappers'
|
||||
import { ensurePlatformSuffix, isBrowser } from './helpers'
|
||||
import { useTelemetryCookie } from './hooks'
|
||||
|
||||
export function handlePageTelemetry(
|
||||
API_URL: string,
|
||||
pathname?: string,
|
||||
featureFlags?: {
|
||||
[key: string]: unknown
|
||||
},
|
||||
telemetryDataOverride?: components['schemas']['TelemetryPageBodyV2Dto']
|
||||
) {
|
||||
return post(
|
||||
`${ensurePlatformSuffix(API_URL)}/telemetry/page`,
|
||||
telemetryDataOverride !== undefined
|
||||
? { feature_flags: featureFlags, ...telemetryDataOverride }
|
||||
: {
|
||||
page_url: isBrowser ? window.location.href : '',
|
||||
page_title: isBrowser ? document?.title : '',
|
||||
pathname: pathname ? pathname : isBrowser ? window.location.pathname : '',
|
||||
ph: {
|
||||
referrer: isBrowser ? document?.referrer : '',
|
||||
language: navigator.language ?? 'en-US',
|
||||
user_agent: navigator.userAgent,
|
||||
search: isBrowser ? window.location.search : '',
|
||||
viewport_height: isBrowser ? window.innerHeight : 0,
|
||||
viewport_width: isBrowser ? window.innerWidth : 0,
|
||||
},
|
||||
feature_flags: featureFlags,
|
||||
},
|
||||
{ headers: { Version: '2' } }
|
||||
)
|
||||
}
|
||||
|
||||
export function handlePageLeaveTelemetry(
|
||||
API_URL: string,
|
||||
pathname: string,
|
||||
featureFlags?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
) {
|
||||
return post(`${ensurePlatformSuffix(API_URL)}/telemetry/page-leave`, {
|
||||
body: {
|
||||
pathname,
|
||||
page_url: isBrowser ? window.location.href : '',
|
||||
page_title: isBrowser ? document?.title : '',
|
||||
feature_flags: featureFlags,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function handleResetTelemetry(API_URL: string) {
|
||||
return post(`${API_URL}/telemetry/reset`, {})
|
||||
}
|
||||
|
||||
export const PageTelemetry = ({
|
||||
API_URL,
|
||||
hasAcceptedConsent,
|
||||
enabled = true,
|
||||
}: {
|
||||
API_URL: string
|
||||
hasAcceptedConsent: boolean
|
||||
enabled?: boolean
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
|
||||
const pagesPathname = router?.pathname
|
||||
const appPathname = usePathname()
|
||||
|
||||
const featureFlags = useFeatureFlags()
|
||||
|
||||
const title = typeof document !== 'undefined' ? document?.title : ''
|
||||
const referrer = typeof document !== 'undefined' ? document?.referrer : ''
|
||||
useTelemetryCookie({ hasAcceptedConsent, title, referrer })
|
||||
|
||||
const sendPageTelemetry = useCallback(() => {
|
||||
if (!(enabled && hasAcceptedConsent)) return Promise.resolve()
|
||||
|
||||
return handlePageTelemetry(
|
||||
API_URL,
|
||||
pagesPathname ?? appPathname ?? undefined,
|
||||
featureFlags.posthog
|
||||
).catch((e) => {
|
||||
console.error('Problem sending telemetry page:', e)
|
||||
})
|
||||
}, [API_URL, pagesPathname, appPathname, hasAcceptedConsent, featureFlags.posthog])
|
||||
|
||||
const sendPageLeaveTelemetry = useCallback(() => {
|
||||
if (!(enabled && hasAcceptedConsent)) return Promise.resolve()
|
||||
|
||||
return handlePageTelemetry(
|
||||
API_URL,
|
||||
pagesPathname ?? appPathname ?? undefined,
|
||||
featureFlags.posthog
|
||||
).catch((e) => {
|
||||
console.error('Problem sending telemetry page-leave:', e)
|
||||
})
|
||||
}, [pagesPathname, appPathname, hasAcceptedConsent, featureFlags.posthog])
|
||||
|
||||
const hasSentInitialPageTelemetryRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Send page telemetry on first page load
|
||||
// Waiting for router ready before sending page_view
|
||||
// if not the path will be dynamic route instead of the browser url
|
||||
if (
|
||||
(router?.isReady ?? true) &&
|
||||
featureFlags.hasLoaded &&
|
||||
!hasSentInitialPageTelemetryRef.current
|
||||
) {
|
||||
sendPageTelemetry()
|
||||
hasSentInitialPageTelemetryRef.current = true
|
||||
}
|
||||
}, [router?.isReady, featureFlags.hasLoaded])
|
||||
|
||||
useEffect(() => {
|
||||
// For pages router
|
||||
if (router === null) return
|
||||
|
||||
function handleRouteChange() {
|
||||
sendPageTelemetry()
|
||||
}
|
||||
|
||||
// Listen for page changes after a navigation or when the query changes
|
||||
router.events.on('routeChangeComplete', handleRouteChange)
|
||||
|
||||
return () => {
|
||||
router.events.off('routeChangeComplete', handleRouteChange)
|
||||
}
|
||||
}, [router])
|
||||
|
||||
useEffect(() => {
|
||||
// For app router
|
||||
if (router !== null) return
|
||||
|
||||
if (appPathname) {
|
||||
sendPageTelemetry()
|
||||
}
|
||||
}, [appPathname, router, sendPageTelemetry])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
|
||||
const handleBeforeUnload = () => sendPageLeaveTelemetry()
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}, [enabled, sendPageLeaveTelemetry])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -5,13 +5,14 @@ import {
|
||||
handlePageTelemetry,
|
||||
isBrowser,
|
||||
useBreakpoint,
|
||||
useFeatureFlags,
|
||||
useTelemetryProps,
|
||||
} from 'common'
|
||||
|
||||
import { noop } from 'lodash'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button, cn } from 'ui'
|
||||
import { proxy, useSnapshot } from 'valtio'
|
||||
import { PrivacySettings } from '../PrivacySettings'
|
||||
|
||||
interface ConsentToastProps {
|
||||
@@ -77,13 +78,23 @@ export const ConsentToast = ({ onAccept = noop, onOptOut = noop }: ConsentToastP
|
||||
)
|
||||
}
|
||||
|
||||
// [Alaister]: Using global state here so multiple components can access the consent value
|
||||
const consentState = proxy({
|
||||
consentValue: (isBrowser ? localStorage?.getItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT) : null) as
|
||||
| string
|
||||
| null,
|
||||
setConsentValue: (value: string | null) => {
|
||||
consentState.consentValue = value
|
||||
},
|
||||
})
|
||||
|
||||
export const useConsent = () => {
|
||||
const { TELEMETRY_CONSENT, TELEMETRY_DATA } = LOCAL_STORAGE_KEYS
|
||||
const consentToastId = useRef<string | number>()
|
||||
const telemetryProps = useTelemetryProps()
|
||||
const featureFlags = useFeatureFlags()
|
||||
|
||||
const initialValue = isBrowser ? localStorage?.getItem(TELEMETRY_CONSENT) : null
|
||||
const [consentValue, setConsentValue] = useState<string | null>(initialValue)
|
||||
const { consentValue } = useSnapshot(consentState)
|
||||
|
||||
const handleConsent = (value: 'true' | 'false') => {
|
||||
if (!isBrowser) return
|
||||
@@ -97,7 +108,8 @@ export const useConsent = () => {
|
||||
const telemetryData = JSON.parse(decodeURIComponent(encodedData))
|
||||
handlePageTelemetry(
|
||||
process.env.NEXT_PUBLIC_API_URL!,
|
||||
window.location.pathname,
|
||||
telemetryProps.pathname,
|
||||
featureFlags.posthog,
|
||||
telemetryData
|
||||
)
|
||||
// remove the telemetry cookie
|
||||
@@ -106,24 +118,10 @@ export const useConsent = () => {
|
||||
console.error('Invalid telemetry data:', error)
|
||||
}
|
||||
} else {
|
||||
const telemetryData = {
|
||||
page_url: telemetryProps.page_url,
|
||||
page_title: typeof document !== 'undefined' ? document?.title : '',
|
||||
pathname: telemetryProps.pathname,
|
||||
ph: {
|
||||
referrer: typeof document !== 'undefined' ? document?.referrer : '',
|
||||
language: telemetryProps.language,
|
||||
search: telemetryProps.search,
|
||||
viewport_height: telemetryProps.viewport_height,
|
||||
viewport_width: telemetryProps.viewport_width,
|
||||
user_agent: navigator.userAgent,
|
||||
},
|
||||
}
|
||||
|
||||
handlePageTelemetry(
|
||||
process.env.NEXT_PUBLIC_API_URL!,
|
||||
window.location.pathname,
|
||||
telemetryData
|
||||
telemetryProps.pathname,
|
||||
featureFlags.posthog
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -131,7 +129,7 @@ export const useConsent = () => {
|
||||
document.cookie = `${TELEMETRY_DATA}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`
|
||||
}
|
||||
|
||||
setConsentValue(value)
|
||||
consentState.setConsentValue(value)
|
||||
localStorage.setItem(TELEMETRY_CONSENT, value)
|
||||
|
||||
if (consentToastId.current) {
|
||||
|
||||
Generated
+6
@@ -1327,6 +1327,12 @@ importers:
|
||||
'@types/dat.gui':
|
||||
specifier: ^0.7.12
|
||||
version: 0.7.12
|
||||
'@vercel/flags':
|
||||
specifier: ^2.6.0
|
||||
version: 2.6.0(next@14.2.21(@babel/core@7.24.7(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.41.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(sass@1.72.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
api-types:
|
||||
specifier: workspace:*
|
||||
version: link:../api-types
|
||||
config:
|
||||
specifier: workspace:*
|
||||
version: link:../config
|
||||
|
||||
Reference in new issue
Block a user