) => {
const [open, setOpen] = useState(false)
// Reset the state when the popover closes
@@ -215,10 +226,20 @@ export const LogsDatePicker = ({ onSubmit, helpers, value }: PropsWithChildren
LOGS_LARGE_DATE_RANGE_DAYS_THRESHOLD - 1
+ const { plan: orgPlan, isLoading: isOrgPlanLoading } = useCurrentOrgPlan()
+ const showHelperBadge = (helper?: DatetimeHelper) => {
+ if (!helper) return false
+ if (!helper.availableIn?.length) return false
+
+ if (helper.availableIn.includes('free')) return false
+ if (helper.availableIn.includes(orgPlan?.id || 'free') && !isOrgPlanLoading) return false
+ return true
+ }
+
return (
- }>
+ } {...buttonTriggerProps}>
{value.isHelper
? value.text
: `${dayjs(value.from).format('DD MMM, HH:mm')} - ${dayjs(value.to || new Date()).format('DD MMM, HH:mm')}`}
@@ -227,8 +248,9 @@ export const LogsDatePicker = ({ onSubmit, helpers, value }: PropsWithChildren
{helper.text}
+ {showHelperBadge(helper) ? (
+
+ {helper.availableIn?.[0] || ''}
+
+ ) : null}
))}
diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.types.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.types.ts
index ef3597a06fb..d1f606a72ff 100644
--- a/apps/studio/components/interfaces/Settings/Logs/Logs.types.ts
+++ b/apps/studio/components/interfaces/Settings/Logs/Logs.types.ts
@@ -1,5 +1,6 @@
import type { Datum } from 'components/ui/Charts/Charts.types'
import React from 'react'
+import type { PlanId } from 'data/subscriptions/types'
interface Metadata {
[key: string]: string | number | Object | Object[] | any
@@ -142,6 +143,7 @@ export interface DatetimeHelper {
calcFrom: () => string
default?: boolean
disabled?: boolean
+ availableIn?: PlanId[]
}
export interface WarehouseCollection {
diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts
index d1f9fbc2273..2a32d88cc81 100644
--- a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts
+++ b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts
@@ -474,8 +474,12 @@ export const fillTimeseries = (
defaultValue: number,
min?: string,
max?: string,
- minPointsToFill: number = 20
+ minPointsToFill: number = 20,
+ interval?: string
) => {
+ if (timeseriesData.length === 0 && !(min && max)) {
+ return []
+ }
// If we have more points than minPointsToFill, just normalize timestamps and return
if (timeseriesData.length > minPointsToFill) {
return timeseriesData.map((datum) => {
@@ -493,41 +497,61 @@ export const fillTimeseries = (
// const truncationSample = timeseriesData.length > 0 ? timeseriesData[0][timestampKey] : min || max
const truncationSamples = timeseriesData.length > 0 ? dates : [minDate, maxDate]
- const truncation = getTimestampTruncation(truncationSamples as Dayjs[])
+ let truncation: 'second' | 'minute' | 'hour' | 'day'
+ let step = 1
+
+ if (interval) {
+ const match = interval.match(/^(\d+)(m|h|d|s)$/)
+ if (match) {
+ step = parseInt(match[1], 10)
+ const unitChar = match[2] as 'm' | 'h' | 'd' | 's'
+ const unitMap = { s: 'second', m: 'minute', h: 'hour', d: 'day' } as const
+ truncation = unitMap[unitChar]
+ } else {
+ // Fallback for invalid format
+ truncation = getTimestampTruncation(truncationSamples as Dayjs[])
+ }
+ } else {
+ truncation = getTimestampTruncation(truncationSamples as Dayjs[])
+ }
const newData = timeseriesData.map((datum) => {
- const iso = dayjs.utc(datum[timestampKey]).toISOString()
+ const timestamp = datum[timestampKey]
+ const iso = isUnixMicro(timestamp)
+ ? unixMicroToIsoTimestamp(timestamp)
+ : dayjs.utc(timestamp).toISOString()
datum[timestampKey] = iso
return datum
})
- const diff = maxDate.diff(minDate, truncation as dayjs.UnitType)
- // Intentional throwing of error here to be caught by Sentry, as this would indicate a bug since charts shouldn't be rendering more than 10k data points
- if (diff > 10000) {
- throw new Error(
- 'The selected date range will render more than 10,000 data points within the charts, which will degrade browser performance. Please select a smaller date range.'
- )
- }
+ let currentDate = minDate
+ while (currentDate.isBefore(maxDate) || currentDate.isSame(maxDate)) {
+ const found = dates.find((d) => {
+ const d_date = d as Dayjs
+ return (
+ d_date.year() === currentDate.year() &&
+ d_date.month() === currentDate.month() &&
+ d_date.date() === currentDate.date() &&
+ d_date.hour() === currentDate.hour() &&
+ d_date.minute() === currentDate.minute()
+ )
+ })
+ if (!found) {
+ const keys = typeof valueKey === 'string' ? [valueKey] : valueKey
- for (let i = 0; i <= diff; i++) {
- const dateToMaybeAdd = minDate.add(i, truncation as dayjs.ManipulateType)
-
- const keys = typeof valueKey === 'string' ? [valueKey] : valueKey
-
- const toMerge = keys.reduce(
- (acc, key) => ({
- ...acc,
- [key]: defaultValue,
- }),
- {}
- )
-
- if (!dates.find((d) => isEqual(d, dateToMaybeAdd))) {
+ const toMerge = keys.reduce(
+ (acc, key) => ({
+ ...acc,
+ [key]: defaultValue,
+ }),
+ {}
+ )
newData.push({
- [timestampKey]: dateToMaybeAdd.toISOString(),
+ [timestampKey]: currentDate.toISOString(),
...toMerge,
})
}
+ currentDate = currentDate.add(step, truncation)
}
return newData
diff --git a/apps/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx b/apps/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx
index 85a79c6533d..f47f9c50861 100644
--- a/apps/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx
+++ b/apps/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx
@@ -14,6 +14,7 @@ import { useSelectedLog } from 'hooks/analytics/useSelectedLog'
import useSingleLog from 'hooks/analytics/useSingleLog'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useUpgradePrompt } from 'hooks/misc/useUpgradePrompt'
+import { useFlag } from 'hooks/ui/useFlag'
import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
import { Button } from 'ui'
import { LogsBarChart } from 'ui-patterns/LogsBarChart'
@@ -27,9 +28,8 @@ import {
} from './Logs.constants'
import type { Filters, LogSearchCallback, LogTemplate, QueryType } from './Logs.types'
import { maybeShowUpgradePrompt } from './Logs.utils'
-import UpgradePrompt from './UpgradePrompt'
-import { useFlag } from 'hooks/ui/useFlag'
import { PreviewFilterPanelWithUniversal } from './PreviewFilterPanelWithUniversal'
+import UpgradePrompt from './UpgradePrompt'
/**
* Acts as a container component for the entire log display
diff --git a/apps/studio/components/interfaces/Settings/Logs/PreviewFilterPanelWithUniversal.tsx b/apps/studio/components/interfaces/Settings/Logs/PreviewFilterPanelWithUniversal.tsx
index ab7f5ae19c8..0a8c4502216 100644
--- a/apps/studio/components/interfaces/Settings/Logs/PreviewFilterPanelWithUniversal.tsx
+++ b/apps/studio/components/interfaces/Settings/Logs/PreviewFilterPanelWithUniversal.tsx
@@ -5,7 +5,6 @@ import { useRouter } from 'next/router'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useParams } from 'common'
-// import CSVButton from 'components/ui/CSVButton'
import DatabaseSelector from 'components/ui/DatabaseSelector'
import { useLoadBalancersQuery } from 'data/read-replicas/load-balancers-query'
import { IS_PLATFORM } from 'lib/constants'
@@ -21,11 +20,6 @@ import { DatePickerValue } from './Logs.DatePickers'
import { FILTER_OPTIONS, LOG_ROUTES_WITH_REPLICA_SUPPORT, LogsTableName } from './Logs.constants'
import type { Filters, LogSearchCallback, LogTemplate } from './Logs.types'
-interface CustomDateRangePickerProps {
- value?: { from: Date; to?: Date }
- onChange: (range: { from: Date; to?: Date } | undefined) => void
-}
-
function CustomDateRangePicker({ onChange, onCancel }: CustomOptionProps) {
const [dateRange, setDateRange] = useState()
diff --git a/apps/studio/components/interfaces/Settings/Logs/UpgradePrompt.tsx b/apps/studio/components/interfaces/Settings/Logs/UpgradePrompt.tsx
index be49b0132bb..5869c5b5f8b 100644
--- a/apps/studio/components/interfaces/Settings/Logs/UpgradePrompt.tsx
+++ b/apps/studio/components/interfaces/Settings/Logs/UpgradePrompt.tsx
@@ -7,9 +7,25 @@ import { TIER_QUERY_LIMITS } from './Logs.constants'
interface Props {
show: boolean
setShowUpgradePrompt: (value: boolean) => void
+ title?: string
+ description?: string
+ source?: string
}
-
-const UpgradePrompt: React.FC = ({ show, setShowUpgradePrompt }) => {
+/**
+ * @param show - whether to show the modal
+ * @param setShowUpgradePrompt - function to set the show state
+ * @param title - title of the modal
+ * @param description - description of the modal
+ * @param source - source of the upgrade prompt used to track the source of the upgrade prompt in the billing page
+ * @returns
+ */
+const UpgradePrompt: React.FC = ({
+ show,
+ setShowUpgradePrompt,
+ title = 'Log retention',
+ description = 'Logs can be retained up to a duration of 3 months depending on the plan that your project is on.',
+ source = 'logsRetentionUpgradePrompt',
+}) => {
const organization = useSelectedOrganization()
return (
@@ -17,15 +33,12 @@ const UpgradePrompt: React.FC = ({ show, setShowUpgradePrompt }) => {
hideFooter
visible={show}
size="medium"
- header="Log retention"
+ header={title}
onCancel={() => setShowUpgradePrompt(false)}
>
-
- Logs can be retained up to a duration of 3 months depending on the plan that your
- project is on.
-
+
{description}
Plan
@@ -58,9 +71,7 @@ const UpgradePrompt: React.FC
= ({ show, setShowUpgradePrompt }) => {
Close
-
+
Upgrade
diff --git a/apps/studio/components/interfaces/Sidebar.tsx b/apps/studio/components/interfaces/Sidebar.tsx
index bb7625b410a..e729bc0b732 100644
--- a/apps/studio/components/interfaces/Sidebar.tsx
+++ b/apps/studio/components/interfaces/Sidebar.tsx
@@ -224,7 +224,6 @@ const ProjectLinks = () => {
const { securityLints, errorLints } = useLints()
const showWarehouse = useFlag('warehouse')
- const showUnifiedLogs = useFlag('unifiedLogs')
const activeRoute = router.pathname.split('/')[3]
@@ -247,7 +246,7 @@ const ProjectLinks = () => {
storage: storageEnabled,
realtime: realtimeEnabled,
})
- const otherRoutes = generateOtherRoutes(ref, project, { unifiedLogs: showUnifiedLogs })
+ const otherRoutes = generateOtherRoutes(ref, project)
const settingsRoutes = generateSettingsRoutes(ref, project)
return (
diff --git a/apps/studio/components/interfaces/SignIn/ForgotPasswordForm.tsx b/apps/studio/components/interfaces/SignIn/ForgotPasswordForm.tsx
deleted file mode 100644
index 176f4112373..00000000000
--- a/apps/studio/components/interfaces/SignIn/ForgotPasswordForm.tsx
+++ /dev/null
@@ -1,106 +0,0 @@
-import HCaptcha from '@hcaptcha/react-hcaptcha'
-import { useRouter } from 'next/router'
-import { useRef, useState } from 'react'
-import { toast } from 'sonner'
-import { object, string } from 'yup'
-
-import { useResetPasswordMutation } from 'data/misc/reset-password-mutation'
-import { BASE_PATH } from 'lib/constants'
-import { Button, Form, Input } from 'ui'
-
-const forgotPasswordSchema = object({
- email: string().email('Must be a valid email').required('Email is required'),
-})
-
-const ForgotPasswordForm = () => {
- const router = useRouter()
- const captchaRef = useRef(null)
- const [captchaToken, setCaptchaToken] = useState(null)
-
- const { mutate: resetPassword, isLoading } = useResetPasswordMutation({
- onSuccess: async () => {
- toast.success(
- `If you registered using your email and password, you will receive a password reset email. The password reset link expires in 10 minutes.`
- )
- await router.push('/sign-in')
- },
- onError: (error) => {
- setCaptchaToken(null)
- captchaRef.current?.resetCaptcha()
- toast.error(`Failed to send reset email: ${error.message}`)
- },
- })
-
- const onForgotPassword = async ({ email }: { email: string }) => {
- let token = captchaToken
- if (!token) {
- const captchaResponse = await captchaRef.current?.execute({ async: true })
- token = captchaResponse?.response ?? null
- }
-
- resetPassword({
- email,
- hcaptchaToken: token,
- redirectTo: `${
- process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview'
- ? location.origin
- : process.env.NEXT_PUBLIC_SITE_URL
- }${BASE_PATH}/reset-password`,
- })
- }
-
- return (
-
- )
-}
-
-export default ForgotPasswordForm
diff --git a/apps/studio/components/interfaces/SignIn/ForgotPasswordWizard.tsx b/apps/studio/components/interfaces/SignIn/ForgotPasswordWizard.tsx
new file mode 100644
index 00000000000..4aa80ad51d1
--- /dev/null
+++ b/apps/studio/components/interfaces/SignIn/ForgotPasswordWizard.tsx
@@ -0,0 +1,197 @@
+import HCaptcha from '@hcaptcha/react-hcaptcha'
+import { zodResolver } from '@hookform/resolvers/zod'
+import { useRouter } from 'next/router'
+import { useRef, useState } from 'react'
+import { SubmitHandler, useForm } from 'react-hook-form'
+import { toast } from 'sonner'
+import * as z from 'zod'
+
+import { useResetPasswordMutation } from 'data/misc/reset-password-mutation'
+import { BASE_PATH } from 'lib/constants'
+import { auth } from 'lib/gotrue'
+import { Button, Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, Input_Shadcn_ } from 'ui'
+import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
+
+const forgotPasswordSchema = z.object({
+ email: z.string().email('Must be a valid email').min(1, 'Email is required'),
+})
+
+const codeSchema = z.object({
+ code: z.string().regex(/^\d{6}$/, 'Code must be 6 digits'),
+})
+
+type ForgotPasswordFormData = z.infer
+type CodeFormData = z.infer
+
+export const ForgotPasswordWizard = () => {
+ const [email, setEmail] = useState('')
+
+ if (email) {
+ return
+ }
+
+ return setEmail(email)} />
+}
+
+const ConfirmResetCodeForm = ({ email }: { email: string }) => {
+ const router = useRouter()
+ const [isLoading, setIsLoading] = useState(false)
+
+ const codeForm = useForm({
+ resolver: zodResolver(codeSchema),
+ defaultValues: { code: '' },
+ })
+
+ const onCodeEntered: SubmitHandler = async (data) => {
+ setIsLoading(true)
+ const {
+ data: { user },
+ error,
+ } = await auth.verifyOtp({ email, token: data.code, type: 'recovery' })
+
+ // This fixes a race condition where the user is redirected to the reset password page without the session being set
+ // which causes the user to be redirected to /sign-in page even though he's signed in
+ await new Promise((resolve) => setTimeout(resolve, 1000))
+
+ if (error) {
+ setIsLoading(false)
+ toast.error(`Failed to verify code: ${error.message}`)
+ } else {
+ if (user?.factors?.length) {
+ await router.push({
+ pathname: '/forgot-password-mfa',
+ query: { returnTo: '/reset-password' },
+ })
+ } else {
+ await router.push('reset-password')
+ }
+ }
+ }
+
+ return (
+
+
+
+ )
+}
+
+const ForgotPasswordForm = ({ onSuccess }: { onSuccess: (email: string) => void }) => {
+ const captchaRef = useRef(null)
+ const [captchaToken, setCaptchaToken] = useState(null)
+
+ const forgotPasswordForm = useForm({
+ resolver: zodResolver(forgotPasswordSchema),
+ defaultValues: { email: '' },
+ })
+
+ const { mutate: resetPassword, isLoading } = useResetPasswordMutation({
+ onSuccess: () => {
+ onSuccess(forgotPasswordForm.getValues('email'))
+ },
+ onError: (error) => {
+ setCaptchaToken(null)
+ captchaRef.current?.resetCaptcha()
+ toast.error(`Failed to send reset email: ${error.message}`)
+ },
+ })
+
+ const onForgotPassword: SubmitHandler = async (data) => {
+ let token = captchaToken
+ if (!token) {
+ const captchaResponse = await captchaRef.current?.execute({ async: true })
+ token = captchaResponse?.response ?? null
+ }
+
+ resetPassword({
+ email: data.email,
+ hcaptchaToken: token,
+ redirectTo: `${
+ process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview'
+ ? location.origin
+ : process.env.NEXT_PUBLIC_SITE_URL
+ }${BASE_PATH}/reset-password`,
+ })
+ }
+
+ return (
+
+
+
+ )
+}
diff --git a/apps/studio/components/interfaces/SignIn/ResetPasswordForm.tsx b/apps/studio/components/interfaces/SignIn/ResetPasswordForm.tsx
index d048a96b0fe..868b5d96162 100644
--- a/apps/studio/components/interfaces/SignIn/ResetPasswordForm.tsx
+++ b/apps/studio/components/interfaces/SignIn/ResetPasswordForm.tsx
@@ -1,22 +1,66 @@
+import { zodResolver } from '@hookform/resolvers/zod'
import * as Sentry from '@sentry/nextjs'
+import { Eye, EyeOff } from 'lucide-react'
import { useRouter } from 'next/router'
+import { useState } from 'react'
+import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
+import { z } from 'zod'
import { auth } from 'lib/gotrue'
-import { passwordSchema } from 'lib/schemas'
-import { Button, Form, Input } from 'ui'
+import {
+ Button,
+ Form_Shadcn_,
+ FormControl_Shadcn_,
+ FormField_Shadcn_,
+ FormItem_Shadcn_,
+ FormMessage_Shadcn_,
+ Input,
+} from 'ui'
+
+import PasswordConditionsHelper from './PasswordConditionsHelper'
const WHITELIST_ERRORS = [
'New password should be different from the old password',
'Password is known to be weak and easy to guess, please choose a different one',
]
+// Convert the existing yup passwordSchema to Zod
+const passwordSchema = z.object({
+ password: z
+ .string()
+ .min(1, 'Password is required')
+ .max(72, 'Password cannot exceed 72 characters')
+ .refine((password) => {
+ // Basic password validation - you can enhance this based on your requirements
+ const hasUppercase = /[A-Z]/.test(password)
+ const hasLowercase = /[a-z]/.test(password)
+ const hasNumber = /[0-9]/.test(password)
+ const hasSpecialChar = /[!@#$%^&*()_+\-=\[\]{};`':"\\|,.<>\/?]/.test(password)
+ const isLongEnough = password.length >= 8
+
+ return hasUppercase && hasLowercase && hasNumber && hasSpecialChar && isLongEnough
+ }, 'Password must contain at least 8 characters, including uppercase, lowercase, number, and special character'),
+})
+
+type FormData = z.infer
+
const ResetPasswordForm = () => {
const router = useRouter()
+ const [showConditions, setShowConditions] = useState(false)
+ const [passwordHidden, setPasswordHidden] = useState(true)
- const onResetPassword = async ({ password }: { password: string }) => {
+ const form = useForm({
+ resolver: zodResolver(passwordSchema),
+ defaultValues: {
+ password: '',
+ },
+ mode: 'onChange',
+ })
+
+ const onResetPassword = async (data: FormData) => {
const toastId = toast.loading('Saving password...')
- const { error } = await auth.updateUser({ password })
+ const { error } = await auth.updateUser({ password: data.password })
if (!error) {
toast.success('Password saved successfully!', { id: toastId })
@@ -33,42 +77,59 @@ const ResetPasswordForm = () => {
}
return (
-
+
+
+
+ Save new password
+
+
+
)
}
diff --git a/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx b/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx
index 552e59f16e8..1d5f0fa8236 100644
--- a/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx
+++ b/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx
@@ -1,10 +1,15 @@
+import { zodResolver } from '@hookform/resolvers/zod'
+import { snakeCase } from 'lodash'
import { ChevronDown } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
+import { SubmitHandler, useForm } from 'react-hook-form'
import { toast } from 'sonner'
+import z from 'zod'
import { useParams } from 'common'
+import { useIcebergWrapperExtension } from 'components/to-be-cleaned/Storage/AnalyticBucketDetails/useIcebergWrapper'
import { StorageSizeUnits } from 'components/to-be-cleaned/Storage/StorageSettings/StorageSettings.constants'
import {
convertFromBytes,
@@ -12,94 +17,145 @@ import {
} from 'components/to-be-cleaned/Storage/StorageSettings/StorageSettings.utils'
import { useProjectStorageConfigQuery } from 'data/config/project-storage-config-query'
import { useBucketCreateMutation } from 'data/storage/bucket-create-mutation'
-import { IS_PLATFORM } from 'lib/constants'
-import { Button, Collapsible, Form, Input, Listbox, Modal, Toggle, cn } from 'ui'
-import { Admonition } from 'ui-patterns'
+import { useIcebergWrapperCreateMutation } from 'data/storage/iceberg-wrapper-create-mutation'
+import { BASE_PATH, IS_PLATFORM } from 'lib/constants'
+import {
+ Alert_Shadcn_,
+ AlertDescription_Shadcn_,
+ AlertTitle_Shadcn_,
+ Button,
+ cn,
+ Collapsible,
+ Form_Shadcn_,
+ FormControl_Shadcn_,
+ FormField_Shadcn_,
+ Input_Shadcn_,
+ Label_Shadcn_,
+ Listbox,
+ Modal,
+ RadioGroupStacked,
+ RadioGroupStackedItem,
+ Toggle,
+ WarningIcon,
+} from 'ui'
+import { Admonition } from 'ui-patterns/admonition'
+import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
export interface CreateBucketModalProps {
visible: boolean
onClose: () => void
}
+const FormSchema = z.object({
+ name: z
+ .string()
+ .trim()
+ .min(1, 'Please provide a name for your bucket')
+ .regex(
+ /^[a-z0-9.-]+$/,
+ 'The name of the bucket must only contain lowercase letters, numbers, dots, and hyphens'
+ )
+ .refine((value) => !value.endsWith(' '), 'The name of the bucket cannot end with a whitespace')
+ .refine(
+ (value) => value !== 'public',
+ '"public" is a reserved name. Please choose another name'
+ ),
+ type: z.enum(['STANDARD', 'ANALYTICS']).default('STANDARD'),
+ public: z.boolean().default(false),
+ has_file_size_limit: z.boolean().default(false),
+ formatted_size_limit: z.coerce
+ .number()
+ .min(0, 'File size upload limit has to be at least 0')
+ .default(0),
+ allowed_mime_types: z.string().trim().default(''),
+})
+
+export type CreateBucketForm = z.infer
+
const CreateBucketModal = ({ visible, onClose }: CreateBucketModalProps) => {
const { ref } = useParams()
const router = useRouter()
- const { mutate: createBucket, isLoading: isCreating } = useBucketCreateMutation({
- onSuccess: (res) => {
- toast.success(`Successfully created bucket ${res.name}`)
- router.push(`/project/${ref}/storage/buckets/${res.name}`)
- onClose()
- },
- })
+ const { mutateAsync: createBucket, isLoading: isCreating } = useBucketCreateMutation()
+ const { mutateAsync: createIcebergWrapper, isLoading: isCreatingIcebergWrapper } =
+ useIcebergWrapperCreateMutation()
- const { data } = useProjectStorageConfigQuery(
- { projectRef: ref },
- { enabled: IS_PLATFORM && visible }
- )
+ const { data } = useProjectStorageConfigQuery({ projectRef: ref }, { enabled: IS_PLATFORM })
const { value, unit } = convertFromBytes(data?.fileSizeLimit ?? 0)
const formattedGlobalUploadLimit = `${value} ${unit}`
const [selectedUnit, setSelectedUnit] = useState(StorageSizeUnits.BYTES)
const [showConfiguration, setShowConfiguration] = useState(false)
- const initialValues = {
- name: '',
- public: false,
- file_size_limit: 0,
- allowed_mime_types: '',
- has_file_size_limit: false,
- formatted_size_limit: 0,
- }
+ const form = useForm({
+ resolver: zodResolver(FormSchema),
+ defaultValues: {
+ name: '',
+ public: false,
+ type: 'STANDARD',
+ has_file_size_limit: false,
+ formatted_size_limit: 0,
+ allowed_mime_types: '',
+ },
+ })
- const validate = (values: any) => {
- const errors = {} as any
+ const bucketName = snakeCase(form.watch('name'))
+ const isPublicBucket = form.watch('public')
+ const isStandardBucket = form.watch('type') === 'STANDARD'
+ const hasFileSizeLimit = form.watch('has_file_size_limit')
+ const formattedSizeLimit = form.watch('formatted_size_limit')
+ const icebergWrapperExtensionState = useIcebergWrapperExtension()
- if (!values.name) {
- errors.name = 'Please provide a name for your bucket'
- }
-
- if (values.name && !/^[a-z0-9.-]+$/.test(values.name)) {
- errors.name =
- 'The name of the bucket must only container lowercase letters, numbers, dots, and hyphens'
- }
-
- if (values.name && values.name.endsWith(' ')) {
- errors.name = 'The name of the bucket cannot end with a whitespace'
- }
-
- if (values.has_file_size_limit && values.formatted_size_limit < 0) {
- errors.formatted_size_limit = 'File size upload limit has to be at least 0'
- }
- if (values.name === 'public') {
- errors.name = '"public" is a reserved name. Please choose another name'
- }
- return errors
- }
-
- const onSubmit = async (values: any) => {
+ const onSubmit: SubmitHandler = async (values) => {
if (!ref) return console.error('Project ref is required')
- createBucket({
- projectRef: ref,
- id: values.name,
- isPublic: values.public,
- file_size_limit: values.has_file_size_limit
+ if (values.type === 'ANALYTICS' && !icebergCatalogEnabled) {
+ toast.error(
+ 'The Analytics catalog feature is not enabled for your project. Please contact support to enable it.'
+ )
+ return
+ }
+
+ try {
+ const fileSizeLimit = values.has_file_size_limit
? convertToBytes(values.formatted_size_limit, selectedUnit)
- : null,
- allowed_mime_types:
+ : undefined
+
+ const allowedMimeTypes =
values.allowed_mime_types.length > 0
- ? values.allowed_mime_types.split(',').map((x: string) => x.trim())
- : null,
- })
+ ? values.allowed_mime_types.split(',').map((x) => x.trim())
+ : undefined
+
+ await createBucket({
+ projectRef: ref,
+ id: values.name,
+ type: values.type,
+ isPublic: values.public,
+ file_size_limit: fileSizeLimit,
+ allowed_mime_types: allowedMimeTypes,
+ })
+
+ if (values.type === 'ANALYTICS' && icebergWrapperExtensionState === 'installed') {
+ await createIcebergWrapper({ bucketName: values.name })
+ }
+ toast.success(`Successfully created bucket ${values.name}`)
+ router.push(`/project/${ref}/storage/buckets/${values.name}`)
+ onClose()
+ } catch (error) {
+ console.error(error)
+ toast.error('Failed to create bucket')
+ }
}
useEffect(() => {
if (visible) {
+ form.reset()
setSelectedUnit(StorageSizeUnits.BYTES)
setShowConfiguration(false)
}
- }, [visible])
+ }, [visible, form])
+
+ const icebergCatalogEnabled = data?.features?.icebergCatalog?.enabled
return (
{
header="Create storage bucket"
onCancel={() => onClose()}
>
-
- {({ values }: { values: any }) => {
- const isPublicBucket = values.public
-
- return (
- <>
-
-
+
+
+ (
+
-
-
+
+
+
+
+ )}
+ />
+
+
+
(
+
+
+ field.onChange(v)}
+ value={field.value}
+ >
+
+
+
+
+ Compatible with S3 buckets.
+
+
+
+
+ {IS_PLATFORM && (
+
+
+
+
+ Stores Iceberg files and is optimized for analytical workloads.
+
+
+
+ {icebergCatalogEnabled ? null : (
+
+
+
+ This feature is currently in alpha and not yet enabled for your
+ project. Sign up{' '}
+
+ here
+
+
+
+ )}
+
+ )}
+
+
+
+ )}
+ />
+
+
+
+ {isStandardBucket ? (
+ <>
+
+
+
(
+
+
+
+
+
+ )}
/>
{isPublicBucket && (
{
)}
+
setShowConfiguration(!showConfiguration)}
>
-
-
Additional configuration
+
+
Additional restrictions
{
-
-
- onClose()}
- >
- Cancel
-
-
- Save
-
-
>
- )
- }}
-
+ ) : (
+
+ {icebergWrapperExtensionState === 'installed' ? (
+
+
+ Supabase will setup a
+
+ foreign data wrapper
+ {bucketName && {`${bucketName}_fdw`} }
+
+
+ {' '}
+ for easier access to the data. This action will also create{' '}
+
+ S3 Access Keys
+ {bucketName && (
+ <>
+ {' '}
+ named {`${bucketName}_keys`}
+ >
+ )}
+
+ and
+
+ four Vault Secrets
+ {bucketName && (
+ <>
+ {' '}
+ prefixed with{' '}
+ {`${bucketName}_vault_`}
+ >
+ )}
+
+ .
+
+
+
+ As a final step, you'll need to create an{' '}
+ Iceberg namespace before you
+ connect the Iceberg data to your database.
+
+
+ ) : (
+
+
+
+ You need to install the Iceberg wrapper extension to connect your Analytic
+ bucket to your database.
+
+
+
+ You need to install the wrappers extension
+ (with the minimum version of 0.5.3 ) if you want to connect your
+ Analytics bucket to your database.
+
+
+
+ )}
+
+ )}
+
+
+ onClose()}
+ >
+ Cancel
+
+
+ Create
+
+
+
+
)
}
diff --git a/apps/studio/components/interfaces/Storage/EditBucketModal.tsx b/apps/studio/components/interfaces/Storage/EditBucketModal.tsx
index aff15a75ef7..5abb63cd4d5 100644
--- a/apps/studio/components/interfaces/Storage/EditBucketModal.tsx
+++ b/apps/studio/components/interfaces/Storage/EditBucketModal.tsx
@@ -15,11 +15,11 @@ import { useProjectStorageConfigQuery } from 'data/config/project-storage-config
import { useBucketUpdateMutation } from 'data/storage/bucket-update-mutation'
import { IS_PLATFORM } from 'lib/constants'
import { Admonition } from 'ui-patterns'
-import type { StorageBucket } from './Storage.types'
+import { Bucket } from 'data/storage/buckets-query'
export interface EditBucketModalProps {
visible: boolean
- bucket?: StorageBucket
+ bucket?: Bucket
onClose: () => void
}
diff --git a/apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.tsx b/apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.tsx
new file mode 100644
index 00000000000..a173dbcfa52
--- /dev/null
+++ b/apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.tsx
@@ -0,0 +1,167 @@
+import { zodResolver } from '@hookform/resolvers/zod'
+import { snakeCase } from 'lodash'
+import { useEffect, useState } from 'react'
+import { SubmitHandler, useForm } from 'react-hook-form'
+import { toast } from 'sonner'
+import z from 'zod'
+
+import { useParams } from 'common'
+import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
+import SchemaSelector from 'components/ui/SchemaSelector'
+import { useFDWImportForeignSchemaMutation } from 'data/fdw/fdw-import-foreign-schema-mutation'
+import {
+ Button,
+ Form_Shadcn_,
+ FormControl_Shadcn_,
+ FormField_Shadcn_,
+ Input_Shadcn_,
+ Modal,
+} from 'ui'
+import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
+import SchemaEditor from '../TableGridEditor/SidePanelEditor/SchemaEditor'
+
+export interface ImportForeignSchemaDialogProps {
+ bucketName: string
+ namespace: string
+ excludedSchemas: string[]
+ wrapperValues: Record
+ visible: boolean
+ onClose: () => void
+}
+
+const FormSchema = z.object({
+ bucketName: z.string().trim(),
+ sourceNamespace: z.string().trim(),
+ targetSchema: z.string().trim(),
+})
+
+export type ImportForeignSchemaForm = z.infer
+
+export const ImportForeignSchemaDialog = ({
+ bucketName,
+ namespace,
+ excludedSchemas,
+ wrapperValues,
+ visible,
+ onClose,
+}: ImportForeignSchemaDialogProps) => {
+ const { project } = useProjectContext()
+ const { ref } = useParams()
+ const [loading, setLoading] = useState(false)
+ const [createSchemaSheetOpen, setCreateSchemaSheetOpen] = useState(false)
+
+ const { mutateAsync: importForeignSchema } = useFDWImportForeignSchemaMutation({
+ onSuccess: () => {
+ toast.success(`Successfully connected ${bucketName} to the database.`)
+ onClose()
+ },
+ })
+
+ const form = useForm({
+ resolver: zodResolver(FormSchema),
+ defaultValues: {
+ bucketName,
+ sourceNamespace: namespace,
+ targetSchema: '',
+ },
+ })
+
+ const onSubmit: SubmitHandler = async (values) => {
+ if (!ref) return console.error('Project ref is required')
+ setLoading(true)
+
+ try {
+ await importForeignSchema({
+ projectRef: ref,
+ connectionString: project?.connectionString,
+ serverName: `${snakeCase(values.bucketName)}_fdw_server`,
+ sourceSchema: values.sourceNamespace,
+ targetSchema: values.targetSchema,
+ })
+ } catch (error: any) {
+ // error will be handled by the mutation onError callback
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ useEffect(() => {
+ if (visible) {
+ form.reset({
+ bucketName,
+ sourceNamespace: namespace,
+ targetSchema: '',
+ })
+ }
+ }, [visible, form, bucketName, namespace])
+
+ return (
+
+ Connect namespace {namespace}
+
+ }
+ onCancel={() => onClose()}
+ >
+
+
+
+ (
+
+
+
+
+
+ )}
+ />
+
+ (
+
+ field.onChange(schema)}
+ onSelectCreateSchema={() => setCreateSchemaSheetOpen(true)}
+ />
+
+ )}
+ />
+
+
+
+
+ Cancel
+
+
+ Save
+
+
+
+
+ setCreateSchemaSheetOpen(false)}
+ onSuccess={(schema) => {
+ form.setValue('targetSchema', schema)
+ setCreateSchemaSheetOpen(false)
+ }}
+ />
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Storage/Storage.types.ts b/apps/studio/components/interfaces/Storage/Storage.types.ts
index 37b00902f28..8de8a32625e 100644
--- a/apps/studio/components/interfaces/Storage/Storage.types.ts
+++ b/apps/studio/components/interfaces/Storage/Storage.types.ts
@@ -4,17 +4,6 @@ export interface StoragePolicyFormField extends PolicyFormField {
allowedOperations: string[]
}
-export interface StorageBucket {
- id: string
- name: string
- owner: string
- public: boolean
- file_size_limit: number | null
- allowed_mime_types: string[] | null
- created_at: string
- updated_at: string
-}
-
export interface BucketUpdatePayload {
public?: boolean
file_size_limit?: number | null
diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.fields.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.fields.tsx
index a2ab7ee4912..b0a2fad0c04 100644
--- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.fields.tsx
+++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.fields.tsx
@@ -3,7 +3,7 @@ import { User } from 'lucide-react'
import { LEVELS } from 'components/ui/DataTable/DataTable.constants'
import { DataTableFilterField, Option } from 'components/ui/DataTable/DataTable.types'
-import { getLevelColor, getStatusColor } from 'components/ui/DataTable/DataTable.utils'
+import { getLevelColor } from 'components/ui/DataTable/DataTable.utils'
import { cn } from 'ui'
import { LOG_TYPES, METHODS, STATUS_CODE_LABELS } from './UnifiedLogs.constants'
import { ColumnSchema } from './UnifiedLogs.schema'
@@ -41,11 +41,8 @@ export const filterFields = [
value: 'status',
type: 'checkbox',
defaultOpen: true,
- options: [
- { label: '2xx', value: 200 },
- { label: '4xx', value: 400 },
- { label: '4xx', value: 500 },
- ], // REMINDER: this is a placeholder to set the type in the client.tsx
+ options: [],
+ hasDynamicOptions: true,
component: (props: Option) => {
if (typeof props.value === 'boolean') return null
if (typeof props.value === 'undefined') return null
@@ -53,10 +50,6 @@ export const filterFields = [
const statusValue = String(props.value)
const statusLabel = STATUS_CODE_LABELS[statusValue as keyof typeof STATUS_CODE_LABELS]
- // Convert string status codes to numbers for HTTP status styling
- const statusCode = typeof props.value === 'string' ? parseInt(props.value, 10) : props.value
- const isHttpStatus = !isNaN(statusCode) && statusCode >= 100 && statusCode < 600
-
return (
{statusValue}
@@ -105,26 +98,14 @@ export const filterFields = [
)
},
},
- {
- label: 'Host',
- value: 'host',
- type: 'checkbox',
- defaultOpen: false,
- options: [], // Will be populated dynamically from facets
- component: (props: Option) => {
- return (
-
- {props.value}
-
- )
- },
- },
{
label: 'Pathname',
value: 'pathname',
type: 'checkbox',
- defaultOpen: true,
- options: [], // Will be populated dynamically from facets
+ defaultOpen: false,
+ options: [],
+ hasDynamicOptions: true,
+ hasAsyncSearch: true,
component: (props: Option) => {
return (
@@ -138,7 +119,9 @@ export const filterFields = [
value: 'auth_user',
type: 'checkbox',
defaultOpen: false,
- options: [], // Will be populated dynamically from facets
+ options: [],
+ hasDynamicOptions: true,
+ hasAsyncSearch: true,
component: (props: Option) => {
return (
@@ -197,12 +180,6 @@ export const sheetFields = [
),
skeletonClassName: 'w-56',
},
- {
- id: 'host',
- label: 'Host',
- type: 'input',
- skeletonClassName: 'w-24',
- },
{
id: 'pathname',
label: 'Pathname',
diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts
index 4d9cba79b51..984be6580c8 100644
--- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts
+++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts
@@ -459,7 +459,7 @@ const getSupabaseStorageLogsQuery = () => {
/**
* Combine all log sources to create the unified logs CTE
*/
-const getUnifiedLogsCTE = () => {
+export const getUnifiedLogsCTE = () => {
return `
WITH unified_logs AS (
${getPostgrestLogsQuery()}
@@ -510,39 +510,72 @@ ${finalWhere}
* Get a count query for the total logs within the timeframe
* Uses proper faceted search behavior where facets show "what would I get if I selected ONLY this option"
*/
+
+// Helper function to build WHERE clause excluding a specific field
+const buildFacetWhere = (search: QuerySearchParamsType, excludeField: string): string => {
+ const conditions: string[] = []
+
+ Object.entries(search).forEach(([key, value]) => {
+ if (key === excludeField) return // Skip the field we're getting facets for
+ if (EXCLUDED_QUERY_PARAMS.includes(key as any)) return // Skip pagination and special params
+
+ // Handle array filters (IN clause)
+ if (Array.isArray(value) && value.length > 0) {
+ conditions.push(`${key} IN (${value.map((v) => `'${v}'`).join(',')})`)
+ return
+ }
+
+ // Handle scalar values
+ if (value !== null && value !== undefined) {
+ if (['host', 'pathname'].includes(key)) {
+ conditions.push(`${key} LIKE '%${value}%'`)
+ } else {
+ conditions.push(`${key} = '${value}'`)
+ }
+ }
+ })
+
+ return conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
+}
+
+export const getFacetCountCTE = ({
+ search,
+ facet,
+ facetSearch,
+}: {
+ search: QuerySearchParamsType
+ facet: string
+ facetSearch?: string
+}) => {
+ const MAX_FACETS_QUANTITY = 20
+
+ return `
+${facet}_count AS (
+ SELECT '${facet}' as dimension, ${facet} as value, COUNT(*) as count
+ FROM unified_logs
+ ${buildFacetWhere(search, `${facet}`) || `WHERE ${facet} IS NOT NULL`}
+ ${buildFacetWhere(search, `${facet}`) ? ` AND ${facet} IS NOT NULL` : ''}
+ ${!!facetSearch ? `AND ${facet} LIKE '%${facetSearch}%'` : ''}
+ GROUP BY ${facet}
+ LIMIT ${MAX_FACETS_QUANTITY}
+)
+`.trim()
+}
+
export const getLogsCountQuery = (search: QuerySearchParamsType): string => {
const { finalWhere } = buildQueryConditions(search)
- // Helper function to build WHERE clause excluding a specific field
- const buildFacetWhere = (excludeField: string): string => {
- const conditions: string[] = []
-
- Object.entries(search).forEach(([key, value]) => {
- if (key === excludeField) return // Skip the field we're getting facets for
- if (EXCLUDED_QUERY_PARAMS.includes(key as any)) return // Skip pagination and special params
-
- // Handle array filters (IN clause)
- if (Array.isArray(value) && value.length > 0) {
- conditions.push(`${key} IN (${value.map((v) => `'${v}'`).join(',')})`)
- return
- }
-
- // Handle scalar values
- if (value !== null && value !== undefined) {
- if (['host', 'pathname'].includes(key)) {
- conditions.push(`${key} LIKE '%${value}%'`)
- } else {
- conditions.push(`${key} = '${value}'`)
- }
- }
- })
-
- return conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
- }
-
// Create a count query using the same unified logs CTE
const sql = `
-${getUnifiedLogsCTE()}
+${getUnifiedLogsCTE()},
+${getFacetCountCTE({ search, facet: 'log_type' })},
+${getFacetCountCTE({ search, facet: 'method' })},
+${getFacetCountCTE({ search, facet: 'level' })},
+${getFacetCountCTE({ search, facet: 'status' })},
+${getFacetCountCTE({ search, facet: 'host' })},
+${getFacetCountCTE({ search, facet: 'pathname' })},
+${getFacetCountCTE({ search, facet: 'auth_user' })}
+
-- Get total count
SELECT 'total' as dimension, 'all' as value, COUNT(*) as count
FROM unified_logs
@@ -551,65 +584,37 @@ ${finalWhere}
UNION ALL
-- Get counts by log_type (exclude log_type filter to avoid self-filtering)
-SELECT 'log_type' as dimension, log_type as value, COUNT(*) as count
-FROM unified_logs
-${buildFacetWhere('log_type') || 'WHERE log_type IS NOT NULL'}
-${buildFacetWhere('log_type') ? ' AND log_type IS NOT NULL' : ''}
-GROUP BY log_type
+SELECT dimension, value, count from log_type_count
UNION ALL
-- Get counts by method (exclude method filter to avoid self-filtering)
-SELECT 'method' as dimension, method as value, COUNT(*) as count
-FROM unified_logs
-${buildFacetWhere('method') || 'WHERE method IS NOT NULL'}
-${buildFacetWhere('method') ? ' AND method IS NOT NULL' : ''}
-GROUP BY method
+SELECT dimension, value, count from method_count
UNION ALL
-- Get counts by level (exclude level filter to avoid self-filtering)
-SELECT 'level' as dimension, level as value, COUNT(*) as count
-FROM unified_logs
-${buildFacetWhere('level') || 'WHERE level IS NOT NULL'}
-${buildFacetWhere('level') ? ' AND level IS NOT NULL' : ''}
-GROUP BY level
+SELECT dimension, value, count from level_count
UNION ALL
-- Get counts by status (exclude status filter to avoid self-filtering)
-SELECT 'status' as dimension, status as value, COUNT(*) as count
-FROM unified_logs
-${buildFacetWhere('status') || 'WHERE status IS NOT NULL'}
-${buildFacetWhere('status') ? ' AND status IS NOT NULL' : ''}
-GROUP BY status
+SELECT dimension, value, count from status_count
UNION ALL
-- Get counts by host (exclude host filter to avoid self-filtering)
-SELECT 'host' as dimension, host as value, COUNT(*) as count
-FROM unified_logs
-${buildFacetWhere('host') || 'WHERE host IS NOT NULL'}
-${buildFacetWhere('host') ? ' AND host IS NOT NULL' : ''}
-GROUP BY host
+SELECT dimension, value, count from host_count
UNION ALL
-- Get counts by pathname (exclude pathname filter to avoid self-filtering)
-SELECT 'pathname' as dimension, pathname as value, COUNT(*) as count
-FROM unified_logs
-${buildFacetWhere('pathname') || 'WHERE pathname IS NOT NULL'}
-${buildFacetWhere('pathname') ? ' AND pathname IS NOT NULL' : ''}
-GROUP BY pathname
+SELECT dimension, value, count from pathname_count
UNION ALL
-- Get counts by auth_user (exclude auth_user filter to avoid self-filtering)
-SELECT 'auth_user' as dimension, auth_user as value, COUNT(*) as count
-FROM unified_logs
-${buildFacetWhere('auth_user') || 'WHERE auth_user IS NOT NULL'}
-${buildFacetWhere('auth_user') ? ' AND auth_user IS NOT NULL' : ''}
-GROUP BY auth_user
+SELECT dimension, value, count from auth_user_count
`
return sql
diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.schema.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.schema.ts
index 361308dd660..0ec4c679a46 100644
--- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.schema.ts
+++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.schema.ts
@@ -11,7 +11,6 @@ export const columnSchema = z.object({
id: z.string(),
log_type: z.enum(LOG_TYPES),
method: z.enum(METHODS),
- host: z.string(),
pathname: z.string(),
level: z.enum(LEVELS),
status: z.number(),
@@ -36,7 +35,6 @@ export const columnFilterSchema = z.object({
.transform((val) => val.split(ARRAY_DELIMITER))
.pipe(z.enum(METHODS).array())
.optional(),
- host: z.string().optional(),
pathname: z.string().optional(),
status: z
.string()
diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx
index 0cb64d8ab94..739270c7ec7 100644
--- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx
+++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx
@@ -41,7 +41,7 @@ import {
Separator,
} from 'ui'
import { RefreshButton } from '../../ui/DataTable/RefreshButton'
-import { UNIFIED_LOGS_COLUMNS } from './components/Columns'
+import { generateDynamicColumns, UNIFIED_LOGS_COLUMNS } from './components/Columns'
import { DownloadLogsButton } from './components/DownloadLogsButton'
import { LogsListPanel } from './components/LogsListPanel'
import { ServiceFlowPanel } from './ServiceFlowPanel'
@@ -71,8 +71,6 @@ export const UnifiedLogs = () => {
const [columnFilters, setColumnFilters] = useState
(defaultColumnFilters)
const [rowSelection, setRowSelection] = useState(defaultRowSelection)
- const [showBottomLogsPanel, setShowBottomLogsPanelState] = useState(false)
-
const [columnVisibility, setColumnVisibility] = useLocalStorageQuery(
'data-table-visibility',
defaultColumnVisibility
@@ -102,6 +100,8 @@ export const UnifiedLogs = () => {
data: unifiedLogsData,
isLoading,
isFetching,
+ isFetchingNextPage,
+ isFetchingPreviousPage,
hasNextPage,
refetch: refetchLogs,
fetchNextPage,
@@ -133,6 +133,9 @@ export const UnifiedLogs = () => {
const isRefetchingData = isFetching || isFetchingCounts || isFetchingCharts
+ // Only fade when filtering (not when loading more data or live mode)
+ const isFetchingButNotPaginating = isFetching && !isFetchingNextPage && !isFetchingPreviousPage
+
const rawFlatData = useMemo(() => {
return unifiedLogsData?.pages?.flatMap((page) => page.data ?? []) ?? []
}, [unifiedLogsData?.pages])
@@ -167,13 +170,18 @@ export const UnifiedLogs = () => {
return cn(levelClassName, isPast ? 'opacity-50' : 'opacity-100', 'h-[30px]')
}
+ // Generate dynamic columns based on current data
+ const { columns: dynamicColumns, columnVisibility: dynamicColumnVisibility } = useMemo(() => {
+ return generateDynamicColumns(flatData)
+ }, [flatData])
+
const table: Table = useReactTable({
data: flatData,
- columns: UNIFIED_LOGS_COLUMNS,
+ columns: dynamicColumns,
state: {
columnFilters,
sorting,
- columnVisibility,
+ columnVisibility: { ...columnVisibility, ...dynamicColumnVisibility },
rowSelection,
columnOrder,
},
@@ -203,7 +211,11 @@ export const UnifiedLogs = () => {
}, [isLoading, isFetching, flatData.length, table, selectedRowKey])
// REMINDER: this is currently needed for the cmdk search
+ // [Joshen] This is where facets are getting dynamically loaded
// TODO: auto search via API when the user changes the filter instead of hardcoded
+
+ // Will need to refactor this bit
+ // - Each facet just handles its own state, rather than getting passed down like this
const filterFields = useMemo(() => {
return defaultFilterFields.map((field) => {
const facetsField = facets?.[field.value]
@@ -289,6 +301,7 @@ export const UnifiedLogs = () => {
rowSelection={rowSelection}
columnOrder={columnOrder}
columnVisibility={columnVisibility}
+ searchParameters={searchParameters}
enableColumnOrdering={true}
isFetching={isFetching}
isLoading={isLoading}
@@ -297,7 +310,7 @@ export const UnifiedLogs = () => {
>
-
+
{
/>
@@ -343,7 +359,14 @@ export const UnifiedLogs = () => {
className="h-full"
>
-
+
[] = [
- {
- accessorKey: 'level',
- header: '',
- cell: ({ row }) => {
- const level = row.getValue('level')
- return
- },
- enableHiding: false,
- enableResizing: false,
- enableSorting: false,
- filterFn: (row, columnId, filterValue) => true,
- size: 48,
- minSize: 48,
- maxSize: 48,
- meta: {
- headerClassName:
- 'w-[--header-level-size] max-w-[--header-level-size] min-w-[--header-level-size]',
- cellClassName: 'w-[--col-level-size] max-w-[--col-level-size] min-w-[--col-level-size]',
- },
- },
- {
- accessorKey: 'date',
- header: ({ column }) => ,
- cell: ({ row }) => {
- const date = new Date(row.getValue('date'))
- return
- },
- filterFn: (row, columnId, filterValue) => true,
- enableResizing: false,
- enableSorting: false,
- size: 130,
- minSize: 130,
- maxSize: 130,
- meta: {
- headerClassName:
- 'w-[--header-date-size] max-w-[--header-date-size] min-w-[--header-date-size]',
- cellClassName:
- 'font-mono w-[--col-date-size] max-w-[--col-date-size] min-w-[--col-date-size]',
- },
- },
- {
- accessorKey: 'log_type',
- header: '',
- cell: ({ row }) => {
- const logType = row.getValue('log_type')
- return (
-
-
-
- )
- },
- enableHiding: false,
- filterFn: (row, columnId, filterValue) => true,
- size: 40,
- minSize: 40,
- maxSize: 40,
- enableResizing: false,
- enableSorting: false,
- meta: {
- headerClassName:
- 'w-[--header-log_type-size] max-w-[--header-log_type-size] min-w-[--header-log_type-size]',
- cellClassName:
- 'text-right px-0 relative justify-end items-center font-mono w-[--col-log_type-size] max-w-[--col-log_type-size] min-w-[--col-log_type-size]',
- },
- },
- {
- // TODO: make it a type of MethodSchema!
- accessorKey: 'method',
- header: 'Method',
- filterFn: 'arrIncludesSome',
- cell: ({ row }) => {
- const value = row.getValue('method')
- return {value}
- },
- enableResizing: false,
- enableSorting: false,
- size: 70,
- minSize: 70,
- meta: {
- cellClassName:
- 'font-mono text-muted-foreground w-[--col-method-size] max-w-[--col-method-size] min-w-[--col-method-size]',
- headerClassName:
- 'w-[--header-method-size] max-w-[--header-method-size] min-w-[--header-method-size]',
- },
- },
- {
- accessorKey: 'status',
- header: '',
- cell: ({ row }) => {
- const value = row.getValue('status')
- return (
-
- {row.original.auth_user && (
-
- )}
-
('level')}
- />
-
- )
- },
- filterFn: (row, columnId, filterValue) => true,
- enableResizing: false,
- enableSorting: false,
- size: 55,
- minSize: 55,
- meta: {
- headerClassName:
- 'w-[--header-status-size] max-w-[--header-status-size] min-w-[--header-status-size]',
- cellClassName:
- 'font-mono w-[--col-status-size] max-w-[--col-status-size] min-w-[--col-status-size]',
- },
- },
+/**
+ * Determines if a column should be hidden based on its values in the data.
+ * Returns true if all values for the specified columnKey are empty, null, or undefined.
+ * Returns false if data is empty or if any value is present.
+ *
+ * @param data - Array of data rows to check
+ * @param columnKey - The key of the column to check for emptiness
+ * @returns boolean - true if column should be hidden, false otherwise
+ */
+function shouldHideColumn(data: ColumnSchema[], columnKey: keyof ColumnSchema): boolean {
+ // If there is no data or the data array is empty, do not hide the column
+ if (!data || data.length === 0) return false
- {
- accessorKey: 'host',
- header: 'Host',
- cell: ({ row }) => {
- const value = row.getValue('host')
- return
- },
- enableSorting: false,
- size: 220,
- minSize: 220,
- meta: {
- cellClassName: 'font-mono w-[--col-host-size] max-w-[--col-host-size]',
- headerClassName: 'min-w-[--header-host-size] w-[--header-host-size]',
- },
- },
- {
- accessorKey: 'pathname',
- header: 'Pathname',
- cell: ({ row }) => {
- const value = row.getValue('pathname') ?? ''
- return
- },
- enableSorting: false,
- size: 320,
- minSize: 320,
- meta: {
- cellClassName: 'font-mono w-[--col-pathname-size] max-w-[--col-pathname-size]',
- headerClassName: 'min-w-[--header-pathname-size] w-[--header-pathname-size]',
- },
- },
- {
- accessorKey: 'event_message',
- header: ({ column }) => ,
- cell: ({ row }) => {
- const value = row.getValue('event_message')
- const logCount = row.original.log_count
+ // Check if every row has an empty, null, or undefined value for the given columnKey
+ return data.every((row) => {
+ const value = row[columnKey]
+ return !value || value === '' || value === null || value === undefined
+ })
+}
- return (
-
- {logCount && (
-
-
-
- {logCount}
-
-
-
- {logCount} {logCount === 1 ? 'log' : 'logs'} for this execution
-
-
- )}
- {value && (
-
-
-
- )}
-
- )
+// Generate dynamic columns based on data
+export function generateDynamicColumns(data: ColumnSchema[]): {
+ columns: ColumnDef[]
+ columnVisibility: Record
+} {
+ const hideMethod = shouldHideColumn(data, 'method')
+ const hidePathname = shouldHideColumn(data, 'pathname')
+ const hideEventMessage = shouldHideColumn(data, 'event_message')
+
+ const columns: ColumnDef[] = [
+ // Level column - always visible
+ {
+ accessorKey: 'level',
+ header: '',
+ cell: ({ row }) => {
+ const level = row.getValue('level')
+ return
+ },
+ enableHiding: false,
+ enableResizing: false,
+ enableSorting: false,
+ filterFn: (row, columnId, filterValue) => true,
+ size: 48,
+ minSize: 48,
+ maxSize: 48,
+ meta: {
+ cellClassName: 'w-[32px]',
+ headerClassName: 'w-[32px]',
+ },
},
- enableResizing: true,
- enableSorting: false,
- size: 200,
- minSize: 200,
- meta: {
- headerClassName:
- 'w-[--header-event_message-size] max-w-[--header-event_message-size] min-w-[--header-event_message-size]',
- cellClassName:
- 'font-mono w-[--col-event_message-size] max-w-[--col-event_message-size] min-w-[--col-event_message-size]',
+ // Date column - always visible
+ {
+ accessorKey: 'date',
+ header: ({ column }) => ,
+ cell: ({ row }) => {
+ const date = new Date(row.getValue('date'))
+ return
+ },
+ filterFn: (row, columnId, filterValue) => true,
+ enableResizing: false,
+ enableSorting: false,
+ size: 130,
+ minSize: 130,
+ maxSize: 130,
+ meta: {
+ cellClassName: 'font-mono w-[130px]',
+ headerClassName: 'w-[130px]',
+ },
},
- },
-]
+ // Log type column - always visible
+ {
+ accessorKey: 'log_type',
+ header: '',
+ cell: ({ row }) => {
+ const logType = row.getValue('log_type')
+ return (
+
+
+
+ )
+ },
+ enableHiding: false,
+ filterFn: (row, columnId, filterValue) => true,
+ enableResizing: false,
+ enableSorting: false,
+ size: 40,
+ minSize: 40,
+ maxSize: 40,
+ meta: {
+ cellClassName: 'w-[32px]',
+ headerClassName: 'w-[32px]',
+ },
+ },
+ // Status column - always visible
+ {
+ accessorKey: 'status',
+ header: '',
+ cell: ({ row }) => {
+ const value = row.getValue('status')
+ return (
+
+ {/* {row.original.auth_user && (
+ // to do: add auth user hover card
+
+ )} */}
+
('level')}
+ />
+
+ )
+ },
+ filterFn: (row, columnId, filterValue) => true,
+ enableResizing: false,
+ enableSorting: false,
+ size: 70,
+ minSize: 70,
+ maxSize: 70,
+ meta: {
+ cellClassName: 'w-[70px]',
+ headerClassName: 'w-[70px]',
+ },
+ },
+ // Method column - controlled by columnVisibility
+ {
+ accessorKey: 'method',
+ header: 'Method',
+ filterFn: 'arrIncludesSome',
+ cell: ({ row }) => {
+ const value = row.getValue('method')
+ return {value}
+ },
+ enableResizing: false,
+ enableSorting: false,
+ size: 70,
+ minSize: 70,
+ maxSize: 70,
+ meta: {
+ cellClassName: 'w-[70px]',
+ headerClassName: 'w-[70px]',
+ },
+ },
+ // Pathname column - controlled by columnVisibility
+ {
+ accessorKey: 'pathname',
+ header: 'Pathname',
+ cell: ({ row }) => {
+ const value = row.getValue('pathname') ?? ''
+ return
+ },
+ enableSorting: false,
+ enableResizing: false,
+ size: 200,
+ minSize: 200,
+ maxSize: 200,
+ meta: {
+ cellClassName: 'max-w-[320px]',
+ headerClassName: 'max-w-[320px]',
+ },
+ },
+ // Event message column - controlled by columnVisibility
+ {
+ accessorKey: 'event_message',
+ header: ({ column }) => ,
+ cell: ({ row }) => {
+ const value = row.getValue('event_message')
+ const logCount = row.original.log_count
+
+ return (
+
+ {logCount && (
+
+
+
+ {logCount}
+
+
+
+ {logCount} {logCount === 1 ? 'log' : 'logs'} for this execution
+
+
+ )}
+ {value && (
+
+
+
+ )}
+
+ )
+ },
+ enableResizing: true,
+ enableSorting: false,
+ size: 200,
+ minSize: 200,
+ maxSize: 400,
+ },
+ ]
+
+ // Define column visibility based on data
+ const columnVisibility = {
+ method: !hideMethod,
+ pathname: !hidePathname,
+ event_message: !hideEventMessage,
+ }
+
+ return { columns, columnVisibility }
+}
+
+// Static fallback columns
+export const UNIFIED_LOGS_COLUMNS: ColumnDef[] = generateDynamicColumns([]).columns
diff --git a/apps/studio/components/interfaces/UserDropdown.tsx b/apps/studio/components/interfaces/UserDropdown.tsx
index d3efda8c593..f9dc116ecfd 100644
--- a/apps/studio/components/interfaces/UserDropdown.tsx
+++ b/apps/studio/components/interfaces/UserDropdown.tsx
@@ -23,6 +23,7 @@ import {
singleThemes,
} from 'ui'
import { useSetCommandMenuOpen } from 'ui-patterns/CommandMenu'
+import { useFeaturePreviewModal } from './App/FeaturePreview/FeaturePreviewContext'
export function UserDropdown() {
const router = useRouter()
@@ -31,6 +32,7 @@ export function UserDropdown() {
const { theme, setTheme } = useTheme()
const appStateSnapshot = useAppStateSnapshot()
const setCommandMenuOpen = useSetCommandMenuOpen()
+ const { openFeaturePreviewModal } = useFeaturePreviewModal()
return (
@@ -87,8 +89,8 @@ export function UserDropdown() {
appStateSnapshot.setShowFeaturePreviewModal(true)}
- onSelect={() => appStateSnapshot.setShowFeaturePreviewModal(true)}
+ onClick={openFeaturePreviewModal}
+ onSelect={openFeaturePreviewModal}
>
Feature previews
diff --git a/apps/studio/components/layouts/APIKeys/APIKeysLayout.tsx b/apps/studio/components/layouts/APIKeys/APIKeysLayout.tsx
index 3b8e6d79ca7..a29be33a270 100644
--- a/apps/studio/components/layouts/APIKeys/APIKeysLayout.tsx
+++ b/apps/studio/components/layouts/APIKeys/APIKeysLayout.tsx
@@ -9,12 +9,12 @@ const ApiKeysLayout = ({ children }: PropsWithChildren) => {
const navigationItems = [
{
- label: 'API Keys',
+ label: 'Legacy API Keys',
href: `/project/${projectRef}/settings/api-keys`,
id: 'legacy-keys',
},
{
- label: 'New API Keys (Coming Soon)',
+ label: 'API Keys',
href: `/project/${projectRef}/settings/api-keys/new`,
id: 'new-keys',
},
diff --git a/apps/studio/components/layouts/AdvisorsLayout/AdvisorRulesLayout.tsx b/apps/studio/components/layouts/AdvisorsLayout/AdvisorRulesLayout.tsx
index 7b4544c017d..a26200f858e 100644
--- a/apps/studio/components/layouts/AdvisorsLayout/AdvisorRulesLayout.tsx
+++ b/apps/studio/components/layouts/AdvisorsLayout/AdvisorRulesLayout.tsx
@@ -11,8 +11,8 @@ export const AdvisorRulesLayout = ({ children }: PropsWithChildren<{}>) => {
) => {
const project = useSelectedProject()
- const advisorRules = useFlag('advisorRules')
+ const advisorRules = useIsAdvisorRulesEnabled()
const router = useRouter()
const page = router.pathname.split('/')[4]
diff --git a/apps/studio/components/layouts/AdvisorsLayout/AdvisorsMenu.utils.ts b/apps/studio/components/layouts/AdvisorsLayout/AdvisorsMenu.utils.ts
index a7eef66d429..ee22c2d7ba5 100644
--- a/apps/studio/components/layouts/AdvisorsLayout/AdvisorsMenu.utils.ts
+++ b/apps/studio/components/layouts/AdvisorsLayout/AdvisorsMenu.utils.ts
@@ -38,7 +38,7 @@ export const generateAdvisorsMenu = (
title: 'Configuration',
items: [
{
- name: 'Advisor Rules',
+ name: 'Settings',
key: 'rules',
url: `/project/${ref}/advisors/rules/security`,
items: [],
diff --git a/apps/studio/components/layouts/AppLayout/BranchDropdown.tsx b/apps/studio/components/layouts/AppLayout/BranchDropdown.tsx
index af8a2f25e6c..08d4b8eb2a7 100644
--- a/apps/studio/components/layouts/AppLayout/BranchDropdown.tsx
+++ b/apps/studio/components/layouts/AppLayout/BranchDropdown.tsx
@@ -2,7 +2,6 @@ import {
AlertCircle,
Check,
ChevronsUpDown,
- GitMerge,
ListTree,
MessageCircle,
Plus,
@@ -15,7 +14,6 @@ import { useState } from 'react'
import { useParams } from 'common'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import { Branch, useBranchesQuery } from 'data/branches/branches-query'
-import { useBranchUpdateMutation } from 'data/branches/branch-update-mutation'
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
import { useAppStateSnapshot } from 'state/app-state'
import {
@@ -35,8 +33,6 @@ import {
cn,
} from 'ui'
import { sanitizeRoute } from './ProjectDropdown'
-import { useFlag } from 'hooks/ui/useFlag'
-import { toast } from 'sonner'
const BranchLink = ({
branch,
@@ -78,22 +74,13 @@ const BranchLink = ({
export const BranchDropdown = () => {
const router = useRouter()
const { ref } = useParams()
- const gitlessBranching = useFlag('gitlessBranching')
- const projectDetails = useSelectedProject()
const snap = useAppStateSnapshot()
+ const projectDetails = useSelectedProject()
+
+ const [open, setOpen] = useState(false)
const projectRef = projectDetails?.parent_project_ref || ref
- const { mutate: updateBranch, isLoading: isUpdatingBranch } = useBranchUpdateMutation({
- onSuccess: (_, variables) => {
- if (variables.requestReview) {
- router.push(`/project/${ref}/merge`)
- }
- setOpen(false)
- toast.success('Branch marked as ready for review')
- },
- })
-
const {
data: branches,
isLoading,
@@ -122,135 +109,10 @@ export const BranchDropdown = () => {
? [mainBranch].concat(restOfBranches ?? [])
: restOfBranches ?? []
: [defaultMainBranch]
+ const branchList = isBranchingEnabled ? sortedBranches ?? [] : [defaultMainBranch]
const BRANCHING_GITHUB_DISCUSSION_LINK = 'https://github.com/orgs/supabase/discussions/18937'
- const [open, setOpen] = useState(false)
-
- const MenuContent = ({
- branchList,
- showSearch,
- }: {
- branchList: Branch[]
- showSearch?: boolean
- }) => (
-
- {showSearch && }
-
- {showSearch && No branches found }
-
-
-
- {branchList.map((branch) => (
-
- ))}
-
-
-
-
-
-
- {
- setOpen(false)
- snap.setShowCreateBranchModal(true)
- }}
- onClick={() => {
- setOpen(false)
- snap.setShowCreateBranchModal(true)
- }}
- >
-
-
- {gitlessBranching &&
- isBranchingEnabled &&
- selectedBranch &&
- !selectedBranch.is_default && (
- <>
- {
- if (selectedBranch?.review_requested_at) {
- router.push(`/project/${ref}/merge`)
- } else {
- if (selectedBranch?.id && projectRef) {
- updateBranch({
- id: selectedBranch.id,
- projectRef,
- requestReview: true,
- })
- }
- }
- }}
- onClick={() => setOpen(false)}
- >
-
-
- {isUpdatingBranch
- ? 'Updating...'
- : selectedBranch?.review_requested_at
- ? 'Review changes'
- : 'Ready for review'}
-
-
- >
- )}
- {
- setOpen(false)
- router.push(`/project/${ref}/branches`)
- }}
- onClick={() => setOpen(false)}
- >
-
-
- Manage branches
-
-
-
-
-
-
-
- {
- setOpen(false)
- window?.open(BRANCHING_GITHUB_DISCUSSION_LINK, '_blank')?.focus()
- }}
- onClick={() => setOpen(false)}
- >
- {
- setOpen(false)
- }}
- className="w-full flex gap-2"
- >
-
-
-
Branching feedback
-
Join Github Discussion
-
-
-
-
-
-
- )
-
return (
<>
{isLoading && }
@@ -271,8 +133,10 @@ export const BranchDropdown = () => {
{isBranchingEnabled ? (
selectedBranch?.is_default ? (
Production
+ ) : selectedBranch?.persistent ? (
+ Persistent
) : (
- Preview Branch
+ Preview
)
) : (
Production
@@ -288,10 +152,91 @@ export const BranchDropdown = () => {
/>
-
+
+ {isBranchingEnabled && }
+
+ {isBranchingEnabled && (
+ No branches found
+ )}
+
+
+
+ {branchList.map((branch) => (
+
+ ))}
+
+
+
+
+
+
+ {
+ setOpen(false)
+ snap.setShowCreateBranchModal(true)
+ }}
+ onClick={() => {
+ setOpen(false)
+ snap.setShowCreateBranchModal(true)
+ }}
+ >
+
+
+ {
+ setOpen(false)
+ router.push(`/project/${ref}/branches`)
+ }}
+ onClick={() => setOpen(false)}
+ >
+
+
+ Manage branches
+
+
+
+
+
+
+
+ {
+ setOpen(false)
+ window?.open(BRANCHING_GITHUB_DISCUSSION_LINK, '_blank')?.focus()
+ }}
+ onClick={() => setOpen(false)}
+ >
+ setOpen(false)}
+ className="w-full flex gap-2"
+ >
+
+
+
Branching feedback
+
Join Github Discussion
+
+
+
+
+
+
>
diff --git a/apps/studio/components/layouts/AppLayout/EnableBranchingButton/BranchingPITRNotice.tsx b/apps/studio/components/layouts/AppLayout/EnableBranchingButton/BranchingPITRNotice.tsx
index 289bf7b33c4..32d66d380a5 100644
--- a/apps/studio/components/layouts/AppLayout/EnableBranchingButton/BranchingPITRNotice.tsx
+++ b/apps/studio/components/layouts/AppLayout/EnableBranchingButton/BranchingPITRNotice.tsx
@@ -24,11 +24,12 @@ export const BranchingPITRNotice = () => {
-
+
Consider enabling Point in Time Recovery (PITR)
- This ensures you can recover data if you make a bad migration (e.g. delete a column).
+ This ensures you can recover production data if you merge a bad migration (e.g. delete a
+ column).
{!canUpdateSubscription ? (
diff --git a/apps/studio/components/layouts/AppLayout/ProjectDropdown.tsx b/apps/studio/components/layouts/AppLayout/ProjectDropdown.tsx
index 1ac41c011b8..5b0c209fac7 100644
--- a/apps/studio/components/layouts/AppLayout/ProjectDropdown.tsx
+++ b/apps/studio/components/layouts/AppLayout/ProjectDropdown.tsx
@@ -9,7 +9,7 @@ import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import { ProjectInfo, useProjectsQuery } from 'data/projects/projects-query'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
-import { useSelectedProject } from 'hooks/misc/useSelectedProject'
+import { useProjectByRef, useSelectedProject } from 'hooks/misc/useSelectedProject'
import { IS_PLATFORM } from 'lib/constants'
import type { Organization } from 'types'
import {
@@ -27,6 +27,7 @@ import {
ScrollArea,
cn,
} from 'ui'
+import { useBranchesQuery } from 'data/branches/branches-query'
// [Fran] the idea is to let users change projects without losing the current page,
// but at the same time we need to redirect correctly between urls that might be
@@ -119,7 +120,9 @@ export const ProjectDropdown = () => {
className="flex items-center gap-2 flex-shrink-0 text-sm"
>
-
{project?.name}
+
+ {selectedProject?.name}
+
diff --git a/apps/studio/components/layouts/AuthenticationLayout.tsx b/apps/studio/components/layouts/AuthenticationLayout.tsx
index 60ad6705137..8f1f32dd306 100644
--- a/apps/studio/components/layouts/AuthenticationLayout.tsx
+++ b/apps/studio/components/layouts/AuthenticationLayout.tsx
@@ -6,7 +6,7 @@ import { AppBannerContextProvider } from 'components/interfaces/App/AppBannerWra
export const AuthenticationLayout = ({ children }: PropsWithChildren<{}>) => {
return (
-
+
{children}
diff --git a/apps/studio/components/layouts/BranchLayout/BranchLayout.tsx b/apps/studio/components/layouts/BranchLayout/BranchLayout.tsx
new file mode 100644
index 00000000000..9ce4fb7d2f7
--- /dev/null
+++ b/apps/studio/components/layouts/BranchLayout/BranchLayout.tsx
@@ -0,0 +1,40 @@
+import { useRouter } from 'next/router'
+import { PropsWithChildren } from 'react'
+
+import { useParams } from 'common'
+import { GitHubStatus } from 'components/interfaces/Settings/Integrations/GithubIntegration/GitHubStatus'
+import { ProductMenu } from 'components/ui/ProductMenu'
+import { withAuth } from 'hooks/misc/withAuth'
+import ProjectLayout from '../ProjectLayout/ProjectLayout'
+import { generateBranchMenu } from './BranchLayout.utils'
+
+const BranchProductMenu = () => {
+ const router = useRouter()
+ const { ref: projectRef = 'default' } = useParams()
+ const page = router.pathname.split('/')[4] ?? 'branches'
+
+ return (
+ <>
+
+
+
Configure
+
+
+ >
+ )
+}
+
+const BranchLayout = ({ children }: PropsWithChildren<{}>) => {
+ return (
+
}
+ isBlocking={false}
+ >
+ {children}
+
+ )
+}
+
+export default withAuth(BranchLayout)
diff --git a/apps/studio/components/layouts/BranchLayout/BranchLayout.utils.ts b/apps/studio/components/layouts/BranchLayout/BranchLayout.utils.ts
new file mode 100644
index 00000000000..40785ed8827
--- /dev/null
+++ b/apps/studio/components/layouts/BranchLayout/BranchLayout.utils.ts
@@ -0,0 +1,23 @@
+import type { ProductMenuGroup } from 'components/ui/ProductMenu/ProductMenu.types'
+
+export const generateBranchMenu = (ref: string): ProductMenuGroup[] => {
+ return [
+ {
+ title: 'Manage',
+ items: [
+ {
+ name: 'Branches',
+ key: 'branches',
+ url: `/project/${ref}/branches`,
+ items: [],
+ },
+ {
+ name: 'Merge requests',
+ key: 'merge-requests',
+ url: `/project/${ref}/branches/merge-requests`,
+ items: [],
+ },
+ ],
+ },
+ ]
+}
diff --git a/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx b/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx
index cf1e55afaac..528108172a4 100644
--- a/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx
+++ b/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx
@@ -56,10 +56,22 @@ const EdgeFunctionDetailsLayout = ({
isError,
} = useEdgeFunctionQuery({ projectRef: ref, slug: functionSlug })
- const { data: functionFiles = [], error: filesError } = useEdgeFunctionBodyQuery({
- projectRef: ref,
- slug: functionSlug,
- })
+ const { data: functionFiles = [], error: filesError } = useEdgeFunctionBodyQuery(
+ {
+ projectRef: ref,
+ slug: functionSlug,
+ },
+ {
+ retry: false,
+ retryOnMount: true,
+ refetchOnWindowFocus: false,
+ staleTime: Infinity,
+ refetchOnMount: false,
+ refetchOnReconnect: false,
+ refetchInterval: false,
+ refetchIntervalInBackground: false,
+ }
+ )
const name = selectedFunction?.name || ''
diff --git a/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx b/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx
index f47831e49e3..b9f02d6b4a0 100644
--- a/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx
+++ b/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx
@@ -1,14 +1,19 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
-import { ChevronRight, FilePlus, Plus } from 'lucide-react'
+import { ChevronRight, CircleHelpIcon, FilePlus, Plus } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useState } from 'react'
import { IS_PLATFORM, useParams } from 'common'
+import {
+ useFeaturePreviewModal,
+ useUnifiedLogsPreview,
+} from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { CreateWarehouseCollectionModal } from 'components/interfaces/DataWarehouse/CreateWarehouseCollection'
import { WarehouseMenuItem } from 'components/interfaces/DataWarehouse/WarehouseMenuItem'
import SavedQueriesItem from 'components/interfaces/Settings/Logs/Logs.SavedQueriesItem'
import { LogsSidebarItem } from 'components/interfaces/Settings/Logs/SidebarV2/SidebarItem'
+import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { useWarehouseCollectionsQuery } from 'data/analytics/warehouse-collections-query'
import { useWarehouseTenantQuery } from 'data/analytics/warehouse-tenant-query'
import { useContentQuery } from 'data/content/content-query'
@@ -17,6 +22,7 @@ import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useFlag } from 'hooks/ui/useFlag'
import {
+ Badge,
Button,
Collapsible_Shadcn_,
CollapsibleContent_Shadcn_,
@@ -37,6 +43,7 @@ import {
InnerSideMenuItem,
} from 'ui-patterns/InnerSideMenu'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
+import { FeaturePreviewSidebarPanel } from '../../ui/FeaturePreviewSidebarPanel'
const SupaIcon = ({ className }: { className?: string }) => {
return (
@@ -82,7 +89,11 @@ export function SidebarCollapsible({
export function LogsSidebarMenuV2() {
const router = useRouter()
const { ref } = useParams() as { ref: string }
+
const warehouseEnabled = useFlag('warehouse')
+ const isUnifiedLogsPreviewAvailable = useFlag('unifiedLogs')
+ const { selectFeaturePreview } = useFeaturePreviewModal()
+ const { enable: enableUnifiedLogs } = useUnifiedLogsPreview()
const [searchText, setSearchText] = useState('')
const [createCollectionOpen, setCreateCollectionOpen] = useState(false)
@@ -213,6 +224,36 @@ export function LogsSidebarMenuV2() {
return (
+ {isUnifiedLogsPreviewAvailable && (
+
Feature Preview}
+ actions={
+ <>
+ {
+ enableUnifiedLogs()
+ router.push(`/project/${ref}/logs`)
+ }}
+ >
+ Enable preview
+
+ }
+ onClick={() => selectFeaturePreview('supabase-ui-preview-unified-logs')}
+ tooltip={{ content: { side: 'bottom', text: 'More information' } }}
+ />
+ >
+ }
+ />
+ )}
+
) => (
@@ -60,6 +62,7 @@ const LayoutHeader = ({
const selectedProject = useSelectedProject()
const selectedOrganization = useSelectedOrganization()
const { setMobileMenuOpen } = useAppStateSnapshot()
+ const gitlessBranching = useIsBranching2Enabled()
// We only want to query the org usage and check for possible over-ages for plans without usage billing enabled (free or pro with spend cap)
const { data: orgUsage } = useOrgUsageQuery(
@@ -136,7 +139,7 @@ const LayoutHeader = ({
{selectedProject && (
<>
-
+ {IS_PLATFORM && }
>
)}
@@ -165,7 +168,7 @@ const LayoutHeader = ({
{projectRef && (
- { }
+ {IS_PLATFORM && gitlessBranching && }
+
)}
diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MergeRequestButton.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MergeRequestButton.tsx
new file mode 100644
index 00000000000..ec88c816115
--- /dev/null
+++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MergeRequestButton.tsx
@@ -0,0 +1,78 @@
+import { ButtonTooltip } from 'components/ui/ButtonTooltip'
+import { GitMerge } from 'lucide-react'
+import { useBranchesQuery } from 'data/branches/branches-query'
+import { useParams } from 'common'
+import { useSelectedProject } from 'hooks/misc/useSelectedProject'
+import { useRouter } from 'next/router'
+import { useBranchUpdateMutation } from 'data/branches/branch-update-mutation'
+import { toast } from 'sonner'
+
+export const MergeRequestButton = () => {
+ const { ref } = useParams()
+ const router = useRouter()
+ const projectDetails = useSelectedProject()
+
+ const projectRef = projectDetails?.parent_project_ref || ref
+
+ const { data: branches } = useBranchesQuery({ projectRef }, { enabled: Boolean(projectDetails) })
+
+ const { mutate: updateBranch, isLoading: isUpdating } = useBranchUpdateMutation({
+ onError: () => {
+ toast.error(`Failed to open merge request`)
+ },
+ })
+
+ const selectedBranch = branches?.find((branch) => branch.project_ref === ref)
+
+ if (!projectRef || !selectedBranch || selectedBranch.is_default || selectedBranch.git_branch)
+ return null
+
+ const hasReviewRequested = !!selectedBranch?.review_requested_at
+ const buttonLabel = hasReviewRequested ? 'Review merge request' : 'Open merge request'
+
+ const handleClick = () => {
+ if (hasReviewRequested) {
+ router.push(`/project/${selectedBranch.project_ref}/merge`)
+ } else {
+ updateBranch(
+ {
+ id: selectedBranch.id,
+ projectRef,
+ requestReview: true,
+ },
+ {
+ onSuccess: () => {
+ toast.success('Merge request created')
+ router.push(`/project/${selectedBranch.project_ref}/merge`)
+ },
+ }
+ )
+ }
+ }
+
+ return (
+
+ {hasReviewRequested && (
+
+ )}
+
+
+ }
+ />
+ )
+}
+
+export default MergeRequestButton
diff --git a/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx
index 41a6434e197..48faa8be673 100644
--- a/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx
+++ b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx
@@ -1,4 +1,4 @@
-import { Blocks, FileText, Lightbulb, List, Logs, Settings } from 'lucide-react'
+import { Blocks, FileText, Lightbulb, List, Settings } from 'lucide-react'
import { ICON_SIZE, ICON_STROKE_WIDTH } from 'components/interfaces/Sidebar'
import { generateAuthMenu } from 'components/layouts/AuthLayout/AuthLayout.utils'
@@ -116,16 +116,10 @@ export const generateProductRoutes = (
]
}
-export const generateOtherRoutes = (
- ref?: string,
- project?: Project,
- features?: { unifiedLogs?: boolean }
-): Route[] => {
+export const generateOtherRoutes = (ref?: string, project?: Project, features?: {}): Route[] => {
const isProjectBuilding = project?.status === PROJECT_STATUS.COMING_UP
const buildingUrl = `/project/${ref}`
- const showUnifiedLogs = features?.unifiedLogs ?? false
-
return [
{
key: 'advisors',
@@ -149,16 +143,6 @@ export const generateOtherRoutes = (
icon:
,
link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/logs`),
},
- ...(showUnifiedLogs
- ? [
- {
- key: 'unified-logs',
- label: 'Unified Logs',
- icon: ,
- link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/unified-logs`),
- },
- ]
- : []),
{
key: 'api',
label: 'API Docs',
diff --git a/apps/studio/components/layouts/ProjectLayout/OrganizationSettingsLayout.tsx b/apps/studio/components/layouts/ProjectLayout/OrganizationSettingsLayout.tsx
index 5cb4130dce2..b52a30832ac 100644
--- a/apps/studio/components/layouts/ProjectLayout/OrganizationSettingsLayout.tsx
+++ b/apps/studio/components/layouts/ProjectLayout/OrganizationSettingsLayout.tsx
@@ -3,7 +3,6 @@ import { PropsWithChildren } from 'react'
import { useParams } from 'common'
import { useCurrentPath } from 'hooks/misc/useCurrentPath'
-import { useFlag } from 'hooks/ui/useFlag'
import { NavMenu, NavMenuItem } from 'ui'
import { ScaffoldContainerLegacy, ScaffoldTitle } from '../Scaffold'
@@ -13,9 +12,6 @@ function OrganizationSettingsLayout({ children }: PropsWithChildren) {
const fullCurrentPath = useCurrentPath()
const [currentPath] = fullCurrentPath.split('#')
- // [Joshen] RE Organization Settings - need to figure out how to enforce MFA across users before this goes live
- const newSecurityPage = useFlag('showOrganizationSecuritySettings')
-
// Hide these settings in the new layout on the following paths
const isHidden = (path: string) => {
return (
@@ -35,7 +31,7 @@ function OrganizationSettingsLayout({ children }: PropsWithChildren) {
label: 'General',
href: `/org/${slug}/general`,
},
- newSecurityPage && {
+ {
label: 'Security',
href: `/org/${slug}/security`,
},
diff --git a/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx b/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx
index 25991f5b885..fa41c5735f3 100644
--- a/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx
+++ b/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx
@@ -86,7 +86,7 @@ const ProjectLayout = forwardRef
}
- if (requiresDbConnection && isProjectBuilding) {
+ if (requiresDbConnection && isProjectBuilding && !isBranchesPage) {
return
}
diff --git a/apps/studio/components/layouts/ReportsLayout/ReportsMenu.tsx b/apps/studio/components/layouts/ReportsLayout/ReportsMenu.tsx
index 1ebb482970c..d3d034eef97 100644
--- a/apps/studio/components/layouts/ReportsLayout/ReportsMenu.tsx
+++ b/apps/studio/components/layouts/ReportsLayout/ReportsMenu.tsx
@@ -29,6 +29,7 @@ const ReportsMenu = () => {
const edgeFnEnabled = useFlag('edgefunctionreport')
const realtimeEnabled = useFlag('realtimeReport')
const storageReportEnabled = useFlag('storagereport')
+ const postgrestReportEnabled = useFlag('postgrestreport')
// b/c fly doesn't support storage
const storageSupported = useIsFeatureEnabled('project_storage:all')
@@ -133,15 +134,6 @@ const ReportsMenu = () => {
key: 'database',
url: `/project/${ref}/reports/database${preservedQueryParams}`,
},
- ...(realtimeEnabled
- ? [
- {
- name: 'Realtime',
- key: 'realtime',
- url: `/project/${ref}/reports/realtime${preservedQueryParams}`,
- },
- ]
- : []),
...(edgeFnEnabled
? [
{
@@ -151,6 +143,25 @@ const ReportsMenu = () => {
},
]
: []),
+ ...(postgrestReportEnabled
+ ? [
+ {
+ name: 'PostgREST',
+ key: 'postgrest',
+ url: `/project/${ref}/reports/postgrest${preservedQueryParams}`,
+ },
+ ]
+ : []),
+ ...(realtimeEnabled
+ ? [
+ {
+ name: 'Realtime',
+ key: 'realtime',
+ url: `/project/${ref}/reports/realtime${preservedQueryParams}`,
+ },
+ ]
+ : []),
+
...(storageEnabled
? [
{
diff --git a/apps/studio/components/layouts/SignInLayout/ForgotPasswordLayout.tsx b/apps/studio/components/layouts/SignInLayout/ForgotPasswordLayout.tsx
index 2973ef53b79..8723843fd13 100644
--- a/apps/studio/components/layouts/SignInLayout/ForgotPasswordLayout.tsx
+++ b/apps/studio/components/layouts/SignInLayout/ForgotPasswordLayout.tsx
@@ -21,7 +21,7 @@ const ForgotPasswordLayout = ({
const { resolvedTheme } = useTheme()
return (
-
+
diff --git a/apps/studio/components/layouts/SignInLayout/SignInLayout.tsx b/apps/studio/components/layouts/SignInLayout/SignInLayout.tsx
index 075623b470f..f58f952f15d 100644
--- a/apps/studio/components/layouts/SignInLayout/SignInLayout.tsx
+++ b/apps/studio/components/layouts/SignInLayout/SignInLayout.tsx
@@ -82,7 +82,7 @@ const SignInLayout = ({
return (
<>
-
+
{bucket.public && Public }
+ {bucket.type === 'ANALYTICS' && (
+
+
+
+
+
+ Analytics bucket
+
+
+ )}
{canUpdateBuckets && isSelected ? (
- } className="mr-1 p-0.5" />
+ } className="mr-1 p-1.5 w-7" />
- onSelectEditBucket()}
- >
-
- Edit bucket
-
-
- onSelectEmptyBucket()}
- >
-
- Empty bucket
-
+ {bucket.type !== 'ANALYTICS' && (
+ <>
+ onSelectEditBucket()}
+ >
+
+ Edit bucket
+
+
+ onSelectEmptyBucket()}
+ >
+
+ Empty bucket
+
+ >
+ )}
) : (
-
+
)}
)
diff --git a/apps/studio/components/layouts/StorageLayout/StorageMenu.tsx b/apps/studio/components/layouts/StorageLayout/StorageMenu.tsx
index a91a4f3de24..094cbc5013d 100644
--- a/apps/studio/components/layouts/StorageLayout/StorageMenu.tsx
+++ b/apps/studio/components/layouts/StorageLayout/StorageMenu.tsx
@@ -8,12 +8,11 @@ import { useState } from 'react'
import { useParams } from 'common'
import CreateBucketModal from 'components/interfaces/Storage/CreateBucketModal'
import EditBucketModal from 'components/interfaces/Storage/EditBucketModal'
-import type { StorageBucket } from 'components/interfaces/Storage/Storage.types'
import { DeleteBucketModal } from 'components/to-be-cleaned/Storage'
import { EmptyBucketModal } from 'components/to-be-cleaned/Storage/EmptyBucketModal'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
-import { useBucketsQuery } from 'data/storage/buckets-query'
+import { Bucket, useBucketsQuery } from 'data/storage/buckets-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, Menu } from 'ui'
@@ -34,9 +33,9 @@ const StorageMenu = () => {
const [searchText, setSearchText] = useState
('')
const [showCreateBucketModal, setShowCreateBucketModal] = useState(false)
- const [selectedBucketToEdit, setSelectedBucketToEdit] = useState()
- const [selectedBucketToEmpty, setSelectedBucketToEmpty] = useState()
- const [selectedBucketToDelete, setSelectedBucketToDelete] = useState()
+ const [selectedBucketToEdit, setSelectedBucketToEdit] = useState()
+ const [selectedBucketToEmpty, setSelectedBucketToEmpty] = useState()
+ const [selectedBucketToDelete, setSelectedBucketToDelete] = useState()
const canCreateBuckets = useCheckPermissions(PermissionAction.STORAGE_WRITE, '*')
const [sort, setSort] = useLocalStorage<'alphabetical' | 'created-at'>(
diff --git a/apps/studio/components/layouts/Tabs/SortableTab.tsx b/apps/studio/components/layouts/Tabs/SortableTab.tsx
index 10895eed481..4f03c326c8d 100644
--- a/apps/studio/components/layouts/Tabs/SortableTab.tsx
+++ b/apps/studio/components/layouts/Tabs/SortableTab.tsx
@@ -71,6 +71,13 @@ export const SortableTab = ({
>
{
+ // Middle click closes tab
+ if (e.button === 1) {
+ e.preventDefault()
+ onClose(tab.id)
+ }
+ }}
onDoubleClick={() => tabs.makeTabPermanent(tab.id)}
className={cn(
'flex items-center gap-2 pl-3 pr-2.5 text-xs',
diff --git a/apps/studio/components/layouts/UnifiedLogsLayout/UnifiedLogsLayout.tsx b/apps/studio/components/layouts/UnifiedLogsLayout/UnifiedLogsLayout.tsx
deleted file mode 100644
index 27ba57f0ce0..00000000000
--- a/apps/studio/components/layouts/UnifiedLogsLayout/UnifiedLogsLayout.tsx
+++ /dev/null
@@ -1,9 +0,0 @@
-import { PropsWithChildren } from 'react'
-
-import ProjectLayout from '../ProjectLayout/ProjectLayout'
-
-const UnifiedLogsLayout = ({ children }: PropsWithChildren) => {
- return {children}
-}
-
-export default UnifiedLogsLayout
diff --git a/apps/studio/components/to-be-cleaned/Storage/AnalyticBucketDetails/CopyEnvButton.tsx b/apps/studio/components/to-be-cleaned/Storage/AnalyticBucketDetails/CopyEnvButton.tsx
new file mode 100644
index 00000000000..1c45eda3cf0
--- /dev/null
+++ b/apps/studio/components/to-be-cleaned/Storage/AnalyticBucketDetails/CopyEnvButton.tsx
@@ -0,0 +1,62 @@
+import { Copy } from 'lucide-react'
+import { useCallback, useState } from 'react'
+import { toast } from 'sonner'
+
+import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
+import { ButtonTooltip } from 'components/ui/ButtonTooltip'
+import { getDecryptedValue } from 'data/vault/vault-secret-decrypted-value-query'
+import { copyToClipboard } from 'ui'
+
+export const CopyEnvButton = ({
+ serverOptions,
+ values,
+}: {
+ serverOptions: { name: string; secureEntry: boolean }[]
+ values: Record
+}) => {
+ const { project } = useProjectContext()
+ const [isLoading, setIsLoading] = useState(false)
+
+ const onCopy = useCallback(async () => {
+ setIsLoading(true)
+ const envFile = Promise.all(
+ serverOptions.map(async (option) => {
+ if (option.secureEntry) {
+ const decryptedValue = await getDecryptedValue({
+ projectRef: project?.ref,
+ connectionString: project?.connectionString,
+ id: values[option.name],
+ })
+ return `${option.name.toUpperCase().replace('VAULT_', '')}=${decryptedValue[0].decrypted_secret}`
+ }
+ return `${option.name.toUpperCase().replace('.', '_')}=${values[option.name]}`
+ })
+ ).then((values) => values.join('\n'))
+
+ copyToClipboard(envFile, () => {
+ toast.success('Copied to clipboard')
+ setIsLoading(false)
+ })
+ }, [serverOptions, values])
+
+ return (
+ }
+ onClick={onCopy}
+ tooltip={{
+ content: {
+ text: (
+
+ Copies an .env file with the configuration details
+ to your clipboard.
+
+ ),
+ },
+ }}
+ >
+ Copy env values
+
+ )
+}
diff --git a/apps/studio/components/to-be-cleaned/Storage/AnalyticBucketDetails/DecryptedReadOnlyInput.tsx b/apps/studio/components/to-be-cleaned/Storage/AnalyticBucketDetails/DecryptedReadOnlyInput.tsx
new file mode 100644
index 00000000000..e22c8a8e694
--- /dev/null
+++ b/apps/studio/components/to-be-cleaned/Storage/AnalyticBucketDetails/DecryptedReadOnlyInput.tsx
@@ -0,0 +1,70 @@
+import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
+import { useVaultSecretDecryptedValueQuery } from 'data/vault/vault-secret-decrypted-value-query'
+import { Eye, EyeOff, Loader } from 'lucide-react'
+import { useState } from 'react'
+import { Button, Input } from 'ui'
+
+export const DecryptedReadOnlyInput = ({
+ value,
+ secureEntry,
+ descriptionText,
+ label,
+}: {
+ value?: string
+ secureEntry: boolean
+ descriptionText: string
+ label: string
+}) => {
+ const [showHidden, setShowHidden] = useState(false)
+ const { project } = useProjectContext()
+
+ const { isLoading: isDecryptedValueLoading, data: decryptedValue } =
+ useVaultSecretDecryptedValueQuery(
+ {
+ projectRef: project?.ref,
+ connectionString: project?.connectionString,
+ id: value ?? '',
+ },
+ { enabled: secureEntry && showHidden }
+ )
+
+ const isLoading = isDecryptedValueLoading && showHidden
+
+ return (
+
+ } />
+
+ ) : (
+
+ : }
+ onClick={() => setShowHidden(!showHidden)}
+ />
+
+ )
+ ) : null
+ }
+ />
+ )
+}
diff --git a/apps/studio/components/to-be-cleaned/Storage/AnalyticBucketDetails/NamespaceRow.tsx b/apps/studio/components/to-be-cleaned/Storage/AnalyticBucketDetails/NamespaceRow.tsx
new file mode 100644
index 00000000000..80d75a33da4
--- /dev/null
+++ b/apps/studio/components/to-be-cleaned/Storage/AnalyticBucketDetails/NamespaceRow.tsx
@@ -0,0 +1,132 @@
+import { RefreshCw, SquareArrowOutUpRight } from 'lucide-react'
+import { useMemo, useState } from 'react'
+
+import { FormattedWrapperTable } from 'components/interfaces/Integrations/Wrappers/Wrappers.utils'
+import { ImportForeignSchemaDialog } from 'components/interfaces/Storage/ImportForeignSchemaDialog'
+import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
+import { ButtonTooltip } from 'components/ui/ButtonTooltip'
+import { useFDWImportForeignSchemaMutation } from 'data/fdw/fdw-import-foreign-schema-mutation'
+import { FDW } from 'data/fdw/fdws-query'
+import { useIcebergNamespaceTablesQuery } from 'data/storage/iceberg-namespace-tables-query'
+import { BASE_PATH } from 'lib/constants'
+import { Badge, Button, TableCell, TableRow } from 'ui'
+
+type NamespaceRowProps = {
+ bucketName: string
+ namespace: string
+ schema: string
+ excludedSchemas: string[]
+ tables: (FormattedWrapperTable & { id: number })[]
+ token: string
+ wrapperInstance: FDW
+ wrapperValues: Record
+}
+
+export const NamespaceRow = ({
+ bucketName,
+ namespace,
+ schema,
+ excludedSchemas,
+ tables,
+ token,
+ wrapperInstance,
+ wrapperValues,
+}: NamespaceRowProps) => {
+ const { project } = useProjectContext()
+ const [importForeignSchemaShown, setImportForeignSchemaShown] = useState(false)
+
+ const targetSchema = tables[0]?.schema_name ?? ''
+
+ const { data: tablesData, isLoading: isLoadingNamespaceTables } = useIcebergNamespaceTablesQuery(
+ {
+ catalogUri: wrapperValues.catalog_uri,
+ warehouse: wrapperValues.warehouse,
+ token: token,
+ namespace: namespace,
+ },
+ { enabled: !!token }
+ )
+
+ const { mutateAsync: importForeignSchema, isLoading: isImportingForeignSchema } =
+ useFDWImportForeignSchemaMutation()
+
+ const rescanNamespace = async () => {
+ await importForeignSchema({
+ projectRef: project?.ref,
+ connectionString: project?.connectionString,
+ serverName: wrapperInstance.server_name,
+ sourceSchema: namespace,
+ targetSchema: targetSchema,
+ })
+ }
+
+ const missingTables = useMemo(() => {
+ return (tablesData || []).filter(
+ (t) => !tables.find((table) => table.table.split('.')[1] === t)
+ )
+ }, [tablesData, tables])
+
+ let scanTooltip = useMemo(() => {
+ if (isImportingForeignSchema) return 'Scanning for new tables...'
+ if (isLoadingNamespaceTables) return 'Loading tables...'
+ if (missingTables.length > 0) return `Found ${missingTables.length} new tables`
+ if (tables.length === 0) return 'No tables found'
+ return 'All tables are up to date'
+ }, [isImportingForeignSchema, isLoadingNamespaceTables, missingTables.length, tables.length])
+
+ return (
+
+
+ {namespace}
+
+ {schema && {schema} }
+
+ {tablesData ? `${tables.length}/${tablesData.length} connected tables` : ``}
+
+
+
+
0 ? 'primary' : 'default'}
+ icon={ }
+ loading={isImportingForeignSchema || isLoadingNamespaceTables}
+ onClick={() => (schema ? rescanNamespace() : setImportForeignSchemaShown(true))}
+ disabled={missingTables.length === 0}
+ tooltip={{ content: { text: scanTooltip } }}
+ >
+ Sync
+
+ {schema ? (
+
+ }>
+ Table Editor
+
+
+ ) : (
+
}
+ disabled
+ tooltip={{
+ content: { text: 'There are no tables connected.' },
+ }}
+ >
+ Table Editor
+
+ )}
+
+
+ setImportForeignSchemaShown(false)}
+ />
+
+ )
+}
diff --git a/apps/studio/components/to-be-cleaned/Storage/AnalyticBucketDetails/SimpleConfigurationDetails.tsx b/apps/studio/components/to-be-cleaned/Storage/AnalyticBucketDetails/SimpleConfigurationDetails.tsx
new file mode 100644
index 00000000000..feccb8aa02f
--- /dev/null
+++ b/apps/studio/components/to-be-cleaned/Storage/AnalyticBucketDetails/SimpleConfigurationDetails.tsx
@@ -0,0 +1,69 @@
+import Link from '@ui/components/Typography/Link'
+import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
+import { ScaffoldSectionDescription, ScaffoldSectionTitle } from 'components/layouts/Scaffold'
+import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
+import { Card } from 'ui'
+import { getCatalogURI, getConnectionURL } from '../StorageSettings/StorageSettings.utils'
+import { DESCRIPTIONS } from './constants'
+import { CopyEnvButton } from './CopyEnvButton'
+import { DecryptedReadOnlyInput } from './DecryptedReadOnlyInput'
+
+const wrapperMeta = {
+ options: [
+ { name: 'vault_token', label: 'Vault Token', secureEntry: false },
+ { name: 'warehouse', label: 'Warehouse', secureEntry: false },
+ { name: 's3.endpoint', label: 'S3 Endpoint', secureEntry: false },
+ { name: 'catalog_uri', label: 'Catalog URI', secureEntry: false },
+ ],
+}
+
+export const SimpleConfigurationDetails = ({ bucketName }: { bucketName: string }) => {
+ const { project } = useProjectContext()
+ const { data: settings } = useProjectSettingsV2Query({ projectRef: project?.ref })
+ const protocol = settings?.app_config?.protocol ?? 'https'
+ const endpoint = settings?.app_config?.endpoint
+ const serviceApiKey =
+ (settings?.service_api_keys ?? []).find((key) => key.tags === 'service_role')?.api_key ??
+ 'SUPABASE_CLIENT_SERVICE_KEY'
+
+ const values: Record = {
+ vault_token: serviceApiKey,
+ warehouse: bucketName,
+ 's3.endpoint': getConnectionURL(project?.ref ?? '', protocol, endpoint),
+ catalog_uri: getCatalogURI(project?.ref ?? '', protocol, endpoint),
+ }
+
+ return (
+
+
+
+ Configuration Details
+
+ You can use the following configuration details to connect to the bucket from your code.
+
+
+
+
+
+
+ To get AWS credentials, you can create them using the{' '}
+
+ S3 Access Keys
+ {' '}
+ feature.
+
+ {wrapperMeta.options.map((option) => {
+ return (
+
+ )
+ })}
+
+
+ )
+}
diff --git a/apps/studio/components/to-be-cleaned/Storage/AnalyticBucketDetails/constants.ts b/apps/studio/components/to-be-cleaned/Storage/AnalyticBucketDetails/constants.ts
new file mode 100644
index 00000000000..c9851d3bef3
--- /dev/null
+++ b/apps/studio/components/to-be-cleaned/Storage/AnalyticBucketDetails/constants.ts
@@ -0,0 +1,26 @@
+export const OPTION_ORDER = [
+ 'catalog_uri',
+ 'vault_token',
+ 'warehouse',
+ 's3.endpoint',
+ 'vault_aws_access_key_id',
+ 'vault_aws_secret_access_key',
+]
+
+export const LABELS: Record = {
+ vault_aws_access_key_id: 'S3 Access Key ID',
+ vault_aws_secret_access_key: 'S3 Secret Access Key',
+ vault_token: 'Catalog Token',
+ warehouse: 'Warehouse Name',
+ 's3.endpoint': 'S3 Endpoint',
+ catalog_uri: 'Catalog URI',
+}
+
+export const DESCRIPTIONS: Record = {
+ vault_aws_access_key_id: 'Matches the AWS access key ID from a S3 Access Key.',
+ vault_aws_secret_access_key: 'Matches the AWS secret access from a S3 Access Key.',
+ vault_token: 'Corresponds to the service role key.',
+ warehouse: 'Matches the name of the bucket.',
+ 's3.endpoint': '',
+ catalog_uri: '',
+}
diff --git a/apps/studio/components/to-be-cleaned/Storage/AnalyticBucketDetails/index.tsx b/apps/studio/components/to-be-cleaned/Storage/AnalyticBucketDetails/index.tsx
new file mode 100644
index 00000000000..49aead6e1f0
--- /dev/null
+++ b/apps/studio/components/to-be-cleaned/Storage/AnalyticBucketDetails/index.tsx
@@ -0,0 +1,403 @@
+import { snakeCase, uniq } from 'lodash'
+import Link from 'next/link'
+import { useMemo } from 'react'
+
+import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integrations.constants'
+import { WRAPPER_HANDLERS } from 'components/interfaces/Integrations/Wrappers/Wrappers.constants'
+import { WrapperMeta } from 'components/interfaces/Integrations/Wrappers/Wrappers.types'
+import {
+ convertKVStringArrayToJson,
+ formatWrapperTables,
+ wrapperMetaComparator,
+} from 'components/interfaces/Integrations/Wrappers/Wrappers.utils'
+import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
+import {
+ ScaffoldContainer,
+ ScaffoldHeader,
+ ScaffoldSectionDescription,
+ ScaffoldSectionTitle,
+ ScaffoldTitle,
+} from 'components/layouts/Scaffold'
+import { DocsButton } from 'components/ui/DocsButton'
+import {
+ DatabaseExtension,
+ useDatabaseExtensionsQuery,
+} from 'data/database-extensions/database-extensions-query'
+import { useFDWsQuery } from 'data/fdw/fdws-query'
+import { Bucket } from 'data/storage/buckets-query'
+import { useIcebergNamespacesQuery } from 'data/storage/iceberg-namespaces-query'
+import { useIcebergWrapperCreateMutation } from 'data/storage/iceberg-wrapper-create-mutation'
+import { useVaultSecretDecryptedValueQuery } from 'data/vault/vault-secret-decrypted-value-query'
+import {
+ Alert_Shadcn_,
+ AlertDescription_Shadcn_,
+ AlertTitle_Shadcn_,
+ Button,
+ Card,
+ Table,
+ TableBody,
+ TableHead,
+ TableHeader,
+ TableRow,
+ WarningIcon,
+} from 'ui'
+import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
+import { DESCRIPTIONS, LABELS, OPTION_ORDER } from './constants'
+import { CopyEnvButton } from './CopyEnvButton'
+import { DecryptedReadOnlyInput } from './DecryptedReadOnlyInput'
+import { NamespaceRow } from './NamespaceRow'
+import { SimpleConfigurationDetails } from './SimpleConfigurationDetails'
+import { useIcebergWrapperExtension } from './useIcebergWrapper'
+
+export const AnalyticBucketDetails = ({ bucket }: { bucket: Bucket }) => {
+ const { project } = useProjectContext()
+
+ const { data: extensionsData } = useDatabaseExtensionsQuery({
+ projectRef: project?.ref,
+ connectionString: project?.connectionString,
+ })
+
+ const { data, isLoading: isFDWsLoading } = useFDWsQuery({
+ projectRef: project?.ref,
+ connectionString: project?.connectionString,
+ })
+
+ /** The wrapper instance is the wrapper that is installed for this Analytics bucket. */
+ const wrapperInstance = useMemo(() => {
+ return data
+ ?.filter((wrapper) =>
+ wrapperMetaComparator(
+ {
+ handlerName: WRAPPER_HANDLERS.ICEBERG,
+ server: {
+ options: [],
+ },
+ },
+ wrapper
+ )
+ )
+ .find((w) => w.name === snakeCase(`${bucket.name}_fdw`))
+ }, [data, bucket.name])
+
+ const extensionState = useIcebergWrapperExtension()
+
+ const integration = INTEGRATIONS.find((i) => i.id === 'iceberg_wrapper' && i.type === 'wrapper')
+
+ const wrapperValues = convertKVStringArrayToJson(wrapperInstance?.server_options ?? [])
+ const wrapperMeta = (integration?.type === 'wrapper' && integration.meta) as WrapperMeta
+
+ const { data: token, isSuccess: isSuccessToken } = useVaultSecretDecryptedValueQuery(
+ {
+ projectRef: project?.ref,
+ connectionString: project?.connectionString,
+ id: wrapperValues.vault_token,
+ },
+ {
+ enabled: wrapperValues.vault_token !== undefined,
+ }
+ )
+
+ const { data: namespacesData, isLoading: isLoadingNamespaces } = useIcebergNamespacesQuery(
+ {
+ catalogUri: wrapperValues.catalog_uri,
+ warehouse: wrapperValues.warehouse,
+ token: token!,
+ },
+ { enabled: isSuccessToken }
+ )
+
+ const wrapperTables = useMemo(() => {
+ if (!wrapperInstance) return []
+
+ return formatWrapperTables(wrapperInstance, wrapperMeta!)
+ }, [wrapperInstance, wrapperMeta])
+
+ const namespaces = useMemo(() => {
+ const fdwNamespaces = wrapperTables.map((t) => t.table.split('.')[0]) as string[]
+ const namespaces = uniq([...fdwNamespaces, ...(namespacesData ?? [])])
+
+ return namespaces.map((namespace) => {
+ const tables = wrapperTables.filter((t) => t.table.split('.')[0] === namespace)
+ const schema = tables[0]?.schema
+
+ return {
+ namespace: namespace,
+ schema: schema,
+ tables: tables,
+ }
+ })
+ }, [wrapperTables, namespacesData])
+ const excludedSchemas = uniq(namespaces.map((n) => n.schema))
+
+ const wrappersExtension = extensionsData?.find((ext) => ext.name === 'wrappers')
+
+ const state = isFDWsLoading
+ ? 'loading'
+ : extensionState === 'installed'
+ ? wrapperInstance
+ ? 'added'
+ : 'missing'
+ : extensionState
+
+ return (
+
+
+
+
+ Analytics Bucket {bucket.name}
+
+
+ Namespaces and tables connected to this bucket.
+
+
+
+
+
+ {state === 'loading' && }
+ {state === 'not-installed' && (
+
+ )}
+ {state === 'needs-upgrade' && (
+
+ )}
+ {state === 'added' && wrapperInstance && (
+ <>
+
+ {isLoadingNamespaces || isFDWsLoading ? (
+
+ ) : namespaces.length === 0 ? (
+
+ No namespaces in this bucket
+
+ Create a namespace and add some data{' '}
+
+ {' '}
+ to get started
+
+
+
+ ) : (
+
+
+
+
+ Namespace
+ Schema
+ Tables
+ Actions
+
+
+
+ {namespaces.map(({ namespace, schema, tables }) => (
+
+ ))}
+
+
+
+ )}
+
+
+
+
+
+ Connection Details
+
+ You can use the following parameters to connect to the bucket from an Iceberg
+ client.
+
+
+
+ !option.hidden && wrapperValues[option.name]
+ )}
+ values={wrapperValues}
+ />
+
+
+
+
+ {wrapperMeta.server.options
+ .filter((option) => !option.hidden && wrapperValues[option.name])
+ .sort((a, b) => OPTION_ORDER.indexOf(a.name) - OPTION_ORDER.indexOf(b.name))
+ .map((option) => {
+ return (
+
+ )
+ })}
+
+
+ >
+ )}
+ {state === 'missing' && }
+
+
+ )
+}
+
+const ExtensionNotInstalled = ({
+ bucketName,
+ projectRef,
+ wrapperMeta,
+ wrappersExtension,
+}: {
+ bucketName: string
+ projectRef: string
+ wrapperMeta: WrapperMeta
+ wrappersExtension: DatabaseExtension
+}) => {
+ const databaseNeedsUpgrading =
+ (wrappersExtension?.default_version ?? '') < (wrapperMeta?.minimumExtensionVersion ?? '')
+
+ return (
+ <>
+
+
+
+ You need to install the wrappers extension to connect this Analytics bucket to the
+ database.
+
+
+
+ The {wrapperMeta.label} wrapper requires the Wrappers extension to be installed. You can
+ install version {wrappersExtension?.installed_version}
+ {databaseNeedsUpgrading &&
+ ' which is below the minimum version that supports Iceberg wrapper'}
+ . Please {databaseNeedsUpgrading && 'upgrade your database then '}install the{' '}
+ wrappers extension to create this wrapper.
+
+
+
+
+
+ {databaseNeedsUpgrading ? 'Upgrade database' : 'Install wrappers extension'}
+
+
+
+
+
+ >
+ )
+}
+
+const ExtensionNeedsUpgrade = ({
+ bucketName,
+ projectRef,
+ wrapperMeta,
+ wrappersExtension,
+}: {
+ bucketName: string
+ projectRef: string
+ wrapperMeta: WrapperMeta
+ wrappersExtension: DatabaseExtension
+}) => {
+ // [Joshen] Default version is what's on the DB, so if the installed version is already the default version
+ // but still doesnt meet the minimum extension version, then DB upgrade is required
+ const databaseNeedsUpgrading =
+ wrappersExtension?.installed_version === wrappersExtension?.default_version
+
+ return (
+ <>
+
+
+
+ Your extension version is outdated for this wrapper.
+
+
+
+ The {wrapperMeta.label} wrapper requires a minimum extension version of{' '}
+ {wrapperMeta.minimumExtensionVersion}. You have version{' '}
+ {wrappersExtension?.installed_version} installed. Please{' '}
+ {databaseNeedsUpgrading && 'upgrade your database then '}update the extension by
+ disabling and enabling the wrappers extension to create
+ this wrapper.
+
+
+ Warning: Before reinstalling the wrapper extension, you must first remove all existing
+ wrappers. Afterward, you can recreate the wrappers.
+
+
+
+
+
+ {databaseNeedsUpgrading ? 'Upgrade database' : 'View wrappers extension'}
+
+
+
+
+
+ >
+ )
+}
+
+const WrapperMissing = ({ bucketName }: { bucketName: string }) => {
+ const { mutateAsync: createIcebergWrapper, isLoading: isCreatingIcebergWrapper } =
+ useIcebergWrapperCreateMutation()
+
+ const onSetupWrapper = async () => {
+ await createIcebergWrapper({ bucketName })
+ }
+
+ return (
+ <>
+
+
+
+ This Analytics bucket does not have a foreign data wrapper setup.
+
+
+ You need to setup a wrapper to connect this bucket to the database.
+
+
+
+ Setup a wrapper
+
+
+
+
+ >
+ )
+}
diff --git a/apps/studio/components/to-be-cleaned/Storage/AnalyticBucketDetails/useIcebergWrapper.tsx b/apps/studio/components/to-be-cleaned/Storage/AnalyticBucketDetails/useIcebergWrapper.tsx
new file mode 100644
index 00000000000..56387d46095
--- /dev/null
+++ b/apps/studio/components/to-be-cleaned/Storage/AnalyticBucketDetails/useIcebergWrapper.tsx
@@ -0,0 +1,35 @@
+import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integrations.constants'
+import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
+import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
+
+export const useIcebergWrapperExtension = () => {
+ const { project } = useProjectContext()
+ const { data: extensionsData, isLoading: isExtensionsLoading } = useDatabaseExtensionsQuery({
+ projectRef: project?.ref,
+ connectionString: project?.connectionString,
+ })
+
+ const integration = INTEGRATIONS.find((i) => i.id === 'iceberg_wrapper')
+
+ if (!integration || integration.type !== 'wrapper') {
+ // This should never happen
+ return 'not-found'
+ }
+
+ const wrapperMeta = integration.meta
+
+ const wrappersExtension = extensionsData?.find((ext) => ext.name === 'wrappers')
+ const isWrappersExtensionInstalled = !!wrappersExtension?.installed_version
+ const hasRequiredVersion =
+ (wrappersExtension?.installed_version ?? '') >= (wrapperMeta?.minimumExtensionVersion ?? '')
+
+ const state = isExtensionsLoading
+ ? 'loading'
+ : isWrappersExtensionInstalled
+ ? hasRequiredVersion
+ ? 'installed'
+ : 'needs-upgrade'
+ : ('not-installed' as const)
+
+ return state
+}
diff --git a/apps/studio/components/to-be-cleaned/Storage/DeleteBucketModal.tsx b/apps/studio/components/to-be-cleaned/Storage/DeleteBucketModal.tsx
index a02f950e8e2..ac7293b105c 100644
--- a/apps/studio/components/to-be-cleaned/Storage/DeleteBucketModal.tsx
+++ b/apps/studio/components/to-be-cleaned/Storage/DeleteBucketModal.tsx
@@ -73,7 +73,7 @@ const DeleteBucketModal = ({ visible = false, bucket, onClose }: DeleteBucketMod
const onDeleteBucket = async () => {
if (!projectRef) return console.error('Project ref is required')
if (!bucket) return console.error('No bucket is selected')
- deleteBucket({ projectRef, id: bucket.id })
+ deleteBucket({ projectRef, id: bucket.id, type: bucket.type })
}
return (
diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/StorageExplorer.tsx b/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/StorageExplorer.tsx
index a268b1aaeff..cfc8ecd5c40 100644
--- a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/StorageExplorer.tsx
+++ b/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/StorageExplorer.tsx
@@ -167,7 +167,7 @@ const StorageExplorer = ({ bucket }: StorageExplorerProps) => {
return (
{selectedItems.length === 0 ? (
!att.name.includes('max'))
const isPercentage = format === '%'
- const isRamChart = chartData?.some((att: any) => att.name.toLowerCase().includes('ram_'))
+ const isRamChart =
+ !chartData?.some((att: any) => att.name.toLowerCase() === 'ram_usage') &&
+ chartData?.some((att: any) => att.name.toLowerCase().includes('ram_'))
const isDiskSpaceChart = chartData?.some((att: any) =>
att.name.toLowerCase().includes('disk_space_')
)
diff --git a/apps/studio/components/ui/Charts/ComposedChart.utils.tsx b/apps/studio/components/ui/Charts/ComposedChart.utils.tsx
index ab9726edb72..14161b06fd3 100644
--- a/apps/studio/components/ui/Charts/ComposedChart.utils.tsx
+++ b/apps/studio/components/ui/Charts/ComposedChart.utils.tsx
@@ -14,6 +14,7 @@ export interface ReportAttributes {
attributes?: (MultiAttribute | false)[]
defaultChartStyle?: 'bar' | 'line' | 'stackedAreaLine'
hide?: boolean
+ availableIn?: string[]
hideChartType?: boolean
format?: string
className?: string
@@ -54,6 +55,7 @@ export type MultiAttribute = {
omitFromTotal?: boolean
tooltip?: string
customValue?: number
+ [key: string]: any
/**
* Manipulate the value of the attribute before it is displayed on the chart.
* @param value - The value of the attribute.
@@ -130,6 +132,7 @@ export const calculateTotalChartAggregate = (
const CustomTooltip = ({
active,
payload,
+ label,
attributes,
isPercentage,
format,
@@ -143,7 +146,9 @@ const CustomTooltip = ({
const maxValueData =
maxValueAttribute && payload?.find((p: any) => p.dataKey === maxValueAttribute.attribute)
const maxValue = maxValueData?.value
- const isRamChart = payload?.some((p: any) => p.dataKey.toLowerCase().includes('ram_'))
+ const isRamChart =
+ !payload?.some((p: any) => p.dataKey.toLowerCase() === 'ram_usage') &&
+ payload?.some((p: any) => p.dataKey.toLowerCase().includes('ram_'))
const isDBSizeChart =
payload?.some((p: any) => p.dataKey.toLowerCase().includes('disk_fs_')) ||
payload?.some((p: any) => p.dataKey.toLowerCase().includes('pg_database_size'))
diff --git a/apps/studio/components/ui/Charts/ComposedChartHandler.tsx b/apps/studio/components/ui/Charts/ComposedChartHandler.tsx
index 833730cf659..25ab41228eb 100644
--- a/apps/studio/components/ui/Charts/ComposedChartHandler.tsx
+++ b/apps/studio/components/ui/Charts/ComposedChartHandler.tsx
@@ -41,6 +41,7 @@ export interface ComposedChartHandlerProps {
valuePrecision?: number
isVisible?: boolean
docsUrl?: string
+ hide?: boolean
}
/**
@@ -237,7 +238,7 @@ const ComposedChartHandler = ({
return (
diff --git a/apps/studio/components/ui/Charts/LogChartHandler.tsx b/apps/studio/components/ui/Charts/LogChartHandler.tsx
index ee21b67be55..610e67d727b 100644
--- a/apps/studio/components/ui/Charts/LogChartHandler.tsx
+++ b/apps/studio/components/ui/Charts/LogChartHandler.tsx
@@ -112,23 +112,6 @@ const LogChartHandler = ({
const [chartStyle, setChartStyle] = useState(defaultChartStyle)
const chartHighlight = useChartHighlight()
- if (isLoading) {
- return (
-
-
- Loading data for {label}
-
- )
- }
-
if (!data) {
return (
@@ -143,10 +126,16 @@ const LogChartHandler = ({
+ {isLoading && (
+
+
+
Loading data for {label}
+
+ )}
{children}
{
- const projectRef = typeof ref === 'string' ? ref : Array.isArray(ref) ? ref[0] : ''
-
const infraAttributes = attributes.filter((attr) => attr.provider === 'infra-monitoring')
const dailyStatsAttributes = attributes.filter((attr) => attr.provider === 'daily-stats')
- const mockAttributes = attributes.filter((attr) => attr.provider === 'mock')
- const referenceLineAttributes = attributes.filter((attr) => attr.provider === 'reference-line')
const infraQueries = useInfraMonitoringQueries(
infraAttributes.map((attr) => attr.attribute as InfraMonitoringAttribute),
diff --git a/apps/studio/components/ui/DataTable/DataTable.types.ts b/apps/studio/components/ui/DataTable/DataTable.types.ts
index 9ee5cada849..19d2b5d77f3 100644
--- a/apps/studio/components/ui/DataTable/DataTable.types.ts
+++ b/apps/studio/components/ui/DataTable/DataTable.types.ts
@@ -30,10 +30,13 @@ export type Slider = {
options?: Option[]
}
+export type DateRangeDisabled = { before?: Date; after?: Date }
+
export type Timerange = {
type: 'timerange'
options?: Option[] // required for TS
presets?: DatePreset[]
+ dateRangeDisabled?: DateRangeDisabled
}
export type Base = {
@@ -47,6 +50,8 @@ export type Base = {
* Defines if the command input is disabled for this field
*/
commandDisabled?: boolean
+ hasDynamicOptions?: boolean
+ hasAsyncSearch?: boolean
}
export type DataTableCheckboxFilterField = Base & Checkbox
diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckbox.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckbox.tsx
index a514895a2b2..02d284968ae 100644
--- a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckbox.tsx
+++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckbox.tsx
@@ -6,6 +6,7 @@ import type { DataTableCheckboxFilterField } from '../DataTable.types'
import { formatCompactNumber } from '../DataTable.utils'
import { InputWithAddons } from '../primitives/InputWithAddons'
import { useDataTable } from '../providers/DataTableProvider'
+import { DataTableFilterCheckboxLoader } from './DataTableFilterCheckboxLoader'
export function DataTableFilterCheckbox({
value: _value,
@@ -34,31 +35,13 @@ export function DataTableFilterCheckbox({
const filters = filterValue ? (Array.isArray(filterValue) ? filterValue : [filterValue]) : []
// REMINDER: if no options are defined, while fetching data, we should show a skeleton
- if (isLoading && !filterOptions?.length)
- return (
-
- {Array.from({ length: 3 }).map((_, index) => (
-
-
-
-
- ))}
-
- )
+ if (isLoading && !filterOptions?.length) return
// Show empty state when no original options are available (not due to search filtering)
if (!options?.length)
return (
-
-
-
-
-
-
-
No options available
-
Try adjusting your filters
-
-
+
)
@@ -77,9 +60,9 @@ export function DataTableFilterCheckbox
({
{filterOptions.length === 0 && inputValue !== '' ? (
-
-
No results found
-
Try a different search term
+
+
No results found
+
Try a different search term
) : (
diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckboxAsync.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckboxAsync.tsx
new file mode 100644
index 00000000000..79ad290266d
--- /dev/null
+++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckboxAsync.tsx
@@ -0,0 +1,136 @@
+import { useDebounce } from '@uidotdev/usehooks'
+import { Loader2, Search } from 'lucide-react'
+import { useState } from 'react'
+
+import { useParams } from 'common'
+import { useUnifiedLogsFacetCountQuery } from 'data/logs/unified-logs-facet-count-query'
+import { Checkbox_Shadcn_ as Checkbox, cn, Label_Shadcn_ as Label, Skeleton } from 'ui'
+import type { DataTableCheckboxFilterField } from '../DataTable.types'
+import { formatCompactNumber } from '../DataTable.utils'
+import { InputWithAddons } from '../primitives/InputWithAddons'
+import { useDataTable } from '../providers/DataTableProvider'
+
+export function DataTableFilterCheckboxAsync
({
+ value: _value,
+ options,
+ component: Component,
+}: DataTableCheckboxFilterField) {
+ const value = _value as string
+ const [inputValue, setInputValue] = useState('')
+
+ const { table, searchParameters, columnFilters, isLoadingCounts, getFacetedUniqueValues } =
+ useDataTable()
+
+ // [Joshen] JFYI for simplicity currently, i'm adding UnifiedLogs logic into this file
+ // despite this supposedly being a reusable component - tbh really, this doesn't need to
+ // be reusable perhaps unless we plan for this to be used in another area of the dashboard
+ // but its too early to say for sure atm.
+ const { ref: projectRef } = useParams()
+ const debouncedSearch = useDebounce(inputValue, 700)
+ const { data: filterOptions = [], isFetching: isFetchingFacetCount } =
+ useUnifiedLogsFacetCountQuery(
+ {
+ projectRef,
+ search: searchParameters,
+ facet: value,
+ facetSearch: debouncedSearch,
+ },
+ {
+ keepPreviousData: true,
+ enabled: debouncedSearch.length > 0,
+ initialData: debouncedSearch.length === 0 ? options : undefined,
+ }
+ )
+
+ const column = table.getColumn(value)
+ const filterValue = columnFilters.find((i) => i.id === value)?.value
+ const facetedValue = getFacetedUniqueValues?.(table, value) || column?.getFacetedUniqueValues()
+ const filters = filterValue ? (Array.isArray(filterValue) ? filterValue : [filterValue]) : []
+
+ if (!options?.length)
+ return (
+
+ )
+
+ return (
+
+
}
+ containerClassName="h-8 rounded"
+ value={inputValue}
+ trailing={isFetchingFacetCount ?
: undefined}
+ onChange={(e) => setInputValue(e.target.value)}
+ />
+
+
+ {filterOptions.length === 0 ? (
+
+
+
No results found
+
Try a different search term
+
+
+ ) : (
+ filterOptions.map((option, index) => {
+ const checked = filters.includes(option.value)
+
+ return (
+
+
{
+ const newValue = checked
+ ? [...(filters || []), option.value]
+ : filters?.filter((value) => option.value !== value)
+ column?.setFilterValue(newValue?.length ? newValue : undefined)
+ }}
+ />
+
+
+ {Component ? (
+
+ ) : (
+ {option.label}
+ )}
+
+
+ {isLoadingCounts ? (
+
+ ) : facetedValue?.has(option.value) ? (
+ formatCompactNumber(facetedValue.get(option.value) || 0)
+ ) : ['log_type', 'method', 'level'].includes(value) ? (
+ '0'
+ ) : null}
+
+ column?.setFilterValue([option.value])}
+ className={cn(
+ 'absolute inset-y-0 right-0 hidden font-normal text-muted-foreground backdrop-blur-sm hover:text-foreground group-hover:block',
+ 'rounded-md ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2'
+ )}
+ >
+ only
+
+
+
+ )
+ })
+ )}
+
+
+ )
+}
diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckboxLoader.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckboxLoader.tsx
new file mode 100644
index 00000000000..3c74d3ce878
--- /dev/null
+++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckboxLoader.tsx
@@ -0,0 +1,14 @@
+import { Skeleton } from 'ui'
+
+export const DataTableFilterCheckboxLoader = () => {
+ return (
+
+ {Array.from({ length: 3 }).map((_, index) => (
+
+
+
+
+ ))}
+
+ )
+}
diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCommand.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCommand.tsx
index 066f881d895..355d482ee45 100644
--- a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCommand.tsx
+++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCommand.tsx
@@ -62,6 +62,10 @@ export function DataTableFilterCommand({
const trimmedInputValue = inputValue.trim()
+ const queryFields = filterFields.filter(
+ (x) => typeof x.value === 'string' && currentWord.includes(`${x.value}:`)
+ )
+
// [Joshen] Temporarily disabling as this conflicts with our current CMD K behaviour
// useHotKey(() => setOpen((open) => !open), 'k')
@@ -80,18 +84,6 @@ export function DataTableFilterCommand({
const field = _filterFields?.find((field) => field.value === filter.id)
return !field?.commandDisabled
})
- const currentDisabledFilters = currentFilters.filter((filter) => {
- const field = _filterFields?.find((field) => field.value === filter.id)
- return field?.commandDisabled
- })
-
- const commandDisabledFilterKeys = currentDisabledFilters.reduce(
- (prev, curr) => {
- prev[curr.id] = curr.value
- return prev
- },
- {} as Record
- )
for (const key of Object.keys(searchParams)) {
const value = searchParams[key as keyof typeof searchParams]
@@ -198,6 +190,7 @@ export function DataTableFilterCommand({
{/* default height is 300px but in case of more, we'd like to tease the user */}
+ No results found.
{filterFields.map((field) => {
if (typeof field.value !== 'string') return null
@@ -227,106 +220,113 @@ export function DataTableFilterCommand({
}}
className="group"
>
- {field.value}
+ {field.label}
)
})}
+
-
- {filterFields?.map((field) => {
- if (typeof field.value !== 'string') return null
- if (!currentWord.includes(`${field.value}:`)) return null
- const column = table.getColumn(field.value)
- const facetedValue =
- getFacetedUniqueValues?.(table, field.value) || column?.getFacetedUniqueValues()
+ {queryFields.length > 0 && (
+ <>
+
+ {queryFields.map((field) => {
+ const column = table.getColumn(field.value)
+ const facetedValue =
+ getFacetedUniqueValues?.(table, field.value) ||
+ column?.getFacetedUniqueValues()
- const options = getFieldOptions({ field })
+ const options = getFieldOptions({ field })
- return options.map((optionValue) => {
- return (
- {
- e.preventDefault()
- e.stopPropagation()
- }}
- onSelect={(value) => {
- setInputValue((prev) =>
- replaceInputByFieldType({
- prev,
- currentWord,
- optionValue,
- value,
- field,
- })
- )
- setCurrentWord('')
- }}
- >
- {`${optionValue}`}
- {facetedValue?.has(optionValue) ? (
-
- {formatCompactNumber(facetedValue.get(optionValue) || 0)}
-
- ) : null}
-
- )
- })
- })}
-
-
-
- {lastSearches
- ?.sort((a, b) => b.timestamp - a.timestamp)
- .slice(0, 5)
- .map((item) => {
- return (
- {
- e.preventDefault()
- e.stopPropagation()
- }}
- onSelect={(value) => {
- const search = value.replace('suggestion:', '')
- setInputValue(`${search} `)
- setCurrentWord('')
- }}
- className="group"
- >
- {item.search}
-
- {formatDistanceToNow(item.timestamp, {
- addSuffix: true,
- })}
-
- {
+ return (
+ {
+ e.preventDefault()
+ e.stopPropagation()
+ }}
+ onSelect={(value) => {
+ setInputValue((prev) =>
+ replaceInputByFieldType({
+ prev,
+ currentWord,
+ optionValue,
+ value,
+ field,
+ })
+ )
+ setCurrentWord('')
+ }}
+ >
+ {`${optionValue}`}
+ {facetedValue?.has(optionValue) ? (
+
+ {formatCompactNumber(facetedValue.get(optionValue) || 0)}
+
+ ) : null}
+
+ )
+ })
+ })}
+
+
+ >
+ )}
+
+ {lastSearches.length > 0 && (
+
+ {lastSearches
+ .sort((a, b) => b.timestamp - a.timestamp)
+ .slice(0, 5)
+ .map((item) => {
+ return (
+ {
e.preventDefault()
e.stopPropagation()
}}
- onClick={(e) => {
- e.preventDefault()
- e.stopPropagation()
- // TODO: extract into function
- setLastSearches(lastSearches.filter((i) => i.search !== item.search))
+ onSelect={(value) => {
+ const search = value.replace('suggestion:', '')
+ setInputValue(`${search} `)
+ setCurrentWord('')
}}
- className="ml-1 hidden rounded-md p-0.5 hover:bg-background group-aria-[selected=true]:block"
+ className="group"
>
-
-
-
- )
- })}
-
- No results found.
+ {item.search}
+
+ {formatDistanceToNow(item.timestamp, {
+ addSuffix: true,
+ })}
+
+ {
+ e.preventDefault()
+ e.stopPropagation()
+ }}
+ onClick={(e) => {
+ e.preventDefault()
+ e.stopPropagation()
+ // TODO: extract into function
+ setLastSearches(lastSearches.filter((i) => i.search !== item.search))
+ }}
+ className="ml-1 hidden rounded-md p-0.5 hover:bg-background group-aria-[selected=true]:block"
+ >
+
+
+
+ )
+ })}
+
+ )}
+
@@ -370,10 +370,12 @@ export function DataTableFilterCommand({
function CommandItemSuggestions({ field }: { field: DataTableFilterField }) {
const { table, getFacetedMinMaxValues, getFacetedUniqueValues } = useDataTable()
const value = field.value as string
+ const className = 'ml-2 hidden truncate text-foreground-lighter group-aria-[selected=true]:block'
+
switch (field.type) {
case 'checkbox': {
return (
-
+
{getFacetedUniqueValues
? Array.from(getFacetedUniqueValues(table, value)?.keys() || [])
.map((value) => `[${value}]`)
@@ -385,17 +387,13 @@ function CommandItemSuggestions({ field }: { field: DataTableFilterField<
case 'slider': {
const [min, max] = getFacetedMinMaxValues?.(table, value) || [field.min, field.max]
return (
-
+
[{min} - {max}]
)
}
case 'input': {
- return (
-
- [{`${String(field.value)}`} input]
-
- )
+ return [{`${String(field.value)}`} input]
}
default: {
return null
diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControls.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControls.tsx
index 97c16ecd89a..2f69d227226 100644
--- a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControls.tsx
+++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControls.tsx
@@ -11,15 +11,22 @@ import { DataTableFilterResetButton } from './DataTableFilterResetButton'
import { DataTableFilterSlider } from './DataTableFilterSlider'
import { DataTableFilterTimerange } from './DataTableFilterTimerange'
+import { DateRangeDisabled } from '../DataTable.types'
import { useDataTable } from '../providers/DataTableProvider'
+import { DataTableFilterCheckboxAsync } from './DataTableFilterCheckboxAsync'
+import { DataTableFilterCheckboxLoader } from './DataTableFilterCheckboxLoader'
// FIXME: use @container (especially for the slider element) to restructure elements
// TODO: only pass the columns to generate the filters!
// https://tanstack.com/table/v8/docs/framework/react/examples/filters
-export function DataTableFilterControls() {
- const { filterFields } = useDataTable()
+interface DataTableFilterControls {
+ dateRangeDisabled?: DateRangeDisabled
+}
+
+export function DataTableFilterControls({ dateRangeDisabled }: DataTableFilterControls) {
+ const { filterFields, isLoadingCounts } = useDataTable()
return (
{
switch (field.type) {
case 'checkbox': {
- return
+ // [Joshen] Loader here so that CheckboxAsync can retrieve the data
+ // immediately to be set in its react query state
+ if (field.hasDynamicOptions && isLoadingCounts) {
+ return
+ } else if (field.hasAsyncSearch) {
+ return
+ } else {
+ return
+ }
}
case 'slider': {
return
@@ -55,7 +70,12 @@ export function DataTableFilterControls() {
return
}
case 'timerange': {
- return
+ return (
+
+ )
}
}
})()}
diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterTimerange.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterTimerange.tsx
index 9981ed31e61..f141f6cef27 100644
--- a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterTimerange.tsx
+++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterTimerange.tsx
@@ -1,20 +1,31 @@
-import { useMemo } from 'react'
+import { useMemo, useState } from 'react'
import type { DateRange } from 'react-day-picker'
+import {
+ LogsDatePicker,
+ DatePickerValue,
+} from 'components/interfaces/Settings/Logs/Logs.DatePickers'
+import { REPORTS_DATEPICKER_HELPERS } from 'components/interfaces/Reports/Reports.constants'
+import { maybeShowUpgradePrompt } from 'components/interfaces/Settings/Logs/Logs.utils'
+import UpgradePrompt from 'components/interfaces/Settings/Logs/UpgradePrompt'
+import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan'
import type { DataTableTimerangeFilterField } from '../DataTable.types'
import { isArrayOfDates } from '../DataTable.utils'
-import { DatePickerWithRange } from '../DatePickerWithRange'
import { useDataTable } from '../providers/DataTableProvider'
export function DataTableFilterTimerange({
value: _value,
presets,
+ dateRangeDisabled,
}: DataTableTimerangeFilterField) {
const value = _value as string
const { table, columnFilters } = useDataTable()
const column = table.getColumn(value)
const filterValue = columnFilters.find((i) => i.id === value)?.value
+ const { plan: orgPlan } = useCurrentOrgPlan()
+ const [showUpgradePrompt, setShowUpgradePrompt] = useState(false)
+
const date: DateRange | undefined = useMemo(
() =>
filterValue instanceof Date
@@ -25,15 +36,88 @@ export function DataTableFilterTimerange({
[filterValue]
)
- const setDate = (date: DateRange | undefined) => {
- if (!date) return // TODO: remove from search params if columnFilter is removed
- if (date.from && !date.to) {
- column?.setFilterValue([date.from])
+ const handleDatePickerChange = (vals: DatePickerValue) => {
+ // Check if the selected date range exceeds the plan limits
+ const shouldShowUpgradePrompt = maybeShowUpgradePrompt(vals.from, orgPlan?.id)
+
+ if (shouldShowUpgradePrompt) {
+ setShowUpgradePrompt(true)
+ return
}
- if (date.to && date.from) {
- column?.setFilterValue([date.from, date.to])
+
+ const startDate = new Date(vals.from)
+ const endDate = new Date(vals.to)
+ column?.setFilterValue([startDate, endDate])
+ }
+
+ // Get current selected DatePickerValue based on the date range
+ const getCurrentDatePickerValue = (): DatePickerValue => {
+ if (!date?.from || !date?.to) {
+ // Default to last 60 minutes
+ const defaultHelper =
+ REPORTS_DATEPICKER_HELPERS.find((h) => h.default) || REPORTS_DATEPICKER_HELPERS[0]
+ return {
+ from: defaultHelper.calcFrom(),
+ to: defaultHelper.calcTo(),
+ text: defaultHelper.text,
+ isHelper: true,
+ }
+ }
+
+ // Try to match with a helper
+ const matchingHelper = REPORTS_DATEPICKER_HELPERS.find((helper) => {
+ const helperFrom = new Date(helper.calcFrom())
+ const helperTo = new Date(helper.calcTo())
+ const timeDiff = 60000 // 1 minute tolerance
+
+ return (
+ Math.abs(date.from!.getTime() - helperFrom.getTime()) < timeDiff &&
+ Math.abs(date.to!.getTime() - helperTo.getTime()) < timeDiff
+ )
+ })
+
+ if (matchingHelper) {
+ return {
+ from: matchingHelper.calcFrom(),
+ to: matchingHelper.calcTo(),
+ text: matchingHelper.text,
+ isHelper: true,
+ }
+ }
+
+ // Custom range
+ return {
+ from: date.from.toISOString(),
+ to: date.to.toISOString(),
+ text: `${date.from.toLocaleDateString()} - ${date.to.toLocaleDateString()}`,
+ isHelper: false,
}
}
- return
+ return (
+ <>
+
+
+ >
+ )
}
diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilters.utils.ts b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilters.utils.ts
index c65b6106b07..45eacf19427 100644
--- a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilters.utils.ts
+++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilters.utils.ts
@@ -218,13 +218,10 @@ export function columnFiltersParser({
const regex = /(\w+):([^]*?)(?=\s+\w+:|$)/g
let match
- console.log('DataTableFilterCommand parsing input:', inputValue)
-
while ((match = regex.exec(inputValue)) !== null) {
const [_, fieldName, fieldValue] = match
if (fieldName && fieldValue) {
filterPairs[fieldName] = fieldValue.trim()
- console.log(`Parsed filter pair: ${fieldName} = ${fieldValue.trim()}`)
}
}
diff --git a/apps/studio/components/ui/DataTable/DatePickerWithRange.tsx b/apps/studio/components/ui/DataTable/DatePickerWithRange.tsx
index 4b035239642..f8d7f75f13f 100644
--- a/apps/studio/components/ui/DataTable/DatePickerWithRange.tsx
+++ b/apps/studio/components/ui/DataTable/DatePickerWithRange.tsx
@@ -22,7 +22,7 @@ import {
SelectValue_Shadcn_ as SelectValue,
} from 'ui'
import { presets as defaultPresets } from './DataTable.constants'
-import type { DatePreset } from './DataTable.types'
+import type { DatePreset, DateRangeDisabled } from './DataTable.types'
import { useDebounce } from './hooks/useDebounce'
import { kdbClassName } from './primitives/Kbd'
@@ -30,6 +30,7 @@ interface DatePickerWithRangeProps extends HTMLAttributes {
date: DateRange | undefined
setDate: (date: DateRange | undefined) => void
presets?: DatePreset[]
+ dateRangeDisabled?: DateRangeDisabled
}
// [Joshen] This might be better placed in ui instead of DataTable since it could be reusable
@@ -38,6 +39,7 @@ export function DatePickerWithRange({
date,
setDate,
presets = defaultPresets,
+ dateRangeDisabled,
}: DatePickerWithRangeProps) {
const [open, setOpen] = useState(false)
@@ -98,6 +100,8 @@ export function DatePickerWithRange({
selected={date}
onSelect={setDate}
numberOfMonths={1}
+ // @ts-ignore
+ disabled={dateRangeDisabled}
/>
diff --git a/apps/studio/components/ui/DataTable/FilterSideBar.tsx b/apps/studio/components/ui/DataTable/FilterSideBar.tsx
index 18216c9bf8b..c81d299fe34 100644
--- a/apps/studio/components/ui/DataTable/FilterSideBar.tsx
+++ b/apps/studio/components/ui/DataTable/FilterSideBar.tsx
@@ -1,11 +1,32 @@
-import { cn, ResizablePanel } from 'ui'
+import { useRouter } from 'next/router'
+
+import { useParams } from 'common'
+import { useUnifiedLogsPreview } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
+import { useFlag } from 'hooks/ui/useFlag'
+import { Button, cn, ResizablePanel } from 'ui'
+import { FeaturePreviewSidebarPanel } from '../FeaturePreviewSidebarPanel'
+import { DateRangeDisabled } from './DataTable.types'
import { DataTableFilterControls } from './DataTableFilters/DataTableFilterControls'
import { DataTableResetButton } from './DataTableResetButton'
import { useDataTable } from './providers/DataTableProvider'
-export function FilterSideBar() {
+interface FilterSideBarProps {
+ dateRangeDisabled?: DateRangeDisabled
+}
+
+export function FilterSideBar({ dateRangeDisabled }: FilterSideBarProps) {
+ const router = useRouter()
+ const { ref } = useParams()
const { table } = useDataTable()
+ const isUnifiedLogsPreviewAvailable = useFlag('unifiedLogs')
+ const { disable: disableUnifiedLogs } = useUnifiedLogsPreview()
+
+ const handleGoBackToOldLogs = () => {
+ disableUnifiedLogs()
+ router.push(`/project/${ref}/logs/explorer`)
+ }
+
return (
-
+
-
Logs
-
{table.getState().columnFilters.length ? : null}
+
Logs
+ {table.getState().columnFilters.length ?
: null}
+
-
+ {isUnifiedLogsPreviewAvailable && (
+
+ Switch back
+
+ }
+ />
+ )}
+
)
diff --git a/apps/studio/components/ui/DataTable/primitives/InputWithAddons.tsx b/apps/studio/components/ui/DataTable/primitives/InputWithAddons.tsx
index 2848c0e3cc3..974dde4b670 100644
--- a/apps/studio/components/ui/DataTable/primitives/InputWithAddons.tsx
+++ b/apps/studio/components/ui/DataTable/primitives/InputWithAddons.tsx
@@ -31,7 +31,9 @@ export const InputWithAddons = forwardRef
{trailing ? (
-
{trailing}
+
+ {trailing}
+
) : null}
)
diff --git a/apps/studio/components/ui/DataTable/providers/DataTableProvider.tsx b/apps/studio/components/ui/DataTable/providers/DataTableProvider.tsx
index ff1abe4f0cb..2515c748a59 100644
--- a/apps/studio/components/ui/DataTable/providers/DataTableProvider.tsx
+++ b/apps/studio/components/ui/DataTable/providers/DataTableProvider.tsx
@@ -9,6 +9,7 @@ import type {
} from '@tanstack/react-table'
import { createContext, ReactNode, useContext, useMemo } from 'react'
+import { QuerySearchParamsType } from 'components/interfaces/UnifiedLogs/UnifiedLogs.types'
import { DataTableFilterField } from '../DataTable.types'
import { ControlsProvider } from './ControlsProvider'
@@ -23,6 +24,7 @@ interface DataTableStateContextType {
columnVisibility: VisibilityState
pagination: PaginationState
enableColumnOrdering: boolean
+ searchParameters: QuerySearchParamsType
}
interface DataTableBaseContextType {
@@ -59,6 +61,7 @@ export function DataTableProvider({
columnVisibility: props.columnVisibility ?? {},
pagination: props.pagination ?? { pageIndex: 0, pageSize: 10 },
enableColumnOrdering: props.enableColumnOrdering ?? false,
+ searchParameters: props.searchParameters ?? ({} as any),
}),
[props]
)
diff --git a/apps/studio/components/ui/FeaturePreviewSidebarPanel.tsx b/apps/studio/components/ui/FeaturePreviewSidebarPanel.tsx
new file mode 100644
index 00000000000..b736f8092df
--- /dev/null
+++ b/apps/studio/components/ui/FeaturePreviewSidebarPanel.tsx
@@ -0,0 +1,39 @@
+import { ReactNode } from 'react'
+import { cn } from 'ui'
+
+interface FeaturePreviewSidebarPanelProps {
+ title: string
+ description: string
+ illustration?: ReactNode
+ actions?: ReactNode
+ className?: string
+}
+
+export function FeaturePreviewSidebarPanel({
+ title,
+ description,
+ illustration,
+ actions,
+ className,
+}: FeaturePreviewSidebarPanelProps) {
+ return (
+
+ {illustration &&
{illustration}
}
+
+
+
{title}
+
{description}
+
+
+ {actions &&
{actions}
}
+
+ )
+}
diff --git a/apps/studio/components/ui/InfoPill.tsx b/apps/studio/components/ui/InfoPill.tsx
index a1287d2a693..d88beae7ebf 100644
--- a/apps/studio/components/ui/InfoPill.tsx
+++ b/apps/studio/components/ui/InfoPill.tsx
@@ -1,11 +1,12 @@
-import React from 'react'
+import { ExternalLink } from 'lucide-react'
+import React, { ReactNode } from 'react'
+
+import { Badge } from 'ui/src/components/shadcn/ui/badge'
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from 'ui/src/components/shadcn/ui/hover-card'
-import { Badge } from 'ui/src/components/shadcn/ui/badge'
-import { ExternalLink } from 'lucide-react'
interface Link {
url: string
@@ -13,19 +14,21 @@ interface Link {
}
interface InfoPillProps {
- label: string | React.ReactNode
- icon: React.ReactNode
+ label: string | ReactNode
+ icon: ReactNode
title: string
- description: string | React.ReactNode
+ description: string | ReactNode
links?: Link[]
}
-export const InfoPill: React.FC = ({ label, icon, title, description, links }) => {
+export const InfoPill = ({ label, icon, title, description, links }: InfoPillProps) => {
return (
-
-
- {icon} {label}
+
+
+
+ {icon} {label}
+
diff --git a/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx b/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx
index d6367fc2a7b..18aaf4c8c5e 100644
--- a/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx
+++ b/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx
@@ -13,6 +13,7 @@ import { useFlag } from 'hooks/ui/useFlag'
import { Input } from 'ui'
import { getLastUsedAPIKeys, useLastUsedAPIKeysLogQuery } from './DisplayApiSettings.utils'
import { ToggleLegacyApiKeysPanel } from './ToggleLegacyApiKeys'
+import Link from 'next/link'
export const DisplayApiSettings = ({
showTitle = true,
@@ -165,11 +166,41 @@ export const DisplayApiSettings = ({
}
onChange={() => {}}
descriptionText={
- x.tags === 'service_role'
- ? 'This key has the ability to bypass Row Level Security. Never share it publicly. If leaked, generate a new JWT secret immediately. ' +
- (showLegacyText ? 'Prefer using Publishable API keys instead.' : '')
- : 'This key is safe to use in a browser if you have enabled Row Level Security for your tables and configured policies. ' +
- (showLegacyText ? 'Prefer using Secret API keys instead.' : '')
+ x.tags === 'service_role' ? (
+ <>
+ This key has the ability to bypass Row Level Security. Never share it
+ publicly. If leaked, generate a new JWT secret immediately.{' '}
+ {showLegacyText && (
+
+ Prefer using{' '}
+
+ Secret API keys
+ {' '}
+ instead.
+
+ )}
+ >
+ ) : (
+ <>
+ This key is safe to use in a browser if you have enabled Row Level Security
+ for your tables and configured policies.{' '}
+ {showLegacyText && (
+
+ Prefer using{' '}
+
+ Publishable API keys
+ {' '}
+ instead.
+
+ )}
+ >
+ )
}
/>
diff --git a/apps/studio/components/ui/ProjectSettings/ToggleLegacyApiKeys.tsx b/apps/studio/components/ui/ProjectSettings/ToggleLegacyApiKeys.tsx
index 66463764cf3..c0a4698dc8d 100644
--- a/apps/studio/components/ui/ProjectSettings/ToggleLegacyApiKeys.tsx
+++ b/apps/studio/components/ui/ProjectSettings/ToggleLegacyApiKeys.tsx
@@ -7,13 +7,28 @@ import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { useToggleLegacyAPIKeysMutation } from 'data/api-keys/legacy-api-key-toggle-mutation'
import { useLegacyAPIKeysStatusQuery } from 'data/api-keys/legacy-api-keys-status-query'
import { useLegacyJWTSigningKeyQuery } from 'data/jwt-signing-keys/legacy-jwt-signing-key-query'
+import { useAuthorizedAppsQuery } from 'data/oauth/authorized-apps-query'
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
+import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from 'ui'
import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal'
import Panel from '../Panel'
export const ToggleLegacyApiKeysPanel = () => {
const { ref: projectRef } = useParams()
+ const org = useSelectedOrganization()
+
const [isConfirmOpen, setIsConfirmOpen] = useState(false)
+ const [isAppsWarningOpen, setIsAppsWarningOpen] = useState(false)
const { data: legacyAPIKeysStatusData, isSuccess: isLegacyAPIKeysStatusSuccess } =
useLegacyAPIKeysStatusQuery({ projectRef })
@@ -23,9 +38,13 @@ export const ToggleLegacyApiKeysPanel = () => {
const { can: canUpdateAPIKeys, isSuccess: isPermissionsSuccess } =
useAsyncCheckProjectPermissions(PermissionAction.SECRETS_WRITE, '*')
+ const { data: authorizedApps = [], isSuccess: isAuthorizedAppsSuccess } = useAuthorizedAppsQuery({
+ slug: org?.slug,
+ })
+
const { enabled: isLegacyKeysEnabled } = legacyAPIKeysStatusData || {}
- if (!(isLegacyAPIKeysStatusSuccess && isPermissionsSuccess)) {
+ if (!(isLegacyAPIKeysStatusSuccess && isPermissionsSuccess && isAuthorizedAppsSuccess)) {
return null
}
@@ -47,7 +66,11 @@ export const ToggleLegacyApiKeysPanel = () => {
setIsConfirmOpen(true)}
+ onClick={
+ authorizedApps?.length
+ ? () => setIsAppsWarningOpen(true)
+ : () => setIsConfirmOpen(true)
+ }
disabled={
!canUpdateAPIKeys ||
(!isLegacyKeysEnabled && legacyJWTSecret?.status === 'revoked')
@@ -77,6 +100,25 @@ export const ToggleLegacyApiKeysPanel = () => {
onClose={() => setIsConfirmOpen(false)}
legacyAPIKeysStatusData={legacyAPIKeysStatusData}
/>
+
+ setIsAppsWarningOpen(value)}>
+
+
+ Apps using Supabase may break
+
+ Your project uses apps that integrate with Supabase. Disabling the legacy API keys is
+ a brand new feature and the apps you're using may not have added support for this yet.
+ It can cause them to stop functioning. Check before continuing.
+
+
+
+ Cancel
+ setIsConfirmOpen(true)}>
+ Proceed to disable API keys
+
+
+
+
)
}
@@ -136,6 +178,12 @@ const ToggleApiKeysModal = ({
use will cause downtime for your application. Ensure they are no longer in use
before proceeding. If you have not created a publishable and at least one secret
API key, some dashboard functionality may become unavailable.
+
+
+
+ This disables API keys when used in the apikey header. They remain
+ valid as a JWT.
+
),
}
diff --git a/apps/studio/csp.js b/apps/studio/csp.js
index f4ddabfcc76..775afd742fa 100644
--- a/apps/studio/csp.js
+++ b/apps/studio/csp.js
@@ -41,7 +41,6 @@ const STRIPE_SUBDOMAINS_URL = 'https://*.stripe.com'
const STRIPE_JS_URL = 'https://js.stripe.com'
const STRIPE_NETWORK_URL = 'https://*.stripe.network'
const CLOUDFLARE_URL = 'https://www.cloudflare.com'
-const ONE_ONE_ONE_ONE_URL = 'https://one.one.one.one'
const VERCEL_URL = 'https://vercel.com'
const VERCEL_INSIGHTS_URL = 'https://*.vercel-insights.com'
const GITHUB_API_URL = 'https://api.github.com'
@@ -81,7 +80,6 @@ module.exports.getCSP = function getCSP() {
STRIPE_SUBDOMAINS_URL,
STRIPE_NETWORK_URL,
CLOUDFLARE_URL,
- ONE_ONE_ONE_ONE_URL,
VERCEL_INSIGHTS_URL,
GITHUB_API_URL,
GITHUB_USER_CONTENT_URL,
diff --git a/apps/studio/data/api-keys/api-keys-query.ts b/apps/studio/data/api-keys/api-keys-query.ts
index de87ed9c42b..a78f3c031d1 100644
--- a/apps/studio/data/api-keys/api-keys-query.ts
+++ b/apps/studio/data/api-keys/api-keys-query.ts
@@ -2,6 +2,7 @@ import { useQuery, UseQueryOptions } from '@tanstack/react-query'
import { get, handleError } from 'data/fetchers'
import { ResponseError } from 'types'
+import { IS_PLATFORM } from 'lib/constants'
import { apiKeysKeys } from './keys'
type LegacyKeys = {
@@ -74,7 +75,7 @@ export const useAPIKeysQuery = (
apiKeysKeys.list(projectRef),
({ signal }) => getAPIKeys({ projectRef, reveal }, signal),
{
- enabled: enabled && !!projectRef,
+ enabled: IS_PLATFORM && enabled && !!projectRef,
...options,
}
)
diff --git a/apps/studio/data/api-keys/legacy-api-keys-status-query.ts b/apps/studio/data/api-keys/legacy-api-keys-status-query.ts
index 964f2f49d54..d768d3713e7 100644
--- a/apps/studio/data/api-keys/legacy-api-keys-status-query.ts
+++ b/apps/studio/data/api-keys/legacy-api-keys-status-query.ts
@@ -1,5 +1,6 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
import { get, handleError } from 'data/fetchers'
+import { IS_PLATFORM } from 'lib/constants'
import { ResponseError } from 'types'
import { apiKeysKeys } from './keys'
@@ -29,13 +30,13 @@ type LegacyAPIKeysStatusData = Awaited
export const useLegacyAPIKeysStatusQuery = (
{ projectRef }: LegacyAPIKeysStatusVariables,
- { ...options }: UseQueryOptions = {}
+ { enabled, ...options }: UseQueryOptions = {}
) =>
useQuery(
apiKeysKeys.status(projectRef),
({ signal }) => getLegacyAPIKeysStatus({ projectRef }, signal),
{
- enabled: !!projectRef,
+ enabled: IS_PLATFORM && enabled && !!projectRef,
...options,
}
)
diff --git a/apps/studio/data/branches/branch-diff-query.ts b/apps/studio/data/branches/branch-diff-query.ts
index eba205e1908..bcff017becc 100644
--- a/apps/studio/data/branches/branch-diff-query.ts
+++ b/apps/studio/data/branches/branch-diff-query.ts
@@ -1,6 +1,7 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
import { get, handleError } from 'data/fetchers'
+import { IS_PLATFORM } from 'lib/constants'
import type { ResponseError } from 'types'
import { branchKeys } from './keys'
@@ -50,7 +51,7 @@ export const useBranchDiffQuery = (
branchKeys.diff(projectRef, branchId),
() => getBranchDiff({ branchId, includedSchemas }),
{
- enabled: enabled && typeof branchId !== 'undefined' && branchId !== '',
+ enabled: IS_PLATFORM && enabled && typeof branchId !== 'undefined' && branchId !== '',
...options,
}
)
diff --git a/apps/studio/data/branches/branch-query.ts b/apps/studio/data/branches/branch-query.ts
index c4a6b6ddfbd..a7a5c99a66d 100644
--- a/apps/studio/data/branches/branch-query.ts
+++ b/apps/studio/data/branches/branch-query.ts
@@ -1,6 +1,7 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
import { get, handleError } from 'data/fetchers'
+import { IS_PLATFORM } from 'lib/constants'
import type { ResponseError } from 'types'
import { branchKeys } from './keys'
@@ -32,7 +33,7 @@ export const useBranchQuery = (
branchKeys.detail(projectRef, id),
({ signal }) => getBranch({ id }, signal),
{
- enabled: enabled && typeof id !== 'undefined',
+ enabled: IS_PLATFORM && enabled && typeof id !== 'undefined',
...options,
}
)
diff --git a/apps/studio/data/branches/branches-query.ts b/apps/studio/data/branches/branches-query.ts
index d83de6a6f4a..c6c82abd22f 100644
--- a/apps/studio/data/branches/branches-query.ts
+++ b/apps/studio/data/branches/branches-query.ts
@@ -1,6 +1,8 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
+
import type { components } from 'data/api'
import { get, handleError } from 'data/fetchers'
+import { IS_PLATFORM } from 'lib/constants'
import type { ResponseError } from 'types'
import { branchKeys } from './keys'
@@ -39,5 +41,5 @@ export const useBranchesQuery = (
useQuery(
branchKeys.list(projectRef),
({ signal }) => getBranches({ projectRef }, signal),
- { enabled: enabled && typeof projectRef !== 'undefined', ...options }
+ { enabled: IS_PLATFORM && enabled && typeof projectRef !== 'undefined', ...options }
)
diff --git a/apps/studio/data/custom-domains/check-cname-mutation.ts b/apps/studio/data/custom-domains/check-cname-mutation.ts
index 97c59858cb5..183a53514d5 100644
--- a/apps/studio/data/custom-domains/check-cname-mutation.ts
+++ b/apps/studio/data/custom-domains/check-cname-mutation.ts
@@ -1,5 +1,6 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
-import { fetchHandler } from 'data/fetchers'
+import { fetchHandler, handleError } from 'data/fetchers'
+import { BASE_PATH } from 'lib/constants'
import { toast } from 'sonner'
import type { ResponseError } from 'types'
@@ -22,19 +23,21 @@ export type CheckCNAMERecordResponse = {
// [Joshen] Should tally with https://github.com/supabase/cli/blob/63790a1bd43bee06f82c4f510e709925526a4daa/internal/utils/api.go#L98
export async function checkCNAMERecord({ domain }: CheckCNAMERecordVariables) {
- const res = await fetchHandler(`https://one.one.one.one/dns-query?name=${domain}&type=CNAME`, {
- method: 'GET',
- headers: { accept: 'application/dns-json' },
- })
- const verification = (await res.json()) as CheckCNAMERecordResponse
+ try {
+ const res: CheckCNAMERecordResponse = await fetchHandler(
+ `${BASE_PATH}/api/check-cname?domain=${domain}`
+ ).then((res) => res.json())
- if (verification.Answer === undefined) {
- throw new Error(
- `Your CNAME record for ${domain} cannot be found - if you've just added the record, do check back in a bit.`
- )
+ if (res.Answer === undefined) {
+ throw new Error(
+ `Your CNAME record for ${domain} cannot be found - if you've just added the record, do check back in a bit.`
+ )
+ }
+
+ return res.Answer.some((x) => x.type === 5)
+ } catch (error) {
+ handleError(error)
}
-
- return verification.Answer.some((x) => x.type === 5)
}
type CheckCNAMERecordData = Awaited>
diff --git a/apps/studio/data/fdw/fdw-create-mutation.ts b/apps/studio/data/fdw/fdw-create-mutation.ts
index c2d203dca6a..c0962350421 100644
--- a/apps/studio/data/fdw/fdw-create-mutation.ts
+++ b/apps/studio/data/fdw/fdw-create-mutation.ts
@@ -20,7 +20,8 @@ export type FDWCreateVariables = {
formState: {
[k: string]: string
}
- mode: 'tables' | 'schema'
+ // If mode is skip, the wrapper will skip the last step, binding the schema/tables to foreign data. This could be done later.
+ mode: 'tables' | 'schema' | 'skip'
tables: any[]
sourceSchema: string
targetSchema: string
@@ -43,9 +44,9 @@ export function getCreateFDWSql({
.join('\n')
const createWrapperSql = /* SQL */ `
- create foreign data wrapper ${formState.wrapper_name}
- handler ${wrapperMeta.handlerName}
- validator ${wrapperMeta.validatorName};
+ create foreign data wrapper "${formState.wrapper_name}"
+ handler "${wrapperMeta.handlerName}"
+ validator "${wrapperMeta.validatorName}";
`
const encryptedOptions = wrapperMeta.server.options.filter((option) => option.encrypted)
@@ -170,7 +171,7 @@ export function getCreateFDWSql({
.join('\n')}
execute format(
- E'create server ${formState.server_name} foreign data wrapper ${formState.wrapper_name} options (${optionsSqlArray});',
+ E'create server "${formState.server_name}" foreign data wrapper "${formState.wrapper_name}" options (${optionsSqlArray});',
${encryptedOptions
.filter((option) => formState[option.name])
.map((option) => `v_${option.name}`)
@@ -233,7 +234,7 @@ export async function createFDW({ projectRef, connectionString, ...rest }: FDWCr
return result
}
-type FDWCreateData = Awaited>
+export type FDWCreateData = Awaited>
export const useFDWCreateMutation = ({
onSuccess,
diff --git a/apps/studio/data/fdw/fdw-import-foreign-schema-mutation.ts b/apps/studio/data/fdw/fdw-import-foreign-schema-mutation.ts
new file mode 100644
index 00000000000..81d257b57e4
--- /dev/null
+++ b/apps/studio/data/fdw/fdw-import-foreign-schema-mutation.ts
@@ -0,0 +1,78 @@
+import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
+import { toast } from 'sonner'
+
+import { entityTypeKeys } from 'data/entity-types/keys'
+import { foreignTableKeys } from 'data/foreign-tables/keys'
+import { executeSql } from 'data/sql/execute-sql-query'
+import { wrapWithTransaction } from 'data/sql/utils/transaction'
+import { vaultSecretsKeys } from 'data/vault/keys'
+import type { ResponseError } from 'types'
+import { fdwKeys } from './keys'
+
+export type FDWImportForeignSchemaVariables = {
+ projectRef?: string
+ connectionString?: string | null
+ serverName: string
+ sourceSchema: string
+ targetSchema: string
+}
+
+export function getImportForeignSchemaSql({
+ serverName,
+ sourceSchema,
+ targetSchema,
+}: Pick) {
+ const sql = /* SQL */ `
+ import foreign schema "${sourceSchema}" from server ${serverName} into ${targetSchema};
+`
+ return sql
+}
+
+export async function importForeignSchema({
+ projectRef,
+ connectionString,
+ ...rest
+}: FDWImportForeignSchemaVariables) {
+ const sql = wrapWithTransaction(getImportForeignSchemaSql(rest))
+ const { result } = await executeSql({ projectRef, connectionString, sql })
+ return result
+}
+
+type ImportForeignSchemaData = Awaited>
+
+export const useFDWImportForeignSchemaMutation = ({
+ onSuccess,
+ onError,
+ ...options
+}: Omit<
+ UseMutationOptions,
+ 'mutationFn'
+> = {}) => {
+ const queryClient = useQueryClient()
+
+ return useMutation(
+ (vars) => importForeignSchema(vars),
+ {
+ async onSuccess(data, variables, context) {
+ const { projectRef } = variables
+
+ await Promise.all([
+ queryClient.invalidateQueries(fdwKeys.list(projectRef), { refetchType: 'all' }),
+ queryClient.invalidateQueries(entityTypeKeys.list(projectRef)),
+ queryClient.invalidateQueries(foreignTableKeys.list(projectRef)),
+ queryClient.invalidateQueries(vaultSecretsKeys.list(projectRef)),
+ ])
+
+ await onSuccess?.(data, variables, context)
+ },
+ async onError(data, variables, context) {
+ if (onError === undefined) {
+ toast.error(`Failed to import schema for foreign data wrapper: ${data.message}`)
+ } else {
+ onError(data, variables, context)
+ }
+ },
+ ...options,
+ }
+ )
+}
diff --git a/apps/studio/data/fetchers.ts b/apps/studio/data/fetchers.ts
index 9c369ee5504..b2c3d4aaa6f 100644
--- a/apps/studio/data/fetchers.ts
+++ b/apps/studio/data/fetchers.ts
@@ -6,7 +6,8 @@ import { API_URL } from 'lib/constants'
import { getAccessToken } from 'lib/gotrue'
import { uuidv4 } from 'lib/helpers'
import { ResponseError } from 'types'
-import type { paths } from './api' // generated from openapi-typescript
+// generated from openapi-typescript
+import type { paths } from './api'
const DEFAULT_HEADERS = { Accept: 'application/json' }
@@ -15,6 +16,7 @@ export const fetchHandler: typeof fetch = async (input, init) => {
return await fetch(input, init)
} catch (err: any) {
if (err instanceof TypeError && err.message === 'Failed to fetch') {
+ console.error(err)
throw new Error('Unable to reach the server. Please check your network or try again later.')
}
throw err
diff --git a/apps/studio/data/jwt-signing-keys/legacy-jwt-signing-key-create-mutation.ts b/apps/studio/data/jwt-signing-keys/legacy-jwt-signing-key-create-mutation.ts
index 3ec3c15fc10..52fd030e2c6 100644
--- a/apps/studio/data/jwt-signing-keys/legacy-jwt-signing-key-create-mutation.ts
+++ b/apps/studio/data/jwt-signing-keys/legacy-jwt-signing-key-create-mutation.ts
@@ -47,6 +47,8 @@ export const useLegacyJWTSigningKeyCreateMutation = ({
const { projectRef } = variables
await queryClient.invalidateQueries(jwtSigningKeysKeys.legacy(projectRef))
+ // invalidate the keys as well since migration creates 2 new keys and the UI needs to be refreshed
+ await queryClient.invalidateQueries(jwtSigningKeysKeys.list(projectRef))
await onSuccess?.(data, variables, context)
},
diff --git a/apps/studio/data/jwt-signing-keys/legacy-jwt-signing-key-query.ts b/apps/studio/data/jwt-signing-keys/legacy-jwt-signing-key-query.ts
index 069b7504776..f7d66873178 100644
--- a/apps/studio/data/jwt-signing-keys/legacy-jwt-signing-key-query.ts
+++ b/apps/studio/data/jwt-signing-keys/legacy-jwt-signing-key-query.ts
@@ -37,6 +37,8 @@ export const useLegacyJWTSigningKeyQuery = (
({ signal }) => getLegacyJWTSigningKey({ projectRef }, signal),
{
enabled: enabled && !!projectRef,
+ retry: false,
+ refetchOnWindowFocus: false,
...options,
}
)
diff --git a/apps/studio/data/lint/create-lint-rule-mutation.ts b/apps/studio/data/lint/create-lint-rule-mutation.ts
index b90d0716d25..ae8bdd527d5 100644
--- a/apps/studio/data/lint/create-lint-rule-mutation.ts
+++ b/apps/studio/data/lint/create-lint-rule-mutation.ts
@@ -41,7 +41,10 @@ export const useLintRuleCreateMutation = ({
{
async onSuccess(data, variables, context) {
const { projectRef } = variables
- await queryClient.invalidateQueries(lintKeys.lintRules(projectRef))
+ await Promise.all([
+ queryClient.invalidateQueries(lintKeys.lintRules(projectRef)),
+ queryClient.invalidateQueries(lintKeys.lint(projectRef)),
+ ])
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
diff --git a/apps/studio/data/lint/delete-lint-rule-mutation.ts b/apps/studio/data/lint/delete-lint-rule-mutation.ts
index 943f3539135..70dca1fdd73 100644
--- a/apps/studio/data/lint/delete-lint-rule-mutation.ts
+++ b/apps/studio/data/lint/delete-lint-rule-mutation.ts
@@ -35,7 +35,10 @@ export const useLintRuleDeleteMutation = ({
{
async onSuccess(data, variables, context) {
const { projectRef } = variables
- await queryClient.invalidateQueries(lintKeys.lintRules(projectRef))
+ await Promise.all([
+ queryClient.invalidateQueries(lintKeys.lintRules(projectRef)),
+ queryClient.invalidateQueries(lintKeys.lint(projectRef)),
+ ])
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
diff --git a/apps/studio/data/logs/keys.ts b/apps/studio/data/logs/keys.ts
index a079740b2c8..af62b1e3116 100644
--- a/apps/studio/data/logs/keys.ts
+++ b/apps/studio/data/logs/keys.ts
@@ -34,6 +34,21 @@ export const logsKeys = {
'chart-data',
...(searchParams ? [searchParams].filter(Boolean) : []),
] as const,
+ unifiedLogsFacetCount: (
+ projectRef: string | undefined,
+ facet: string,
+ facetSearch: string | undefined,
+ searchParams: QuerySearchParamsType | undefined
+ ) =>
+ [
+ 'projects',
+ projectRef,
+ 'unified-logs',
+ 'count-data',
+ facet,
+ facetSearch,
+ ...(searchParams ? [searchParams].filter(Boolean) : []),
+ ] as const,
serviceFlow: (
projectRef: string | undefined,
searchParams: QuerySearchParamsType | undefined,
diff --git a/apps/studio/data/logs/unified-logs-chart-query.ts b/apps/studio/data/logs/unified-logs-chart-query.ts
index 8def604da4f..b2132dd1e48 100644
--- a/apps/studio/data/logs/unified-logs-chart-query.ts
+++ b/apps/studio/data/logs/unified-logs-chart-query.ts
@@ -155,6 +155,7 @@ export const useUnifiedLogsChartQuery = (
({ signal }) => getUnifiedLogsChart({ projectRef, search }, signal),
{
enabled: enabled && typeof projectRef !== 'undefined',
+ keepPreviousData: true,
...UNIFIED_LOGS_QUERY_OPTIONS,
...options,
}
diff --git a/apps/studio/data/logs/unified-logs-facet-count-query.ts b/apps/studio/data/logs/unified-logs-facet-count-query.ts
new file mode 100644
index 00000000000..dea0d5a885c
--- /dev/null
+++ b/apps/studio/data/logs/unified-logs-facet-count-query.ts
@@ -0,0 +1,64 @@
+import { useQuery, UseQueryOptions } from '@tanstack/react-query'
+
+import {
+ getFacetCountCTE,
+ getUnifiedLogsCTE,
+} from 'components/interfaces/UnifiedLogs/UnifiedLogs.queries'
+import { Option } from 'components/ui/DataTable/DataTable.types'
+import { handleError, post } from 'data/fetchers'
+import { ExecuteSqlError } from 'data/sql/execute-sql-query'
+import { logsKeys } from './keys'
+import {
+ getUnifiedLogsISOStartEnd,
+ UNIFIED_LOGS_QUERY_OPTIONS,
+ UnifiedLogsVariables,
+} from './unified-logs-infinite-query'
+
+type UnifiedLogsFacetCountVariables = UnifiedLogsVariables & {
+ facet: string
+ facetSearch?: string
+}
+
+export async function getUnifiedLogsFacetCount(
+ { projectRef, search, facet, facetSearch }: UnifiedLogsFacetCountVariables,
+ signal?: AbortSignal
+) {
+ if (typeof projectRef === 'undefined') {
+ throw new Error('projectRef is required for getUnifiedLogsFacetCount')
+ }
+
+ const { isoTimestampStart, isoTimestampEnd } = getUnifiedLogsISOStartEnd(search)
+ const sql = `
+${getUnifiedLogsCTE()},
+${getFacetCountCTE({ search, facet, facetSearch })}
+SELECT dimension, value, count from ${facet}_count;
+`.trim()
+ const { data, error } = await post(`/platform/projects/{ref}/analytics/endpoints/logs.all`, {
+ params: { path: { ref: projectRef } },
+ body: { iso_timestamp_start: isoTimestampStart, iso_timestamp_end: isoTimestampEnd, sql },
+ signal,
+ })
+
+ if (error) handleError(error)
+ return (data.result ?? []) as Option[]
+}
+
+export type UnifiedLogsFacetCountData = Awaited>
+export type UnifiedLogsFacetCountError = ExecuteSqlError
+
+export const useUnifiedLogsFacetCountQuery = (
+ { projectRef, search, facet, facetSearch }: UnifiedLogsFacetCountVariables,
+ {
+ enabled = true,
+ ...options
+ }: UseQueryOptions = {}
+) =>
+ useQuery(
+ logsKeys.unifiedLogsFacetCount(projectRef, facet, facetSearch, search),
+ ({ signal }) => getUnifiedLogsFacetCount({ projectRef, search, facet, facetSearch }, signal),
+ {
+ enabled: enabled && typeof projectRef !== 'undefined',
+ ...UNIFIED_LOGS_QUERY_OPTIONS,
+ ...options,
+ }
+ )
diff --git a/apps/studio/data/logs/unified-logs-infinite-query.ts b/apps/studio/data/logs/unified-logs-infinite-query.ts
index c8e04f258ef..6c58cfee83c 100644
--- a/apps/studio/data/logs/unified-logs-infinite-query.ts
+++ b/apps/studio/data/logs/unified-logs-infinite-query.ts
@@ -168,6 +168,7 @@ export const useUnifiedLogsInfiniteQuery = (
},
{
enabled: enabled && typeof projectRef !== 'undefined',
+ keepPreviousData: true,
getPreviousPageParam: (firstPage) => {
if (!firstPage.prevCursor) return null
const result = { cursor: firstPage.prevCursor, direction: 'prev' }
diff --git a/apps/studio/data/misc/get-default-region-query.ts b/apps/studio/data/misc/get-default-region-query.ts
index 8dc7620fde6..3a304a0250f 100644
--- a/apps/studio/data/misc/get-default-region-query.ts
+++ b/apps/studio/data/misc/get-default-region-query.ts
@@ -37,7 +37,9 @@ export async function getDefaultRegionOption({
if (locLatLon === undefined) return undefined
- const allRegions = cloudProvider === 'AWS' ? AWS_REGIONS_COORDINATES : FLY_REGIONS_COORDINATES
+ const isAWSProvider = ['AWS', 'AWS_K8S'].includes(cloudProvider)
+
+ const allRegions = isAWSProvider ? AWS_REGIONS_COORDINATES : FLY_REGIONS_COORDINATES
const locations =
useRestrictedPool && restrictedPool
? Object.entries(allRegions)
@@ -55,7 +57,7 @@ export async function getDefaultRegionOption({
const shortestDistance = Math.min(...distances)
const closestRegion = Object.keys(locations)[distances.indexOf(shortestDistance)]
- return cloudProvider === 'AWS'
+ return isAWSProvider
? AWS_REGIONS[closestRegion as keyof typeof AWS_REGIONS].displayName
: FLY_REGIONS[closestRegion as keyof typeof FLY_REGIONS].displayName
} catch (error) {
diff --git a/apps/studio/data/reports/auth-charts.ts b/apps/studio/data/reports/auth-charts.ts
index 27965738927..14676a0b23c 100644
--- a/apps/studio/data/reports/auth-charts.ts
+++ b/apps/studio/data/reports/auth-charts.ts
@@ -29,28 +29,28 @@ export const getAuthReportAttributes = (isFreePlan: boolean) => [
attribute: 'SignInAttempts',
provider: 'logs',
label: 'Password',
- grantType: 'password',
+ login_type_provider: 'password',
enabled: true,
},
{
attribute: 'SignInAttempts',
provider: 'logs',
label: 'PKCE',
- grantType: 'pkce',
+ login_type_provider: 'pkce',
enabled: true,
},
{
attribute: 'SignInAttempts',
provider: 'logs',
label: 'Refresh Token',
- grantType: 'refresh_token',
+ login_type_provider: 'token',
enabled: true,
},
{
attribute: 'SignInAttempts',
provider: 'logs',
label: 'ID Token',
- grantType: 'id_token',
+ login_type_provider: 'id_token',
enabled: true,
},
],
diff --git a/apps/studio/data/reports/auth-report-query.ts b/apps/studio/data/reports/auth-report-query.ts
index 99f6ec0c352..0b98e1bd2d1 100644
--- a/apps/studio/data/reports/auth-report-query.ts
+++ b/apps/studio/data/reports/auth-report-query.ts
@@ -64,14 +64,29 @@ const METRIC_SQL: Record string> = {
const granularity = analyticsIntervalToGranularity(interval)
return `
--sign-in-attempts
- select
+ SELECT
timestamp_trunc(timestamp, ${granularity}) as timestamp,
- json_value(event_message, "$.grant_type") as grant_type,
- count(*) as count
- from auth_logs
- where json_value(event_message, "$.path") = '/token'
- group by timestamp, grant_type
- order by timestamp desc, grant_type
+ CASE
+ WHEN JSON_VALUE(event_message, "$.provider") IS NOT NULL
+ AND JSON_VALUE(event_message, "$.provider") != ''
+ THEN CONCAT(
+ JSON_VALUE(event_message, "$.login_method"),
+ ' (',
+ JSON_VALUE(event_message, "$.provider"),
+ ')'
+ )
+ ELSE JSON_VALUE(event_message, "$.login_method")
+ END as login_type_provider,
+ COUNT(*) as count
+ FROM
+ auth_logs
+ WHERE
+ JSON_VALUE(event_message, "$.action") = 'login'
+ AND JSON_VALUE(event_message, "$.metering") = "true"
+ GROUP BY
+ timestamp, login_type_provider
+ ORDER BY
+ timestamp desc, login_type_provider
`
},
PasswordResetRequests: (interval) => {
@@ -196,8 +211,8 @@ const METRIC_FORMATTER: Record<
ActiveUsers: (rawData, attributes) => defaultFormatter(rawData, attributes),
SignInAttempts: (rawData, attributes) => {
const chartAttributes = attributes.map((attr) => {
- if (attr.attribute === 'SignInAttempts' && attr.grantType) {
- return { ...attr, attribute: `${attr.attribute}_${attr.grantType}` }
+ if (attr.attribute === 'SignInAttempts' && attr.login_type_provider) {
+ return { ...attr, attribute: `${attr.attribute}_${attr.login_type_provider}` }
}
return attr
})
@@ -213,7 +228,7 @@ const METRIC_FORMATTER: Record<
})
const matchingPoints = result.filter((p: any) => p.timestamp === timestamp)
matchingPoints.forEach((p: any) => {
- point[`SignInAttempts_${p.grant_type}`] = p.count
+ point[`SignInAttempts_${p.login_type_provider}`] = p.count
})
return point
})
@@ -292,6 +307,7 @@ export function useAuthLogsReport({
data: rawData,
error,
isLoading,
+ isFetching,
} = useQuery(
['auth-logs-report', projectRef, logsMetric, startDate, endDate, interval, sql],
async () => {
@@ -324,5 +340,6 @@ export function useAuthLogsReport({
attributes: chartAttributes,
isLoading,
error,
+ isFetching,
}
}
diff --git a/apps/studio/data/reports/database-charts.ts b/apps/studio/data/reports/database-charts.ts
index addcce7073a..1c7fcb47d33 100644
--- a/apps/studio/data/reports/database-charts.ts
+++ b/apps/studio/data/reports/database-charts.ts
@@ -4,28 +4,187 @@ import { Organization } from 'types'
import { Project } from '../projects/project-detail-query'
import { ReportAttributes } from 'components/ui/Charts/ComposedChart.utils'
-export const getReportAttributes = (isFreePlan: boolean) => [
- { id: 'ram_usage', label: 'Memory usage', hide: false },
- { id: 'avg_cpu_usage', label: 'Average CPU usage', hide: false },
- { id: 'max_cpu_usage', label: 'Max CPU usage', hide: false },
- { id: 'disk_iops_write', label: 'Disk IOps write', hide: false },
- { id: 'disk_iops_read', label: 'Disk IOps read', hide: false },
- {
- id: 'pg_stat_database_num_backends',
- label: 'Pooler to database connections',
- hide: false,
- },
- {
- id: 'supavisor_connections_active',
- label: 'Client to Shared Pooler connections',
- hide: false,
- },
- {
- id: 'client_connections_pgbouncer',
- label: 'Client to Dedicated Pooler connections',
- hide: isFreePlan,
- },
-]
+export const getReportAttributes = (org: Organization, project: Project): ReportAttributes[] => {
+ const computeSize = project?.infra_compute_size || 'medium'
+
+ return [
+ {
+ id: 'ram-usage',
+ label: 'Memory usage',
+ availableIn: ['free', 'pro'],
+ hide: false,
+ showTooltip: false,
+ showLegend: false,
+ hideChartType: false,
+ defaultChartStyle: 'bar',
+ showMaxValue: false,
+ showGrid: false,
+ syncId: 'database-reports',
+ valuePrecision: 0,
+ format: '%',
+ attributes: [
+ {
+ attribute: 'ram_usage',
+ provider: 'infra-monitoring',
+ label: 'Memory usage',
+ format: '%',
+ tooltip: 'RAM usage by the database',
+ },
+ ],
+ },
+ {
+ id: 'avg_cpu_usage',
+ label: 'CPU usage',
+ syncId: 'database-reports',
+ format: '%',
+ valuePrecision: 2,
+ availableIn: ['free', 'pro'],
+ hide: false,
+ showTooltip: false,
+ showLegend: false,
+ showMaxValue: false,
+ showGrid: false,
+ hideChartType: false,
+ defaultChartStyle: 'bar',
+ attributes: [
+ {
+ attribute: 'avg_cpu_usage',
+ provider: 'infra-monitoring',
+ label: 'Average CPU usage',
+ format: '%',
+ tooltip: 'Average CPU usage',
+ },
+ ],
+ },
+ {
+ id: 'disk-iops',
+ label: 'Disk Input/Output operations per second (IOPS)',
+ syncId: 'database-reports',
+ availableIn: ['free', 'pro'],
+ hide: false,
+ showTooltip: true,
+ valuePrecision: 2,
+ showLegend: true,
+ hideChartType: false,
+ showGrid: true,
+ showMaxValue: false,
+ YAxisProps: {
+ width: 35,
+ tickFormatter: (value: any) => numberFormatter(value, 2),
+ },
+ defaultChartStyle: 'line',
+ docsUrl: 'https://supabase.com/docs/guides/platform/compute-and-disk#compute-size',
+ attributes: [
+ {
+ attribute: 'disk_iops_write',
+ provider: 'infra-monitoring',
+ label: 'write IOPS',
+ tooltip:
+ 'Number of write operations per second. High values indicate frequent data writes, logging, or transaction activity',
+ },
+ {
+ attribute: 'disk_iops_read',
+ provider: 'infra-monitoring',
+ label: 'read IOPS',
+ tooltip:
+ 'Number of read operations per second. High values suggest frequent disk reads due to queries or poor caching',
+ },
+ {
+ attribute: 'disk_iops_max',
+ provider: 'reference-line',
+ label: 'Max IOPS',
+ value: getIOPSLimits(computeSize),
+ tooltip:
+ 'Maximum IOPS (Input/Output Operations Per Second) for your current compute size',
+ isMaxValue: true,
+ },
+ ],
+ },
+ {
+ id: 'disk-io-usage',
+ label: 'Disk IO Usage',
+ syncId: 'database-reports',
+ availableIn: ['team', 'enterprise'],
+ hide: false,
+ format: '%',
+ attributes: [],
+ },
+ {
+ id: 'pooler-database-connections',
+ label: 'Pooler to Database connections',
+ syncId: 'database-reports',
+ valuePrecision: 0,
+ availableIn: ['free', 'pro'],
+ hide: false,
+ showTooltip: false,
+ showLegend: false,
+ showMaxValue: false,
+ hideChartType: false,
+ showGrid: false,
+ defaultChartStyle: 'bar',
+ attributes: [
+ {
+ attribute: 'pg_stat_database_num_backends',
+ provider: 'infra-monitoring',
+ label: 'Database connections',
+ tooltip: 'Number of pooler connections to the database',
+ },
+ ],
+ },
+ {
+ id: 'supavisor-connections',
+ label: 'Shared Pooler connections',
+ syncId: 'database-reports',
+ valuePrecision: 0,
+ availableIn: ['free', 'pro'],
+ hide: false,
+ showTooltip: false,
+ showLegend: false,
+ showMaxValue: false,
+ showGrid: false,
+ hideChartType: false,
+ defaultChartStyle: 'bar',
+ attributes: [
+ {
+ attribute: 'supavisor_connections_active',
+ provider: 'infra-monitoring',
+ label: 'Client to Shared Pooler connections',
+ tooltip: 'Active connections from clients to the shared pooler',
+ },
+ ],
+ },
+ {
+ id: 'pgbouncer-connections',
+ label: 'Dedicated Pooler connections',
+ syncId: 'database-reports',
+ valuePrecision: 0,
+ availableIn: ['pro', 'team'],
+ hide: false,
+ showTooltip: false,
+ showLegend: false,
+ showMaxValue: false,
+ showGrid: false,
+ hideChartType: false,
+ defaultChartStyle: 'bar',
+ attributes: [
+ {
+ attribute: 'client_connections_pgbouncer',
+ provider: 'infra-monitoring',
+ label: 'Client to Dedicated Pooler connections',
+ tooltip: 'PgBouncer connections',
+ },
+ ],
+ },
+ {
+ id: 'disk-size',
+ label: 'Disk Usage',
+ syncId: 'database-reports',
+ availableIn: ['team', 'enterprise'],
+ hide: false,
+ attributes: [],
+ },
+ ]
+}
export const getReportAttributesV2: (org: Organization, project: Project) => ReportAttributes[] = (
org,
@@ -40,6 +199,8 @@ export const getReportAttributesV2: (org: Organization, project: Project) => Rep
{
id: 'ram-usage',
label: 'Memory usage',
+ docsUrl: 'https://supabase.com/docs/guides/telemetry/reports#memory-usage',
+ availableIn: ['team', 'enterprise'],
hide: false,
showTooltip: true,
showLegend: true,
@@ -80,9 +241,11 @@ export const getReportAttributesV2: (org: Organization, project: Project) => Rep
{
id: 'cpu-usage',
label: 'CPU usage',
+ docsUrl: 'https://supabase.com/docs/guides/telemetry/reports#cpu-usage',
syncId: 'database-reports',
format: '%',
valuePrecision: 2,
+ availableIn: ['team', 'enterprise'],
hide: false,
showTooltip: true,
showLegend: true,
@@ -147,7 +310,10 @@ export const getReportAttributesV2: (org: Organization, project: Project) => Rep
{
id: 'disk-iops',
label: 'Disk Input/Output operations per second (IOPS)',
+ docsUrl:
+ 'https://supabase.com/docs/guides/telemetry/reports#disk-inputoutput-operations-per-second-iops',
syncId: 'database-reports',
+ availableIn: ['team', 'enterprise'],
hide: false,
showTooltip: true,
valuePrecision: 2,
@@ -160,7 +326,6 @@ export const getReportAttributesV2: (org: Organization, project: Project) => Rep
tickFormatter: (value: any) => numberFormatter(value, 2),
},
defaultChartStyle: 'line',
- docsUrl: 'https://supabase.com/docs/guides/platform/compute-and-disk#compute-size',
attributes: [
{
attribute: 'disk_iops_write',
@@ -190,7 +355,9 @@ export const getReportAttributesV2: (org: Organization, project: Project) => Rep
{
id: 'disk-io-usage',
label: 'Disk IO Usage',
+ docsUrl: 'https://supabase.com/docs/guides/telemetry/reports#disk-io-usage',
syncId: 'database-reports',
+ availableIn: ['team', 'enterprise'],
hide: false,
showTooltip: true,
format: '%',
@@ -216,8 +383,10 @@ export const getReportAttributesV2: (org: Organization, project: Project) => Rep
},
{
id: 'client-connections',
- label: 'Database client connections',
+ label: 'Database Connections',
+ syncId: 'database-reports',
valuePrecision: 0,
+ availableIn: ['team', 'enterprise'],
hide: false,
showTooltip: true,
showLegend: true,
@@ -226,6 +395,7 @@ export const getReportAttributesV2: (org: Organization, project: Project) => Rep
showGrid: true,
YAxisProps: { width: 30 },
defaultChartStyle: 'line',
+ docsUrl: 'https://supabase.com/docs/guides/telemetry/reports#database-connections',
attributes: [
{
attribute: 'client_connections_postgres',
@@ -277,10 +447,11 @@ export const getReportAttributesV2: (org: Organization, project: Project) => Rep
},
{
id: 'pgbouncer-connections',
- label: 'Dedicated Pooler client connections',
+ label: 'Dedicated Pooler Client Connections',
syncId: 'database-reports',
valuePrecision: 0,
- hide: false,
+ availableIn: ['pro', 'team'],
+ hide: isFreePlan,
showTooltip: true,
showLegend: true,
showMaxValue: true,
@@ -311,6 +482,7 @@ export const getReportAttributesV2: (org: Organization, project: Project) => Rep
label: 'Shared Pooler (Supavisor) client connections',
syncId: 'database-reports',
valuePrecision: 0,
+ availableIn: ['pro', 'team'],
hide: isFreePlan,
showTooltip: false,
showLegend: false,
@@ -333,6 +505,7 @@ export const getReportAttributesV2: (org: Organization, project: Project) => Rep
label: 'Disk Usage',
syncId: 'database-reports',
valuePrecision: 2,
+ availableIn: ['free', 'pro', 'team'],
hide: false,
showTooltip: true,
showLegend: true,
@@ -344,7 +517,7 @@ export const getReportAttributesV2: (org: Organization, project: Project) => Rep
},
hideChartType: false,
defaultChartStyle: 'line',
- docsUrl: 'https://supabase.com/docs/guides/platform/database-size',
+ docsUrl: 'https://supabase.com/docs/guides/telemetry/reports#disk-size',
attributes: [
{
attribute: 'disk_fs_used_system',
@@ -361,7 +534,6 @@ export const getReportAttributesV2: (org: Organization, project: Project) => Rep
tooltip:
'Disk usage by the write-ahead log. The usage depends on your WAL settings and the amount of data being written to the database',
},
-
{
attribute: 'pg_database_size',
provider: 'infra-monitoring',
diff --git a/apps/studio/data/reports/edgefn-charts.ts b/apps/studio/data/reports/edgefn-charts.ts
index 315c10c0d02..aa2dd98de82 100644
--- a/apps/studio/data/reports/edgefn-charts.ts
+++ b/apps/studio/data/reports/edgefn-charts.ts
@@ -1,29 +1,9 @@
import { ReportAttributes } from 'components/ui/Charts/ComposedChart.utils'
export const getEdgeFunctionReportAttributes = (): ReportAttributes[] => [
- {
- id: 'invocations',
- label: 'Edge Function Invocations',
- valuePrecision: 0,
- hide: false,
- showTooltip: true,
- showLegend: true,
- showMaxValue: false,
- hideChartType: false,
- defaultChartStyle: 'bar',
- titleTooltip: 'The total number of edge function invocations.',
- attributes: [
- {
- attribute: 'TotalInvocations',
- provider: 'logs',
- label: 'Invocations',
- enabled: true,
- },
- ],
- },
{
id: 'execution-status-codes',
- label: 'Edge Function Execution Status Codes',
+ label: 'Edge Function Status Codes',
valuePrecision: 0,
hide: false,
showTooltip: true,
@@ -32,6 +12,7 @@ export const getEdgeFunctionReportAttributes = (): ReportAttributes[] => [
hideChartType: false,
defaultChartStyle: 'bar',
titleTooltip: 'The total number of edge function executions by status code.',
+ availableIn: ['free', 'pro', 'team'],
attributes: [
{
attribute: 'ExecutionStatusCodes',
@@ -40,25 +21,6 @@ export const getEdgeFunctionReportAttributes = (): ReportAttributes[] => [
},
],
},
- {
- id: 'invocations-by-region',
- label: 'Edge Function Invocations by Region',
- valuePrecision: 0,
- hide: false,
- showTooltip: true,
- showLegend: true,
- showMaxValue: false,
- hideChartType: false,
- defaultChartStyle: 'bar',
- titleTooltip: 'The total number of edge function invocations by region.',
- attributes: [
- {
- attribute: 'InvocationsByRegion',
- provider: 'logs',
- label: 'Invocations by Region',
- },
- ],
- },
{
id: 'execution-time',
label: 'Edge Function Execution Time',
@@ -70,6 +32,7 @@ export const getEdgeFunctionReportAttributes = (): ReportAttributes[] => [
hideChartType: false,
defaultChartStyle: 'line',
titleTooltip: 'Average execution time for edge functions.',
+ availableIn: ['free', 'pro', 'team'],
format: 'ms',
YAxisProps: {
width: 50,
@@ -84,4 +47,24 @@ export const getEdgeFunctionReportAttributes = (): ReportAttributes[] => [
},
],
},
+ {
+ id: 'invocations-by-region',
+ label: 'Edge Function Invocations by Region',
+ valuePrecision: 0,
+ hide: false,
+ showTooltip: true,
+ showLegend: true,
+ showMaxValue: false,
+ hideChartType: false,
+ defaultChartStyle: 'bar',
+ titleTooltip: 'The total number of edge function invocations by region.',
+ availableIn: ['pro', 'team'],
+ attributes: [
+ {
+ attribute: 'InvocationsByRegion',
+ provider: 'logs',
+ label: 'Invocations by Region',
+ },
+ ],
+ },
]
diff --git a/apps/studio/data/storage/bucket-create-mutation.ts b/apps/studio/data/storage/bucket-create-mutation.ts
index 81dc63d8b17..adfde525a43 100644
--- a/apps/studio/data/storage/bucket-create-mutation.ts
+++ b/apps/studio/data/storage/bucket-create-mutation.ts
@@ -6,12 +6,9 @@ import { handleError, post } from 'data/fetchers'
import type { ResponseError } from 'types'
import { storageKeys } from './keys'
-export type BucketCreateVariables = {
+export type BucketCreateVariables = Omit & {
projectRef: string
- id: string
isPublic: boolean
- file_size_limit: number | null
- allowed_mime_types: string[] | null
}
type CreateStorageBucketBody = components['schemas']['CreateStorageBucketBody']
@@ -19,6 +16,7 @@ type CreateStorageBucketBody = components['schemas']['CreateStorageBucketBody']
export async function createBucket({
projectRef,
id,
+ type,
isPublic,
file_size_limit,
allowed_mime_types,
@@ -26,13 +24,15 @@ export async function createBucket({
if (!projectRef) throw new Error('projectRef is required')
if (!id) throw new Error('Bucket name is required')
- const payload: Partial = { id, public: isPublic }
- if (file_size_limit) payload.file_size_limit = file_size_limit
- if (allowed_mime_types) payload.allowed_mime_types = allowed_mime_types
+ const payload: CreateStorageBucketBody = { id, type, public: isPublic }
+ if (type === 'STANDARD') {
+ if (file_size_limit) payload.file_size_limit = file_size_limit
+ if (allowed_mime_types) payload.allowed_mime_types = allowed_mime_types
+ }
const { data, error } = await post('/platform/storage/{ref}/buckets', {
params: { path: { ref: projectRef } },
- body: payload as CreateStorageBucketBody,
+ body: payload,
})
if (error) handleError(error)
diff --git a/apps/studio/data/storage/bucket-delete-mutation.ts b/apps/studio/data/storage/bucket-delete-mutation.ts
index 2333e5839a9..a623863551d 100644
--- a/apps/studio/data/storage/bucket-delete-mutation.ts
+++ b/apps/studio/data/storage/bucket-delete-mutation.ts
@@ -3,25 +3,29 @@ import { toast } from 'sonner'
import { del, handleError, post } from 'data/fetchers'
import type { ResponseError } from 'types'
+import { BucketType } from './buckets-query'
import { storageKeys } from './keys'
export type BucketDeleteVariables = {
projectRef: string
id: string
+ type: BucketType
}
-export async function deleteBucket({ projectRef, id }: BucketDeleteVariables) {
+export async function deleteBucket({ projectRef, id, type }: BucketDeleteVariables) {
if (!projectRef) throw new Error('projectRef is required')
if (!id) throw new Error('Bucket name is requried')
- const { error: emptyBucketError } = await post('/platform/storage/{ref}/buckets/{id}/empty', {
- params: { path: { ref: projectRef, id } },
- })
- if (emptyBucketError) handleError(emptyBucketError)
+ if (type !== 'ANALYTICS') {
+ const { error: emptyBucketError } = await post('/platform/storage/{ref}/buckets/{id}/empty', {
+ params: { path: { ref: projectRef, id } },
+ })
+ if (emptyBucketError) handleError(emptyBucketError)
+ }
const { data, error: deleteBucketError } = await del('/platform/storage/{ref}/buckets/{id}', {
- params: { path: { ref: projectRef, id } },
- })
+ params: { path: { ref: projectRef, id }, query: { type } },
+ } as any)
if (deleteBucketError) handleError(deleteBucketError)
return data
}
diff --git a/apps/studio/data/storage/buckets-query.ts b/apps/studio/data/storage/buckets-query.ts
index 1684a4ca86b..e26887d1a8f 100644
--- a/apps/studio/data/storage/buckets-query.ts
+++ b/apps/studio/data/storage/buckets-query.ts
@@ -1,5 +1,6 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
+import { components } from 'api-types'
import { get, handleError } from 'data/fetchers'
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
import { PROJECT_STATUS } from 'lib/constants'
@@ -8,16 +9,9 @@ import { storageKeys } from './keys'
export type BucketsVariables = { projectRef?: string }
-export type Bucket = {
- id: string
- name: string
- owner: string
- public: boolean
- created_at: string
- updated_at: string
- file_size_limit: null | number
- allowed_mime_types: null | string[]
-}
+export type Bucket = components['schemas']['StorageBucketResponse']
+
+export type BucketType = Bucket['type']
export async function getBuckets({ projectRef }: BucketsVariables, signal?: AbortSignal) {
if (!projectRef) throw new Error('projectRef is required')
diff --git a/apps/studio/data/storage/iceberg-namespace-create-mutation.ts b/apps/studio/data/storage/iceberg-namespace-create-mutation.ts
new file mode 100644
index 00000000000..58f93dd4bd4
--- /dev/null
+++ b/apps/studio/data/storage/iceberg-namespace-create-mutation.ts
@@ -0,0 +1,89 @@
+import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
+import { toast } from 'sonner'
+
+import { constructHeaders, fetchHandler, handleError } from 'data/fetchers'
+import type { ResponseError } from 'types'
+import { storageKeys } from './keys'
+
+type CreateIcebergNamespaceVariables = {
+ catalogUri: string
+ warehouse: string
+ token: string
+ namespace: string
+}
+
+async function createIcebergNamespace({
+ catalogUri,
+ warehouse,
+ token,
+ namespace,
+}: CreateIcebergNamespaceVariables) {
+ const headers = await constructHeaders({
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${token}`,
+ })
+
+ const url = `${catalogUri}/v1/${warehouse}/namespaces`.replaceAll(/(?>
+
+export const useIcebergNamespaceCreateMutation = ({
+ onSuccess,
+ onError,
+ ...options
+}: Omit<
+ UseMutationOptions,
+ 'mutationFn'
+> = {}) => {
+ const queryClient = useQueryClient()
+
+ return useMutation(
+ (vars) => createIcebergNamespace(vars),
+ {
+ async onSuccess(data, variables, context) {
+ await queryClient.invalidateQueries(
+ storageKeys.icebergNamespace(
+ variables.catalogUri,
+ variables.warehouse,
+ variables.namespace
+ )
+ )
+ await onSuccess?.(data, variables, context)
+ },
+ async onError(data, variables, context) {
+ if ((data.message = 'Request failed with status code 409')) {
+ toast.error(`A namespace named ${variables.namespace} already exists in the catalog.`)
+ return
+ }
+ if (onError === undefined) {
+ toast.error(`Failed to create Iceberg namespace: ${data.message}`)
+ } else {
+ onError(data, variables, context)
+ }
+ },
+ ...options,
+ }
+ )
+}
diff --git a/apps/studio/data/storage/iceberg-namespace-exists-query.ts b/apps/studio/data/storage/iceberg-namespace-exists-query.ts
new file mode 100644
index 00000000000..e1a4a026bfb
--- /dev/null
+++ b/apps/studio/data/storage/iceberg-namespace-exists-query.ts
@@ -0,0 +1,54 @@
+import { UseQueryOptions, useQuery } from '@tanstack/react-query'
+
+import { constructHeaders, fetchHandler, handleError } from 'data/fetchers'
+import type { ResponseError } from 'types'
+import { storageKeys } from './keys'
+
+type ExistsNamespaceVariables = {
+ catalogUri: string
+ warehouse: string
+ token: string
+ namespace: string
+}
+
+async function checkNamespaceExists({
+ catalogUri,
+ warehouse,
+ token,
+ namespace,
+}: ExistsNamespaceVariables) {
+ const headers = await constructHeaders({
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${token}`,
+ })
+
+ const url = `${catalogUri}/v1/${warehouse}/namespaces/${namespace}`.replaceAll(/(?>
+
+export type IcebergNamespaceExistsError = ResponseError
+
+export const useIcebergNamespaceExistsQuery = (
+ params: ExistsNamespaceVariables,
+ {
+ ...options
+ }: UseQueryOptions = {}
+) => {
+ return useQuery(
+ storageKeys.icebergNamespace(params.catalogUri, params.warehouse, params.namespace),
+ () => checkNamespaceExists(params),
+ { ...options }
+ )
+}
diff --git a/apps/studio/data/storage/iceberg-namespace-tables-query.ts b/apps/studio/data/storage/iceberg-namespace-tables-query.ts
new file mode 100644
index 00000000000..dd52d964d99
--- /dev/null
+++ b/apps/studio/data/storage/iceberg-namespace-tables-query.ts
@@ -0,0 +1,65 @@
+import { UseQueryOptions, useQuery } from '@tanstack/react-query'
+
+import { constructHeaders, fetchHandler, handleError } from 'data/fetchers'
+import type { ResponseError } from 'types'
+import { storageKeys } from './keys'
+
+type GetNamespaceTablesVariables = {
+ catalogUri: string
+ warehouse: string
+ token: string
+ namespace: string
+}
+
+async function getNamespaceTables({
+ catalogUri,
+ warehouse,
+ token,
+ namespace,
+}: GetNamespaceTablesVariables) {
+ const headers = await constructHeaders({
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${token}`,
+ })
+
+ const url = `${catalogUri}/v1/${warehouse}/namespaces/${namespace}/tables`.replaceAll(
+ /(? i.name)
+ } catch (error) {
+ handleError(error)
+ }
+}
+
+type IcebergNamespaceTablesData = Awaited>
+
+export type IcebergNamespaceTablesError = ResponseError
+
+export const useIcebergNamespaceTablesQuery = (
+ params: GetNamespaceTablesVariables,
+ {
+ ...options
+ }: UseQueryOptions = {}
+) => {
+ return useQuery(
+ storageKeys.icebergNamespaceTables(params.catalogUri, params.warehouse, params.namespace),
+ () => getNamespaceTables(params),
+ { ...options }
+ )
+}
diff --git a/apps/studio/data/storage/iceberg-namespaces-query.ts b/apps/studio/data/storage/iceberg-namespaces-query.ts
new file mode 100644
index 00000000000..8ae16d34465
--- /dev/null
+++ b/apps/studio/data/storage/iceberg-namespaces-query.ts
@@ -0,0 +1,54 @@
+import { UseQueryOptions, useQuery } from '@tanstack/react-query'
+
+import { constructHeaders, fetchHandler, handleError } from 'data/fetchers'
+import type { ResponseError } from 'types'
+import { storageKeys } from './keys'
+
+type GetNamespacesVariables = {
+ catalogUri: string
+ warehouse: string
+ token: string
+}
+
+async function getNamespaces({ catalogUri, warehouse, token }: GetNamespacesVariables) {
+ const headers = await constructHeaders({
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${token}`,
+ })
+
+ const url = `${catalogUri}/v1/${warehouse}/namespaces`.replaceAll(/(?>
+
+export type IcebergNamespacesError = ResponseError
+
+export const useIcebergNamespacesQuery = (
+ params: GetNamespacesVariables,
+ { ...options }: UseQueryOptions = {}
+) => {
+ return useQuery(
+ storageKeys.icebergNamespaces(params.catalogUri, params.warehouse),
+ () => getNamespaces(params),
+ { ...options }
+ )
+}
diff --git a/apps/studio/data/storage/iceberg-wrapper-create-mutation.ts b/apps/studio/data/storage/iceberg-wrapper-create-mutation.ts
new file mode 100644
index 00000000000..e82f77ca537
--- /dev/null
+++ b/apps/studio/data/storage/iceberg-wrapper-create-mutation.ts
@@ -0,0 +1,80 @@
+import { PermissionAction } from '@supabase/shared-types/out/constants'
+
+import { WRAPPERS } from 'components/interfaces/Integrations/Wrappers/Wrappers.constants'
+import {
+ useIsProjectActive,
+ useProjectContext,
+} from 'components/layouts/ProjectLayout/ProjectContext'
+import {
+ getCatalogURI,
+ getConnectionURL,
+} from 'components/to-be-cleaned/Storage/StorageSettings/StorageSettings.utils'
+import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
+import { useProjectStorageConfigQuery } from 'data/config/project-storage-config-query'
+import { FDWCreateVariables, useFDWCreateMutation } from 'data/fdw/fdw-create-mutation'
+import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
+import { snakeCase } from 'lodash'
+import { useS3AccessKeyCreateMutation } from './s3-access-key-create-mutation'
+
+export const useIcebergWrapperCreateMutation = () => {
+ const { project } = useProjectContext()
+ const { data: settings } = useProjectSettingsV2Query({ projectRef: project?.ref })
+ const protocol = settings?.app_config?.protocol ?? 'https'
+ const endpoint = settings?.app_config?.endpoint
+
+ const serviceApiKey =
+ (settings?.service_api_keys ?? []).find((key) => key.tags === 'service_role')?.api_key ??
+ 'SUPABASE_CLIENT_SERVICE_KEY'
+
+ const wrapperMeta = WRAPPERS.find((wrapper) => wrapper.name === 'iceberg_wrapper')
+
+ const isProjectActive = useIsProjectActive()
+
+ const canCreateCredentials = useCheckPermissions(PermissionAction.STORAGE_ADMIN_WRITE, '*')
+
+ const { data: config } = useProjectStorageConfigQuery({ projectRef: project?.ref })
+ const isS3ConnectionEnabled = config?.features.s3Protocol.enabled
+ const disableCreation = !isProjectActive || !canCreateCredentials || !isS3ConnectionEnabled
+
+ const { mutateAsync: createS3AccessKey, isLoading: isCreatingS3AccessKey } =
+ useS3AccessKeyCreateMutation()
+
+ const { mutateAsync: createFDW, isLoading: isCreatingFDW } = useFDWCreateMutation()
+
+ const mutateAsync = async ({ bucketName }: { bucketName: string }) => {
+ const createS3KeyData = await createS3AccessKey({
+ projectRef: project?.ref,
+ description: `${snakeCase(bucketName)}_keys`,
+ })
+
+ const wrapperName = `${snakeCase(bucketName)}_fdw`
+
+ const params: FDWCreateVariables = {
+ projectRef: project?.ref,
+ connectionString: project?.connectionString,
+ wrapperMeta: wrapperMeta!,
+ formState: {
+ wrapper_name: wrapperName,
+ server_name: `${wrapperName}_server`,
+ vault_aws_access_key_id: createS3KeyData?.access_key,
+ vault_aws_secret_access_key: createS3KeyData?.secret_key,
+ vault_token: serviceApiKey,
+ warehouse: bucketName,
+ 's3.endpoint': getConnectionURL(project?.ref ?? '', protocol, endpoint),
+ catalog_uri: getCatalogURI(project?.ref ?? '', protocol, endpoint),
+ },
+ mode: 'skip',
+ tables: [],
+ sourceSchema: '',
+ targetSchema: '',
+ }
+
+ await createFDW(params)
+ }
+
+ return {
+ mutateAsync,
+ isLoading: isCreatingFDW || isCreatingS3AccessKey,
+ hasPermission: canCreateCredentials,
+ }
+}
diff --git a/apps/studio/data/storage/keys.ts b/apps/studio/data/storage/keys.ts
index 5fa399773f4..e68911d547c 100644
--- a/apps/studio/data/storage/keys.ts
+++ b/apps/studio/data/storage/keys.ts
@@ -1,4 +1,10 @@
export const storageKeys = {
buckets: (projectRef: string | undefined) => ['projects', projectRef, 'buckets'] as const,
archive: (projectRef: string | undefined) => ['projects', projectRef, 'archive'] as const,
+ icebergNamespaces: (catalog: string, warehouse: string) =>
+ ['catalog', catalog, 'warehouse', warehouse, 'namespaces'] as const,
+ icebergNamespace: (catalog: string, warehouse: string, namespace: string) =>
+ ['catalog', catalog, 'warehouse', warehouse, 'namespaces', namespace] as const,
+ icebergNamespaceTables: (catalog: string, warehouse: string, namespace: string) =>
+ ['catalog', catalog, 'warehouse', warehouse, 'namespaces', namespace, 'tables'] as const,
}
diff --git a/apps/studio/data/vault/vault-secret-decrypted-value-query.ts b/apps/studio/data/vault/vault-secret-decrypted-value-query.ts
index b7009f02165..7b1235c206d 100644
--- a/apps/studio/data/vault/vault-secret-decrypted-value-query.ts
+++ b/apps/studio/data/vault/vault-secret-decrypted-value-query.ts
@@ -1,5 +1,5 @@
-import { UseQueryOptions, useQuery } from '@tanstack/react-query'
import { Query } from '@supabase/pg-meta/src/query'
+import { UseQueryOptions, useQuery } from '@tanstack/react-query'
import { executeSql } from '../sql/execute-sql-query'
import { vaultSecretsKeys } from './keys'
@@ -33,25 +33,26 @@ export const getDecryptedValue = async (
},
signal
)
- return result
+ return result as { decrypted_secret: string }[]
}
+type getDecryptedValueResult = Awaited>
export type VaultSecretsDecryptedValueData = string
export type VaultSecretsDecryptedValueError = unknown
-export const useVaultSecretDecryptedValueQuery = (
+export const useVaultSecretDecryptedValueQuery = (
{ projectRef, connectionString, id }: VaultSecretsDecryptedValueVariables,
{
enabled = true,
...options
- }: UseQueryOptions = {}
+ }: UseQueryOptions = {}
) =>
- useQuery(
+ useQuery(
vaultSecretsKeys.getDecryptedValue(projectRef, id),
({ signal }) => getDecryptedValue({ projectRef, connectionString, id }, signal),
{
select(data) {
- return (data[0] as any).decrypted_secret
+ return (data[0]?.decrypted_secret ?? '') as TData
},
enabled: enabled && typeof projectRef !== 'undefined',
...options,
diff --git a/apps/studio/hooks/misc/useReportDateRange.ts b/apps/studio/hooks/misc/useReportDateRange.ts
index 216448751bc..af9502de124 100644
--- a/apps/studio/hooks/misc/useReportDateRange.ts
+++ b/apps/studio/hooks/misc/useReportDateRange.ts
@@ -1,14 +1,21 @@
-import { useCallback, useMemo } from 'react'
+import { useCallback, useMemo, useState } from 'react'
import dayjs from 'dayjs'
import { createParser, useQueryState } from 'nuqs'
import { DatePickerValue } from 'components/interfaces/Settings/Logs/Logs.DatePickers'
import {
- createFilteredDatePickerHelpers,
REPORTS_DATEPICKER_HELPERS,
ReportsDatetimeHelper,
REPORT_DATERANGE_HELPER_LABELS,
} from 'components/interfaces/Reports/Reports.constants'
import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan'
+import { maybeShowUpgradePrompt } from 'components/interfaces/Settings/Logs/Logs.utils'
+
+export const DATERANGE_LIMITS: { [key: string]: number } = {
+ free: 1,
+ pro: 7,
+ team: 28,
+ enterprise: 90,
+}
export interface ReportDateRange {
period_start: { date: string; time_period: string }
@@ -40,9 +47,17 @@ export const useReportDateRange = (
| ReportsDatetimeHelper = REPORT_DATERANGE_HELPER_LABELS.LAST_60_MINUTES
) => {
const { plan: orgPlan, isLoading: isOrgPlanLoading } = useCurrentOrgPlan()
+ const [showUpgradePrompt, setShowUpgradePrompt] = useState(false)
// Get filtered date picker helpers based on organization plan
- const datePickerHelpers = createFilteredDatePickerHelpers(orgPlan?.id || 'free')
+ const datePickerHelpers: ReportsDatetimeHelper[] = useMemo(
+ () =>
+ REPORTS_DATEPICKER_HELPERS.map((helper) => ({
+ ...helper,
+ disabled: false,
+ })),
+ []
+ )
// Use nuqs for URL state management with individual parameters (empty defaults)
const [timestampStartValue, setTimestampStart] = useQueryState('its', stringWithDefault(''))
@@ -159,17 +174,22 @@ export const useReportDateRange = (
}, [timestampStartValue, timestampEndValue, helperTextValue, getDefaultHelper])
const handleIntervalGranularity = useCallback((from: string, to: string) => {
+ const diffInDays = dayjs(to).diff(from, 'day', true)
+ const diffInHours = dayjs(to).diff(from, 'hour', true)
const conditions = {
- '1m': dayjs(to).diff(from, 'hour') < 3, // less than 3 hours
- '10m': dayjs(to).diff(from, 'hour') < 6, // less than 6 hours
- '30m': dayjs(to).diff(from, 'hour') < 18, // less than 18 hours
- '1h': dayjs(to).diff(from, 'day') < 10, // less than 10 days
- '1d': dayjs(to).diff(from, 'day') >= 10, // more than 10 days
+ '1m': diffInHours < 1.1, // less than 1.1 hours
+ '5m': diffInHours < 3.1, // less than 3.1 hours
+ '10m': diffInHours < 6.1, // less than 6.1 hours
+ '30m': diffInHours < 25, // less than 25 hours
+ '1h': diffInDays < 10, // less than 10 days
+ '1d': diffInDays >= 10, // more than 10 days
}
switch (true) {
case conditions['1m']:
return '1m'
+ case conditions['5m']:
+ return '5m'
case conditions['10m']:
return '10m'
case conditions['30m']:
@@ -199,18 +219,6 @@ export const useReportDateRange = (
[setTimestampStart, setTimestampEnd, setIsHelper, setHelperText]
)
- const handleDatePickerChange = useCallback(
- (values: DatePickerValue) => {
- if (values.from && values.to) {
- setTimestampStart(values.from)
- setTimestampEnd(values.to)
- setIsHelper(values.isHelper || false)
- setHelperText(values.text || '')
- }
- },
- [setTimestampStart, setTimestampEnd, setIsHelper, setHelperText]
- )
-
// For backward compatibility, provide a setter that updates the derived state
const setSelectedDateRange = useCallback(
(newRange: ReportDateRange) => {
@@ -234,14 +242,32 @@ export const useReportDateRange = (
[timestampStart, timestampEnd, isHelper, helperText]
)
+ const handleDatePickerChange = (values: DatePickerValue) => {
+ const shouldShowUpgradePrompt = maybeShowUpgradePrompt(values.from, orgPlan?.id)
+ if (shouldShowUpgradePrompt) {
+ setShowUpgradePrompt(true)
+ return true
+ } else {
+ if (values.from && values.to) {
+ setTimestampStart(values.from)
+ setTimestampEnd(values.to)
+ setIsHelper(values.isHelper || false)
+ setHelperText(values.text || '')
+ }
+ return false
+ }
+ }
+
return {
selectedDateRange,
setSelectedDateRange,
updateDateRange,
- handleDatePickerChange,
datePickerValue,
datePickerHelpers,
isOrgPlanLoading,
orgPlan,
+ showUpgradePrompt,
+ setShowUpgradePrompt,
+ handleDatePickerChange,
}
}
diff --git a/apps/studio/hooks/useChartData.ts b/apps/studio/hooks/useChartData.ts
index ce90139b960..63998abc939 100644
--- a/apps/studio/hooks/useChartData.ts
+++ b/apps/studio/hooks/useChartData.ts
@@ -11,12 +11,10 @@
import { useMemo } from 'react'
import { useRouter } from 'next/router'
-import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
import type { AnalyticsInterval, DataPoint } from 'data/analytics/constants'
import { useAuthLogsReport } from 'data/reports/auth-report-query'
import type { ChartData } from 'components/ui/Charts/Charts.types'
import type { MultiAttribute } from 'components/ui/Charts/ComposedChart.utils'
-import { useAttributeQueries } from 'components/ui/Charts/LogChartHandler'
import { useEdgeFunctionReport } from 'data/reports/edgefn-query'
export const useChartData = ({
@@ -27,6 +25,7 @@ export const useChartData = ({
data,
highlightedValue,
functionIds,
+ enabled = true,
}: {
attributes: MultiAttribute[]
startDate: string
@@ -35,13 +34,12 @@ export const useChartData = ({
data?: ChartData
highlightedValue?: string | number
functionIds?: string[]
+ enabled?: boolean
}) => {
const router = useRouter()
const { ref } = router.query
- const state = useDatabaseSelectorStateSnapshot()
const logsAttributes = attributes.filter((attr) => attr.provider === 'logs')
- const nonLogsAttributes = attributes.filter((attr) => attr.provider !== 'logs')
const isEdgeFunctionRoute = router.asPath.includes('/reports/edge-functions')
@@ -55,7 +53,7 @@ export const useChartData = ({
startDate,
endDate,
interval: interval as AnalyticsInterval,
- enabled: logsAttributes.length > 0 && !isEdgeFunctionRoute,
+ enabled: enabled && logsAttributes.length > 0 && !isEdgeFunctionRoute,
})
const {
@@ -68,7 +66,7 @@ export const useChartData = ({
startDate,
endDate,
interval: interval as AnalyticsInterval,
- enabled: logsAttributes.length > 0 && isEdgeFunctionRoute,
+ enabled: enabled && logsAttributes.length > 0 && isEdgeFunctionRoute,
functionIds,
})
@@ -78,44 +76,9 @@ export const useChartData = ({
: authChartAttributes
const isLogsLoading = isEdgeFunctionRoute ? isEdgeFunctionLoading : isAuthLoading
- const chartAttributes = useMemo(
- () => nonLogsAttributes.concat(logsChartAttributes || []),
- [nonLogsAttributes, logsChartAttributes]
- )
-
- const databaseIdentifier = state.selectedDatabaseId
-
- // Use the custom hook at the top level of the component
- const attributeQueries = useAttributeQueries(
- attributes,
- ref,
- startDate,
- endDate,
- interval as AnalyticsInterval,
- databaseIdentifier,
- data,
- true
- )
-
- // Combine all the data into a single dataset
const combinedData = useMemo(() => {
if (data) return data
- const regularAttributeQueries = attributeQueries.filter(
- (q) => q.data?.provider !== 'logs' && q.data?.provider !== 'reference-line'
- )
- const isLoading =
- (logsAttributes.length > 0 && isLogsLoading) ||
- regularAttributeQueries.some((query: any) => query.isLoading)
- if (isLoading) {
- return undefined
- }
-
- const hasError = regularAttributeQueries.some((query: any) => !query.data)
- if (hasError) {
- return undefined
- }
-
// Get all unique timestamps from all datasets
const timestamps = new Set()
if (logsData) {
@@ -125,17 +88,6 @@ export const useChartData = ({
}
})
}
- regularAttributeQueries.forEach((query: any) => {
- query.data?.data?.forEach((point: any) => {
- if (point?.period_start) {
- timestamps.add(point.period_start)
- }
- })
- })
-
- const referenceLineQueries = attributeQueries.filter(
- (q) => q.data?.provider === 'reference-line'
- )
// Combine data points for each timestamp
const combined = Array.from(timestamps)
@@ -146,44 +98,20 @@ export const useChartData = ({
const logPoint = logsData?.find((p: any) => p.period_start === timestamp) || {}
Object.assign(point, logPoint)
- chartAttributes.forEach((attr) => {
- if (!attr) return
- if (attr.provider === 'logs') return
- if (attr.provider === 'reference-line') return
- if (attr.customValue !== undefined) {
- point[attr.attribute] = attr.customValue
- return
- }
-
- const query = regularAttributeQueries.find((q) => q.data?.attribute === attr.attribute)
- const matchingPoint = query?.data?.data?.find((p: any) => p.period_start === timestamp)
- point[attr.attribute] = matchingPoint?.[attr.attribute] ?? 0
- })
-
- // Add reference line values for each timestamp
- referenceLineQueries.forEach((query: any) => {
- const attr = query.data.attribute
- const value = query.data.total
- point[attr] = value
- })
-
return point as DataPoint
})
return combined as DataPoint[]
- }, [data, attributeQueries, attributes, chartAttributes, isLogsLoading, logsData, logsAttributes])
+ }, [data, attributes, isLogsLoading, logsData, logsAttributes])
- const loading =
- (logsAttributes.length > 0 && isLogsLoading) ||
- attributeQueries.some((query: any) => query.isLoading)
+ const loading = logsAttributes.length > 0 && isLogsLoading
// Calculate highlighted value based on the first attribute's data
const _highlightedValue = useMemo(() => {
if (highlightedValue !== undefined) return highlightedValue
const firstAttr = attributes[0]
- const firstQuery = attributeQueries[0]
- const firstData = firstQuery?.data
+ const firstData = logsChartAttributes.find((p: any) => p.attribute === firstAttr.attribute)
if (!firstData) return undefined
@@ -202,12 +130,11 @@ export const useChartData = ({
: shouldHighlightTotalGroupedValue
? firstData.totalGrouped?.[firstAttr.attribute as keyof typeof firstData.totalGrouped]
: (firstData.data?.[firstData.data?.length - 1] as any)?.[firstAttr.attribute]
- }, [highlightedValue, attributes, attributeQueries])
+ }, [highlightedValue, attributes])
return {
data: combinedData,
isLoading: loading,
- chartAttributes,
highlightedValue: _highlightedValue,
}
}
diff --git a/apps/studio/lib/ai/bedrock.test.ts b/apps/studio/lib/ai/bedrock.test.ts
deleted file mode 100644
index 292d0da2858..00000000000
--- a/apps/studio/lib/ai/bedrock.test.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-import { describe, it, expect } from 'vitest'
-import { selectBedrockRegion, bedrockRegionMap } from './bedrock'
-
-describe('selectBedrockRegion', () => {
- it('should return a valid region for a given routing key', async () => {
- const region = await selectBedrockRegion('test-key')
- const validRegions = Object.keys(bedrockRegionMap)
-
- expect(validRegions).toContain(region)
- })
-
- it('should return the same region for the same routing key', async () => {
- const routingKey = 'consistent-key'
- const region1 = await selectBedrockRegion(routingKey)
- const region2 = await selectBedrockRegion(routingKey)
-
- expect(region1).toBe(region2)
- })
-
- it('should distribute different keys across regions', async () => {
- const keys = Array.from({ length: 100 }, (_, i) => `key-${i}`)
- const regions = await Promise.all(keys.map((key) => selectBedrockRegion(key)))
- const uniqueRegions = new Set(regions)
- const validRegions = Object.keys(bedrockRegionMap)
-
- // Should use all regions for 100 different keys
- expect(uniqueRegions.size).toEqual(validRegions.length)
- })
-
- it('should distribute keys evenly across regions', async () => {
- const numKeys = 3000
- const keys = Array.from({ length: numKeys }, (_, i) => `key-${i}`)
- const regions = await Promise.all(keys.map((key) => selectBedrockRegion(key)))
- const validRegions = Object.keys(bedrockRegionMap)
-
- // Count occurrences of each region
- const regionCounts = regions.reduce>((acc, region) => {
- acc[region] = (acc[region] ?? 0) + 1
- return acc
- }, {})
-
- const expectedCountPerRegion = numKeys / validRegions.length
- const tolerance = expectedCountPerRegion * 0.2 // Allow 20% deviation
-
- // Each region should have roughly equal distribution
- for (const count of Object.values(regionCounts)) {
- expect(count).toBeGreaterThan(expectedCountPerRegion - tolerance)
- expect(count).toBeLessThan(expectedCountPerRegion + tolerance)
- }
- })
-
- it('should handle empty string', async () => {
- const region = await selectBedrockRegion('')
- const validRegions = Object.keys(bedrockRegionMap)
-
- expect(validRegions).toContain(region)
- })
-
- it('should handle special characters in routing key', async () => {
- const region = await selectBedrockRegion('key-with-special-chars!@#$%')
- const validRegions = Object.keys(bedrockRegionMap)
-
- expect(validRegions).toContain(region)
- })
-
- it('should return consistent results for unicode characters', async () => {
- const routingKey = '🔑-unicode-key-测试'
- const region1 = await selectBedrockRegion(routingKey)
- const region2 = await selectBedrockRegion(routingKey)
-
- expect(region1).toBe(region2)
- })
-})
diff --git a/apps/studio/lib/ai/bedrock.ts b/apps/studio/lib/ai/bedrock.ts
index 0e28b1e9bc5..b32e15dedbf 100644
--- a/apps/studio/lib/ai/bedrock.ts
+++ b/apps/studio/lib/ai/bedrock.ts
@@ -2,6 +2,7 @@ import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock'
import { createCredentialChain, fromNodeProviderChain } from '@aws-sdk/credential-providers'
import { CredentialsProviderError } from '@smithy/property-provider'
import { awsCredentialsProvider } from '@vercel/functions/oidc'
+import { selectWeightedKey } from './util'
const credentialProvider = createCredentialChain(
// Vercel OIDC provider will be used for staging/production
@@ -34,40 +35,6 @@ async function vercelOidcProvider() {
}
}
-export const bedrockRegionMap = {
- us1: 'us-east-1',
- us3: 'us-west-2',
-} as const
-
-export type BedrockRegion = keyof typeof bedrockRegionMap
-
-export const bedrockForRegion = (region: BedrockRegion) =>
- createAmazonBedrock({
- credentialProvider,
- region: bedrockRegionMap[region],
- })
-
-/**
- * Selects a region based on a routing key using a consistent hashing algorithm.
- *
- * Ensures that the same key always maps to the same region
- * while distributing keys evenly across available regions.
- */
-export async function selectBedrockRegion(routingKey: string) {
- const regions = Object.keys(bedrockRegionMap) as BedrockRegion[]
- const encoder = new TextEncoder()
- const data = encoder.encode(routingKey)
- const hashBuffer = await crypto.subtle.digest('SHA-256', data)
-
- // Use first 4 bytes (32 bit integer)
- const hashInt = new DataView(hashBuffer).getUint32(0)
-
- // Use modulo to map to available regions
- const regionIndex = hashInt % regions.length
-
- return regions[regionIndex]
-}
-
export async function checkAwsCredentials() {
try {
const credentials = await credentialProvider()
@@ -76,3 +43,74 @@ export async function checkAwsCredentials() {
return false
}
}
+
+export const bedrockRegionMap = {
+ use1: 'us-east-1',
+ use2: 'us-east-2',
+ usw2: 'us-west-2',
+ euc1: 'eu-central-1',
+} as const
+
+export type BedrockRegion = keyof typeof bedrockRegionMap
+
+export const regionPrefixMap: Record = {
+ use1: 'us',
+ use2: 'us',
+ usw2: 'us',
+ euc1: 'eu',
+}
+
+export type BedrockModel =
+ | 'anthropic.claude-3-7-sonnet-20250219-v1:0'
+ | 'anthropic.claude-3-5-haiku-20241022-v1:0'
+
+export type RegionWeights = Record
+
+/**
+ * Weights for distributing requests across Bedrock regions.
+ * Weights are proportional to our rate limits per model per region.
+ */
+const modelRegionWeights: Record = {
+ ['anthropic.claude-3-7-sonnet-20250219-v1:0']: {
+ use1: 40,
+ use2: 10,
+ usw2: 10,
+ euc1: 10,
+ },
+ ['anthropic.claude-3-5-haiku-20241022-v1:0']: {
+ use1: 40,
+ use2: 0,
+ usw2: 40,
+ euc1: 0,
+ },
+}
+
+/**
+ * Creates a Bedrock client that routes requests to different regions
+ * based on a routing key.
+ *
+ * Used to load balance requests across multiple regions depending on
+ * their capacities.
+ */
+export function createRoutedBedrock(routingKey?: string) {
+ return async (modelId: BedrockModel) => {
+ const regionWeights = modelRegionWeights[modelId]
+
+ // Select the Bedrock region based on the routing key and the model
+ const bedrockRegion = routingKey
+ ? await selectWeightedKey(routingKey, regionWeights)
+ : // There's a few places where getModel is called without a routing key
+ // Will cause disproportionate load on use1 region
+ 'use1'
+
+ const bedrock = createAmazonBedrock({
+ credentialProvider,
+ region: bedrockRegionMap[bedrockRegion],
+ })
+
+ // Cross-region models require the region prefix
+ const modelName = `${regionPrefixMap[bedrockRegion]}.${modelId}`
+
+ return bedrock(modelName)
+ }
+}
diff --git a/apps/studio/lib/ai/model.test.ts b/apps/studio/lib/ai/model.test.ts
index 2d7d2e27658..3c76e678193 100644
--- a/apps/studio/lib/ai/model.test.ts
+++ b/apps/studio/lib/ai/model.test.ts
@@ -7,10 +7,10 @@ vi.mock('@ai-sdk/openai', () => ({
openai: vi.fn(() => 'openai-model'),
}))
-vi.mock('./bedrock', () => ({
- bedrockForRegion: vi.fn(() => () => 'bedrock-model'),
+vi.mock('./bedrock', async () => ({
+ ...(await vi.importActual('./bedrock')),
+ createRoutedBedrock: vi.fn(() => () => 'bedrock-model'),
checkAwsCredentials: vi.fn(),
- selectBedrockRegion: vi.fn(() => 'us'),
}))
describe('getModel', () => {
@@ -29,10 +29,7 @@ describe('getModel', () => {
const { model, error } = await getModel()
- console.log('Model:', model)
-
expect(model).toEqual('bedrock-model')
- expect(bedrockModule.bedrockForRegion).toHaveBeenCalledWith('us1')
expect(error).toBeUndefined()
})
@@ -40,7 +37,7 @@ describe('getModel', () => {
vi.mocked(bedrockModule.checkAwsCredentials).mockResolvedValue(false)
process.env.OPENAI_API_KEY = 'test-key'
- const { model } = await getModel('test-key')
+ const { model } = await getModel()
expect(model).toEqual('openai-model')
expect(openai).toHaveBeenCalledWith('gpt-4.1-2025-04-14')
diff --git a/apps/studio/lib/ai/model.ts b/apps/studio/lib/ai/model.ts
index ede4906ba93..b6afbf2d141 100644
--- a/apps/studio/lib/ai/model.ts
+++ b/apps/studio/lib/ai/model.ts
@@ -1,21 +1,12 @@
import { openai } from '@ai-sdk/openai'
import { LanguageModel } from 'ai'
-import {
- bedrockForRegion,
- BedrockRegion,
- checkAwsCredentials,
- selectBedrockRegion,
-} from './bedrock'
+import { checkAwsCredentials, createRoutedBedrock } from './bedrock'
-export const regionMap = {
- us1: 'us',
- us2: 'us',
- us3: 'us',
- eu: 'eu',
-}
+// Default behaviour here is to be throttled (e.g if this env var is not available, IS_THROTTLED should be true, unless specified 'false')
+const IS_THROTTLED = process.env.IS_THROTTLED !== 'false'
-const SONNET_MODEL = 'anthropic.claude-3-7-sonnet-20250219-v1:0'
-const HAIKU_MODEL = 'anthropic.claude-3-haiku-20240307-v1:0'
+const BEDROCK_PRO_MODEL = 'anthropic.claude-3-7-sonnet-20250219-v1:0'
+const BEDROCK_NORMAL_MODEL = 'anthropic.claude-3-5-haiku-20241022-v1:0'
const OPENAI_MODEL = 'gpt-4.1-2025-04-14'
export type ModelSuccess = {
@@ -44,14 +35,11 @@ export async function getModel(routingKey?: string, isLimited?: boolean): Promis
const hasOpenAIKey = !!process.env.OPENAI_API_KEY
if (hasAwsCredentials) {
- // Select the Bedrock region based on the routing key
- const bedrockRegion: BedrockRegion = routingKey ? await selectBedrockRegion(routingKey) : 'us1'
- const bedrock = bedrockForRegion(bedrockRegion)
- const model = HAIKU_MODEL
- const modelName = `${regionMap[bedrockRegion]}.${model}`
+ const bedrockModel = IS_THROTTLED || isLimited ? BEDROCK_NORMAL_MODEL : BEDROCK_PRO_MODEL
+ const bedrock = createRoutedBedrock(routingKey)
return {
- model: bedrock(modelName),
+ model: await bedrock(bedrockModel),
}
}
diff --git a/apps/studio/lib/ai/util.test.ts b/apps/studio/lib/ai/util.test.ts
new file mode 100644
index 00000000000..99cd00205da
--- /dev/null
+++ b/apps/studio/lib/ai/util.test.ts
@@ -0,0 +1,83 @@
+import { describe, it, expect } from 'vitest'
+import { selectWeightedKey } from './util'
+
+describe('selectWeightedKey', () => {
+ it('should return a valid key from the weights object', async () => {
+ const weights = { a: 10, b: 20, c: 30 }
+ const result = await selectWeightedKey('test-input', weights)
+
+ expect(Object.keys(weights)).toContain(result)
+ })
+
+ it('should return consistent results for the same input', async () => {
+ const weights = { region1: 40, region2: 10, region3: 20 }
+ const input = 'consistent-key'
+
+ const result1 = await selectWeightedKey(input, weights)
+ const result2 = await selectWeightedKey(input, weights)
+ const result3 = await selectWeightedKey(input, weights)
+
+ expect(result1).toBe(result2)
+ expect(result2).toBe(result3)
+ })
+
+ it('should distribute keys according to weights', async () => {
+ const weights = { a: 80, b: 10, c: 10 }
+ const numSamples = 10000
+ const samples = Array.from({ length: numSamples }, (_, i) => `sample-${i}`)
+
+ const results = await Promise.all(samples.map((sample) => selectWeightedKey(sample, weights)))
+
+ const counts = results.reduce>((acc, key) => {
+ acc[key] = (acc[key] ?? 0) + 1
+ return acc
+ }, {})
+
+ expect(counts.a / numSamples).toBeCloseTo(0.8, 1)
+ expect(counts.b / numSamples).toBeCloseTo(0.1, 1)
+ expect(counts.c / numSamples).toBeCloseTo(0.1, 1)
+ })
+
+ it('should handle equal weights', async () => {
+ const weights = { x: 25, y: 25, z: 25, w: 25 }
+ const numSamples = 8000
+ const samples = Array.from({ length: numSamples }, (_, i) => `equal-${i}`)
+
+ const results = await Promise.all(samples.map((sample) => selectWeightedKey(sample, weights)))
+
+ const counts = results.reduce>((acc, key) => {
+ acc[key] = (acc[key] ?? 0) + 1
+ return acc
+ }, {})
+
+ // Each key should get roughly 25% of the samples
+ Object.values(counts).forEach((count) => {
+ expect(count / numSamples).toBeCloseTo(0.25, 1)
+ })
+ })
+
+ it('should handle single key', async () => {
+ const weights = { only: 100 }
+ const result = await selectWeightedKey('any-input', weights)
+
+ expect(result).toBe('only')
+ })
+
+ it('should handle empty string input', async () => {
+ const weights = { a: 10, b: 20 }
+ const result = await selectWeightedKey('', weights)
+
+ expect(Object.keys(weights)).toContain(result)
+ })
+
+ it('should handle unicode characters in input', async () => {
+ const weights = { option1: 50, option2: 50 }
+ const unicodeInput = '🔑-unicode-key-测试'
+
+ const result1 = await selectWeightedKey(unicodeInput, weights)
+ const result2 = await selectWeightedKey(unicodeInput, weights)
+
+ expect(result1).toBe(result2)
+ expect(Object.keys(weights)).toContain(result1)
+ })
+})
diff --git a/apps/studio/lib/ai/util.ts b/apps/studio/lib/ai/util.ts
new file mode 100644
index 00000000000..462d95d12be
--- /dev/null
+++ b/apps/studio/lib/ai/util.ts
@@ -0,0 +1,42 @@
+/**
+ * Selects a key from weighted choices using consistent hashing
+ * on an input string.
+ *
+ * The same input always returns the same key, with distribution
+ * proportional to the provided weights.
+ *
+ * @example
+ * const region = await selectWeightedKey('my-unique-id', {
+ * use1: 40,
+ * use2: 10,
+ * usw2: 10,
+ * euc1: 10,
+ * })
+ * // Returns one of the keys based on the input and weights
+ */
+export async function selectWeightedKey(
+ input: string,
+ weights: Record
+): Promise {
+ const keys = Object.keys(weights) as T[]
+ const encoder = new TextEncoder()
+ const data = encoder.encode(input)
+ const hashBuffer = await crypto.subtle.digest('SHA-256', data)
+
+ // Use first 4 bytes (32 bit integer)
+ const hashInt = new DataView(hashBuffer).getUint32(0)
+
+ const totalWeight = keys.reduce((sum, key) => sum + weights[key], 0)
+
+ let cumulativeWeight = 0
+ const targetWeight = hashInt % totalWeight
+
+ for (const key of keys) {
+ cumulativeWeight += weights[key]
+ if (cumulativeWeight > targetWeight) {
+ return key
+ }
+ }
+
+ return keys[0]
+}
diff --git a/apps/studio/lib/auth.tsx b/apps/studio/lib/auth.tsx
index e2ef0cdca9f..18ef96866c1 100644
--- a/apps/studio/lib/auth.tsx
+++ b/apps/studio/lib/auth.tsx
@@ -1,27 +1,41 @@
import { useQueryClient } from '@tanstack/react-query'
-import { AuthProvider as AuthProviderInternal, clearLocalStorage, gotrueClient } from 'common'
+import {
+ AuthProvider as AuthProviderInternal,
+ clearLocalStorage,
+ gotrueClient,
+ useAuthError,
+} from 'common'
import { PropsWithChildren, useCallback, useEffect } from 'react'
import { toast } from 'sonner'
import { GOTRUE_ERRORS, IS_PLATFORM } from './constants'
-export const AuthProvider = ({ children }: PropsWithChildren<{}>) => {
- // Check for unverified GitHub users after a GitHub sign in
- useEffect(() => {
- async function handleEmailVerificationError() {
- const { error } = await gotrueClient.initialize()
+const AuthErrorToaster = ({ children }: PropsWithChildren) => {
+ const error = useAuthError()
- if (error?.message === GOTRUE_ERRORS.UNVERIFIED_GITHUB_USER) {
+ useEffect(() => {
+ if (error !== null) {
+ // Check for unverified GitHub users after a GitHub sign in
+ if (error.message === GOTRUE_ERRORS.UNVERIFIED_GITHUB_USER) {
toast.error(
'Please verify your email on GitHub first, then reach out to us at support@supabase.io to log into the dashboard'
)
+ return
}
+
+ toast.error(error.message)
}
+ }, [error])
- handleEmailVerificationError()
- }, [])
+ return children
+}
- return {children}
+export const AuthProvider = ({ children }: PropsWithChildren) => {
+ return (
+
+ {children}
+
+ )
}
export { useAuth, useIsLoggedIn, useSession, useUser } from 'common'
diff --git a/apps/studio/middleware.ts b/apps/studio/middleware.ts
index 37f16b6a41d..34d405e60ed 100644
--- a/apps/studio/middleware.ts
+++ b/apps/studio/middleware.ts
@@ -24,12 +24,16 @@ const HOSTED_SUPPORTED_API_URLS = [
'/ai/feedback/classify',
'/get-ip-address',
'/get-utc-time',
+ '/check-cname',
'/edge-functions/test',
'/edge-functions/body',
]
export function middleware(request: NextRequest) {
- if (IS_PLATFORM && !HOSTED_SUPPORTED_API_URLS.some((url) => request.url.endsWith(url))) {
+ if (
+ IS_PLATFORM &&
+ !HOSTED_SUPPORTED_API_URLS.some((url) => request.nextUrl.pathname.endsWith(url))
+ ) {
return Response.json(
{ success: false, message: 'Endpoint not supported on hosted' },
{ status: 404 }
diff --git a/apps/studio/package.json b/apps/studio/package.json
index 278fbb6fde3..79a34be18e5 100644
--- a/apps/studio/package.json
+++ b/apps/studio/package.json
@@ -25,7 +25,7 @@
"build:graphql-types:watch": "pnpm graphql-codegen --config scripts/codegen.ts --watch"
},
"dependencies": {
- "@ai-sdk/amazon-bedrock": "^2.2.9",
+ "@ai-sdk/amazon-bedrock": "^2.2.10",
"@ai-sdk/openai": "^1.3.22",
"@ai-sdk/react": "^1.2.12",
"@aws-sdk/credential-providers": "^3.804.0",
diff --git a/apps/studio/pages/api/ai/sql/generate-v4.ts b/apps/studio/pages/api/ai/sql/generate-v4.ts
index 58d8422da26..1ff811f2781 100644
--- a/apps/studio/pages/api/ai/sql/generate-v4.ts
+++ b/apps/studio/pages/api/ai/sql/generate-v4.ts
@@ -1,5 +1,5 @@
import pgMeta from '@supabase/pg-meta'
-import { streamText, tool, ToolSet } from 'ai'
+import { convertToCoreMessages, CoreMessage, streamText, tool, ToolSet } from 'ai'
import { source } from 'common-tags'
import { NextApiRequest, NextApiResponse } from 'next'
import { z } from 'zod'
@@ -108,8 +108,7 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse) {
}
const aiOptInLevel = getAiOptInLevel(selectedOrg?.opt_in_tags)
-
- const isLimited = messages.length > 4
+ const isLimited = selectedOrg?.plan.id === 'free'
const { model, error: modelError } = await getModel(projectRef, isLimited) // use project ref as routing key
@@ -435,8 +434,8 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse) {
...filteredLocalTools,
}
+ // Important: do not use dynamic content in the system prompt or Bedrock will not cache it
const system = source`
- The current project is ${projectRef}.
You are a Supabase Postgres expert. Your goal is to generate SQL or Edge Function code based on user requests, using specific tools for rendering.
# Response Style:
@@ -581,16 +580,34 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse) {
\`\`\`
# General Instructions:
-
- - **Available Schemas**: ${schemasString}
- **Understand Context**: Attempt to use \`list_tables\`, \`list_extensions\` first. If they are not available or return a privacy/permission error, state this and proceed with caution, relying on the user's description and general knowledge.
`
+ // Note: these must be of type `CoreMessage` to prevent AI SDK from stripping `providerOptions`
+ // https://github.com/vercel/ai/blob/81ef2511311e8af34d75e37fc8204a82e775e8c3/packages/ai/core/prompt/standardize-prompt.ts#L83-L88
+ const coreMessages: CoreMessage[] = [
+ {
+ role: 'system',
+ content: system,
+ providerOptions: {
+ bedrock: {
+ // Always cache the system prompt (must not contain dynamic content)
+ cachePoint: { type: 'default' },
+ },
+ },
+ },
+ {
+ role: 'assistant',
+ // Add any dynamic context here
+ content: `The user's current project is ${projectRef}. Their available schemas are: ${schemasString}`,
+ },
+ ...convertToCoreMessages(messages),
+ ]
+
const result = streamText({
model,
maxSteps: 5,
- system,
- messages,
+ messages: coreMessages,
tools,
})
diff --git a/apps/studio/pages/api/check-cname.ts b/apps/studio/pages/api/check-cname.ts
new file mode 100644
index 00000000000..aad6be583b9
--- /dev/null
+++ b/apps/studio/pages/api/check-cname.ts
@@ -0,0 +1,21 @@
+import { CheckCNAMERecordResponse } from 'data/custom-domains/check-cname-mutation'
+import { NextApiRequest, NextApiResponse } from 'next'
+
+const handler = async (req: NextApiRequest, res: NextApiResponse) => {
+ const { domain } = req.query
+
+ try {
+ const result: CheckCNAMERecordResponse = await fetch(
+ `https://cloudflare-dns.com/dns-query?name=${domain}&type=CNAME`,
+ {
+ method: 'GET',
+ headers: { Accept: 'application/dns-json' },
+ }
+ ).then((res) => res.json())
+ return res.status(200).json(result)
+ } catch (error: any) {
+ return res.status(400).json({ message: error.message })
+ }
+}
+
+export default handler
diff --git a/apps/studio/pages/api/cli-release-version.ts b/apps/studio/pages/api/cli-release-version.ts
index 7c6ce96b38a..4719fcfd946 100644
--- a/apps/studio/pages/api/cli-release-version.ts
+++ b/apps/studio/pages/api/cli-release-version.ts
@@ -8,7 +8,7 @@ type GitHubRepositoryRelease = {
published_at: string
}
-const current = `v${process.env.CURRENT_CLI_VERSION}`
+const current = process.env.CURRENT_CLI_VERSION ? `v${process.env.CURRENT_CLI_VERSION}` : undefined
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
try {
diff --git a/apps/studio/pages/api/platform/projects/[ref]/analytics/endpoints/[name].ts b/apps/studio/pages/api/platform/projects/[ref]/analytics/endpoints/[name].ts
index b18473d013a..1646996989f 100644
--- a/apps/studio/pages/api/platform/projects/[ref]/analytics/endpoints/[name].ts
+++ b/apps/studio/pages/api/platform/projects/[ref]/analytics/endpoints/[name].ts
@@ -1,5 +1,5 @@
-import { NextApiRequest, NextApiResponse } from 'next'
import apiWrapper from 'lib/api/apiWrapper'
+import { NextApiRequest, NextApiResponse } from 'next'
import { PROJECT_ANALYTICS_URL } from 'pages/api/constants'
export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler)
@@ -9,6 +9,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
switch (method) {
case 'GET':
+ case 'POST':
const missingEnvVars = [
process.env.LOGFLARE_PRIVATE_ACCESS_TOKEN ? null : 'LOGFLARE_PRIVATE_ACCESS_TOKEN',
process.env.LOGFLARE_URL ? null : 'LOGFLARE_URL',
@@ -23,15 +24,26 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
}
default:
- res.setHeader('Allow', ['GET'])
+ res.setHeader('Allow', ['GET', 'POST'])
res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } })
}
}
const proxyRequest = async (req: NextApiRequest) => {
const { name, ...toForward } = req.query
- const payload = { ...toForward, project_tier: 'ENTERPRISE' }
- const search = '?' + new URLSearchParams(payload as any).toString()
+ const project_tier = 'ENTERPRISE'
+
+ if (req.method === 'GET') {
+ const payload = { ...toForward, project_tier }
+ return retrieveAnalyticsData(name as string, payload)
+ } else if (req.method === 'POST') {
+ const payload = { ...req.body, project_tier }
+ return retrieveAnalyticsData(name as string, payload)
+ }
+}
+
+const retrieveAnalyticsData = async (name: string, payload: any) => {
+ const search = '?' + new URLSearchParams(payload).toString()
const apiKey = process.env.LOGFLARE_PRIVATE_ACCESS_TOKEN
const url = `${PROJECT_ANALYTICS_URL}endpoints/query/${name}${search}`
const result = await fetch(url, {
diff --git a/apps/studio/pages/forgot-password-mfa.tsx b/apps/studio/pages/forgot-password-mfa.tsx
new file mode 100644
index 00000000000..7061c9c84a8
--- /dev/null
+++ b/apps/studio/pages/forgot-password-mfa.tsx
@@ -0,0 +1,94 @@
+import * as Sentry from '@sentry/nextjs'
+import { useQueryClient } from '@tanstack/react-query'
+import { useRouter } from 'next/router'
+import { useEffect, useState } from 'react'
+import { toast } from 'sonner'
+
+import { useParams } from 'common'
+import SignInMfaForm from 'components/interfaces/SignIn/SignInMfaForm'
+import ForgotPasswordLayout from 'components/layouts/SignInLayout/ForgotPasswordLayout'
+import { Loading } from 'components/ui/Loading'
+import useLatest from 'hooks/misc/useLatest'
+import { auth, buildPathWithParams, getAccessToken, getReturnToPath } from 'lib/gotrue'
+import type { NextPageWithLayout } from 'types'
+
+const ForgotPasswordMfa: NextPageWithLayout = () => {
+ const router = useRouter()
+
+ const queryClient = useQueryClient()
+ const {
+ // current methods for mfa are github and sso
+ method: signInMethod = 'unknown',
+ } = useParams()
+ const signInMethodRef = useLatest(signInMethod)
+
+ const [loading, setLoading] = useState(true)
+
+ // This useEffect redirects the user to MFA if they're already halfway signed in
+ useEffect(() => {
+ auth
+ .initialize()
+ .then(async ({ error }) => {
+ if (error) {
+ // if there was a problem signing in via the url, don't redirect
+ setLoading(false)
+ return
+ }
+
+ const token = await getAccessToken()
+
+ if (token) {
+ const { data, error } = await auth.mfa.getAuthenticatorAssuranceLevel()
+ if (error) {
+ // if there was a problem signing in via the url, don't redirect
+ toast.error(
+ `Failed to retrieve assurance level: ${error.message}. Please try signing in again`
+ )
+ setLoading(false)
+ return router.push({ pathname: '/sign-in', query: router.query })
+ }
+
+ if (data.currentLevel === data.nextLevel) {
+ await queryClient.resetQueries()
+ router.push(getReturnToPath())
+ return
+ } else {
+ // Show the MFA form
+ setLoading(false)
+ return
+ }
+ } else {
+ // if the user doesn't have a token, he needs to go back to the sign-in page
+ const redirectTo = buildPathWithParams('/sign-in')
+ router.replace(redirectTo)
+ return
+ }
+ })
+ .catch((error) => {
+ Sentry.captureException(error)
+ console.error('Auth initialization error:', error)
+ toast.error('Failed to initialize authentication. Please try again.')
+ setLoading(false)
+ router.push({ pathname: '/sign-in', query: router.query })
+ })
+ }, [])
+
+ if (loading) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+
+
+ )
+}
+
+export default ForgotPasswordMfa
diff --git a/apps/studio/pages/forgot-password.tsx b/apps/studio/pages/forgot-password.tsx
index 9768b22a13c..03f7a5a75fa 100644
--- a/apps/studio/pages/forgot-password.tsx
+++ b/apps/studio/pages/forgot-password.tsx
@@ -1,4 +1,4 @@
-import ForgotPasswordForm from 'components/interfaces/SignIn/ForgotPasswordForm'
+import { ForgotPasswordWizard } from 'components/interfaces/SignIn/ForgotPasswordWizard'
import ForgotPasswordLayout from 'components/layouts/SignInLayout/ForgotPasswordLayout'
import Link from 'next/link'
import type { NextPageWithLayout } from 'types'
@@ -7,7 +7,7 @@ const ForgotPasswordPage: NextPageWithLayout = () => {
return (
<>
-
+
@@ -22,8 +22,8 @@ const ForgotPasswordPage: NextPageWithLayout = () => {
ForgotPasswordPage.getLayout = (page) => (
{page}
diff --git a/apps/studio/pages/project/[ref]/branches.tsx b/apps/studio/pages/project/[ref]/branches.tsx
deleted file mode 100644
index 355bee32e2a..00000000000
--- a/apps/studio/pages/project/[ref]/branches.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-import BranchManagement from 'components/interfaces/BranchManagement/BranchManagement'
-import { ProjectLayoutWithAuth } from 'components/layouts/ProjectLayout/ProjectLayout'
-import DefaultLayout from 'components/layouts/DefaultLayout'
-import type { NextPageWithLayout } from 'types'
-
-const BranchManagementPage: NextPageWithLayout = () => {
- return
-}
-
-BranchManagementPage.getLayout = (page) => (
-
- {page}
-
-)
-
-export default BranchManagementPage
diff --git a/apps/studio/pages/project/[ref]/branches/index.tsx b/apps/studio/pages/project/[ref]/branches/index.tsx
new file mode 100644
index 00000000000..975d6b9e3a1
--- /dev/null
+++ b/apps/studio/pages/project/[ref]/branches/index.tsx
@@ -0,0 +1,229 @@
+import { PermissionAction } from '@supabase/shared-types/out/constants'
+import { partition } from 'lodash'
+import { MessageCircle } from 'lucide-react'
+import { useRouter } from 'next/router'
+import { useState } from 'react'
+import { toast } from 'sonner'
+
+import { useParams } from 'common'
+import { Overview } from 'components/interfaces/BranchManagement/Overview'
+import BranchLayout from 'components/layouts/BranchLayout/BranchLayout'
+import DefaultLayout from 'components/layouts/DefaultLayout'
+import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
+import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
+import AlertError from 'components/ui/AlertError'
+import { ButtonTooltip } from 'components/ui/ButtonTooltip'
+import { DocsButton } from 'components/ui/DocsButton'
+import NoPermission from 'components/ui/NoPermission'
+import { useBranchDeleteMutation } from 'data/branches/branch-delete-mutation'
+import { Branch, useBranchesQuery } from 'data/branches/branches-query'
+import { useGitHubConnectionsQuery } from 'data/integrations/github-connections-query'
+import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
+import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
+import { useSelectedProject } from 'hooks/misc/useSelectedProject'
+import { useAppStateSnapshot } from 'state/app-state'
+import type { NextPageWithLayout } from 'types'
+import { Button } from 'ui'
+import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal'
+
+const BranchesPage: NextPageWithLayout = () => {
+ const router = useRouter()
+ const { ref } = useParams()
+ const snap = useAppStateSnapshot()
+ const project = useSelectedProject()
+ const selectedOrg = useSelectedOrganization()
+
+ const [selectedBranchToDelete, setSelectedBranchToDelete] = useState
()
+
+ const isBranch = project?.parent_project_ref !== undefined
+ const projectRef =
+ project !== undefined ? (isBranch ? project.parent_project_ref : ref) : undefined
+
+ const canReadBranches = useCheckPermissions(PermissionAction.READ, 'preview_branches')
+
+ const {
+ data: connections,
+ error: connectionsError,
+ isLoading: isLoadingConnections,
+ isSuccess: isSuccessConnections,
+ isError: isErrorConnections,
+ } = useGitHubConnectionsQuery({
+ organizationId: selectedOrg?.id,
+ })
+
+ const {
+ data: branches,
+ error: branchesError,
+ isLoading: isLoadingBranches,
+ isError: isErrorBranches,
+ isSuccess: isSuccessBranches,
+ } = useBranchesQuery({ projectRef })
+ const [[mainBranch], previewBranchesUnsorted] = partition(branches, (branch) => branch.is_default)
+ const previewBranches = previewBranchesUnsorted.sort((a, b) =>
+ new Date(a.updated_at) < new Date(b.updated_at) ? 1 : -1
+ )
+
+ const githubConnection = connections?.find((connection) => connection.project.ref === projectRef)
+ const repo = githubConnection?.repository.name ?? ''
+
+ const isError = isErrorConnections || isErrorBranches
+ const isLoading = isLoadingConnections || isLoadingBranches
+ const isSuccess = isSuccessConnections && isSuccessBranches
+
+ const { mutate: deleteBranch, isLoading: isDeleting } = useBranchDeleteMutation({
+ onSuccess: () => {
+ toast.success('Successfully deleted branch')
+ setSelectedBranchToDelete(undefined)
+ },
+ })
+
+ const generateCreatePullRequestURL = (branch?: string) => {
+ if (githubConnection === undefined) return 'https://github.com'
+
+ return branch !== undefined
+ ? `https://github.com/${githubConnection.repository.name}/compare/${mainBranch?.git_branch}...${branch}`
+ : `https://github.com/${githubConnection.repository.name}/compare`
+ }
+
+ const onConfirmDeleteBranch = () => {
+ if (selectedBranchToDelete == undefined) return console.error('No branch selected')
+ if (projectRef == undefined) return console.error('Project ref is required')
+ deleteBranch(
+ { id: selectedBranchToDelete?.id, projectRef },
+ {
+ onSuccess: () => {
+ if (selectedBranchToDelete.project_ref === ref) {
+ router.push(`/project/${selectedBranchToDelete.parent_project_ref}/branches`)
+ }
+ },
+ }
+ )
+ }
+
+ return (
+ <>
+
+
+
+
+ {!canReadBranches ? (
+
+ ) : (
+ <>
+ {isErrorConnections && (
+
+ )}
+
+ {isErrorBranches && (
+
+ )}
+
+ {!isError && (
+
snap.setShowCreateBranchModal(true)}
+ onSelectDeleteBranch={setSelectedBranchToDelete}
+ generateCreatePullRequestURL={generateCreatePullRequestURL}
+ />
+ )}
+ >
+ )}
+
+
+
+
+
+ setSelectedBranchToDelete(undefined)}
+ onConfirm={() => onConfirmDeleteBranch()}
+ loading={isDeleting}
+ title="Delete branch"
+ confirmLabel="Delete branch"
+ confirmPlaceholder="Type in name of branch"
+ confirmString={selectedBranchToDelete?.name ?? ''}
+ alert={{ title: 'You cannot recover this branch once deleted' }}
+ text={
+ <>
+ This will delete your database preview branch{' '}
+ {selectedBranchToDelete?.name} .
+ >
+ }
+ />
+ >
+ )
+}
+
+BranchesPage.getLayout = (page) => {
+ const BranchesPageWrapper = () => {
+ const snap = useAppStateSnapshot()
+ const canCreateBranches = useCheckPermissions(PermissionAction.CREATE, 'preview_branches', {
+ resource: { is_default: false },
+ })
+
+ const primaryActions = (
+ snap.setShowCreateBranchModal(true)}
+ tooltip={{
+ content: {
+ side: 'bottom',
+ text: !canCreateBranches
+ ? 'You need additional permissions to create branches'
+ : undefined,
+ },
+ }}
+ >
+ Create branch
+
+ )
+
+ const secondaryActions = (
+
+ )
+
+ return (
+
+ {page}
+
+ )
+ }
+
+ return (
+
+
+
+
+
+ )
+}
+
+export default BranchesPage
diff --git a/apps/studio/pages/project/[ref]/branches/merge-requests.tsx b/apps/studio/pages/project/[ref]/branches/merge-requests.tsx
new file mode 100644
index 00000000000..1ee8c0bc86c
--- /dev/null
+++ b/apps/studio/pages/project/[ref]/branches/merge-requests.tsx
@@ -0,0 +1,364 @@
+import { PermissionAction } from '@supabase/shared-types/out/constants'
+import { partition } from 'lodash'
+import { ArrowRight, GitMerge, MessageCircle, MoreVertical, Shield, X } from 'lucide-react'
+import { useRouter } from 'next/router'
+import { PropsWithChildren } from 'react'
+import { toast } from 'sonner'
+
+import { useParams } from 'common'
+import { useIsBranching2Enabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
+import {
+ BranchManagementSection,
+ BranchRow,
+} from 'components/interfaces/BranchManagement/BranchPanels'
+import { BranchSelector } from 'components/interfaces/BranchManagement/BranchSelector'
+import { PullRequestsEmptyState } from 'components/interfaces/BranchManagement/EmptyStates'
+import BranchLayout from 'components/layouts/BranchLayout/BranchLayout'
+import DefaultLayout from 'components/layouts/DefaultLayout'
+import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
+import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
+import AlertError from 'components/ui/AlertError'
+import { DocsButton } from 'components/ui/DocsButton'
+import NoPermission from 'components/ui/NoPermission'
+import { useBranchUpdateMutation } from 'data/branches/branch-update-mutation'
+import { Branch, useBranchesQuery } from 'data/branches/branches-query'
+import { useGitHubConnectionsQuery } from 'data/integrations/github-connections-query'
+import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
+import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
+import { useSelectedProject } from 'hooks/misc/useSelectedProject'
+import type { NextPageWithLayout } from 'types'
+import {
+ Button,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+ Tooltip,
+} from 'ui'
+import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
+
+const MergeRequestsPage: NextPageWithLayout = () => {
+ const router = useRouter()
+ const { ref } = useParams()
+ const project = useSelectedProject()
+ const selectedOrg = useSelectedOrganization()
+ const gitlessBranching = useIsBranching2Enabled()
+
+ const isBranch = project?.parent_project_ref !== undefined
+ const projectRef =
+ project !== undefined ? (isBranch ? project.parent_project_ref : ref) : undefined
+
+ const canReadBranches = useCheckPermissions(PermissionAction.READ, 'preview_branches')
+
+ const {
+ data: connections,
+ error: connectionsError,
+ isError: isErrorConnections,
+ } = useGitHubConnectionsQuery({
+ organizationId: selectedOrg?.id,
+ })
+
+ const {
+ data: branches = [],
+ error: branchesError,
+ isLoading: isLoadingBranches,
+ isError: isErrorBranches,
+ } = useBranchesQuery({ projectRef })
+ const [[mainBranch], previewBranchesUnsorted] = partition(branches, (branch) => branch.is_default)
+ const previewBranches = previewBranchesUnsorted.sort((a, b) =>
+ new Date(a.updated_at) < new Date(b.updated_at) ? 1 : -1
+ )
+
+ const mergeRequestBranches = previewBranches.filter(
+ (branch) =>
+ branch.pr_number !== undefined ||
+ (branch.review_requested_at !== undefined && branch.review_requested_at !== null)
+ )
+
+ const currentBranch = branches?.find((branch) => branch.project_ref === ref)
+ const isCurrentBranchReadyForReview = !!currentBranch?.review_requested_at
+
+ const githubConnection = connections?.find((connection) => connection.project.ref === projectRef)
+ const repo = githubConnection?.repository.name ?? ''
+
+ const isError = isErrorConnections || isErrorBranches
+
+ const { mutate: updateBranch, isLoading: isUpdating } = useBranchUpdateMutation({
+ onError: () => {
+ toast.error(`Failed to update the branch`)
+ },
+ })
+
+ const handleMarkBranchForReview = (branch: Branch) => {
+ if (branch.id && projectRef) {
+ updateBranch(
+ {
+ id: branch.id,
+ projectRef,
+ requestReview: true,
+ },
+ {
+ onSuccess: () => {
+ toast.success('Merge request created')
+ router.push(`/project/${branch.project_ref}/merge`)
+ },
+ }
+ )
+ }
+ }
+
+ const handleCloseMergeRequest = (branch: Branch) => {
+ if (branch.id && projectRef) {
+ updateBranch(
+ {
+ id: branch.id,
+ projectRef,
+ requestReview: false,
+ },
+ {
+ onSuccess: () => {
+ toast.success('Merge request closed')
+ },
+ }
+ )
+ }
+ }
+
+ const generateCreatePullRequestURL = (branch?: string) => {
+ if (githubConnection === undefined) return 'https://github.com'
+
+ return branch !== undefined
+ ? `https://github.com/${githubConnection.repository.name}/compare/${mainBranch?.git_branch}...${branch}`
+ : `https://github.com/${githubConnection.repository.name}/compare`
+ }
+
+ return (
+
+
+
+
+ {!canReadBranches ? (
+
+ ) : (
+ <>
+ {isErrorConnections && (
+
+ )}
+
+ {isErrorBranches && (
+
+ )}
+
+ {!isError && (
+
+ {gitlessBranching && (
+ <>
+ {isBranch && !isCurrentBranchReadyForReview && currentBranch && (
+
+
+
+
+ {currentBranch.name}
+ last viewed
+
+
+ currentBranch && handleMarkBranchForReview(currentBranch)
+ }
+ >
+ Create merge request
+
+
+
+ )}
+ >
+ )}
+
+ {isLoadingBranches && (
+
+
+
+ )}
+ {mergeRequestBranches.length > 0 ? (
+ mergeRequestBranches.map((branch) => {
+ const isPR = branch.pr_number !== undefined
+ const rowLink = isPR
+ ? `https://github.com/${repo}/pull/${branch.pr_number}`
+ : `/project/${branch.project_ref}/merge`
+ return (
+
+ {branch.name}
+
+
+ {branch.pr_number ? (
+
#{branch.pr_number}
+ ) : (
+ <>
+
+
{mainBranch.name}
+ >
+ )}
+
+
+ }
+ repo={repo}
+ branch={branch}
+ rowLink={rowLink}
+ external={isPR}
+ rowActions={
+ !isPR && (
+
+
+ }
+ className="px-1"
+ onClick={(e) => e.stopPropagation()}
+ />
+
+
+
+ {
+ e.stopPropagation()
+ handleCloseMergeRequest(branch)
+ }}
+ >
+ Close this merge request
+
+
+
+
+ )
+ }
+ />
+ )
+ })
+ ) : (
+
+ )}
+
+
+ )}
+ >
+ )}
+
+
+
+
+ )
+}
+
+const MergeRequestsPageWrapper = ({ children }: PropsWithChildren<{}>) => {
+ const router = useRouter()
+ const { ref } = useParams()
+ const project = useSelectedProject()
+ const gitlessBranching = useIsBranching2Enabled()
+
+ const isBranch = project?.parent_project_ref !== undefined
+ const projectRef =
+ project !== undefined ? (isBranch ? project.parent_project_ref : ref) : undefined
+
+ const { data: branches } = useBranchesQuery({ projectRef })
+ const previewBranches = (branches || []).filter((b) => !b.is_default)
+
+ const { mutate: updateBranch, isLoading: isUpdating } = useBranchUpdateMutation({
+ onError: () => {
+ toast.error(`Failed to update the branch`)
+ },
+ })
+
+ const handleMarkBranchForReview = (branch: Branch) => {
+ if (branch.id && projectRef) {
+ updateBranch(
+ {
+ id: branch.id,
+ projectRef,
+ requestReview: true,
+ },
+ {
+ onSuccess: () => {
+ toast.success('Merge request created')
+ router.push(`/project/${branch.project_ref}/merge`)
+ },
+ }
+ )
+ }
+ }
+
+ const primaryActions = gitlessBranching ? (
+
+ ) : null
+
+ const secondaryActions = (
+
+ )
+
+ return (
+
+ {children}
+
+ )
+}
+
+MergeRequestsPage.getLayout = (page) => {
+ return (
+
+
+ {page}
+
+
+ )
+}
+
+export default MergeRequestsPage
diff --git a/apps/studio/pages/project/[ref]/database/column-privileges.tsx b/apps/studio/pages/project/[ref]/database/column-privileges.tsx
index 3a632285c63..abbd13827b3 100644
--- a/apps/studio/pages/project/[ref]/database/column-privileges.tsx
+++ b/apps/studio/pages/project/[ref]/database/column-privileges.tsx
@@ -4,7 +4,10 @@ import Link from 'next/link'
import { useCallback, useMemo, useState } from 'react'
import { toast } from 'sonner'
-import { useIsColumnLevelPrivilegesEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
+import {
+ useFeaturePreviewModal,
+ useIsColumnLevelPrivilegesEnabled,
+} from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import {
getDefaultColumnCheckedStates,
getDefaultTableCheckedStates,
@@ -28,7 +31,6 @@ import { useTablesQuery } from 'data/tables/tables-query'
import { useLocalStorage } from 'hooks/misc/useLocalStorage'
import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState'
import { PROTECTED_SCHEMAS } from 'lib/constants/schemas'
-import { useAppStateSnapshot } from 'state/app-state'
import type { NextPageWithLayout } from 'types'
import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button } from 'ui'
@@ -37,7 +39,7 @@ const EDITABLE_ROLES = ['authenticated', 'anon', 'service_role']
const PrivilegesPage: NextPageWithLayout = () => {
const { ref, table: paramTable } = useParams()
const { project } = useProjectContext()
- const snap = useAppStateSnapshot()
+ const { openFeaturePreviewModal } = useFeaturePreviewModal()
const isEnabled = useIsColumnLevelPrivilegesEnabled()
const { selectedSchema, setSelectedSchema } = useQuerySchemaState()
@@ -345,7 +347,7 @@ const PrivilegesPage: NextPageWithLayout = () => {
You may access this feature by enabling it under dashboard feature previews.
- snap.setShowFeaturePreviewModal(true)}>
+
View feature previews
diff --git a/apps/studio/pages/project/[ref]/index.tsx b/apps/studio/pages/project/[ref]/index.tsx
index eb3a86ed847..9f555e26cc9 100644
--- a/apps/studio/pages/project/[ref]/index.tsx
+++ b/apps/studio/pages/project/[ref]/index.tsx
@@ -18,7 +18,7 @@ import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query'
import { useReadReplicasQuery } from 'data/read-replicas/replicas-query'
import { useTablesQuery } from 'data/tables/tables-query'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
-import { useIsOrioleDb, useSelectedProject } from 'hooks/misc/useSelectedProject'
+import { useIsOrioleDb, useProjectByRef, useSelectedProject } from 'hooks/misc/useSelectedProject'
import { IS_PLATFORM, PROJECT_STATUS } from 'lib/constants'
import { useAppStateSnapshot } from 'state/app-state'
import type { NextPageWithLayout } from 'types'
@@ -34,10 +34,12 @@ import {
TooltipTrigger,
} from 'ui'
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
+import { useBranchesQuery } from 'data/branches/branches-query'
const Home: NextPageWithLayout = () => {
const organization = useSelectedOrganization()
const project = useSelectedProject()
+ const parentProject = useProjectByRef(project?.parent_project_ref)
const isOrioleDb = useIsOrioleDb()
const snap = useAppStateSnapshot()
const { ref, enableBranching } = useParams()
@@ -54,11 +56,6 @@ const Home: NextPageWithLayout = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enableBranching])
- const projectName =
- project?.ref !== 'default' && project?.name !== undefined
- ? project?.name
- : 'Welcome to your project'
-
const { data: tablesData, isLoading: isLoadingTables } = useTablesQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
@@ -71,6 +68,21 @@ const Home: NextPageWithLayout = () => {
projectRef: project?.ref,
})
+ const { data: branches } = useBranchesQuery({
+ projectRef: project?.parent_project_ref ?? project?.ref,
+ })
+
+ const mainBranch = branches?.find((branch) => branch.is_default)
+ const currentBranch = branches?.find((branch) => branch.project_ref === project?.ref)
+ const isMainBranch = currentBranch?.name === mainBranch?.name
+ let projectName = 'Welcome to your project'
+
+ if (currentBranch && !isMainBranch) {
+ projectName = currentBranch?.name
+ } else if (project?.name) {
+ projectName = project?.name
+ }
+
const tablesCount = Math.max(0, tablesData?.length ?? 0)
const functionsCount = Math.max(0, functionsData?.length ?? 0)
// [Joshen] JFYI minus 1 as the replicas endpoint returns the primary DB minimally
@@ -81,31 +93,43 @@ const Home: NextPageWithLayout = () => {
-
-
{projectName}
- {isOrioleDb && (
-
-
- OrioleDB
-
-
- This project is using Postgres with OrioleDB which is currently in preview and
- not suitable for production workloads. View our{' '}
-
- documentation
- {' '}
- for all limitations.
-
-
- )}
-
+
+
+ {!isMainBranch && (
+
+ {parentProject?.name}
+
+ )}
+
{projectName}
+
+
+ {isOrioleDb && (
+
+
+ OrioleDB
+
+
+ This project is using Postgres with OrioleDB which is currently in preview and
+ not suitable for production workloads. View our{' '}
+
+ documentation
+ {' '}
+ for all limitations.
+
+
+ )}
+
+
{project?.status === PROJECT_STATUS.ACTIVE_HEALTHY && (
diff --git a/apps/studio/pages/project/[ref]/logs/index.tsx b/apps/studio/pages/project/[ref]/logs/index.tsx
index 1276db2782f..92ac4e81db7 100644
--- a/apps/studio/pages/project/[ref]/logs/index.tsx
+++ b/apps/studio/pages/project/[ref]/logs/index.tsx
@@ -1,15 +1,20 @@
import { useRouter } from 'next/router'
-import { useEffect } from 'react'
+import { useContext, useEffect } from 'react'
-import { LOCAL_STORAGE_KEYS, useParams } from 'common'
+import { FeatureFlagContext, LOCAL_STORAGE_KEYS, useParams } from 'common'
+import { useUnifiedLogsPreview } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
+import { UnifiedLogs } from 'components/interfaces/UnifiedLogs/UnifiedLogs'
import DefaultLayout from 'components/layouts/DefaultLayout'
import LogsLayout from 'components/layouts/LogsLayout/LogsLayout'
+import ProjectLayout from 'components/layouts/ProjectLayout/ProjectLayout'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import type { NextPageWithLayout } from 'types'
export const LogPage: NextPageWithLayout = () => {
const router = useRouter()
const { ref } = useParams()
+ const { hasLoaded } = useContext(FeatureFlagContext)
+ const { isEnabled: isUnifiedLogsEnabled } = useUnifiedLogsPreview()
const [lastVisitedLogsPage] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.LAST_VISITED_LOGS_PAGE,
@@ -17,16 +22,51 @@ export const LogPage: NextPageWithLayout = () => {
)
useEffect(() => {
- router.replace(`/project/${ref}/logs/${lastVisitedLogsPage}`)
- }, [router, lastVisitedLogsPage, ref])
+ if (hasLoaded && !isUnifiedLogsEnabled) {
+ router.replace(`/project/${ref}/logs/${lastVisitedLogsPage}`)
+ }
+ }, [router, hasLoaded, lastVisitedLogsPage, ref, isUnifiedLogsEnabled])
- return null
+ // Handle redirects when unified logs preview flag changes
+ useEffect(() => {
+ // Only handle redirects if we're currently on a logs page
+ if (!router.asPath.includes('/logs') || !hasLoaded) return
+
+ if (isUnifiedLogsEnabled) {
+ // If unified logs preview is enabled and we're not already on the main logs page
+ if (router.asPath !== `/project/${ref}/logs` && router.asPath.includes('/logs/')) {
+ router.push(`/project/${ref}/logs`)
+ }
+ } else {
+ // If unified logs preview is disabled and admin flag is also off
+ // and we're on the main logs page, redirect to explorer
+ if (router.asPath === `/project/${ref}/logs`) {
+ router.push(`/project/${ref}/logs/explorer`)
+ }
+ }
+ }, [isUnifiedLogsEnabled, router, ref, hasLoaded])
+
+ if (isUnifiedLogsEnabled) {
+ return (
+
+
+
+
+
+ )
+ }
+
+ return (
+
+
+ {/* Empty placeholder - the useEffect will handle redirect */}
+
+
+
+ )
}
-LogPage.getLayout = (page) => (
-
- {page}
-
-)
+// Don't use getLayout since we're handling layouts conditionally within the component
+LogPage.getLayout = (page) => page
export default LogPage
diff --git a/apps/studio/pages/project/[ref]/merge.tsx b/apps/studio/pages/project/[ref]/merge.tsx
index ccd69fe17df..de80a26e713 100644
--- a/apps/studio/pages/project/[ref]/merge.tsx
+++ b/apps/studio/pages/project/[ref]/merge.tsx
@@ -1,61 +1,49 @@
-import { useState, useEffect, useMemo, useCallback } from 'react'
-import { useRouter } from 'next/router'
import dayjs from 'dayjs'
+import { AlertTriangle, GitBranchIcon, GitMerge, MoreVertical, Shield, X } from 'lucide-react'
+import Link from 'next/link'
+import { useRouter } from 'next/router'
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import { toast } from 'sonner'
import { useParams } from 'common'
-import { ProjectLayoutWithAuth } from 'components/layouts/ProjectLayout/ProjectLayout'
-import DefaultLayout from 'components/layouts/DefaultLayout'
-import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
-import { useProjectByRef, useSelectedProject } from 'hooks/misc/useSelectedProject'
-import { useBranchesQuery } from 'data/branches/branches-query'
-import { useBranchMergeMutation } from 'data/branches/branch-merge-mutation'
-import { useBranchPushMutation } from 'data/branches/branch-push-mutation'
-import { useBranchDeleteMutation } from 'data/branches/branch-delete-mutation'
-import { useBranchUpdateMutation } from 'data/branches/branch-update-mutation'
-import { useBranchMergeDiff } from 'hooks/branches/useBranchMergeDiff'
-import { useWorkflowManagement } from 'hooks/branches/useWorkflowManagement'
import DatabaseDiffPanel from 'components/interfaces/BranchManagement/DatabaseDiffPanel'
import EdgeFunctionsDiffPanel from 'components/interfaces/BranchManagement/EdgeFunctionsDiffPanel'
import { OutOfDateNotice } from 'components/interfaces/BranchManagement/OutOfDateNotice'
+import { ReviewWithAI } from 'components/interfaces/BranchManagement/ReviewWithAI'
+import WorkflowLogsCard from 'components/interfaces/BranchManagement/WorkflowLogsCard'
+import DefaultLayout from 'components/layouts/DefaultLayout'
+import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
+import { ProjectLayoutWithAuth } from 'components/layouts/ProjectLayout/ProjectLayout'
+import { ScaffoldContainer } from 'components/layouts/Scaffold'
+import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState'
+import { ButtonTooltip } from 'components/ui/ButtonTooltip'
+import { useBranchDeleteMutation } from 'data/branches/branch-delete-mutation'
+import { useBranchMergeMutation } from 'data/branches/branch-merge-mutation'
+import { useBranchPushMutation } from 'data/branches/branch-push-mutation'
+import { useBranchUpdateMutation } from 'data/branches/branch-update-mutation'
+import { useBranchesQuery } from 'data/branches/branches-query'
+import { useBranchMergeDiff } from 'hooks/branches/useBranchMergeDiff'
+import { useWorkflowManagement } from 'hooks/branches/useWorkflowManagement'
+import { useProjectByRef, useSelectedProject } from 'hooks/misc/useSelectedProject'
+import type { NextPageWithLayout } from 'types'
import {
Badge,
Button,
+ cn,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
NavMenu,
NavMenuItem,
- cn,
- DropdownMenuTrigger,
- DropdownMenuItem,
- DropdownMenuContent,
- DropdownMenu,
} from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
-import { toast } from 'sonner'
-import type { NextPageWithLayout } from 'types'
-
-import { ScaffoldContainer } from 'components/layouts/Scaffold'
-import {
- GitBranchIcon,
- GitMerge,
- Shield,
- ExternalLink,
- AlertTriangle,
- X,
- MoreVertical,
-} from 'lucide-react'
-import Link from 'next/link'
-import { ButtonTooltip } from 'components/ui/ButtonTooltip'
-import WorkflowLogsCard from 'components/interfaces/BranchManagement/WorkflowLogsCard'
-import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState'
-import { useFlag } from 'hooks/ui/useFlag'
-import { ReviewWithAI } from 'components/interfaces/BranchManagement/ReviewWithAI'
const MergePage: NextPageWithLayout = () => {
const router = useRouter()
const { ref } = useParams()
-
- const gitlessBranching = useFlag('gitlessBranching')
-
const project = useSelectedProject()
+
const [isSubmitting, setIsSubmitting] = useState(false)
const [workflowFinalStatus, setWorkflowFinalStatus] = useState
(null)
const [showConfirmDialog, setShowConfirmDialog] = useState(false)
@@ -103,10 +91,7 @@ const MergePage: NextPageWithLayout = () => {
currentBranchCreatedAt: currentBranch?.created_at,
})
- const { mutate: updateBranch } = useBranchUpdateMutation({
- onSuccess: () => {
- toast.success('Branch updated successfully')
- },
+ const { mutate: updateBranch, isLoading: isUpdating } = useBranchUpdateMutation({
onError: (error) => {
toast.error(`Failed to update branch: ${error.message}`)
},
@@ -116,10 +101,6 @@ const MergePage: NextPageWithLayout = () => {
const currentWorkflowRunId = router.query.workflow_run_id as string | undefined
- useEffect(() => {
- setWorkflowFinalStatus(null)
- }, [currentWorkflowRunId])
-
const handleCurrentBranchWorkflowComplete = useCallback(
(status: string) => {
setWorkflowFinalStatus(status)
@@ -135,11 +116,16 @@ const MergePage: NextPageWithLayout = () => {
refetchDiff()
clearDiffsOptimistically()
if (parentProjectRef && currentBranch?.id && currentBranch.review_requested_at) {
- updateBranch({
- id: currentBranch.id,
- projectRef: parentProjectRef,
- requestReview: false,
- })
+ updateBranch(
+ {
+ id: currentBranch.id,
+ projectRef: parentProjectRef,
+ requestReview: false,
+ },
+ {
+ onSuccess: () => toast.success('Branch updated successfully'),
+ }
+ )
}
},
[
@@ -267,31 +253,23 @@ const MergePage: NextPageWithLayout = () => {
const handleReadyForReview = () => {
if (!currentBranch?.id || !parentProjectRef) return
- updateBranch({
- id: currentBranch.id,
- projectRef: parentProjectRef,
- requestReview: true,
- })
- }
-
- const handleShowConfirmDialog = () => {
- setShowConfirmDialog(true)
- }
-
- const handleConfirmMerge = () => {
- setShowConfirmDialog(false)
- handleMerge()
- }
-
- const handleCancelMerge = () => {
- setShowConfirmDialog(false)
+ updateBranch(
+ {
+ id: currentBranch.id,
+ projectRef: parentProjectRef,
+ requestReview: true,
+ },
+ {
+ onSuccess: () => toast.success('Successfully marked as ready for review'),
+ }
+ )
}
const breadcrumbs = useMemo(
() => [
{
- label: 'Branches',
- href: `/project/${project?.ref}/branches?tab=prs`,
+ label: 'Merge requests',
+ href: `/project/${project?.ref}/branches/merge-requests`,
},
],
[parentProjectRef]
@@ -321,32 +299,9 @@ const MergePage: NextPageWithLayout = () => {
]
}, [currentWorkflowRunId, currentTab])
- if (!gitlessBranching) {
- return (
-
-
-
-
-
- The branch merge feature is currently in development and will be available soon.
-
-
-
-
-
-
- )
- }
+ useEffect(() => {
+ setWorkflowFinalStatus(null)
+ }, [currentWorkflowRunId])
// If not on a preview branch or branch info unavailable, show notice
if (!isBranch || !currentBranch) {
@@ -356,7 +311,7 @@ const MergePage: NextPageWithLayout = () => {
- This page is only available for preview branches.
+ You can only review changes when on a preview branch
@@ -368,9 +323,6 @@ const MergePage: NextPageWithLayout = () => {
const isMergeDisabled =
!combinedHasChanges || isCombinedDiffLoading || isBranchOutOfDateOverall || isWorkflowRunning
- const isReadyForReview = !!currentBranch?.review_requested_at
-
- // Update primary actions - remove push button if branch is out of date (it will be in the notice)
const primaryActions = (
{
diffContent={diffContent}
disabled={!currentBranch || !mainBranch || isCombinedDiffLoading}
/>
- {!isReadyForReview ? (
- }
- >
- Ready for review
-
- ) : isMergeDisabled ? (
+ {isMergeDisabled ? (
{
? 'No changes to merge'
: isWorkflowRunning
? 'Workflow is currently running'
- : 'Branch is out of date',
+ : 'Unable to merge at this time',
},
}}
type="primary"
loading={isMerging || isSubmitting}
disabled={isMergeDisabled}
- onClick={handleShowConfirmDialog}
+ onClick={() => setShowConfirmDialog(true)}
icon={ }
>
Merge branch
@@ -412,7 +355,7 @@ const MergePage: NextPageWithLayout = () => {
setShowConfirmDialog(true)}
disabled={isBranchOutOfDateOverall}
icon={ }
>
@@ -421,22 +364,29 @@ const MergePage: NextPageWithLayout = () => {
)}
- } />
+ } />
{
if (!currentBranch?.id || !parentProjectRef) return
- updateBranch({
- id: currentBranch.id,
- projectRef: parentProjectRef,
- requestReview: false,
- })
- router.push(`/project/${project?.ref}/branches?tab=prs`)
+ updateBranch(
+ {
+ id: currentBranch.id,
+ projectRef: parentProjectRef,
+ requestReview: false,
+ },
+ {
+ onSuccess: () => {
+ toast.success('Successfully closed merge request')
+ router.push(`/project/${project?.ref}/branches?tab=prs`)
+ },
+ }
+ )
}}
>
- Not ready for review
+ Close this merge request
@@ -473,7 +423,7 @@ const MergePage: NextPageWithLayout = () => {
}
const reviewRequestedTime = dayjs(currentBranch.review_requested_at).fromNow()
- return `Review requested ${reviewRequestedTime}`
+ return `Request opened ${reviewRequestedTime}`
}
return (
@@ -532,7 +482,8 @@ const MergePage: NextPageWithLayout = () => {
>
Create new branch
- ) : hasCurrentWorkflowCompleted ? (
+ ) : hasCurrentWorkflowCompleted &&
+ currentWorkflowRun?.id === parentBranchWorkflow?.id ? (
{
description={`Are you sure you want to merge "${currentBranch?.name}" into "${mainBranch?.name || 'main'}"? This action cannot be undone.`}
confirmLabel="Merge Branch"
confirmLabelLoading="Merging..."
- onConfirm={handleConfirmMerge}
- onCancel={handleCancelMerge}
+ onConfirm={() => {
+ setShowConfirmDialog(false)
+ handleMerge()
+ }}
+ onCancel={() => setShowConfirmDialog(false)}
loading={isMerging || isSubmitting}
/>
diff --git a/apps/studio/pages/project/[ref]/realtime/settings.tsx b/apps/studio/pages/project/[ref]/realtime/settings.tsx
index bddfd65f9c2..0dae595dd4f 100644
--- a/apps/studio/pages/project/[ref]/realtime/settings.tsx
+++ b/apps/studio/pages/project/[ref]/realtime/settings.tsx
@@ -22,7 +22,9 @@ RealtimePoliciesPage.getLayout = (page) => (
title="Realtime Settings"
subtitle="Configure your project's Realtime settings"
// [Joshen] Scaffolding for now - once docs for this is ready
- primaryActions={ }
+ primaryActions={
+
+ }
>
{page}
diff --git a/apps/studio/pages/project/[ref]/reports/api-overview.tsx b/apps/studio/pages/project/[ref]/reports/api-overview.tsx
index ac4fbf258d2..6db01dc456b 100644
--- a/apps/studio/pages/project/[ref]/reports/api-overview.tsx
+++ b/apps/studio/pages/project/[ref]/reports/api-overview.tsx
@@ -14,10 +14,12 @@ import DefaultLayout from 'components/layouts/DefaultLayout'
import ReportsLayout from 'components/layouts/ReportsLayout/ReportsLayout'
import ReportStickyNav from 'components/interfaces/Reports/ReportStickyNav'
import { REPORT_DATERANGE_HELPER_LABELS } from 'components/interfaces/Reports/Reports.constants'
+import { useState } from 'react'
import { useApiReport } from 'data/reports/api-report-query'
import { useReportDateRange } from 'hooks/misc/useReportDateRange'
import { NextPageWithLayout } from 'types'
+import UpgradePrompt from 'components/interfaces/Settings/Logs/UpgradePrompt'
export const ApiReport: NextPageWithLayout = () => {
const report = useApiReport()
@@ -34,18 +36,22 @@ export const ApiReport: NextPageWithLayout = () => {
refresh,
} = report
- const { datePickerHelpers, datePickerValue, handleDatePickerChange } = useReportDateRange(
- REPORT_DATERANGE_HELPER_LABELS.LAST_60_MINUTES
- )
+ const {
+ datePickerHelpers,
+ datePickerValue,
+ handleDatePickerChange: handleDatePickerChangeFromHook,
+ showUpgradePrompt,
+ setShowUpgradePrompt,
+ } = useReportDateRange(REPORT_DATERANGE_HELPER_LABELS.LAST_60_MINUTES)
const handleDatepickerChange = (vals: DatePickerValue) => {
- // Update localStorage and hook state
- handleDatePickerChange(vals)
- // Update query params for the report
- mergeParams({
- iso_timestamp_start: vals.from || '',
- iso_timestamp_end: vals.to || '',
- })
+ const promptShown = handleDatePickerChangeFromHook(vals)
+ if (!promptShown) {
+ mergeParams({
+ iso_timestamp_start: vals.from || '',
+ iso_timestamp_end: vals.to || '',
+ })
+ }
}
return (
@@ -53,20 +59,29 @@ export const ApiReport: NextPageWithLayout = () => {
+
+
+
+
}
>
{
return (
@@ -41,14 +44,32 @@ const AuthUsage = () => {
const {
selectedDateRange,
- updateDateRange: updateDateRangeFromHook,
- handleDatePickerChange,
+ updateDateRange,
datePickerValue,
datePickerHelpers,
isOrgPlanLoading,
orgPlan,
+ showUpgradePrompt,
+ setShowUpgradePrompt,
+ handleDatePickerChange,
} = useReportDateRange(REPORT_DATERANGE_HELPER_LABELS.LAST_60_MINUTES)
+ const {
+ data,
+ error,
+ isLoading,
+ refetch,
+ isRefetching,
+ filters,
+ addFilter,
+ removeFilters,
+ isLoadingData,
+ } = useSharedAPIReport({
+ filterBy: 'auth',
+ start: selectedDateRange?.period_start?.date,
+ end: selectedDateRange?.period_end?.date,
+ })
+
const queryClient = useQueryClient()
const [isRefreshing, setIsRefreshing] = useState(false)
@@ -61,23 +82,13 @@ const AuthUsage = () => {
setIsRefreshing(true)
AUTH_REPORT_ATTRIBUTES.forEach((attr) => {
attr.attributes.forEach((subAttr) => {
- queryClient.invalidateQueries([
- 'auth-metrics',
- ref,
- subAttr.attribute,
- selectedDateRange.period_start.date,
- selectedDateRange.period_end.date,
- selectedDateRange.interval,
- ])
+ queryClient.invalidateQueries(['auth-logs-report', 'auth-metrics'])
})
})
+ refetch()
setTimeout(() => setIsRefreshing(false), 1000)
}
- const updateDateRange: UpdateDateRange = (from: string, to: string) => {
- updateDateRangeFromHook(from, to)
- }
-
return (
<>
@@ -98,6 +109,13 @@ const AuthUsage = () => {
value={datePickerValue}
helpers={datePickerHelpers}
/>
+
{selectedDateRange && (
@@ -124,8 +142,31 @@ const AuthUsage = () => {
startDate={selectedDateRange?.period_start?.date}
endDate={selectedDateRange?.period_end?.date}
updateDateRange={updateDateRange}
+ orgPlanId={orgPlan?.id}
+ isLoading={isRefreshing}
/>
))}
+
+
+
Auth API Gateway
+
+
+
+
>
)
diff --git a/apps/studio/pages/project/[ref]/reports/database.tsx b/apps/studio/pages/project/[ref]/reports/database.tsx
index 5aac7249277..3876405462f 100644
--- a/apps/studio/pages/project/[ref]/reports/database.tsx
+++ b/apps/studio/pages/project/[ref]/reports/database.tsx
@@ -24,6 +24,8 @@ import ComposedChartHandler from 'components/ui/Charts/ComposedChartHandler'
import { LogsDatePicker } from 'components/interfaces/Settings/Logs/Logs.DatePickers'
import ReportStickyNav from 'components/interfaces/Reports/ReportStickyNav'
import GrafanaPromoBanner from 'components/ui/GrafanaPromoBanner'
+import UpgradePrompt from 'components/interfaces/Settings/Logs/UpgradePrompt'
+import ReportChart from 'components/interfaces/Reports/ReportChart'
import { analyticsKeys } from 'data/analytics/keys'
import { getReportAttributes, getReportAttributesV2 } from 'data/reports/database-charts'
@@ -65,15 +67,16 @@ const DatabaseUsage = () => {
const {
selectedDateRange,
- updateDateRange: updateDateRangeFromHook,
- handleDatePickerChange,
+ updateDateRange,
datePickerValue,
datePickerHelpers,
isOrgPlanLoading,
orgPlan,
+ showUpgradePrompt,
+ setShowUpgradePrompt,
+ handleDatePickerChange,
} = useReportDateRange(REPORT_DATERANGE_HELPER_LABELS.LAST_60_MINUTES)
- const isFreePlan = !isOrgPlanLoading && orgPlan?.id === 'free'
const isTeamsOrEnterprisePlan =
!isOrgPlanLoading && (orgPlan?.id === 'team' || orgPlan?.id === 'enterprise')
const showChartsV2 = isReportsV2 || isTeamsOrEnterprisePlan
@@ -82,6 +85,7 @@ const DatabaseUsage = () => {
const queryClient = useQueryClient()
const [isRefreshing, setIsRefreshing] = useState(false)
+ const [showIncreaseDiskSizeModal, setshowIncreaseDiskSizeModal] = useState(false)
const isReplicaSelected = state.selectedDatabaseId !== project?.ref
@@ -95,14 +99,13 @@ const DatabaseUsage = () => {
const databaseSizeBytes = databaseSizeData ?? 0
const currentDiskSize = project?.volumeSizeGb ?? 0
- const [showIncreaseDiskSizeModal, setshowIncreaseDiskSizeModal] = useState(false)
const canUpdateDiskSizeConfig = useCheckPermissions(PermissionAction.UPDATE, 'projects', {
resource: {
project_id: project?.id,
},
})
- const REPORT_ATTRIBUTES = getReportAttributes(isFreePlan)
+ const REPORT_ATTRIBUTES = getReportAttributes(org!, project!)
const REPORT_ATTRIBUTES_V2 = getReportAttributesV2(org!, project!)
const { isLoading: isUpdatingDiskSize } = useProjectDiskResizeMutation({
@@ -145,6 +148,20 @@ const DatabaseUsage = () => {
)
})
})
+ } else {
+ REPORT_ATTRIBUTES.forEach((chart: any) => {
+ chart.attributes.forEach((attr: any) => {
+ queryClient.invalidateQueries(
+ analyticsKeys.infraMonitoring(ref, {
+ attribute: attr.attribute,
+ startDate: period_start.date,
+ endDate: period_end.date,
+ interval,
+ databaseIdentifier: state.selectedDatabaseId,
+ })
+ )
+ })
+ })
}
if (isReplicaSelected) {
queryClient.invalidateQueries(
@@ -178,10 +195,6 @@ const DatabaseUsage = () => {
}
}, [db, chart])
- const updateDateRange: UpdateDateRange = (from: string, to: string) => {
- updateDateRangeFromHook(from, to)
- }
-
return (
<>
@@ -203,6 +216,13 @@ const DatabaseUsage = () => {
value={datePickerValue}
helpers={datePickerHelpers}
/>
+
{selectedDateRange && (
@@ -220,44 +240,59 @@ const DatabaseUsage = () => {
>
}
>
- {showChartsV2 ? (
- selectedDateRange &&
- REPORT_ATTRIBUTES_V2.filter((chart) => !chart.hide).map((chart) => (
-
- ))
- ) : (
- Database health}>
-
- {selectedDateRange &&
- REPORT_ATTRIBUTES.filter((attr) => !attr.hide).map((attr) => (
- !chart.hide).map((chart) => (
+
+ ))
+ : REPORT_ATTRIBUTES.filter((chart) => !chart.hide).map((chart, i) =>
+ chart.availableIn?.includes(orgPlan?.id) ? (
+
- ))}
-
-
- )}
+ ) : (
+
+ )
+ ))}
{selectedDateRange && isReplicaSelected && (
diff --git a/apps/studio/pages/project/[ref]/reports/edge-functions.tsx b/apps/studio/pages/project/[ref]/reports/edge-functions.tsx
index fe5a1329bb3..0de2d1c7df7 100644
--- a/apps/studio/pages/project/[ref]/reports/edge-functions.tsx
+++ b/apps/studio/pages/project/[ref]/reports/edge-functions.tsx
@@ -11,14 +11,19 @@ import ReportsLayout from 'components/layouts/ReportsLayout/ReportsLayout'
import ReportChart from 'components/interfaces/Reports/ReportChart'
import ReportStickyNav from 'components/interfaces/Reports/ReportStickyNav'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
-import { LogsDatePicker } from 'components/interfaces/Settings/Logs/Logs.DatePickers'
+import {
+ LogsDatePicker,
+ DatePickerValue,
+} from 'components/interfaces/Settings/Logs/Logs.DatePickers'
import { Button, Checkbox, DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from 'ui'
import { Label } from '@ui/components/shadcn/ui/label'
import { getEdgeFunctionReportAttributes } from 'data/reports/edgefn-charts'
import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query'
+
import { useReportDateRange } from 'hooks/misc/useReportDateRange'
import { REPORT_DATERANGE_HELPER_LABELS } from 'components/interfaces/Reports/Reports.constants'
+import UpgradePrompt from 'components/interfaces/Settings/Logs/UpgradePrompt'
import type { NextPageWithLayout } from 'types'
@@ -56,10 +61,14 @@ const EdgeFunctionsUsage = () => {
const {
selectedDateRange,
- updateDateRange: updateDateRangeFromHook,
- handleDatePickerChange,
+ updateDateRange,
datePickerValue,
datePickerHelpers,
+ isOrgPlanLoading,
+ orgPlan,
+ showUpgradePrompt,
+ setShowUpgradePrompt,
+ handleDatePickerChange,
} = useReportDateRange(REPORT_DATERANGE_HELPER_LABELS.LAST_60_MINUTES)
const queryClient = useQueryClient()
@@ -75,10 +84,6 @@ const EdgeFunctionsUsage = () => {
setTimeout(() => setIsRefreshing(false), 1000)
}
- const updateDateRange: UpdateDateRange = (from: string, to: string) => {
- updateDateRangeFromHook(from, to)
- }
-
if (!ref) {
// Prevent rendering charts until the ref is available
return <>>
@@ -89,31 +94,51 @@ const EdgeFunctionsUsage = () => {
- }
- className="w-7"
- tooltip={{ content: { side: 'bottom', text: 'Refresh report' } }}
- onClick={onRefreshReport}
- />
+
+
}
+ className="w-7"
+ tooltip={{ content: { side: 'bottom', text: 'Refresh report' } }}
+ onClick={onRefreshReport}
+ />
+
+ {selectedDateRange && (
+
+
+ {dayjs(selectedDateRange.period_start.date).format('MMM D, h:mma')}
+
+
+
+
+
+ {dayjs(selectedDateRange.period_end.date).format('MMM D, h:mma')}
+
+
+ )}
+
+
}>
-
- {functionIds.length === 0
- ? 'All Functions'
- : `${functionIds.length} function${
- functionIds.length > 1 ? 's' : ''
- } selected`}
-
+ {functionIds.length === 0
+ ? 'All Functions'
+ : `${functionIds.length} function${
+ functionIds.length > 1 ? 's' : ''
+ } selected`}
@@ -186,35 +211,25 @@ const EdgeFunctionsUsage = () => {
- {selectedDateRange && (
-
-
- {dayjs(selectedDateRange.period_start.date).format('MMM D, h:mma')}
-
-
-
-
-
- {dayjs(selectedDateRange.period_end.date).format('MMM D, h:mma')}
-
-
- )}
- >
+
}
>
- {selectedDateRange &&
- EDGEFN_CHARTS.filter((attr) => !attr.hide).map((attr, i) => (
-
- ))}
+
+ {selectedDateRange &&
+ EDGEFN_CHARTS.filter((attr) => !attr.hide).map((attr, i) => (
+
+ ))}
+
>
)
diff --git a/apps/studio/pages/project/[ref]/reports/postgrest.tsx b/apps/studio/pages/project/[ref]/reports/postgrest.tsx
new file mode 100644
index 00000000000..65f1c238a7d
--- /dev/null
+++ b/apps/studio/pages/project/[ref]/reports/postgrest.tsx
@@ -0,0 +1,159 @@
+import { useEffect } from 'react'
+import dayjs from 'dayjs'
+import { ArrowRight, RefreshCw } from 'lucide-react'
+import { useParams } from 'common'
+
+import DefaultLayout from 'components/layouts/DefaultLayout'
+import ReportHeader from 'components/interfaces/Reports/ReportHeader'
+import ReportPadding from 'components/interfaces/Reports/ReportPadding'
+import ReportsLayout from 'components/layouts/ReportsLayout/ReportsLayout'
+import { ButtonTooltip } from 'components/ui/ButtonTooltip'
+import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
+import {
+ LogsDatePicker,
+ DatePickerValue,
+} from 'components/interfaces/Settings/Logs/Logs.DatePickers'
+import { useReportDateRange } from 'hooks/misc/useReportDateRange'
+import { REPORT_DATERANGE_HELPER_LABELS } from 'components/interfaces/Reports/Reports.constants'
+import ReportStickyNav from 'components/interfaces/Reports/ReportStickyNav'
+import UpgradePrompt from 'components/interfaces/Settings/Logs/UpgradePrompt'
+
+import type { NextPageWithLayout } from 'types'
+import { SharedAPIReport } from 'components/interfaces/Reports/SharedAPIReport/SharedAPIReport'
+import { useSharedAPIReport } from 'components/interfaces/Reports/SharedAPIReport/SharedAPIReport.constants'
+import ReportFilterBar from 'components/interfaces/Reports/ReportFilterBar'
+
+const PostgRESTReport: NextPageWithLayout = () => {
+ return (
+
+
+
+ )
+}
+
+PostgRESTReport.getLayout = (page) => (
+
+ {page}
+
+)
+
+export type UpdateDateRange = (from: string, to: string) => void
+export default PostgRESTReport
+
+const PostgrestReport = () => {
+ const { db, chart } = useParams()
+ const {
+ selectedDateRange,
+ datePickerValue,
+ datePickerHelpers,
+ showUpgradePrompt,
+ setShowUpgradePrompt,
+ handleDatePickerChange: handleDatePickerChangeFromHook,
+ } = useReportDateRange(REPORT_DATERANGE_HELPER_LABELS.LAST_60_MINUTES)
+
+ const {
+ data,
+ error,
+ isLoading,
+ refetch,
+ isRefetching,
+ filters,
+ addFilter,
+ removeFilters,
+ isLoadingData,
+ } = useSharedAPIReport({
+ filterBy: 'postgrest',
+ start: selectedDateRange?.period_start?.date,
+ end: selectedDateRange?.period_end?.date,
+ })
+
+ const state = useDatabaseSelectorStateSnapshot()
+
+ // [Joshen] Empty dependency array as we only want this running once
+ useEffect(() => {
+ if (db !== undefined) {
+ setTimeout(() => {
+ // [Joshen] Adding a timeout here to support navigation from settings to reports
+ // Both are rendering different instances of ProjectLayout which is where the
+ // DatabaseSelectorContextProvider lies in (unless we reckon shifting the provider up one more level is better)
+ state.setSelectedDatabaseId(db)
+ }, 100)
+ }
+ if (chart !== undefined) {
+ setTimeout(() => {
+ const el = document.getElementById(chart)
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
+ }, 200)
+ }
+ }, [])
+
+ const handleDatePickerChange = (values: DatePickerValue) => {
+ handleDatePickerChangeFromHook(values)
+ }
+
+ return (
+ <>
+
+
+
+
}
+ className="w-7"
+ tooltip={{ content: { side: 'bottom', text: 'Refresh report' } }}
+ onClick={() => refetch()}
+ />
+
+
+ {selectedDateRange && (
+
+
+ {dayjs(selectedDateRange.period_start.date).format('MMM D, h:mma')}
+
+
+
+
+
+ {dayjs(selectedDateRange.period_end.date).format('MMM D, h:mma')}
+
+
+ )}
+
+
+
+ }
+ >
+
+
+
+
+ >
+ )
+}
diff --git a/apps/studio/pages/project/[ref]/reports/realtime.tsx b/apps/studio/pages/project/[ref]/reports/realtime.tsx
index 17de0d3a1b4..27b2c81abce 100644
--- a/apps/studio/pages/project/[ref]/reports/realtime.tsx
+++ b/apps/studio/pages/project/[ref]/reports/realtime.tsx
@@ -10,7 +10,10 @@ import ReportPadding from 'components/interfaces/Reports/ReportPadding'
import ReportsLayout from 'components/layouts/ReportsLayout/ReportsLayout'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
-import { LogsDatePicker } from 'components/interfaces/Settings/Logs/Logs.DatePickers'
+import {
+ LogsDatePicker,
+ DatePickerValue,
+} from 'components/interfaces/Settings/Logs/Logs.DatePickers'
import {
ResponseSpeedChartRenderer,
TopApiRoutesRenderer,
@@ -26,9 +29,12 @@ import { useApiReport } from 'data/reports/api-report-query'
import { useReportDateRange } from 'hooks/misc/useReportDateRange'
import { REPORT_DATERANGE_HELPER_LABELS } from 'components/interfaces/Reports/Reports.constants'
import ReportStickyNav from 'components/interfaces/Reports/ReportStickyNav'
+import UpgradePrompt from 'components/interfaces/Settings/Logs/UpgradePrompt'
import type { NextPageWithLayout } from 'types'
import type { MultiAttribute } from 'components/ui/Charts/ComposedChart.utils'
+import { SharedAPIReport } from 'components/interfaces/Reports/SharedAPIReport/SharedAPIReport'
+import { useSharedAPIReport } from 'components/interfaces/Reports/SharedAPIReport/SharedAPIReport.constants'
const RealtimeReport: NextPageWithLayout = () => {
return (
@@ -50,32 +56,36 @@ export default RealtimeReport
const RealtimeUsage = () => {
const [isRefreshing, setIsRefreshing] = useState(false)
const { db, chart, ref } = useParams()
- const report = useApiReport()
- const {
- data,
- error,
- filters,
- isLoading,
- params,
- mergeParams,
- removeFilters,
- addFilter,
- refresh,
- } = report
-
- const state = useDatabaseSelectorStateSnapshot()
const {
selectedDateRange,
updateDateRange: updateDateRangeFromHook,
- handleDatePickerChange,
datePickerValue,
datePickerHelpers,
+ showUpgradePrompt,
+ setShowUpgradePrompt,
+ handleDatePickerChange: handleDatePickerChangeFromHook,
isOrgPlanLoading,
orgPlan,
} = useReportDateRange(REPORT_DATERANGE_HELPER_LABELS.LAST_60_MINUTES)
-
const queryClient = useQueryClient()
+ const {
+ data,
+ error,
+ isLoading,
+ refetch,
+ isRefetching,
+ filters,
+ addFilter,
+ removeFilters,
+ isLoadingData,
+ } = useSharedAPIReport({
+ filterBy: 'realtime',
+ start: selectedDateRange?.period_start?.date,
+ end: selectedDateRange?.period_end?.date,
+ })
+
+ const state = useDatabaseSelectorStateSnapshot()
const isFreePlan = !isOrgPlanLoading && orgPlan?.id === 'free'
const REALTIME_REPORT_ATTRIBUTES = getRealtimeReportAttributes(isFreePlan)
@@ -100,7 +110,7 @@ const RealtimeUsage = () => {
)
})
- refresh()
+ refetch()
setTimeout(() => setIsRefreshing(false), 1000)
}
@@ -123,12 +133,12 @@ const RealtimeUsage = () => {
}
}, [])
+ const handleDatePickerChange = (values: DatePickerValue) => {
+ const promptShown = handleDatePickerChangeFromHook(values)
+ }
+
const updateDateRange: UpdateDateRange = (from: string, to: string) => {
updateDateRangeFromHook(from, to)
- report.mergeParams({
- iso_timestamp_start: from,
- iso_timestamp_end: to,
- })
}
return (
@@ -136,21 +146,28 @@ const RealtimeUsage = () => {
- }
- className="w-7"
- tooltip={{ content: { side: 'bottom', text: 'Refresh report' } }}
- onClick={onRefreshReport}
- />
-
+
+
+
}
+ className="w-7"
+ tooltip={{ content: { side: 'bottom', text: 'Refresh report' } }}
+ onClick={onRefreshReport}
+ />
+
{selectedDateRange && (
@@ -165,7 +182,7 @@ const RealtimeUsage = () => {
)}
- >
+
}
>
{selectedDateRange &&
@@ -181,46 +198,28 @@ const RealtimeUsage = () => {
defaultChartStyle={chart.defaultChartStyle as 'line' | 'bar' | 'stackedAreaLine'}
/>
))}
-
+
+
Realtime API Gateway
- }
- >
-
+
-
-
+
>
)
diff --git a/apps/studio/pages/project/[ref]/reports/storage.tsx b/apps/studio/pages/project/[ref]/reports/storage.tsx
index 2497f568645..d7a5d52c4ee 100644
--- a/apps/studio/pages/project/[ref]/reports/storage.tsx
+++ b/apps/studio/pages/project/[ref]/reports/storage.tsx
@@ -1,5 +1,5 @@
import Link from 'next/link'
-import { ExternalLinkIcon } from 'lucide-react'
+import { ArrowRight, ExternalLinkIcon, RefreshCw } from 'lucide-react'
import ReportHeader from 'components/interfaces/Reports/ReportHeader'
import ReportPadding from 'components/interfaces/Reports/ReportPadding'
import ReportFilterBar from 'components/interfaces/Reports/ReportFilterBar'
@@ -8,7 +8,10 @@ import {
CacheHitRateChartRenderer,
TopCacheMissesRenderer,
} from 'components/interfaces/Reports/renderers/StorageRenderers'
-import { DatePickerValue } from 'components/interfaces/Settings/Logs/Logs.DatePickers'
+import {
+ DatePickerValue,
+ LogsDatePicker,
+} from 'components/interfaces/Settings/Logs/Logs.DatePickers'
import DefaultLayout from 'components/layouts/DefaultLayout'
import {
NetworkTrafficRenderer,
@@ -19,16 +22,19 @@ import {
import ReportsLayout from 'components/layouts/ReportsLayout/ReportsLayout'
import { REPORT_DATERANGE_HELPER_LABELS } from 'components/interfaces/Reports/Reports.constants'
import ReportStickyNav from 'components/interfaces/Reports/ReportStickyNav'
+import { useState } from 'react'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useReportDateRange } from 'hooks/misc/useReportDateRange'
import { useStorageReport } from 'data/reports/storage-report-query'
+import UpgradePrompt from 'components/interfaces/Settings/Logs/UpgradePrompt'
import type { NextPageWithLayout } from 'types'
+import dayjs from 'dayjs'
+import { ButtonTooltip } from 'components/ui/ButtonTooltip'
export const StorageReport: NextPageWithLayout = () => {
const report = useStorageReport()
- const organization = useSelectedOrganization()
const {
data,
@@ -42,18 +48,24 @@ export const StorageReport: NextPageWithLayout = () => {
refresh,
} = report
- const { datePickerHelpers, datePickerValue, handleDatePickerChange } = useReportDateRange(
- REPORT_DATERANGE_HELPER_LABELS.LAST_60_MINUTES
- )
+ const {
+ datePickerHelpers,
+ datePickerValue,
+ handleDatePickerChange: handleDatePickerChangeFromHook,
+ showUpgradePrompt,
+ setShowUpgradePrompt,
+ selectedDateRange,
+ } = useReportDateRange(REPORT_DATERANGE_HELPER_LABELS.LAST_60_MINUTES)
const handleDatepickerChange = (vals: DatePickerValue) => {
- // Update localStorage and hook state
- handleDatePickerChange(vals)
- // Update query params for the report
- mergeParams({
- iso_timestamp_start: vals.from || '',
- iso_timestamp_end: vals.to || '',
- })
+ const promptShown = handleDatePickerChangeFromHook(vals)
+ if (!promptShown) {
+ // Update query params for the report
+ mergeParams({
+ iso_timestamp_start: vals.from || '',
+ iso_timestamp_end: vals.to || '',
+ })
+ }
}
return (
@@ -61,80 +73,117 @@ export const StorageReport: NextPageWithLayout = () => {
+
+
+
}
+ className="w-7"
+ tooltip={{ content: { side: 'bottom', text: 'Refresh report' } }}
+ onClick={() => report.refresh()}
+ />
+
+ {selectedDateRange && (
+
+
+ {dayjs(selectedDateRange.period_start.date).format('MMM D, h:mma')}
+
+
+
+
+
+ {dayjs(selectedDateRange.period_end.date).format('MMM D, h:mma')}
+
+
+ )}
+
+
- >
+
}
>
-
-
-
+
+
+
+
-
- The number of storage requests that are cached at the edge level. A higher number of
- hits is better.{' '}
-
-
- Read More
-
-
-
-
- }
- data={data.cacheHitRate || []}
- renderer={CacheHitRateChartRenderer}
- append={TopCacheMissesRenderer}
- appendProps={{ data: data.topCacheMisses || [] }}
- />
+
+ The number of storage requests that are cached at the edge level. A higher number of
+ hits is better.{' '}
+
+
+ Read More
+
+
+
+
+ }
+ data={data.cacheHitRate || []}
+ renderer={CacheHitRateChartRenderer}
+ append={TopCacheMissesRenderer}
+ appendProps={{ data: data.topCacheMisses || [] }}
+ />
+
)
diff --git a/apps/studio/pages/project/[ref]/storage/buckets/[bucketId].tsx b/apps/studio/pages/project/[ref]/storage/buckets/[bucketId].tsx
index 24e88ca1ade..39cf2671d82 100644
--- a/apps/studio/pages/project/[ref]/storage/buckets/[bucketId].tsx
+++ b/apps/studio/pages/project/[ref]/storage/buckets/[bucketId].tsx
@@ -5,6 +5,7 @@ import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectConte
import StorageBucketsError from 'components/layouts/StorageLayout/StorageBucketsError'
import StorageLayout from 'components/layouts/StorageLayout/StorageLayout'
import { StorageExplorer } from 'components/to-be-cleaned/Storage'
+import { AnalyticBucketDetails } from 'components/to-be-cleaned/Storage/AnalyticBucketDetails'
import { useSelectedBucket } from 'components/to-be-cleaned/Storage/StorageExplorer/useSelectedBucket'
import { useStorageExplorerStateSnapshot } from 'state/storage-explorer'
import type { NextPageWithLayout } from 'types'
@@ -18,7 +19,7 @@ const PageLayout: NextPageWithLayout = () => {
if (!project || !projectRef) return null
return (
-
+
{isError &&
}
{isSuccess ? (
@@ -26,6 +27,8 @@ const PageLayout: NextPageWithLayout = () => {
Bucket {bucketId} cannot be found
+ ) : bucket.type === 'ANALYTICS' ? (
+
) : (
)
diff --git a/apps/studio/pages/project/[ref]/unified-logs.tsx b/apps/studio/pages/project/[ref]/unified-logs.tsx
deleted file mode 100644
index dd752c26b8e..00000000000
--- a/apps/studio/pages/project/[ref]/unified-logs.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import { useRouter } from 'next/router'
-
-import { useParams } from 'common'
-import { UnifiedLogs } from 'components/interfaces/UnifiedLogs/UnifiedLogs'
-import DefaultLayout from 'components/layouts/DefaultLayout'
-import UnifiedLogsLayout from 'components/layouts/UnifiedLogsLayout/UnifiedLogsLayout'
-import { useFlag } from 'hooks/ui/useFlag'
-import type { NextPageWithLayout } from 'types'
-
-export const LogPage: NextPageWithLayout = () => {
- const router = useRouter()
- const { ref } = useParams()
- const unifiedLogsEnabled = useFlag('unifiedLogs')
-
- // Redirect if flag is disabled
- if (unifiedLogsEnabled === false) {
- router.push(`/project/${ref}/logs`)
- }
-
- return
-}
-
-LogPage.getLayout = (page) => (
-
- {page}
-
-)
-
-export default LogPage
diff --git a/apps/studio/pages/reset-password.tsx b/apps/studio/pages/reset-password.tsx
index 96368073eff..67585813e65 100644
--- a/apps/studio/pages/reset-password.tsx
+++ b/apps/studio/pages/reset-password.tsx
@@ -11,21 +11,14 @@ const ResetPasswordPage: NextPageWithLayout = () => {
-
-
- Already have an account? {' '}
-
- Sign In
-
-
>
)
}
ResetPasswordPage.getLayout = (page) => (
{page}
diff --git a/apps/studio/pages/sign-in-mfa.tsx b/apps/studio/pages/sign-in-mfa.tsx
index bc256f89428..2eaa06b5dcb 100644
--- a/apps/studio/pages/sign-in-mfa.tsx
+++ b/apps/studio/pages/sign-in-mfa.tsx
@@ -1,3 +1,4 @@
+import * as Sentry from '@sentry/nextjs'
import { useQueryClient } from '@tanstack/react-query'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
@@ -65,8 +66,8 @@ const SignInMfaPage: NextPageWithLayout = () => {
await queryClient.resetQueries()
router.push(getReturnToPath())
return
- }
- if (data.currentLevel !== data.nextLevel) {
+ } else {
+ // Show the MFA form
setLoading(false)
return
}
@@ -77,7 +78,13 @@ const SignInMfaPage: NextPageWithLayout = () => {
return
}
})
- .catch(() => {}) // catch all errors thrown by auth methods
+ .catch((error) => {
+ Sentry.captureException(error)
+ console.error('Auth initialization error:', error)
+ toast.error('Failed to initialize authentication. Please try again.')
+ setLoading(false)
+ router.push({ pathname: '/sign-in', query: router.query })
+ })
}, [])
if (loading) {
diff --git a/apps/studio/public/img/previews/advisor-rule-preview.png b/apps/studio/public/img/previews/advisor-rule-preview.png
new file mode 100644
index 00000000000..703d5a9d596
Binary files /dev/null and b/apps/studio/public/img/previews/advisor-rule-preview.png differ
diff --git a/apps/studio/public/img/previews/branching-preview.png b/apps/studio/public/img/previews/branching-preview.png
new file mode 100644
index 00000000000..91b5435f111
Binary files /dev/null and b/apps/studio/public/img/previews/branching-preview.png differ
diff --git a/apps/studio/public/img/previews/new-logs-preview.png b/apps/studio/public/img/previews/new-logs-preview.png
new file mode 100644
index 00000000000..a7c4f510a50
Binary files /dev/null and b/apps/studio/public/img/previews/new-logs-preview.png differ
diff --git a/apps/studio/state/app-state.ts b/apps/studio/state/app-state.ts
index 56a6ca3b6f1..9303cc4ed12 100644
--- a/apps/studio/state/app-state.ts
+++ b/apps/studio/state/app-state.ts
@@ -52,8 +52,6 @@ const getInitialState = () => {
docsLanguage: 'js',
showProjectApiDocs: false,
showCreateBranchModal: false,
- showFeaturePreviewModal: false,
- selectedFeaturePreview: '',
showAiSettingsModal: false,
showConnectDialog: false,
ongoingQueriesPanelOpen: false,
@@ -82,8 +80,6 @@ const getInitialState = () => {
docsLanguage: 'js',
showProjectApiDocs: false,
showCreateBranchModal: false,
- showFeaturePreviewModal: false,
- selectedFeaturePreview: '',
showAiSettingsModal: false,
showConnectDialog: false,
ongoingQueriesPanelOpen: false,
@@ -137,16 +133,6 @@ export const appState = proxy({
appState.showCreateBranchModal = value
},
- showFeaturePreviewModal: false,
- setShowFeaturePreviewModal: (value: boolean) => {
- appState.showFeaturePreviewModal = value
- },
-
- selectedFeaturePreview: '',
- setSelectedFeaturePreview: (value: string) => {
- appState.selectedFeaturePreview = value
- },
-
showAiSettingsModal: false,
setShowAiSettingsModal: (value: boolean) => {
appState.showAiSettingsModal = value
diff --git a/apps/studio/tests/features/reports/api-report.test.tsx b/apps/studio/tests/features/reports/api-report.test.tsx
index 40b671833c0..73692908236 100644
--- a/apps/studio/tests/features/reports/api-report.test.tsx
+++ b/apps/studio/tests/features/reports/api-report.test.tsx
@@ -1,26 +1,8 @@
import { screen } from '@testing-library/react'
-import { beforeAll, test, vi } from 'vitest'
+import { test } from 'vitest'
import { ApiReport } from 'pages/project/[ref]/reports/api-overview'
-import { render } from 'tests/helpers'
-
-// [Joshen] Mock data for ApiReport is in __mocks__/hooks/useApiReport
-// I don't think this is an ideal set up as the mock data is not clear in this file itself
-// But this is the method that worked for me after hours of wrangling with jest.spyOn and jest.mock
-// which for some reason none of them worked when I was trying to mock the data within the file itself
-// I'd be keen to see how we can do this better if anyone is more familiar to jest 🙏
-
-beforeAll(() => {
- vi.mock('nuqs', async () => {
- let queryValue = 'example'
- return {
- useQueryState: () => [queryValue, (v: string) => (queryValue = v)],
- parseAsBoolean: {
- withDefault: () => true,
- },
- }
- })
-})
+import { customRender as render } from 'tests/lib/custom-render'
test(`Render static elements`, async () => {
render(
)
@@ -28,7 +10,7 @@ test(`Render static elements`, async () => {
await screen.findByText('Response Errors')
await screen.findByText('Response Speed')
await screen.findByText('Network Traffic')
- await screen.findByText(/Last 10 minutes/)
+ await screen.findByText(/Last 60 minutes/)
await screen.findByText(/Add filter/)
await screen.findByText(/All Requests/)
})
diff --git a/apps/ui-library/__registry__/index.tsx b/apps/ui-library/__registry__/index.tsx
index 28f3f0c29f0..003c426d55e 100644
--- a/apps/ui-library/__registry__/index.tsx
+++ b/apps/ui-library/__registry__/index.tsx
@@ -403,6 +403,18 @@ export const Index: Record
= {
chunks: []
}
,
+ "platform-kit-nextjs": {
+ name: "platform-kit-nextjs",
+ type: "registry:block",
+ registryDependencies: ["alert","badge","button","card","chart","command","dialog","drawer","form","hover-card","input","label","popover","select","skeleton","switch","table","toggle","toggle-group","tooltip"],
+ component: React.lazy(() => import("@/registry/default/platform/platform-kit-nextjs/components/dynamic-form.tsx")),
+ source: "",
+ files: ["registry/default/platform/platform-kit-nextjs/app/api/ai/sql/route.ts","registry/default/platform/platform-kit-nextjs/app/api/supabase-proxy/[...path]/route.ts","registry/default/platform/platform-kit-nextjs/components/dynamic-form.tsx","registry/default/platform/platform-kit-nextjs/components/logo-supabase.tsx","registry/default/platform/platform-kit-nextjs/components/results-table.tsx","registry/default/platform/platform-kit-nextjs/components/sql-editor.tsx","registry/default/platform/platform-kit-nextjs/components/supabase-manager/auth.tsx","registry/default/platform/platform-kit-nextjs/components/supabase-manager/database.tsx","registry/default/platform/platform-kit-nextjs/components/supabase-manager/index.tsx","registry/default/platform/platform-kit-nextjs/components/supabase-manager/logs.tsx","registry/default/platform/platform-kit-nextjs/components/supabase-manager/secrets.tsx","registry/default/platform/platform-kit-nextjs/components/supabase-manager/storage.tsx","registry/default/platform/platform-kit-nextjs/components/supabase-manager/suggestions.tsx","registry/default/platform/platform-kit-nextjs/components/supabase-manager/users.tsx","registry/default/platform/platform-kit-nextjs/components/users-growth-chart.tsx","registry/default/platform/platform-kit-nextjs/hooks/use-auth.ts","registry/default/platform/platform-kit-nextjs/hooks/use-logs.ts","registry/default/platform/platform-kit-nextjs/hooks/use-run-query.ts","registry/default/platform/platform-kit-nextjs/hooks/use-secrets.ts","registry/default/platform/platform-kit-nextjs/hooks/use-storage.ts","registry/default/platform/platform-kit-nextjs/hooks/use-suggestions.ts","registry/default/platform/platform-kit-nextjs/hooks/use-tables.ts","registry/default/platform/platform-kit-nextjs/hooks/use-user-counts.ts","registry/default/platform/platform-kit-nextjs/lib/logs.ts","registry/default/platform/platform-kit-nextjs/lib/management-api-schema.d.ts","registry/default/platform/platform-kit-nextjs/lib/management-api.ts","registry/default/platform/platform-kit-nextjs/lib/pg-meta/sql.ts","registry/default/platform/platform-kit-nextjs/lib/pg-meta/index.ts","registry/default/platform/platform-kit-nextjs/lib/pg-meta/types.ts","registry/default/platform/platform-kit-nextjs/lib/schemas/auth.ts","registry/default/platform/platform-kit-nextjs/lib/schemas/secrets.ts","registry/default/platform/platform-kit-nextjs/contexts/SheetNavigationContext.tsx"],
+ category: "undefined",
+ subcategory: "undefined",
+ chunks: []
+ }
+ ,
"ai-editor-rules": {
name: "ai-editor-rules",
type: "registry:file",
diff --git a/apps/ui-library/components/side-navigation.tsx b/apps/ui-library/components/side-navigation.tsx
index 614864cd7b1..ec58a9c4da5 100644
--- a/apps/ui-library/components/side-navigation.tsx
+++ b/apps/ui-library/components/side-navigation.tsx
@@ -1,7 +1,7 @@
import Link from 'next/link'
import NavigationItem from '@/components/side-navigation-item'
-import { aiEditorsRules, componentPages, gettingStarted } from '@/config/docs'
+import { aiEditorsRules, componentPages, gettingStarted, platformBlocks } from '@/config/docs'
import { CommandMenu } from './command-menu'
import { ThemeSwitcherDropdown } from './theme-switcher-dropdown'
@@ -97,6 +97,14 @@ function SideNavigation() {
))}
+
+
+ {platformBlocks.title}
+
+ {platformBlocks.items.map((item, i) => (
+
+ ))}
+
)
}
diff --git a/apps/ui-library/config/docs.ts b/apps/ui-library/config/docs.ts
index 55bfa3b7bbe..c257d7dacf9 100644
--- a/apps/ui-library/config/docs.ts
+++ b/apps/ui-library/config/docs.ts
@@ -36,6 +36,18 @@ export const aiEditorsRules: SidebarNavGroup = {
],
}
+export const platformBlocks: SidebarNavGroup = {
+ title: 'Platform',
+ items: [
+ {
+ title: 'Platform Kit',
+ href: '/docs/platform/platform-kit',
+ items: [],
+ commandItemLabel: 'Platform Kit',
+ },
+ ],
+}
+
// Component definitions with supported frameworks
export const componentPages: SidebarNavGroup = {
title: 'Components',
diff --git a/apps/ui-library/content/docs/platform/platform-kit.mdx b/apps/ui-library/content/docs/platform/platform-kit.mdx
new file mode 100644
index 00000000000..9e9ff343ea9
--- /dev/null
+++ b/apps/ui-library/content/docs/platform/platform-kit.mdx
@@ -0,0 +1,110 @@
+---
+title: Platform Kit
+description: The easiest way to build platforms on top of Supabase
+---
+
+
+
+ Your browser does not support the video tag.
+
+
+## Installation
+
+
+
+## Folder structure
+
+
+
+## Introduction
+
+The Platform Kit is a collection of customizable API's, hooks and components you can use to provide an embedded Supabase experience within your own platform. It comes in the form of a single dialog that enables the management of database, authentication, storage, users, secrets, logs, and performance monitoring.
+
+**Features**
+
+- Database, Auth, Storage, User, Secrets, Logs, and Performance management
+- Responsive dialog/drawer interface (desktop & mobile)
+- API proxy for Management API
+- AI-powered SQL generation (optional)
+- Customize to your liking
+
+## Who is it for
+
+Anyone who is providing Postgres databases to their users.
+
+## Usage
+
+Embed the manager dialog in your app and manage its state:
+
+```tsx
+import { useState } from 'react'
+import { useMobile } from '@/hooks/use-mobile'
+import { Button } from '@/components/ui/button'
+import SupabaseManagerDialog from '@/components/supabase-manager'
+
+export default function Example() {
+ const [open, setOpen] = useState(false)
+ const projectRef = 'your-project-ref' // Replace with your actual project ref
+ const isMobile = useMobile()
+
+ return (
+ <>
+
setOpen(true)}>Open Supabase Manager
+
+ >
+ )
+}
+```
+
+## Quick Start
+
+1. **Set up environment variables:** in your `.env.local` file:
+
+ ```bash
+ SUPABASE_MANAGEMENT_API_TOKEN=your-personal-access-token
+ NEXT_PUBLIC_ENABLE_AI_QUERIES=true
+ OPENAI_API_KEY=your-openai-api-key
+ ```
+
+2. **Add project-level authentication checks** in your API proxy at `app/api/supabase-proxy/[...path]/route.ts` as well as your ai/sql route at `app/api/ai/sql/route.ts` to ensure only authorized users can access their own project resources.
+
+3. **Add a Toaster for notifications:**
+ Place the following component at the root of your app (e.g., in your `layout.tsx` or `App.tsx`) to enable toast notifications:
+
+ ```tsx
+ import { Toaster } from '@/components/ui/sonner'
+
+ export default function RootLayout({ children }) {
+ return (
+
+
+
+ {children}
+
+
+
+ )
+ }
+ ```
+
+That's it! The default setup uses your Supabase personal access token for the Management API.
+
+## Security
+
+- Never expose your Management API token to the client
+- Always implement authentication and permission checks in your proxy
+
+## Further reading
+
+- [Supabase Management API](https://supabase.com/docs/reference/api/introduction)
diff --git a/apps/ui-library/package.json b/apps/ui-library/package.json
index 9bc4d888e92..b47da318fa6 100644
--- a/apps/ui-library/package.json
+++ b/apps/ui-library/package.json
@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"preinstall": "npx only-allow pnpm",
- "dev": "next dev --turbopack --port 3004",
+ "dev": "next dev --port 3004",
"build": "pnpm run content:build && pnpm run build:registry && pnpm run build:llms && next build",
"build:registry": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/build-registry.mts && prettier --cache --write registry.json && rimraf -G public/r && shadcn build && tsx scripts/clean-registry.ts",
"build:llms": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/build-llms-txt.ts",
@@ -17,17 +17,44 @@
"typecheck": "contentlayer2 build && tsc --noEmit -p tsconfig.json"
},
"dependencies": {
+ "@hookform/resolvers": "^3.1.1",
+ "@monaco-editor/react": "^4.6.0",
+ "@radix-ui/react-accordion": "*",
+ "@radix-ui/react-alert-dialog": "*",
+ "@radix-ui/react-aspect-ratio": "*",
"@radix-ui/react-avatar": "*",
"@radix-ui/react-checkbox": "*",
+ "@radix-ui/react-collapsible": "*",
+ "@radix-ui/react-context-menu": "*",
+ "@radix-ui/react-dialog": "*",
+ "@radix-ui/react-dropdown-menu": "*",
+ "@radix-ui/react-hover-card": "*",
"@radix-ui/react-label": "*",
+ "@radix-ui/react-menubar": "*",
+ "@radix-ui/react-navigation-menu": "*",
+ "@radix-ui/react-popover": "*",
"@radix-ui/react-progress": "*",
+ "@radix-ui/react-radio-group": "*",
+ "@radix-ui/react-scroll-area": "*",
+ "@radix-ui/react-select": "*",
+ "@radix-ui/react-separator": "*",
+ "@radix-ui/react-slider": "*",
"@radix-ui/react-slot": "*",
+ "@radix-ui/react-switch": "*",
+ "@radix-ui/react-tabs": "*",
+ "@radix-ui/react-toast": "*",
+ "@radix-ui/react-toggle": "*",
+ "@radix-ui/react-toggle-group": "*",
"@radix-ui/react-tooltip": "*",
"@react-router/fs-routes": "^7.4.0",
"@supabase/postgrest-js": "*",
"@supabase/supa-mdx-lint": "0.2.6-alpha",
+ "@tanstack/react-query": "^5.83.0",
+ "axios": "^1.10.0",
"class-variance-authority": "*",
+ "cmdk": "^1.0.0",
"common": "workspace:*",
+ "common-tags": "^1.8.2",
"contentlayer2": "0.4.6",
"eslint-config-supabase": "workspace:*",
"framer-motion": "^11.0.3",
@@ -37,11 +64,16 @@
"next": "catalog:",
"next-contentlayer2": "0.4.6",
"next-themes": "^0.3.0",
+ "openai": "^5.9.0",
+ "openapi-fetch": "0.12.4",
"react": "catalog:",
"react-docgen": "^7.0.3",
"react-dom": "catalog:",
+ "react-hook-form": "^7.45.0",
"react-inlinesvg": "^4.0.4",
+ "react-markdown": "^10.1.0",
"react-wrap-balancer": "^1.1.0",
+ "recharts": "^2.8.0",
"rehype-autolink-headings": "^7.1.0",
"rehype-pretty-code": "^0.9.0",
"rehype-slug": "^6.0.0",
@@ -52,6 +84,7 @@
"ui": "workspace:*",
"ui-patterns": "workspace:*",
"unist-util-visit": "^5.0.0",
+ "vaul": "^0.9.6",
"zod": "^3.22.4"
},
"devDependencies": {
@@ -61,6 +94,7 @@
"@supabase/supabase-js": "^2.49.1",
"@tanstack/react-router": "^1.114.27",
"@tanstack/react-start": "^1.114.25",
+ "@types/common-tags": "^1.8.4",
"@types/lodash": "^4.17.16",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
diff --git a/apps/ui-library/public/llms.txt b/apps/ui-library/public/llms.txt
index 863d7d8581e..137187575d0 100644
--- a/apps/ui-library/public/llms.txt
+++ b/apps/ui-library/public/llms.txt
@@ -1,5 +1,5 @@
# Supabase UI Library
-Last updated: 2025-07-07T17:50:06.137Z
+Last updated: 2025-07-13T02:57:35.186Z
## Overview
Library of components for your project. The components integrate with Supabase and are shadcn compatible.
diff --git a/apps/ui-library/public/r/platform-kit-nextjs.json b/apps/ui-library/public/r/platform-kit-nextjs.json
new file mode 100644
index 00000000000..7b9a5d2083e
--- /dev/null
+++ b/apps/ui-library/public/r/platform-kit-nextjs.json
@@ -0,0 +1,209 @@
+{
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
+ "name": "platform-kit-nextjs",
+ "type": "registry:block",
+ "title": "Platform Kit for Nextjs and Supabase",
+ "description": "Platform Kit for Nextjs and Supabase",
+ "dependencies": [
+ "@hookform/resolvers",
+ "@monaco-editor/react",
+ "@tanstack/react-query",
+ "@tanstack/react-table",
+ "axios",
+ "cmdk",
+ "common-tags",
+ "openai",
+ "openapi-fetch",
+ "react-hook-form",
+ "react-markdown",
+ "recharts",
+ "sonner"
+ ],
+ "registryDependencies": [
+ "alert",
+ "badge",
+ "button",
+ "card",
+ "chart",
+ "command",
+ "dialog",
+ "drawer",
+ "form",
+ "hover-card",
+ "input",
+ "label",
+ "popover",
+ "select",
+ "skeleton",
+ "switch",
+ "table",
+ "toggle",
+ "toggle-group",
+ "tooltip"
+ ],
+ "files": [
+ {
+ "path": "registry/default/platform/platform-kit-nextjs/app/api/ai/sql/route.ts",
+ "content": "import type { paths } from '@/registry/default/platform/platform-kit-nextjs/lib/management-api-schema'\nimport { listTablesSql } from '@/registry/default/platform/platform-kit-nextjs/lib/pg-meta'\nimport { NextResponse } from 'next/server'\nimport OpenAI from 'openai'\nimport createClient from 'openapi-fetch'\n\nconst openai = new OpenAI({\n apiKey: process.env.OPENAI_API_KEY,\n})\n\nconst client = createClient({\n baseUrl: 'https://api.supabase.com',\n headers: {\n Authorization: `Bearer ${process.env.SUPABASE_MANAGEMENT_API_TOKEN}`,\n },\n})\n\n// Function to get database schema\nasync function getDbSchema(projectRef: string) {\n const token = process.env.SUPABASE_MANAGEMENT_API_TOKEN\n if (!token) {\n throw new Error('Supabase Management API token is not configured.')\n }\n\n const sql = listTablesSql()\n\n const { data, error } = await client.POST('/v1/projects/{ref}/database/query', {\n params: {\n path: {\n ref: projectRef,\n },\n },\n body: {\n query: sql,\n read_only: true,\n },\n })\n\n if (error) {\n throw error\n }\n\n return data as any\n}\n\nfunction formatSchemaForPrompt(schema: any) {\n let schemaString = ''\n if (schema && Array.isArray(schema)) {\n schema.forEach((table: any) => {\n const columnInfo = table.columns.map((c: any) => `${c.name} (${c.data_type})`)\n schemaString += `Table \"${table.name}\" has columns: ${columnInfo.join(', ')}.\\n`\n })\n }\n return schemaString\n}\n\nexport async function POST(request: Request) {\n try {\n const { prompt, projectRef } = await request.json()\n\n if (!prompt) {\n return NextResponse.json({ message: 'Prompt is required.' }, { status: 400 })\n }\n if (!projectRef) {\n return NextResponse.json({ message: 'projectRef is required.' }, { status: 400 })\n }\n\n // Implement your permission check here (e.g. check if the user is a member of the project)\n // In this example, everyone can access all projects\n const userHasPermissionForProject = Boolean(projectRef)\n\n if (!userHasPermissionForProject) {\n return NextResponse.json(\n { message: 'You do not have permission to access this project.' },\n { status: 403 }\n )\n }\n\n // 1. Get database schema\n const schema = await getDbSchema(projectRef)\n const formattedSchema = formatSchemaForPrompt(schema)\n\n // 2. Create a prompt for OpenAI\n const systemPrompt = `You are an expert SQL assistant. Given the following database schema, write a SQL query that answers the user's question. Return only the SQL query, do not include any explanations or markdown.\\n\\nSchema:\\n${formattedSchema}`\n\n // 3. Call OpenAI to generate SQL using responses.create (plain text output)\n const response = await openai.responses.create({\n model: 'gpt-4.1',\n instructions: systemPrompt, // Use systemPrompt as instructions\n input: prompt, // User's question\n })\n\n const sql = response.output_text\n\n if (!sql) {\n return NextResponse.json(\n { message: 'Could not generate SQL from the prompt.' },\n { status: 500 }\n )\n }\n\n // 4. Return the generated SQL\n return NextResponse.json({ sql })\n } catch (error: any) {\n console.error('AI SQL generation error:', error)\n const errorMessage = error.message || 'An unexpected error occurred.'\n const status = error.response?.status || 500\n return NextResponse.json({ message: errorMessage }, { status })\n }\n}\n",
+ "type": "registry:page",
+ "target": "app/api/ai/sql/route.ts"
+ },
+ {
+ "path": "registry/default/platform/platform-kit-nextjs/app/api/supabase-proxy/[...path]/route.ts",
+ "content": "import { NextResponse } from 'next/server'\n\nasync function forwardToSupabaseAPI(request: Request, method: string, params: { path: string[] }) {\n if (!process.env.SUPABASE_MANAGEMENT_API_TOKEN) {\n console.error('Supabase Management API token is not configured.')\n return NextResponse.json({ message: 'Server configuration error.' }, { status: 500 })\n }\n\n const { path } = params\n const apiPath = path.join('/')\n\n const url = new URL(request.url)\n url.protocol = 'https'\n url.hostname = 'api.supabase.com'\n url.port = '443'\n url.pathname = apiPath\n\n const projectRef = path[2]\n\n // Implement your permission check here (e.g. check if the user is a member of the project)\n // In this example, everyone can access all projects\n const userHasPermissionForProject = Boolean(projectRef)\n\n if (!userHasPermissionForProject) {\n return NextResponse.json(\n { message: 'You do not have permission to access this project.' },\n { status: 403 }\n )\n }\n\n try {\n const forwardHeaders: HeadersInit = {\n Authorization: `Bearer ${process.env.SUPABASE_MANAGEMENT_API_TOKEN}`,\n }\n\n // Copy relevant headers from the original request\n const contentType = request.headers.get('content-type')\n if (contentType) {\n forwardHeaders['Content-Type'] = contentType\n }\n\n const fetchOptions: RequestInit = {\n method,\n headers: forwardHeaders,\n }\n\n // Include body for methods that support it\n if (method !== 'GET' && method !== 'HEAD') {\n try {\n const body = await request.text()\n if (body) {\n fetchOptions.body = body\n }\n } catch (error) {\n // Handle cases where body is not readable\n console.warn('Could not read request body:', error)\n }\n }\n\n const response = await fetch(url, fetchOptions)\n\n // Get response body\n const responseText = await response.text()\n let responseData\n\n try {\n responseData = responseText ? JSON.parse(responseText) : null\n } catch {\n responseData = responseText\n }\n\n // Return the response with the same status\n return NextResponse.json(responseData, { status: response.status })\n } catch (error: any) {\n console.error('Supabase API proxy error:', error)\n const errorMessage = error.message || 'An unexpected error occurred.'\n return NextResponse.json({ message: errorMessage }, { status: 500 })\n }\n}\n\nexport async function GET(request: Request, { params }: { params: Promise<{ path: string[] }> }) {\n const resolvedParams = await params\n return forwardToSupabaseAPI(request, 'GET', resolvedParams)\n}\n\nexport async function HEAD(request: Request, { params }: { params: Promise<{ path: string[] }> }) {\n const resolvedParams = await params\n return forwardToSupabaseAPI(request, 'HEAD', resolvedParams)\n}\n\nexport async function POST(request: Request, { params }: { params: Promise<{ path: string[] }> }) {\n const resolvedParams = await params\n return forwardToSupabaseAPI(request, 'POST', resolvedParams)\n}\n\nexport async function PUT(request: Request, { params }: { params: Promise<{ path: string[] }> }) {\n const resolvedParams = await params\n return forwardToSupabaseAPI(request, 'PUT', resolvedParams)\n}\n\nexport async function DELETE(\n request: Request,\n { params }: { params: Promise<{ path: string[] }> }\n) {\n const resolvedParams = await params\n return forwardToSupabaseAPI(request, 'DELETE', resolvedParams)\n}\n\nexport async function PATCH(request: Request, { params }: { params: Promise<{ path: string[] }> }) {\n const resolvedParams = await params\n return forwardToSupabaseAPI(request, 'PATCH', resolvedParams)\n}\n",
+ "type": "registry:page",
+ "target": "app/api/supabase-proxy/[...path]/route.ts"
+ },
+ {
+ "path": "registry/default/platform/platform-kit-nextjs/components/dynamic-form.tsx",
+ "content": "'use client'\n\nimport { zodResolver } from '@hookform/resolvers/zod'\nimport { useForm } from 'react-hook-form'\nimport type { z, ZodTypeAny } from 'zod'\nimport { Button } from '@/registry/default/components/ui/button'\nimport {\n Form,\n FormControl,\n FormDescription,\n FormField,\n FormItem,\n FormLabel,\n FormMessage,\n} from '@/registry/default/components/ui/form'\nimport { Input } from '@/registry/default/components/ui/input'\nimport { Switch } from '@/registry/default/components/ui/switch'\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from '@/registry/default/components/ui/select'\nimport { useEffect, useRef } from 'react'\n\ninterface DynamicFormProps {\n schema: z.ZodObject\n onSubmit: (data: z.infer>) => void\n isLoading?: boolean\n initialValues?: Partial>>\n labels?: Record }>\n columnInfo?: Record\n}\n\nconst getZodDef = (schema: ZodTypeAny): Record =>\n (schema as any)._def || (schema as any).def\n\nconst unwrapZodType = (fieldSchema: ZodTypeAny): ZodTypeAny => {\n if (\n !fieldSchema ||\n typeof getZodDef(fieldSchema) !== 'object' ||\n getZodDef(fieldSchema) === null\n ) {\n throw new Error(\n `unwrapZodType received an invalid Zod schema object. Check the console for the problematic schema/key.`\n )\n }\n let currentSchema = fieldSchema\n\n // Support both old (typeName) and new (type) Zod formats\n const getTypeName = (def: Record) => def.typeName || def.type\n\n while (\n getTypeName(getZodDef(currentSchema)) === 'ZodOptional' ||\n getTypeName(getZodDef(currentSchema)) === 'optional' ||\n getTypeName(getZodDef(currentSchema)) === 'ZodDefault' ||\n getTypeName(getZodDef(currentSchema)) === 'default' ||\n getTypeName(getZodDef(currentSchema)) === 'ZodNullable' ||\n getTypeName(getZodDef(currentSchema)) === 'nullable' ||\n getTypeName(getZodDef(currentSchema)) === 'ZodEffects' ||\n getTypeName(getZodDef(currentSchema)) === 'effects'\n ) {\n const typeName = getTypeName(getZodDef(currentSchema))\n if (typeName === 'ZodEffects' || typeName === 'effects') {\n // For ZodEffects, get the schema inside the effect\n currentSchema = getZodDef(currentSchema).schema\n } else {\n currentSchema = getZodDef(currentSchema).innerType\n }\n }\n return currentSchema\n}\n\nexport function DynamicForm({\n schema,\n onSubmit,\n isLoading = false,\n initialValues,\n labels,\n columnInfo,\n}: DynamicFormProps) {\n const isInitializingRef = useRef(true)\n\n const defaultValues = Object.keys(schema.shape).reduce(\n (acc, key) => {\n const originalFieldSchema = schema.shape[key]\n if (typeof originalFieldSchema === 'undefined') {\n throw new Error(\n `Schema error: schema.shape['${key}'] is undefined. Check schema definition.`\n )\n }\n\n // Support both old (typeName) and new (type) Zod formats\n const getTypeName = (def: Record) => def.typeName || def.type\n\n if (\n getTypeName(getZodDef(originalFieldSchema)) === 'ZodDefault' ||\n getTypeName(getZodDef(originalFieldSchema)) === 'default'\n ) {\n acc[key] = getZodDef(originalFieldSchema).defaultValue()\n return acc\n }\n\n const baseType = unwrapZodType(originalFieldSchema)\n\n switch (getTypeName(getZodDef(baseType))) {\n case 'ZodString':\n case 'string':\n acc[key] = ''\n break\n case 'ZodBoolean':\n case 'boolean':\n acc[key] = false // Default optional booleans to false\n break\n case 'ZodEnum':\n case 'enum':\n // For enums, use the first enum value as default\n const enumValues = getZodDef(baseType).values || []\n acc[key] = enumValues.length > 0 ? enumValues[0] : ''\n break\n case 'ZodNumber':\n case 'number':\n acc[key] = 0 // Default optional numbers to 0\n break\n case 'ZodArray':\n case 'array':\n acc[key] = [] // Default arrays to empty array\n break\n default:\n acc[key] = undefined // For other types, or if truly no default makes sense\n break\n }\n return acc\n },\n {} as Record\n )\n\n const form = useForm>({\n resolver: zodResolver(schema),\n defaultValues: defaultValues as any,\n })\n\n useEffect(() => {\n if (initialValues) {\n isInitializingRef.current = true\n const schemaKeys = Object.keys(schema.shape)\n const processedInitialValues = schemaKeys.reduce(\n (acc, key) => {\n const fieldDefFromSchema = schema.shape[key]\n if (typeof fieldDefFromSchema === 'undefined') {\n throw new Error(`Schema error in useEffect: schema.shape['${key}'] is undefined.`)\n }\n const value = initialValues.hasOwnProperty(key) ? initialValues[key] : undefined\n const baseFieldType = unwrapZodType(fieldDefFromSchema)\n\n // Support both old (typeName) and new (type) Zod formats\n const getTypeName = (def: Record) => def.typeName || def.type\n\n const fieldTypeName = getTypeName(getZodDef(baseFieldType))\n if (fieldTypeName === 'ZodBoolean' || fieldTypeName === 'boolean') {\n acc[key] = !!value\n } else if (fieldTypeName === 'ZodString' || fieldTypeName === 'string') {\n acc[key] = value === null || value === undefined ? '' : String(value)\n } else if (fieldTypeName === 'ZodEnum' || fieldTypeName === 'enum') {\n const enumValues = getZodDef(baseFieldType).values || []\n acc[key] =\n value === null || value === undefined || !enumValues.includes(value)\n ? enumValues[0] || ''\n : String(value)\n } else if (fieldTypeName === 'ZodNumber' || fieldTypeName === 'number') {\n const num = Number(value)\n acc[key] = isNaN(num) ? 0 : num\n } else if (fieldTypeName === 'ZodArray' || fieldTypeName === 'array') {\n // For arrays, the value should already be an array from the database\n // Handle null values properly\n if (Array.isArray(value)) {\n acc[key] = value\n } else if (value === null || value === undefined) {\n acc[key] = []\n } else if (typeof value === 'string' && value.startsWith('{') && value.endsWith('}')) {\n // Parse PostgreSQL array format like {ONE,TWO} into JavaScript array\n try {\n const innerContent = value.slice(1, -1) // Remove { and }\n if (innerContent.trim() === '') {\n acc[key] = []\n } else {\n // Split by comma and trim whitespace\n acc[key] = innerContent.split(',').map((item: string) => item.trim())\n }\n } catch {\n // If parsing fails, default to empty array\n acc[key] = []\n }\n } else {\n acc[key] = []\n }\n } else {\n acc[key] = value\n }\n return acc\n },\n {} as Record\n )\n form.reset(processedInitialValues as any)\n setTimeout(() => {\n isInitializingRef.current = false\n }, 0)\n } else {\n isInitializingRef.current = false\n }\n }, [initialValues, form, schema])\n\n const renderField = (fieldName: string, fieldSchema: ZodTypeAny) => {\n const baseType = unwrapZodType(fieldSchema)\n // Support both old (typeName) and new (type) Zod formats\n const getTypeName = (def: Record) => def.typeName || def.type\n const typeName = getTypeName(getZodDef(baseType))\n const description = fieldSchema.description\n\n return (\n {\n const labelConfig = labels?.[fieldName]\n const label =\n typeof labelConfig === 'string' ? labelConfig : labelConfig?.label || fieldName\n const typeDisplay = columnInfo?.[fieldName]?.data_type || ''\n switch (typeName) {\n case 'ZodString':\n case 'string':\n return (\n \n \n
\n
{label} \n
{typeDisplay}
\n {description &&
{description} }\n
\n
\n
\n \n \n \n
\n
\n \n )\n case 'ZodNumber':\n case 'number':\n return (\n \n \n
\n
{label} \n
{typeDisplay}
\n {description &&
{description} }\n
\n
\n
\n \n {\n const value = e.target.value\n const num = parseInt(value, 10)\n field.onChange(isNaN(num) ? undefined : num)\n }}\n />\n \n
\n
\n \n )\n case 'ZodBoolean':\n case 'boolean':\n return (\n \n \n
{label} \n
{typeDisplay}
\n {description &&
{description} }\n
\n
\n \n \n \n \n )\n case 'ZodEnum':\n case 'enum':\n const options = getZodDef(baseType).values\n const optionLabels = typeof labelConfig === 'object' ? labelConfig.options : undefined\n return (\n \n \n
\n
{label} \n
{typeDisplay}
\n {description &&
{description} }\n
\n
\n
\n {\n if (!isInitializingRef.current) {\n field.onChange(value)\n }\n }}\n value={String(field.value || '')}\n >\n \n \n \n \n \n \n {options.map((option: string) => (\n \n {optionLabels?.[option] || option}\n \n ))}\n \n \n
\n
\n \n )\n case 'ZodArray':\n case 'array':\n return (\n \n \n
\n
{label} \n
\n Enter as JSON array: ["item1", "item2"]\n {columnInfo?.[fieldName]?.is_nullable && ' (leave empty for null)'}\n
\n\n {description &&
{description} }\n
\n
\n
\n \n {\n const value = e.target.value\n if (value.trim() === '') {\n // Empty string means null (for nullable) or empty array (for non-nullable)\n field.onChange(null)\n } else {\n try {\n const parsed = JSON.parse(value)\n if (Array.isArray(parsed)) {\n field.onChange(parsed)\n } else {\n // If it's not an array, treat as invalid input\n field.onChange(value)\n }\n } catch {\n // Invalid JSON, keep the string value to show error\n field.onChange(value)\n }\n }\n }}\n />\n \n
\n
\n \n )\n default:\n return <>>\n }\n }}\n />\n )\n }\n\n return (\n \n \n {Object.keys(schema.shape).map((fieldName) =>\n renderField(fieldName, schema.shape[fieldName])\n )}\n \n \n {isLoading ? 'Saving...' : 'Save'}\n \n
\n \n \n )\n}\n",
+ "type": "registry:component"
+ },
+ {
+ "path": "registry/default/platform/platform-kit-nextjs/components/logo-supabase.tsx",
+ "content": "export function LogoSupabase({ className, size = 24 }: { className?: string; size?: number }) {\n return (\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n )\n}\n",
+ "type": "registry:component"
+ },
+ {
+ "path": "registry/default/platform/platform-kit-nextjs/components/results-table.tsx",
+ "content": "'use client'\n\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from '@/registry/default/components/ui/table'\nimport { cn } from '@/lib/utils'\n\ninterface ResultsTableProps {\n data: any[]\n onRowClick?: (row: any) => void\n}\n\nexport function ResultsTable({ data, onRowClick }: ResultsTableProps) {\n if (!data || data.length === 0) {\n return The query returned no results.
\n }\n\n const headers = Object.keys(data[0])\n\n return (\n \n
\n \n \n {headers.map((header) => (\n \n {header}\n \n ))}\n \n \n \n {data.map((row, rowIndex) => (\n onRowClick?.(row)}\n className={cn(onRowClick && 'cursor-pointer hover:bg-muted/50 group')}\n >\n {headers.map((header) => (\n \n \n {JSON.stringify(row[header]).replace(/^\"|\"$/g, '')}\n
\n \n ))}\n \n ))}\n \n
\n
\n )\n}\n",
+ "type": "registry:component"
+ },
+ {
+ "path": "registry/default/platform/platform-kit-nextjs/components/sql-editor.tsx",
+ "content": "'use client'\n\nimport { useState, useEffect, useCallback, useMemo } from 'react'\nimport { Editor } from '@monaco-editor/react'\nimport { Button } from '@/registry/default/components/ui/button'\nimport { ResultsTable } from '@/registry/default/platform/platform-kit-nextjs/components/results-table'\nimport { Label } from '@/registry/default/components/ui/label'\nimport { useRunQuery } from '@/registry/default/platform/platform-kit-nextjs/hooks/use-run-query'\nimport {\n ArrowUp,\n Loader2,\n BarChart as BarChartIcon,\n Wand,\n FileText,\n AlertTriangle,\n} from 'lucide-react'\nimport { Input } from '@/registry/default/components/ui/input'\nimport { Skeleton } from '@/registry/default/components/ui/skeleton'\nimport { Popover, PopoverContent, PopoverTrigger } from '@/registry/default/components/ui/popover'\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from '@/registry/default/components/ui/select'\nimport { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts'\nimport {\n ChartConfig,\n ChartContainer,\n ChartTooltip,\n ChartTooltipContent,\n} from '@/registry/default/components/ui/chart'\nimport { ToggleGroup, ToggleGroupItem } from '@/registry/default/components/ui/toggle-group'\nimport { Switch } from '@/registry/default/components/ui/switch'\nimport { Alert, AlertDescription, AlertTitle } from '@/registry/default/components/ui/alert'\n\ninterface SqlEditorProps {\n projectRef: string\n initialSql?: string\n queryKey?: any\n label?: string\n onResults?: (data: any[] | undefined) => void\n onRowClick?: (row: any, queryKey?: any) => void\n hideSql?: boolean\n readOnly?: boolean\n runAutomatically?: boolean\n refetch?: number\n initialNaturalLanguageMode?: boolean\n hideChartOption?: boolean\n}\n\nexport function SqlEditor({\n projectRef,\n initialSql,\n queryKey,\n onResults,\n onRowClick,\n label = 'Query your data',\n hideSql = false,\n readOnly = false,\n runAutomatically = false,\n refetch,\n initialNaturalLanguageMode = false,\n hideChartOption = false,\n}: SqlEditorProps) {\n const [sql, setSql] = useState(initialSql || '')\n const [isSqlVisible, setIsSqlVisible] = useState(!hideSql)\n const [isNaturalLanguageMode, setIsNaturalLanguageMode] = useState(\n process.env.NEXT_PUBLIC_ENABLE_AI_QUERIES === 'true' && initialNaturalLanguageMode\n )\n const [naturalLanguageQuery, setNaturalLanguageQuery] = useState('')\n const { mutate: runQuery, data, isPending, error } = useRunQuery()\n const [isGeneratingSql, setIsGeneratingSql] = useState(false)\n const [aiError, setAiError] = useState(null)\n const [isChartVisible, setIsChartVisible] = useState(false)\n const [xAxisColumn, setXAxisColumn] = useState(null)\n const [yAxisColumn, setYAxisColumn] = useState(null)\n\n const columns = useMemo(() => {\n if (!data || data.length === 0) return []\n return Object.keys(data[0])\n }, [data])\n\n useEffect(() => {\n if (initialSql) {\n setSql(initialSql)\n }\n }, [initialSql])\n\n const handleRunNaturalLanguageQuery = async () => {\n if (!naturalLanguageQuery) return\n\n setIsGeneratingSql(true)\n setAiError(null)\n try {\n const response = await fetch('/api/ai/sql', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({\n prompt: naturalLanguageQuery,\n projectRef,\n }),\n })\n\n if (!response.ok) {\n const errorData = await response.json()\n throw new Error(errorData.message || 'Failed to generate SQL')\n }\n\n const { sql: generatedSql } = await response.json()\n setSql(generatedSql)\n runQuery({ projectRef, query: generatedSql, readOnly: true })\n } catch (error: any) {\n console.error(error)\n setAiError(error.message)\n } finally {\n setIsGeneratingSql(false)\n }\n }\n\n const handleRunQuery = useCallback(() => {\n if (sql) {\n runQuery({ projectRef, query: sql, readOnly: true })\n }\n }, [sql, projectRef, runQuery])\n\n useEffect(() => {\n setIsSqlVisible(!hideSql)\n }, [hideSql])\n\n useEffect(() => {\n if (runAutomatically && initialSql) {\n runQuery({ projectRef, query: initialSql, readOnly: true })\n }\n }, [runAutomatically, initialSql, projectRef, runQuery])\n\n useEffect(() => {\n if (refetch && refetch > 0) {\n handleRunQuery()\n }\n }, [refetch, handleRunQuery])\n\n useEffect(() => {\n if (onResults) {\n onResults(data)\n }\n }, [data, onResults])\n\n useEffect(() => {\n const noResults = !data || (Array.isArray(data) && data.length === 0)\n if (noResults && !isSqlVisible && !isNaturalLanguageMode && !readOnly && !isPending) {\n setIsSqlVisible(true)\n }\n }, [data, isSqlVisible, isNaturalLanguageMode])\n\n const serverErrorMessage = (error as any)?.response?.data?.message || ''\n const isReadOnlyError =\n serverErrorMessage.includes('permission denied') || serverErrorMessage.includes('42501')\n const customReadOnlyError = \"You can't directly alter your database schema, use chat instead\"\n\n // Build the toggle-group selection based on current UI state\n const toggleValues = useMemo(() => {\n const values: string[] = []\n if (isNaturalLanguageMode) values.push('chat')\n if (isSqlVisible) values.push('sql')\n if (!hideChartOption && isChartVisible) values.push('chart')\n return values\n }, [isNaturalLanguageMode, isSqlVisible, isChartVisible, hideChartOption])\n\n const handleToggleGroupChange = (values: string[]) => {\n setIsNaturalLanguageMode(values.includes('chat'))\n setIsSqlVisible(values.includes('sql'))\n if (!hideChartOption) {\n setIsChartVisible(values.includes('chart'))\n }\n }\n\n return (\n \n
\n
\n
{label} \n \n {process.env.NEXT_PUBLIC_ENABLE_AI_QUERIES === 'true' && (\n