No secrets created
There are no secrets associated with your project yet
diff --git a/apps/studio/components/interfaces/Home/Home.constants.ts b/apps/studio/components/interfaces/Home/Home.constants.ts
index dc3aa38e387..46181f40fdc 100644
--- a/apps/studio/components/interfaces/Home/Home.constants.ts
+++ b/apps/studio/components/interfaces/Home/Home.constants.ts
@@ -63,7 +63,7 @@ export const EXAMPLE_PROJECTS = [
framework: 'nextjs',
title: 'Next.js Subscription and Auth',
description: 'The all-in-one starter kit for high-performance SaaS applications.',
- url: 'https://github.com/vercel/nextjs-subscription-payments',
+ url: 'https://github.com/nextjs/saas-starter',
type: 'app',
},
{
diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobScheduleSection.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobScheduleSection.tsx
index 5dc322de01a..09ad555434f 100644
--- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobScheduleSection.tsx
+++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobScheduleSection.tsx
@@ -3,11 +3,10 @@ import { useEffect, useState } from 'react'
import { UseFormReturn } from 'react-hook-form'
import { useDebounce } from 'use-debounce'
-import { useCompletion } from 'ai/react'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
+import { useSqlCronGenerateMutation } from 'data/ai/sql-cron-mutation'
import { useCronTimezoneQuery } from 'data/database-cron-jobs/database-cron-timezone-query'
-import { constructHeaders } from 'data/fetchers'
-import { BASE_PATH } from 'lib/constants'
+import { useFlag } from 'hooks/ui/useFlag'
import {
Accordion_Shadcn_,
AccordionContent_Shadcn_,
@@ -37,6 +36,7 @@ interface CronJobScheduleSectionProps {
export const CronJobScheduleSection = ({ form, supportsSeconds }: CronJobScheduleSectionProps) => {
const { project } = useProjectContext()
+ const useBedrockAssistant = useFlag('useBedrockAssistant')
const [inputValue, setInputValue] = useState('')
const [debouncedValue] = useDebounce(inputValue, 750)
const [useNaturalLanguage, setUseNaturalLanguage] = useState(false)
@@ -50,25 +50,13 @@ export const CronJobScheduleSection = ({ form, supportsSeconds }: CronJobSchedul
{ name: 'Every Monday at 2 AM', expression: '0 2 * * 1' },
] as const
- const {
- complete: generateCronSyntax,
- isLoading: isGeneratingCron,
- stop,
- } = useCompletion({
- api: `${BASE_PATH}/api/ai/sql/cron`,
- onResponse: async (response) => {
- if (response.ok) {
- // remove quotes from the cron expression
- const expression = (await response.text()).trim().replace(/^"|"$/g, '')
- form.setValue('schedule', expression, {
- shouldValidate: true,
- shouldDirty: true,
- shouldTouch: true,
- })
- }
- },
- onError: (error) => {
- console.error('Error generating cron:', error)
+ const { mutate: generateCronSyntax, isLoading: isGeneratingCron } = useSqlCronGenerateMutation({
+ onSuccess: (expression) => {
+ form.setValue('schedule', expression, {
+ shouldValidate: true,
+ shouldDirty: true,
+ shouldTouch: true,
+ })
},
})
@@ -79,12 +67,7 @@ export const CronJobScheduleSection = ({ form, supportsSeconds }: CronJobSchedul
useEffect(() => {
if (useNaturalLanguage && debouncedValue) {
- constructHeaders().then((headers) =>
- generateCronSyntax(debouncedValue, {
- headers: { Authorization: headers.get('Authorization') ?? '' },
- })
- )
- return () => stop()
+ generateCronSyntax({ prompt: debouncedValue, useBedrockAssistant })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedValue, useNaturalLanguage])
diff --git a/apps/studio/components/interfaces/JwtSecrets/algorithm-details.ts b/apps/studio/components/interfaces/JwtSecrets/algorithm-details.ts
new file mode 100644
index 00000000000..66b1f589697
--- /dev/null
+++ b/apps/studio/components/interfaces/JwtSecrets/algorithm-details.ts
@@ -0,0 +1,97 @@
+export interface AlgorithmDetail {
+ name: string
+ description: string
+ pros: string[]
+ cons: string[]
+ label: string
+ shortDescription: string
+ links: { url: string; label: string }[]
+}
+
+export const algorithmDetails: Record = {
+ HS256: {
+ label: 'HS256 (Symmetric)',
+ name: 'HMAC with SHA-256',
+ description: 'Symmetric algorithm using a shared secret key',
+ pros: [
+ 'Fast and simple to use',
+ 'Requires less computational power',
+ 'Suitable for server-to-server communication',
+ ],
+ cons: [
+ 'Requires secure key exchange',
+ "Not suitable when the verifier shouldn't be able to sign tokens",
+ 'Key needs to be kept secret on both sides',
+ ],
+ shortDescription: 'HMAC with SHA-256: Fast, simple, requires secure key exchange',
+ links: [
+ { url: 'https://jwt.io/introduction', label: 'JWT.io Introduction' },
+ {
+ url: 'https://datatracker.ietf.org/doc/html/rfc7518#section-3.2',
+ label: 'RFC 7518 Specification',
+ },
+ ],
+ },
+ RS256: {
+ label: 'RSA 2048',
+ name: 'RSA with SHA-256',
+ description: 'Asymmetric algorithm using a public/private key pair',
+ pros: [
+ 'Allows public key to be distributed freely',
+ 'Private key can be kept secret on the signing side',
+ "Suitable for scenarios where the verifier shouldn't be able to sign tokens",
+ ],
+ cons: [
+ 'Slower than HS256',
+ 'Requires more computational power',
+ 'Keys are larger than ECDSA keys',
+ ],
+ shortDescription: 'RSA with SHA-256: Allows public key distribution, slower',
+ links: [
+ { url: 'https://jwt.io/introduction', label: 'JWT.io Introduction' },
+ {
+ url: 'https://datatracker.ietf.org/doc/html/rfc7518#section-3.3',
+ label: 'RFC 7518 Specification',
+ },
+ ],
+ },
+ ES256: {
+ label: 'ECC (P-256)',
+ name: 'ECDSA with SHA-256',
+ description: 'Asymmetric algorithm using elliptic curve cryptography',
+ pros: [
+ 'Faster than RSA',
+ 'Smaller key and signature sizes compared to RSA',
+ 'Provides forward secrecy',
+ ],
+ cons: [
+ 'Less widely supported than RSA',
+ 'More complex to implement correctly',
+ 'Requires careful implementation to avoid timing attacks',
+ ],
+ shortDescription: 'ECDSA with SHA-256: Compact keys, fast, modern alternative to RSA',
+ links: [
+ { url: 'https://jwt.io/introduction', label: 'JWT.io Introduction' },
+ {
+ url: 'https://datatracker.ietf.org/doc/html/rfc7518#section-3.4',
+ label: 'RFC 7518 Specification',
+ },
+ ],
+ },
+}
+
+export const algorithmLabels = Object.keys(algorithmDetails).reduce(
+ (a, i) => {
+ a[i] = algorithmDetails[i].label
+ return a
+ },
+ {} as { [name: keyof typeof algorithmDetails]: string }
+)
+
+export const algorithmDescriptions = Object.keys(algorithmDetails).reduce(
+ (a, i) => {
+ a[i] = algorithmDetails[i].shortDescription
+ return a
+ },
+ {} as { [name: keyof typeof algorithmDetails]: string }
+)
diff --git a/apps/studio/components/interfaces/JwtSecrets/algorithm-hover-card.tsx b/apps/studio/components/interfaces/JwtSecrets/algorithm-hover-card.tsx
new file mode 100644
index 00000000000..79d9cf31777
--- /dev/null
+++ b/apps/studio/components/interfaces/JwtSecrets/algorithm-hover-card.tsx
@@ -0,0 +1,24 @@
+import { GlobeLock } from 'lucide-react'
+import React from 'react'
+
+import { InfoPill } from '../../ui/InfoPill'
+import { AlgorithmDetail, algorithmDetails } from './algorithm-details'
+
+interface AlgorithmHoverCardProps {
+ algorithm: keyof typeof algorithmDetails
+ legacy?: boolean
+}
+
+export const AlgorithmHoverCard: React.FC = ({ algorithm, legacy }) => {
+ const details: AlgorithmDetail = algorithmDetails[algorithm]
+
+ return (
+ }
+ title={details.name}
+ description={details.description}
+ links={details.links}
+ />
+ )
+}
diff --git a/apps/studio/components/interfaces/JwtSecrets/illustrations.tsx b/apps/studio/components/interfaces/JwtSecrets/illustrations.tsx
new file mode 100644
index 00000000000..f261c0bdc5f
--- /dev/null
+++ b/apps/studio/components/interfaces/JwtSecrets/illustrations.tsx
@@ -0,0 +1,384 @@
+export const StandbyKeyIllustration = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+)
+
+export const WhyRotateKeysIllustration = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+)
+
+export const WhyUseStandbyKeysIllustration = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+)
diff --git a/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/action-panel.tsx b/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/action-panel.tsx
new file mode 100644
index 00000000000..cfebd69aa98
--- /dev/null
+++ b/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/action-panel.tsx
@@ -0,0 +1,37 @@
+import { ComponentProps, forwardRef } from 'react'
+import { Button, Card, CardDescription, CardHeader, CardTitle } from 'ui'
+
+interface ActionPanelProps extends Omit, 'onClick' | 'type'> {
+ title: string
+ description: string
+ buttonLabel: ComponentProps['children']
+ onClick: ComponentProps['onClick']
+ loading: ComponentProps['loading']
+ icon?: ComponentProps['icon']
+ type?: ComponentProps['type']
+}
+
+export const ActionPanel = forwardRef(
+ ({ title, description, buttonLabel, onClick, loading, icon, type, ...props }, ref) => {
+ return (
+
+
+
+ {title}
+ {description}
+
+
+
+ {buttonLabel}
+
+
+
+
+ )
+ }
+)
+ActionPanel.displayName = 'ActionPanel'
diff --git a/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/create-key-dialog.tsx b/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/create-key-dialog.tsx
new file mode 100644
index 00000000000..c0a4bb2bdc2
--- /dev/null
+++ b/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/create-key-dialog.tsx
@@ -0,0 +1,88 @@
+import { useJWTSigningKeyCreateMutation } from 'data/jwt-signing-keys/jwt-signing-key-create-mutation'
+import { JWTAlgorithm } from 'data/jwt-signing-keys/jwt-signing-keys-query'
+import { useState } from 'react'
+import { toast } from 'sonner'
+import {
+ Button,
+ DialogFooter,
+ DialogHeader,
+ DialogSection,
+ DialogSectionSeparator,
+ DialogTitle,
+ Label_Shadcn_,
+ SelectContent_Shadcn_,
+ SelectItem_Shadcn_,
+ SelectTrigger_Shadcn_,
+ SelectValue_Shadcn_,
+ Select_Shadcn_,
+} from 'ui'
+import { algorithmDescriptions } from '../algorithm-details'
+
+export const CreateKeyDialog = ({
+ projectRef,
+ onClose,
+}: {
+ projectRef: string
+ onClose: () => void
+}) => {
+ const [newKeyAlgorithm, setNewKeyAlgorithm] = useState('RS256')
+
+ const { mutate, isLoading: isLoadingMutation } = useJWTSigningKeyCreateMutation({
+ onSuccess: () => {
+ onClose()
+ },
+ onError: (error) => {
+ toast.error(`Failed to add new standby key: ${error.message}`)
+ },
+ })
+
+ const handleAddNewStandbyKey = async () => {
+ mutate({
+ projectRef: projectRef!,
+ algorithm: newKeyAlgorithm,
+ status: 'standby',
+ })
+ }
+
+ return (
+ <>
+
+ Create a new Standby Key
+
+
+
+
+
Choose the key type to use:
+
setNewKeyAlgorithm(value)}
+ >
+
+
+
+
+ HS256 (Symmetric)
+ RS256 (RSA)
+ ES256 (ECC)
+
+ EdDSA (Ed25519)
+
+
+
+
+ {algorithmDescriptions[newKeyAlgorithm]}
+
+
+
+
+ handleAddNewStandbyKey()}
+ disabled={isLoadingMutation}
+ loading={isLoadingMutation}
+ >
+ Create standby key
+
+
+ >
+ )
+}
diff --git a/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/index.tsx b/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/index.tsx
new file mode 100644
index 00000000000..222edf7b24a
--- /dev/null
+++ b/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/index.tsx
@@ -0,0 +1,861 @@
+import { AnimatePresence, motion } from 'framer-motion'
+import {
+ ArrowRight,
+ CircleArrowUp,
+ Eye,
+ FileKey,
+ Key,
+ MoreVertical,
+ RotateCw,
+ ShieldOff,
+ Timer,
+ Trash2,
+} from 'lucide-react'
+import { useMemo, useState } from 'react'
+
+import { useParams } from 'common'
+import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
+import { useJWTSigningKeyDeleteMutation } from 'data/jwt-signing-keys/jwt-signing-key-delete-mutation'
+import { useJWTSigningKeyUpdateMutation } from 'data/jwt-signing-keys/jwt-signing-key-update-mutation'
+import {
+ JWTAlgorithm,
+ JWTSigningKey,
+ useJWTSigningKeysQuery,
+} from 'data/jwt-signing-keys/jwt-signing-keys-query'
+import { useLegacyJWTSigningKeyCreateMutation } from 'data/jwt-signing-keys/legacy-jwt-signing-key-create-mutation'
+import { useLegacyJWTSigningKeyQuery } from 'data/jwt-signing-keys/legacy-jwt-signing-key-query'
+import { useLegacyAPIKeysStatusQuery } from 'data/api-keys/legacy-api-keys-status-query'
+import { useFlag } from 'hooks/ui/useFlag'
+import {
+ Badge,
+ Button,
+ Card,
+ CardContent,
+ cn,
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogSection,
+ DialogSectionSeparator,
+ DialogTitle,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+ Select_Shadcn_,
+ SelectContent_Shadcn_,
+ SelectItem_Shadcn_,
+ SelectTrigger_Shadcn_,
+ SelectValue_Shadcn_,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from 'ui'
+import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal'
+import { algorithmDescriptions, algorithmLabels } from '../algorithm-details'
+import { AlgorithmHoverCard } from '../algorithm-hover-card'
+import { statusColors, statusLabels } from '../jwt.constants'
+import { SigningKeysComingSoonBanner } from '../signing-keys-coming-soon'
+import { StartUsingJwtSigningKeysBanner } from '../start-using-keys-banner'
+import { ActionPanel } from './action-panel'
+import { CreateKeyDialog } from './create-key-dialog'
+import { SigningKeyRow } from './signing-key-row'
+
+const MotionTableRow = motion(TableRow)
+
+export default function JWTSecretKeysTable() {
+ const { ref: projectRef } = useParams()
+ const newJwtSecrets = useFlag('newJwtSecrets')
+
+ const [selectedKey, setSelectedKey] = useState(null)
+ const [shownDialog, setShownDialog] = useState<
+ 'legacy' | 'create' | 'rotate' | 'confirm-rotate' | 'key-details' | 'revoke' | 'delete' | null
+ >(null)
+
+ const resetDialog = () => {
+ setSelectedKey(null)
+ setShownDialog(null)
+ }
+
+ const [newKeyAlgorithm, setNewKeyAlgorithm] = useState('RS256')
+
+ const { data: signingKeys, isLoading: isLoadingSigningKeys } = useJWTSigningKeysQuery({
+ projectRef,
+ })
+ const { data: legacyKey, isLoading: isLoadingLegacyKey } = useLegacyJWTSigningKeyQuery({
+ projectRef,
+ })
+ const { data: legacyAPIKeysStatus, isLoading: isLoadingLegacyAPIKeysStatus } =
+ useLegacyAPIKeysStatusQuery({ projectRef })
+
+ const legacyMutation = useLegacyJWTSigningKeyCreateMutation()
+
+ const updateMutation = useJWTSigningKeyUpdateMutation()
+ const deleteMutation = useJWTSigningKeyDeleteMutation()
+
+ const isLoadingMutation =
+ updateMutation.isLoading || deleteMutation.isLoading || legacyMutation.isLoading
+ const isLoading = isLoadingSigningKeys || isLoadingLegacyKey || isLoadingLegacyAPIKeysStatus
+
+ const sortedKeys = useMemo(() => {
+ if (!signingKeys || !Array.isArray(signingKeys.keys)) return []
+
+ return signingKeys.keys.sort((a: JWTSigningKey, b: JWTSigningKey) => {
+ const order: Record = {
+ standby: 0,
+ in_use: 1,
+ previously_used: 2,
+ revoked: 3,
+ }
+ return (
+ order[a.status] - order[b.status] ||
+ new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
+ )
+ })
+ }, [signingKeys])
+
+ const standbyKey = useMemo(() => sortedKeys.find((key) => key.status === 'standby'), [sortedKeys])
+ const inUseKey = useMemo(() => sortedKeys.find((key) => key.status === 'in_use'), [sortedKeys])
+ const previouslyUsedKeys = useMemo(
+ () => sortedKeys.filter((key) => key.status === 'previously_used'),
+ [sortedKeys]
+ )
+ const revokedKeys = useMemo(
+ () => sortedKeys.filter((key) => key.status === 'revoked'),
+ [sortedKeys]
+ )
+
+ const handleLegacyMigration = async () => {
+ try {
+ await legacyMutation.mutateAsync({
+ projectRef: projectRef!,
+ })
+ } catch (error) {
+ console.error('Failed to migrate legacy JWT secret to new JWT signing keys', error)
+ }
+ }
+
+ const handlePreviouslyUsedKey = async (keyId: string) => {
+ if (!projectRef) {
+ return
+ }
+ try {
+ await updateMutation.mutateAsync({ projectRef, keyId, status: 'previously_used' })
+ resetDialog()
+ } catch (error) {
+ console.error('Failed to move key to previously used', error)
+ }
+ }
+
+ const handleStandbyKey = async (keyId: string) => {
+ try {
+ await updateMutation.mutateAsync({ projectRef: projectRef!, keyId, status: 'standby' })
+ resetDialog()
+ } catch (error) {
+ console.error('Failed to move key to standby', error)
+ }
+ }
+
+ const handleRevokeKey = async (keyId: string) => {
+ try {
+ await updateMutation.mutateAsync({ projectRef: projectRef!, keyId, status: 'revoked' })
+ resetDialog()
+ } catch (error) {
+ console.error('Failed to revoke key', error)
+ }
+ }
+
+ const handleDeleteKey = async (keyId: string) => {
+ try {
+ await deleteMutation.mutateAsync({ projectRef: projectRef!, keyId })
+ resetDialog()
+ } catch (error) {
+ console.error('Failed to delete key', error)
+ }
+ }
+
+ const handleRotateKey = async () => {
+ try {
+ await updateMutation.mutateAsync({
+ projectRef: projectRef!,
+ keyId: standbyKey!.id,
+ status: 'in_use',
+ })
+ resetDialog()
+ } catch (error) {
+ console.error('Failed to rotate key', error)
+ }
+ }
+
+ if (isLoading) {
+ return
+ }
+
+ if (!newJwtSecrets) {
+ return
+ }
+
+ return (
+ <>
+
+ {legacyKey ? (
+ <>
+ {standbyKey && (
+
setShownDialog('rotate')}
+ loading={isLoadingMutation}
+ icon={ }
+ type="warning"
+ />
+ )}
+
+ {!standbyKey && (
+ setShownDialog('create')}
+ loading={isLoadingMutation}
+ type="primary"
+ icon={ }
+ />
+ )}
+ >
+ ) : (
+ setShownDialog('legacy')}
+ isLoading={isLoadingMutation}
+ />
+ )}
+
+
+ {sortedKeys.length > 0 && (
+ <>
+
+
+
+
+
+
+
+ Status
+
+
+ Key ID
+
+
+ Type
+
+
+ Actions
+
+
+
+
+
+ {standbyKey && (
+
+ )}
+ {inUseKey && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
Previously used keys
+
+ These JWT signing keys are still used to verify JWTs already
+ issued. Revoke them once all JWTs have expired.
+
+
+
+
+ {previouslyUsedKeys.length > 0 ? (
+
+
+
+
+ Status
+
+
+ Key ID
+
+
+ Type
+
+
+ Actions
+
+
+
+
+
+ {previouslyUsedKeys.map((key) => (
+
+
+
+
+
+ {statusLabels[key.status]}
+
+
+
+
+
+
+ {key.id}
+ {
+ setSelectedKey(key)
+ setShownDialog('key-details')
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+
+ } />
+
+
+ {
+ setSelectedKey(key)
+ setShownDialog('key-details')
+ }}
+ >
+
+ View key details
+
+ handleStandbyKey(key.id)}
+ >
+
+ Set as standby key
+
+ {
+ setSelectedKey(key)
+ setShownDialog('revoke')
+ }}
+ className="text-destructive"
+ >
+
+ Revoke key
+
+
+
+
+
+ ))}
+
+
+
+ ) : (
+
+ No previously used keys
+
+ )}
+
+
+
+ >
+ )}
+
+ {revokedKeys.length > 0 && (
+
+
+
Revoked keys
+
+ These keys are no longer used to verify or sign JWTs.
+
+
+
+
+
+
+
+
+ Status
+
+
+ Key ID
+
+
+ Type
+
+
+ Actions
+
+
+
+
+
+ {revokedKeys.map((key) => (
+
+
+
+
+
+ {statusLabels[key.status]}
+
+
+
+
+
+
+ {key.id}
+ {
+ setSelectedKey(key)
+ setShownDialog('key-details')
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+
+ } />
+
+
+ {
+ setSelectedKey(key)
+ setShownDialog('key-details')
+ }}
+ >
+
+ View key details
+
+ handleStandbyKey(key.id)}
+ >
+
+ Set as standby key
+
+ {
+ setSelectedKey(key)
+ setShownDialog('delete')
+ }}
+ className="text-destructive"
+ >
+
+ Permanently delete key
+
+
+
+
+
+ ))}
+
+
+
+
+
+
+ )}
+
+ {/* TODO(hf): For launch
+
Resources
+
+
+
+
+
+
+
+
+
Why Rotate keys?
+
+ Create Standby keys ahead of time which can then be promoted to 'In use' at any
+ time.
+
+
}>
+ View guide
+
+
+
+
+
+
+
+
+
+
+
+
Why use a Standby key?
+
+ Create Standby keys ahead of time which can then be promoted to 'In use' at any
+ time.
+
+
}>
+ View guide
+
+
+
+
+
+
*/}
+
+
+
+
+ Start using new JWT signing keys
+
+
+
+
+ Your project today uses a legacy symmetric JWT secret to create JWTs. To be able to
+ use an asymmetric JWT signing key you first have to migrate it to the new system. This
+ change does not cause any downtime on your project.
+
+
+
+ handleLegacyMigration()}
+ disabled={isLoadingMutation}
+ loading={isLoadingMutation}
+ >
+ Migrate JWT secret
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Rotate Key
+
+
+
+
+ {standbyKey ? (
+ <>
+ The standby key ({algorithmLabels[standbyKey.algorithm]}) will be promoted to 'In
+ use'. This will:
+
+ Change the current standby key to 'In use'
+ Move the current 'In use' key to 'Previously used'
+ Move any 'Previously used' key to 'Revoked'
+
+ >
+ ) : (
+ <>
+ Since there is no standby key, you need to choose an algorithm for the new key:
+
+
setNewKeyAlgorithm(value)}
+ >
+
+
+
+
+ HS256 (Symmetric)
+ ES256 (ECC)
+ RS256 (RSA)
+ EdDSA (Ed25519)
+
+
+
+ {algorithmDescriptions[newKeyAlgorithm]}
+
+
+ >
+ )}
+
+
+
+ setShownDialog('confirm-rotate')}>Review Rotation
+
+
+
+
+
+
+
+ Confirm key rotation
+
+ Review the key rotation process below. Ensure your application's components have
+ already picked up and are trusting your standby key to avoid downtime.
+
+
+
+
+
+ {standbyKey ? (
+
+
+
+ STANDBY KEY
+
+ {algorithmLabels[standbyKey!.algorithm]}
+
+
+
+
+
+ CURRENTLY USED
+
+
+ ) : (
+
+
+
+ New Key
+
+ {algorithmLabels[newKeyAlgorithm]}
+
+
+
+
+
+ CURRENTLY USED
+
+
+ )}
+
+
+
+
+ CURRENTLY USED
+
+ {inUseKey?.algorithm && algorithmLabels[inUseKey.algorithm]}
+
+
+
+
+
+ PREVIOUS KEY
+
+
+
+
+
+
+ Cancel
+
+ handleRotateKey()} loading={isLoadingMutation}>
+ Confirm rotation
+
+
+
+
+
+
+
+
+ Key Details
+
+
+
+ {selectedKey && (
+ <>
+
+
+
+
Public Key (PEM format)
+
+
+ {typeof selectedKey.public_jwk === 'string'
+ ? selectedKey.public_jwk
+ : JSON.stringify(selectedKey.public_jwk ?? '', null, 2)}
+
+
+
+
JWKS URL
+
+ {`${window.location.origin}/jwt/v1/jwks.json`}
+
+
+ >
+ )}
+
+
+
+
+ {selectedKey &&
+ selectedKey.status === 'previously_used' &&
+ (legacyKey?.id !== selectedKey.id || !(legacyAPIKeysStatus?.enabled ?? false)) && (
+ handleRevokeKey(selectedKey.id)}
+ onCancel={resetDialog}
+ title={`Revoke ${selectedKey.id}`}
+ confirmString={selectedKey.id}
+ confirmLabel="Yes, revoke this signing key"
+ confirmPlaceholder="Type the ID of the key to confirm"
+ variant="destructive"
+ alert={{
+ title: 'This key will no longer be trusted!',
+ description:
+ 'By revoking a signing key, all applications trusting it will no longer do so. If there are JWTs (access tokens) that are valid at the time of revocation, they will no longer be trusted, causing users with such JWTs to be signed out.',
+ }}
+ />
+ )}
+
+ {selectedKey &&
+ selectedKey.status === 'previously_used' &&
+ legacyKey?.id === selectedKey.id &&
+ (legacyAPIKeysStatus?.enabled ?? true) && (
+ resetDialog()}>
+
+
+ Disable JWT-based legacy API keys first
+
+
+ It's not possible to revoke the legacy JWT secret unless you have already disabled
+ JWT-based legacy API keys. This is because revoking the JWT secret invalidates the
+ JWT-based legacy API keys.
+
+
+ OK
+
+
+
+ )}
+
+ {selectedKey && selectedKey.status === 'revoked' && (
+ handleDeleteKey(selectedKey.id)}
+ onCancel={resetDialog}
+ title={`Permanently delete ${selectedKey.id}`}
+ confirmString={selectedKey.id}
+ confirmLabel="Yes, permanently delete this key"
+ confirmPlaceholder="Type the ID of the key to confirm"
+ variant="destructive"
+ alert={{
+ title: 'This key will be permanently deleted.',
+ description:
+ 'The private key and all information about this key will be permanently deleted from our records. This action cannot be undone.',
+ }}
+ />
+ )}
+ >
+ )
+}
diff --git a/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/signing-key-row.tsx b/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/signing-key-row.tsx
new file mode 100644
index 00000000000..e8395ed57b0
--- /dev/null
+++ b/apps/studio/components/interfaces/JwtSecrets/jwt-secret-keys-table/signing-key-row.tsx
@@ -0,0 +1,147 @@
+import { motion } from 'framer-motion'
+import { CircleArrowDown, Eye, Key, MoreVertical, ShieldOff, Timer } from 'lucide-react'
+
+import { components } from 'api-types'
+import { JWTSigningKey } from 'data/jwt-signing-keys/jwt-signing-keys-query'
+import {
+ Badge,
+ Button,
+ cn,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+ TableCell,
+ TableRow,
+} from 'ui'
+import { AlgorithmHoverCard } from '../algorithm-hover-card'
+import { statusColors, statusLabels } from '../jwt.constants'
+
+interface SigningKeyRowProps {
+ signingKey: components['schemas']['SigningKeyResponse']
+ setSelectedKey: (key: JWTSigningKey | null) => void
+ setShownDialog: (
+ dialog:
+ | 'legacy'
+ | 'create'
+ | 'rotate'
+ | 'confirm-rotate'
+ | 'key-details'
+ | 'revoke'
+ | 'delete'
+ | null
+ ) => void
+ handlePreviouslyUsedKey: (keyId: string) => void
+ legacyKey?: components['schemas']['SigningKeyResponse'] | null
+}
+
+const MotionTableRow = motion(TableRow)
+
+export const SigningKeyRow = ({
+ signingKey,
+ setSelectedKey,
+ setShownDialog,
+ handlePreviouslyUsedKey,
+ legacyKey,
+}: SigningKeyRowProps) => (
+
+
+
+
+ {signingKey.status === 'standby' ? : }
+ {statusLabels[signingKey.status]}
+
+
+
+
+
+
+ {signingKey.id}
+ {
+ setSelectedKey(signingKey)
+ setShownDialog('key-details')
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+
+ } />
+
+
+ {
+ setSelectedKey(signingKey)
+ setShownDialog('key-details')
+ }}
+ >
+
+ View key details
+
+ {signingKey.status === 'standby' && (
+ <>
+ handlePreviouslyUsedKey(signingKey.id)}
+ className="text-destructive"
+ >
+
+ Move to previously used
+
+ >
+ )}
+ {signingKey.status === 'previously_used' && (
+ {
+ setSelectedKey(signingKey)
+ setShownDialog('revoke')
+ }}
+ className="text-destructive"
+ >
+
+ Revoke key
+ (after 30 days)
+
+ )}
+
+
+
+
+)
diff --git a/apps/studio/components/interfaces/Settings/API/JWTSettings.tsx b/apps/studio/components/interfaces/JwtSecrets/jwt-settings.tsx
similarity index 99%
rename from apps/studio/components/interfaces/Settings/API/JWTSettings.tsx
rename to apps/studio/components/interfaces/JwtSecrets/jwt-settings.tsx
index 0e944240265..a4163c21798 100644
--- a/apps/studio/components/interfaces/Settings/API/JWTSettings.tsx
+++ b/apps/studio/components/interfaces/JwtSecrets/jwt-settings.tsx
@@ -49,12 +49,12 @@ import {
Modal,
WarningIcon,
} from 'ui'
-import { Admonition } from 'ui-patterns'
+import { Admonition } from 'ui-patterns/admonition'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import {
JWT_SECRET_UPDATE_ERROR_MESSAGES,
JWT_SECRET_UPDATE_PROGRESS_MESSAGES,
-} from './API.constants'
+} from './jwt.constants'
const schema = object({
JWT_EXP: number()
@@ -155,7 +155,6 @@ const JWTSettings = () => {
return (
JWT Settings}
footer={
= {
+ in_use: 'Current key',
+ standby: 'Standby key',
+ previously_used: 'Previous key',
+ revoked: 'Revoked',
+}
+
+export const statusColors: Record = {
+ standby: 'bg-surface-300 bg-opacity-100 text-foreground border border-foreground-muted',
+ in_use: 'bg-brand-200 bg-opacity-100 text-brand-600 border-brand-500',
+ previously_used: 'bg-purple-300 dark:bg-purple-100 text-purple-1200 border-purple-800',
+ revoked: 'bg-destructive-200 bg-opacity-100 text-destructive-600 border-destructive-500',
+}
export const JWT_SECRET_UPDATE_ERROR_MESSAGES = {
[JwtSecretUpdateError.APIServicesConfigurationUpdateFailed]:
diff --git a/apps/studio/components/interfaces/JwtSecrets/signing-keys-coming-soon.tsx b/apps/studio/components/interfaces/JwtSecrets/signing-keys-coming-soon.tsx
new file mode 100644
index 00000000000..dcfdb7999ff
--- /dev/null
+++ b/apps/studio/components/interfaces/JwtSecrets/signing-keys-coming-soon.tsx
@@ -0,0 +1,28 @@
+import { Github } from 'lucide-react'
+import { Button } from 'ui'
+
+import { FeatureBanner } from 'components/ui/FeatureBanner'
+
+export const SigningKeysComingSoonBanner = () => {
+ return (
+
+
+
JWT signing keys are coming soon
+
+ We're rolling out JWT signing keys to better support your application needs.
+
+
+
+
+ )
+}
diff --git a/apps/studio/components/interfaces/JwtSecrets/start-using-keys-banner.tsx b/apps/studio/components/interfaces/JwtSecrets/start-using-keys-banner.tsx
new file mode 100644
index 00000000000..d0460153ee9
--- /dev/null
+++ b/apps/studio/components/interfaces/JwtSecrets/start-using-keys-banner.tsx
@@ -0,0 +1,28 @@
+import { FeatureBanner } from 'components/ui/FeatureBanner'
+import { Import } from 'lucide-react'
+import { Button } from 'ui'
+
+export const StartUsingJwtSigningKeysBanner = ({
+ onClick,
+ isLoading,
+}: {
+ onClick: () => void
+ isLoading: boolean
+}) => {
+ return (
+
+
+
Start using JWT signing keys
+
+ Right now your project is using the legacy JWT secret. To start taking advantage of the
+ new JWT signing keys, migrate your project's secret to the new set up.
+
+
+ } onClick={onClick} loading={isLoading}>
+ Migrate JWT secret
+
+
+
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx
index 8af7d72ade4..778dd9933ed 100644
--- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx
+++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx
@@ -91,7 +91,7 @@ const BillingEmail = () => {
useEffect(() => {
if (billingCustomer) {
form.reset({
- billingEmail: billing_email,
+ billingEmail: billing_email ?? '',
additionalBillingEmails: billingCustomer.additional_emails ?? [],
})
}
diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingSettings.constants.ts b/apps/studio/components/interfaces/Organization/BillingSettings/BillingSettings.constants.ts
deleted file mode 100644
index d2f5238e5f4..00000000000
--- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingSettings.constants.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-export const CANCELLATION_REASONS = [
- 'Pricing',
- "My project isn't getting traction",
- 'Poor customer service',
- 'Missing feature',
- "I didn't see the value",
- "Supabase didn't meet my needs",
- 'Dashboard is too complicated',
- 'Postgres is too complicated',
- 'Problem not solved',
- 'Too many bugs/issues',
- 'I decided to use something else',
- 'My work has finished/discontinued',
- 'I’m migrating to/starting a new project',
- 'None of the above',
-]
diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingSettings.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingSettings.tsx
index 445b2c2ba7b..6a942301803 100644
--- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingSettings.tsx
+++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingSettings.tsx
@@ -82,7 +82,7 @@ export const BillingSettings = () => {
>
)}
- {isBillingCreditsEnabledOnProfileLevel && isNotOrgWithPartnerBilling && (
+ {isBillingCreditsEnabledOnProfileLevel && (
<>
diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx
index a2e71206e62..b33f047760d 100644
--- a/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx
+++ b/apps/studio/components/interfaces/Organization/BillingSettings/CreditTopUp.tsx
@@ -325,7 +325,6 @@ export const CreditTopUp = ({ slug }: { slug: string | undefined }) => {
paymentIntentConfirmed(paymentIntentConfirmation)
}
onLoadingChange={(loading) => setPaymentConfirmationLoading(loading)}
- paymentMethodId={form.getValues().paymentMethod}
/>
)}
diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/NewPaymentMethodElement.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/NewPaymentMethodElement.tsx
index 5123b3bca2c..9070143191f 100644
--- a/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/NewPaymentMethodElement.tsx
+++ b/apps/studio/components/interfaces/Organization/BillingSettings/PaymentMethods/NewPaymentMethodElement.tsx
@@ -5,18 +5,18 @@
*/
import { PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js'
-import { PaymentMethod } from '@stripe/stripe-js'
-import { getURL } from 'lib/helpers'
import { forwardRef, useImperativeHandle } from 'react'
import { toast } from 'sonner'
const NewPaymentMethodElement = forwardRef(
(
{
- pending_subscription_flow_enabled,
email,
readOnly,
- }: { pending_subscription_flow_enabled: boolean; email?: string; readOnly: boolean },
+ }: {
+ email?: string | null | undefined
+ readOnly: boolean
+ },
ref
) => {
const stripe = useStripe()
@@ -26,40 +26,26 @@ const NewPaymentMethodElement = forwardRef(
if (!stripe || !elements) return
await elements.submit()
- if (pending_subscription_flow_enabled) {
- // To avoid double 3DS confirmation, we just create the payment method here, as there might be a confirmation step while doing the actual payment
- const { error, paymentMethod } = await stripe.createPaymentMethod({
- elements,
- })
- if (error || paymentMethod == null) {
- toast.error(error?.message ?? ' Failed to process card details')
- return
- }
- return paymentMethod
- } else {
- const { error, setupIntent } = await stripe.confirmSetup({
- elements,
- redirect: 'if_required',
- confirmParams: {
- return_url: getURL(),
- expand: ['payment_method'],
- },
- })
-
- if (error || !setupIntent.payment_method) {
- toast.error(error?.message ?? ' Failed to save card details')
- return
- }
-
- return setupIntent.payment_method as PaymentMethod
+ // To avoid double 3DS confirmation, we just create the payment method here, as there might be a confirmation step while doing the actual payment
+ const { error, paymentMethod } = await stripe.createPaymentMethod({
+ elements,
+ })
+ if (error || paymentMethod == null) {
+ toast.error(error?.message ?? ' Failed to process card details')
+ return
}
+ return paymentMethod
}
useImperativeHandle(ref, () => ({
createPaymentMethod,
}))
- return
+ return (
+
+ )
}
)
diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Restriction.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Restriction.tsx
index 02d6159a65d..eb2cb8e44be 100644
--- a/apps/studio/components/interfaces/Organization/BillingSettings/Restriction.tsx
+++ b/apps/studio/components/interfaces/Organization/BillingSettings/Restriction.tsx
@@ -7,11 +7,15 @@ import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button } from 'ui'
import { CriticalIcon, WarningIcon } from 'ui'
import { PricingMetric } from 'data/analytics/org-daily-stats-query'
+import { usePathname } from 'next/navigation'
export const Restriction = () => {
const org = useSelectedOrganization()
const { data: usage, isSuccess: isSuccessOrgUsage } = useOrgUsageQuery({ orgSlug: org?.slug })
+ const pathname = usePathname()
+ const isUsagePage = pathname?.endsWith('/usage')
+
const hasExceededAnyLimits = Boolean(
usage?.usages.find(
(metric) =>
@@ -69,6 +73,11 @@ export const Restriction = () => {
{org.plan.id === 'free' ? 'Upgrade plan' : 'Change spend cap'}
+ {!isUsagePage && (
+
+ View usage
+
+ )}
}>
About spend cap
@@ -106,6 +115,13 @@ export const Restriction = () => {
{org.plan.id === 'free' ? 'Upgrade plan' : 'Disable spend cap'}
+
+ {!isUsagePage && (
+
+ View usage
+
+ )}
+
}>
About Fair Use Policy
@@ -141,6 +157,11 @@ export const Restriction = () => {
{org.plan.id === 'free' ? 'Upgrade plan' : 'Disable spend cap'}
+ {!isUsagePage && (
+
+ View usage
+
+ )}
}>
About Fair Use Policy
@@ -177,6 +198,11 @@ export const Restriction = () => {
{org.plan.id === 'free' ? 'Upgrade plan' : 'Disable spend cap'}
+ {!isUsagePage && (
+
+ View usage
+
+ )}
}>
About Fair Use Policy
diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/ExitSurveyModal.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/ExitSurveyModal.tsx
index 99307541b11..ca96700cee5 100644
--- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/ExitSurveyModal.tsx
+++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/ExitSurveyModal.tsx
@@ -1,16 +1,13 @@
-import HCaptcha from '@hcaptcha/react-hcaptcha'
-import { includes, without } from 'lodash'
-import { useReducer, useRef, useState } from 'react'
+import { useState } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
+import { CANCELLATION_REASONS } from 'components/interfaces/Billing/Billing.constants'
import { useSendDowngradeFeedbackMutation } from 'data/feedback/exit-survey-send'
+import { ProjectInfo } from 'data/projects/projects-query'
import { useOrgSubscriptionUpdateMutation } from 'data/subscriptions/org-subscription-update-mutation'
-import type { OrgSubscription } from 'data/subscriptions/types'
import { useFlag } from 'hooks/ui/useFlag'
-import { Alert, Button, Input, Modal } from 'ui'
-import type { ProjectInfo } from '../../../../../data/projects/projects-query'
-import { CANCELLATION_REASONS } from '../BillingSettings.constants'
+import { Alert, Button, cn, Input, Modal } from 'ui'
import ProjectUpdateDisabledTooltip from '../ProjectUpdateDisabledTooltip'
export interface ExitSurveyModalProps {
@@ -20,19 +17,16 @@ export interface ExitSurveyModalProps {
}
// [Joshen] For context - Exit survey is only when going to Free Plan from a paid plan
-const ExitSurveyModal = ({ visible, projects, onClose }: ExitSurveyModalProps) => {
+export const ExitSurveyModal = ({ visible, projects, onClose }: ExitSurveyModalProps) => {
const { slug } = useParams()
- const captchaRef = useRef(null)
const [message, setMessage] = useState('')
- const [captchaToken, setCaptchaToken] = useState(null)
- const [selectedReasons, dispatchSelectedReasons] = useReducer(reducer, [])
+ const [selectedReason, setSelectedReason] = useState([])
const subscriptionUpdateDisabled = useFlag('disableProjectCreationAndUpdate')
const { mutate: updateOrgSubscription, isLoading: isUpdating } = useOrgSubscriptionUpdateMutation(
{
onError: (error) => {
- resetCaptcha()
toast.error(`Failed to downgrade project: ${error.message}`)
},
}
@@ -47,31 +41,29 @@ const ExitSurveyModal = ({ visible, projects, onClose }: ExitSurveyModalProps) =
const hasProjectsWithComputeDowngrade = projectsWithComputeDowngrade.length > 0
- function reducer(state: any, action: any) {
- if (includes(state, action.target.value)) {
- return without(state, action.target.value)
- } else {
- return [...state, action.target.value]
- }
+ const [shuffledReasons] = useState(() => [
+ ...CANCELLATION_REASONS.sort(() => Math.random() - 0.5),
+ { value: 'None of the above' },
+ ])
+
+ const onSelectCancellationReason = (reason: string) => {
+ setSelectedReason([reason])
}
- const resetCaptcha = () => {
- setCaptchaToken(null)
- captchaRef.current?.resetCaptcha()
+ // Helper to get label for selected reason
+ const getReasonLabel = (reason: string | undefined) => {
+ const found = CANCELLATION_REASONS.find((r) => r.value === reason)
+ return found?.label || 'What can we improve on?'
}
+ const textareaLabel = getReasonLabel(selectedReason[0])
+
const onSubmit = async () => {
- if (selectedReasons.length === 0) {
- return toast.error('Please select at least one reason for canceling your subscription')
+ if (selectedReason.length === 0) {
+ return toast.error('Please select a reason for canceling your subscription')
}
- let token = captchaToken
-
- if (!token) {
- const captchaResponse = await captchaRef.current?.execute({ async: true })
- token = captchaResponse?.response ?? null
- await downgradeOrganization()
- }
+ await downgradeOrganization()
}
const downgradeOrganization = async () => {
@@ -83,11 +75,10 @@ const ExitSurveyModal = ({ visible, projects, onClose }: ExitSurveyModalProps) =
{ slug, tier: 'tier_free' },
{
onSuccess: async () => {
- resetCaptcha()
try {
await sendExitSurvey({
orgSlug: slug,
- reasons: selectedReasons.reduce((a, b) => `${a}- ${b}\n`, ''),
+ reasons: selectedReason.reduce((a, b) => `${a}- ${b}\n`, ''),
message,
exitAction: 'downgrade',
})
@@ -109,119 +100,87 @@ const ExitSurveyModal = ({ visible, projects, onClose }: ExitSurveyModalProps) =
}
return (
- <>
-
- {
- setCaptchaToken(token)
- if (document !== undefined) document.body.classList.remove('!pointer-events-auto')
- }}
- onExpire={() => setCaptchaToken(null)}
- onOpen={() => {
- // [Joshen] This is to ensure that hCaptcha popup remains clickable
- if (document !== undefined) document.body.classList.add('!pointer-events-auto')
- }}
- onClose={() => {
- if (document !== undefined) document.body.classList.remove('!pointer-events-auto')
- }}
- />
-
-
-
-
-
-
- We always strive to improve Supabase as much as we can. Please let us know the reasons
- you are canceling your subscription so that we can improve in the future.
-
-
-
- {CANCELLATION_REASONS.map((option) => {
- const active = selectedReasons.find((x) => x === option)
- return (
-
-
- {option}
-
- )
- })}
-
-
- setMessage(event.target.value)}
- label="Anything else that we can improve on?"
- />
-
-
- {hasProjectsWithComputeDowngrade && (
-
- This is due to changes in compute instances from the downgrade. Affected projects
- include {projectsWithComputeDowngrade.map((project) => project.name).join(', ')}.
-
- )}
-
-
-
-
-
- The unused amount for the remaining time of your billing cycle will be refunded as
- credits
+
+
+
+
+ Share with us why you're downgrading your plan.
-
-
-
onClose()}>
- Cancel
-
-
-
- Confirm downgrade
-
-
+
+
+ {shuffledReasons.map((option) => {
+ const active = selectedReason[0] === option.value
+ return (
+
+ onSelectCancellationReason(option.value)}
+ />
+ {option.value}
+
+ )
+ })}
+
+
+ {textareaLabel}
+ setMessage(event.target.value)}
+ rows={3}
+ />
+
+ {hasProjectsWithComputeDowngrade && (
+
+ This is due to changes in compute instances from the downgrade. Affected projects
+ include {projectsWithComputeDowngrade.map((project) => project.name).join(', ')}.
+
+ )}
-
- >
+
+
+
+
+ The unused amount for the remaining time of your billing cycle will be refunded as credits
+
+
+
+
onClose()}>
+ Cancel
+
+
+
+ Confirm downgrade
+
+
+
+
+
)
}
-
-export default ExitSurveyModal
diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx
index 85441004f95..3754491fbd1 100644
--- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx
+++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx
@@ -286,8 +286,7 @@ const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx
index 4102d6f65bb..a91fa6368eb 100644
--- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx
+++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx
@@ -5,7 +5,9 @@ import { useRouter } from 'next/router'
import { useEffect, useRef, useState } from 'react'
import { StudioPricingSidePanelOpenedEvent } from 'common/telemetry-constants'
+import { getPlanChangeType } from 'components/interfaces/Billing/Subscription/Subscription.utils'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
+import PartnerManagedResource from 'components/ui/PartnerManagedResource'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import { useFreeProjectLimitCheckQuery } from 'data/organizations/free-project-limit-check-query'
import { useOrganizationBillingSubscriptionPreview } from 'data/organizations/organization-billing-subscription-preview'
@@ -23,12 +25,10 @@ import { useOrgSettingsPageStateSnapshot } from 'state/organization-settings'
import { Button, SidePanel, cn } from 'ui'
import DowngradeModal from './DowngradeModal'
import { EnterpriseCard } from './EnterpriseCard'
-import ExitSurveyModal from './ExitSurveyModal'
+import { ExitSurveyModal } from './ExitSurveyModal'
import MembersExceedLimitModal from './MembersExceedLimitModal'
import { SubscriptionPlanUpdateDialog } from './SubscriptionPlanUpdateDialog'
import UpgradeSurveyModal from './UpgradeModal'
-import PartnerManagedResource from 'components/ui/PartnerManagedResource'
-import { getPlanChangeType } from 'components/interfaces/Billing/Subscription/Subscription.utils'
const PlanUpdateSidePanel = () => {
const router = useRouter()
diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx
index 9cc8fbc274f..e1b473bf68e 100644
--- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx
+++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/SubscriptionPlanUpdateDialog.tsx
@@ -1,4 +1,3 @@
-import { useQueryClient } from '@tanstack/react-query'
import { Check, InfoIcon } from 'lucide-react'
import Link from 'next/link'
import { useMemo, useRef, useState } from 'react'
@@ -11,7 +10,6 @@ import {
} from 'components/interfaces/Billing/Subscription/Subscription.utils'
import AlertError from 'components/ui/AlertError'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
-import { organizationKeys } from 'data/organizations/keys'
import { OrganizationBillingSubscriptionPreviewResponse } from 'data/organizations/organization-billing-subscription-preview'
import { ProjectInfo } from 'data/projects/projects-query'
import { useOrgSubscriptionUpdateMutation } from 'data/subscriptions/org-subscription-update-mutation'
@@ -43,7 +41,7 @@ const getRandomTweet = () => {
const PLAN_HEADINGS = {
tier_pro:
- 'the Pro plan and create unlimited projects, daily backups and premium support when you need it',
+ 'the Pro plan to unlock unlimited projects, daily backups, and email support whenever you need it',
tier_team: 'the Team plan for SOC2, SSO, priority support and greater data and log retention',
default: 'to a new plan',
} as const
@@ -85,7 +83,6 @@ export const SubscriptionPlanUpdateDialog = ({
projects,
}: Props) => {
const { resolvedTheme } = useTheme()
- const queryClient = useQueryClient()
const selectedOrganization = useSelectedOrganization()
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState
()
const [paymentIntentSecret, setPaymentIntentSecret] = useState(null)
@@ -116,6 +113,7 @@ export const SubscriptionPlanUpdateDialog = ({
)
const onSuccessfulPlanChange = () => {
+ setPaymentConfirmationLoading(false)
toast.success(
`Successfully ${changeType === 'downgrade' ? 'downgraded' : 'upgraded'} subscription to ${subscriptionPlanMeta?.name}!`
)
@@ -134,6 +132,7 @@ export const SubscriptionPlanUpdateDialog = ({
onSuccessfulPlanChange()
},
onError: (error) => {
+ setPaymentConfirmationLoading(false)
toast.error(`Unable to update subscription: ${error.message}`)
},
}
@@ -169,9 +168,13 @@ export const SubscriptionPlanUpdateDialog = ({
if (!selectedOrganization?.slug) return console.error('org slug is required')
if (!selectedTier) return console.error('Selected plan is required')
+ setPaymentConfirmationLoading(true)
+
const paymentMethod = await paymentMethodSelection.current?.createPaymentMethod()
if (paymentMethod) {
setSelectedPaymentMethod(paymentMethod.id)
+ } else {
+ setPaymentConfirmationLoading(false)
}
if (
@@ -182,23 +185,6 @@ export const SubscriptionPlanUpdateDialog = ({
return
}
- if (paymentMethod) {
- queryClient.setQueriesData(
- organizationKeys.paymentMethods(selectedOrganization.slug),
- (prev: any) => {
- if (!prev) return prev
- return {
- ...prev,
- defaultPaymentMethodId: paymentMethod?.id,
- data: prev.data.map((pm: any) => ({
- ...pm,
- is_default: pm.id === paymentMethod?.id,
- })),
- }
- }
- )
- }
-
// If the user is downgrading from team, should have spend cap disabled by default
const tier =
subscription?.plan?.id === 'team' && selectedTier === PRICING_TIER_PRODUCT_IDS.PRO
@@ -253,6 +239,10 @@ export const SubscriptionPlanUpdateDialog = ({
{
+ // Do not allow closing mid-change
+ if (isUpdating || paymentConfirmationLoading || isConfirming) {
+ return
+ }
if (!open) onClose()
}}
>
@@ -561,17 +551,15 @@ export const SubscriptionPlanUpdateDialog = ({
- {!billingViaPartner && !subscriptionPreviewIsLoading && changeType === 'upgrade' && (
+ {!billingViaPartner && subscriptionPreview != null && changeType === 'upgrade' && (
setSelectedPaymentMethod(pm)}
+ createPaymentMethodInline={true}
readOnly={paymentConfirmationLoading || isConfirming || isUpdating}
/>
@@ -700,7 +688,6 @@ export const SubscriptionPlanUpdateDialog = ({
paymentIntentConfirmed(paymentIntentConfirmation)
}
onLoadingChange={(loading) => setPaymentConfirmationLoading(loading)}
- paymentMethodId={selectedPaymentMethod!}
/>
)}
diff --git a/apps/studio/components/interfaces/Organization/GeneralSettings/AIOptInLevelSelector.tsx b/apps/studio/components/interfaces/Organization/GeneralSettings/AIOptInLevelSelector.tsx
new file mode 100644
index 00000000000..7d890a0fb09
--- /dev/null
+++ b/apps/studio/components/interfaces/Organization/GeneralSettings/AIOptInLevelSelector.tsx
@@ -0,0 +1,132 @@
+import { ReactNode } from 'react'
+import { Control } from 'react-hook-form'
+
+import { AIOptInFormValues } from 'hooks/forms/useAIOptInForm'
+import { useFlag } from 'hooks/ui/useFlag'
+import { FormField_Shadcn_, RadioGroup_Shadcn_, RadioGroupItem_Shadcn_ } from 'ui'
+import { Admonition } from 'ui-patterns'
+import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
+import { OptInToOpenAIToggle } from './OptInToOpenAIToggle'
+
+interface AIOptInLevelSelectorProps {
+ control: Control
+ disabled?: boolean
+ label?: ReactNode
+ layout?: 'horizontal' | 'vertical' | 'flex-row-reverse'
+}
+
+const AI_OPT_IN_LEVELS_OLD = [
+ {
+ value: 'disabled',
+ title: 'Disabled',
+ description:
+ 'You do not consent to sharing any database information with Supabase AI and understand that responses will be generic and not tailored to your database',
+ },
+ {
+ value: 'schema',
+ title: 'Send anonymous metadata',
+ description:
+ 'You consent to sending anonymous data to Supabase AI, which can improve the answers it shows you.',
+ },
+]
+
+const AI_OPT_IN_LEVELS = [
+ {
+ value: 'disabled',
+ title: 'Disabled',
+ description:
+ 'You do not consent to sharing any database information with Amazon Bedrock and understand that responses will be generic and not tailored to your database',
+ },
+ {
+ value: 'schema',
+ title: 'Schema Only',
+ description:
+ 'You consent to sharing your database’s schema metadata (such as table and column names, data types, and relationships—but not actual database data) with Amazon Bedrock',
+ },
+ {
+ value: 'schema_and_log',
+ title: 'Schema & Logs',
+ description:
+ 'You consent to sharing your schema and logs (which may contain PII/database data) with Amazon Bedrock for better results',
+ },
+ {
+ value: 'schema_and_log_and_data',
+ title: 'Schema, Logs & Database Data',
+ description:
+ 'You consent to give Amazon Bedrock full access to run database read only queries and analyze results for optimal results',
+ },
+]
+
+export const AIOptInLevelSelector = ({
+ control,
+ disabled,
+ label,
+ layout = 'vertical',
+}: AIOptInLevelSelectorProps) => {
+ const newOrgAiOptIn = useFlag('newOrgAiOptIn')
+ const useBedrockAssistant = useFlag('useBedrockAssistant')
+
+ const optInLevels = useBedrockAssistant ? AI_OPT_IN_LEVELS : AI_OPT_IN_LEVELS_OLD
+
+ return (
+
+ {useBedrockAssistant && (
+ <>
+ {!newOrgAiOptIn && (
+
+ )}
+
+ Supabase AI can provide more relevant answers if you choose to share different
+ levels of data. This feature is powered by Amazon Bedrock which does not store or
+ log your prompts and completions, nor does it use them to train AWS models or
+ distribute them to third parties. This is an organization-wide setting, so please
+ select the level of data you are comfortable sharing.
+
+ >
+ )}
+
+
+ }
+ >
+
+
(
+
+ {optInLevels.map((item) => (
+
+
+
+ {item.title}
+ {item.description}
+
+
+ ))}
+
+ )}
+ />
+
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Organization/GeneralSettings/DataPrivacyForm.tsx b/apps/studio/components/interfaces/Organization/GeneralSettings/DataPrivacyForm.tsx
new file mode 100644
index 00000000000..2838f7df444
--- /dev/null
+++ b/apps/studio/components/interfaces/Organization/GeneralSettings/DataPrivacyForm.tsx
@@ -0,0 +1,50 @@
+import { PermissionAction } from '@supabase/shared-types/out/constants'
+import { useEffect } from 'react'
+
+import { FormActions } from 'components/ui/Forms/FormActions'
+import { useAIOptInForm } from 'hooks/forms/useAIOptInForm'
+import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
+import { useFlag } from 'hooks/ui/useFlag'
+import { Card, CardContent, CardFooter, Form_Shadcn_ } from 'ui'
+import { AIOptInLevelSelector } from './AIOptInLevelSelector'
+
+export const DataPrivacyForm = () => {
+ const newOrgAiOptIn = useFlag('newOrgAiOptIn')
+ const { form, onSubmit, isUpdating, currentOptInLevel } = useAIOptInForm()
+ const canUpdateOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations')
+
+ const permissionsHelperText = !canUpdateOrganization
+ ? "You need additional permissions to manage this organization's settings"
+ : undefined
+
+ useEffect(() => {
+ form.reset({ aiOptInLevel: currentOptInLevel })
+ }, [currentOptInLevel, form])
+
+ return (
+
+
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Organization/GeneralSettings/GeneralSettings.tsx b/apps/studio/components/interfaces/Organization/GeneralSettings/GeneralSettings.tsx
index d256ecfc4f2..2f28ffd5781 100644
--- a/apps/studio/components/interfaces/Organization/GeneralSettings/GeneralSettings.tsx
+++ b/apps/studio/components/interfaces/Organization/GeneralSettings/GeneralSettings.tsx
@@ -1,139 +1,38 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
-import { useQueryClient } from '@tanstack/react-query'
-import { useEffect } from 'react'
-import { toast } from 'sonner'
-
-import { useParams } from 'common'
import { NoProjectsOnPaidOrgInfo } from 'components/interfaces/Billing/NoProjectsOnPaidOrgInfo'
-import { ScaffoldContainerLegacy } from 'components/layouts/Scaffold'
-import { FormActions } from 'components/ui/Forms/FormActions'
-import { FormPanel } from 'components/ui/Forms/FormPanel'
-import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection'
-import { useOrganizationUpdateMutation } from 'data/organizations/organization-update-mutation'
-import { invalidateOrganizationsQuery } from 'data/organizations/organizations-query'
+import {
+ ScaffoldContainer,
+ ScaffoldSection,
+ ScaffoldSectionTitle,
+} from 'components/layouts/Scaffold'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
-import { useOrgOptedIntoAi } from 'hooks/misc/useOrgOptedIntoAi'
-import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
-import { OPT_IN_TAGS } from 'lib/constants'
-import { Form, Input, Toggle } from 'ui'
-import OptInToOpenAIToggle from './OptInToOpenAIToggle'
import OrganizationDeletePanel from './OrganizationDeletePanel'
+import { DataPrivacyForm } from './DataPrivacyForm'
+import { OrganizationDetailsForm } from './OrganizationDetailsForm'
+
const GeneralSettings = () => {
- const { slug } = useParams()
- const queryClient = useQueryClient()
- const selectedOrganization = useSelectedOrganization()
- const { name } = selectedOrganization ?? {}
-
- const formId = 'org-general-settings'
- const isOptedIntoAi = useOrgOptedIntoAi()
- const initialValues = { name: name ?? '', isOptedIntoAi }
-
const organizationDeletionEnabled = useIsFeatureEnabled('organizations:delete')
- const canUpdateOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations')
const canDeleteOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations')
- const { mutate: updateOrganization, isLoading: isUpdating } = useOrganizationUpdateMutation()
-
- const onUpdateOrganization = async (values: any, { resetForm }: any) => {
- if (!canUpdateOrganization) {
- return toast.error('You do not have the required permissions to update this organization')
- }
-
- if (!slug) return console.error('Slug is required')
-
- const existingOptInTags = selectedOrganization?.opt_in_tags ?? []
- const updatedOptInTags =
- values.isOptedIntoAi && !existingOptInTags.includes(OPT_IN_TAGS.AI_SQL)
- ? existingOptInTags.concat([OPT_IN_TAGS.AI_SQL])
- : !values.isOptedIntoAi && existingOptInTags.includes(OPT_IN_TAGS.AI_SQL)
- ? existingOptInTags.filter((x) => x !== OPT_IN_TAGS.AI_SQL)
- : existingOptInTags
-
- updateOrganization(
- { slug, name: values.name, opt_in_tags: updatedOptInTags },
- {
- onSuccess: () => {
- resetForm({ values, initialValues: values })
- invalidateOrganizationsQuery(queryClient)
- toast.success('Successfully saved settings')
- },
- }
- )
- }
return (
-
-
+
+
-
- }
- >
- General settings}>
-
-
-
-
-
-
-
-
-
-
-
- )
- }}
-
+
+ Data Privacy
+
+
{organizationDeletionEnabled && canDeleteOrganization && }
-
+
)
}
diff --git a/apps/studio/components/interfaces/Organization/GeneralSettings/OptInToOpenAIToggle.tsx b/apps/studio/components/interfaces/Organization/GeneralSettings/OptInToOpenAIToggle.tsx
index e360e000d20..becd7b76488 100644
--- a/apps/studio/components/interfaces/Organization/GeneralSettings/OptInToOpenAIToggle.tsx
+++ b/apps/studio/components/interfaces/Organization/GeneralSettings/OptInToOpenAIToggle.tsx
@@ -1,71 +1,111 @@
-import Link from 'next/link'
-import { cn, Collapsible_Shadcn_, CollapsibleTrigger_Shadcn_, CollapsibleContent_Shadcn_ } from 'ui'
-import { useState } from 'react'
-import { ChevronRight } from 'lucide-react'
+import { InlineLink } from 'components/ui/InlineLink'
+import { useFlag } from 'hooks/ui/useFlag'
-interface OptInToOpenAIToggleProps {
- className?: string
-}
-export default function OptInToOpenAIToggle({ className }: OptInToOpenAIToggleProps) {
- const [open, setOpen] = useState(false)
+import {
+ Button,
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogSection,
+ DialogTitle,
+ DialogTrigger,
+} from 'ui'
+
+export const OptInToOpenAIToggle = () => {
+ const useBedrockAssistant = useFlag('useBedrockAssistant')
return (
-
-
-
-
-
- Important information regarding opting in
-
-
-
-
-
-
- Supabase AI is a chatbot support tool powered by OpenAI. Supabase will share the query
- you submit and information about the databases you manage through Supabase with OpenAI,
- L.L.C. and its affiliates in order to provide the Supabase AI tool.
-
-
- OpenAI will only access information about the structure of your databases, such as table
- names, column and row headings. OpenAI will not access the contents of the database
- itself.
-
-
- OpenAI uses this information to generate responses to your query, and does not retain or
- use the information to train its algorithms or otherwise improve its products and
- services.
-
-
- If you have your own individual account on Supabase, we will use any personal
- information collected through [Supabase AI] to provide you with the [Supabase AI] tool.
- If you are in the UK, EEA or Switzerland, the processing of this personal information is
- necessary for the performance of a contract between you and us.
-
-
- Supabase collects information about the queries you submit through Supabase AI and the
- responses you receive to assess the performance of the Supabase AI tool and improve our
- services. If you are in the UK, EEA or Switzerland, the processing is necessary for our
- legitimate interests, namely informing our product development and improvement.
-
-
- For more information about how we use personal information, please see our{' '}
-
- privacy policy
-
- .
-
-
-
-
+
+
+
+ Learn more about data privacy
+
+
+
+
+ Data Privacy and Supabase AI
+
+
+ {useBedrockAssistant ? (
+ <>
+
+ Supabase AI utilizes Amazon Bedrock ("Bedrock"), a service designed with a strong
+ focus on data privacy and security.
+
+
+
+ Amazon Bedrock does not store or log your prompts and completions. This data is not
+ used to train any AWS models and is not distributed to third parties or model
+ providers. Model providers do not have access to Amazon Bedrock logs or customer
+ prompts and completions.
+
+
+
+ By default, no information is shared with Bedrock unless you explicitly provide
+ consent. With your permission, Supabase may share customer-generated prompts,
+ database schema, database data, and project logs with Bedrock. This information is
+ used solely to generate responses to your queries and is not retained by Bedrock or
+ used to train their foundation models.
+
+
+
+ If you are a HIPAA Covered Entity, please note that Bedrock is HIPAA eligible, and
+ Supabase has a Business Associate Agreement in place covering this use.
+
+
+
+ For more detailed information about how we collect and use your data, see our{' '}
+ Privacy Policy . You can
+ choose which types of information you consent to share by selecting from the options
+ in the AI settings.
+
+ >
+ ) : (
+ <>
+
+ Supabase AI is a chatbot support tool powered by OpenAI. Supabase will share the
+ query you submit and information about the databases you manage through Supabase
+ with OpenAI, L.L.C. and its affiliates in order to provide the Supabase AI tool.
+
+
+
+ OpenAI will only access information about the structure of your databases, such as
+ table names, column and row headings. OpenAI will not access the contents of the
+ database itself.
+
+
+
+ OpenAI uses this information to generate responses to your query, and does not
+ retain or use the information to train its algorithms or otherwise improve its
+ products and services.
+
+
+
+ If you have your own individual account on Supabase, we will use any personal
+ information collected through [Supabase AI] to provide you with the [Supabase AI]
+ tool. If you are in the UK, EEA or Switzerland, the processing of this personal
+ information is necessary for the performance of a contract between you and us.
+
+
+
+ Supabase collects information about the queries you submit through Supabase AI and
+ the responses you receive to assess the performance of the Supabase AI tool and
+ improve our services. If you are in the UK, EEA or Switzerland, the processing is
+ necessary for our legitimate interests, namely informing our product development and
+ improvement.
+
+
+
+ For more information about how we use personal information, please see our{' '}
+ privacy policy .
+
+ >
+ )}
+
+
+
)
}
diff --git a/apps/studio/components/interfaces/Organization/GeneralSettings/OrganizationDeletePanel.tsx b/apps/studio/components/interfaces/Organization/GeneralSettings/OrganizationDeletePanel.tsx
index d0cd4ba4e73..dc878869f72 100644
--- a/apps/studio/components/interfaces/Organization/GeneralSettings/OrganizationDeletePanel.tsx
+++ b/apps/studio/components/interfaces/Organization/GeneralSettings/OrganizationDeletePanel.tsx
@@ -1,4 +1,4 @@
-import Panel from 'components/ui/Panel'
+import { ScaffoldSection, ScaffoldSectionTitle } from 'components/layouts/Scaffold'
import PartnerManagedResource from 'components/ui/PartnerManagedResource'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { Admonition } from 'ui-patterns'
@@ -8,30 +8,28 @@ const OrganizationDeletePanel = () => {
const selectedOrganization = useSelectedOrganization()
return (
- DANGER ZONE}>
-
- {selectedOrganization?.managed_by !== 'vercel-marketplace' ? (
-
-
-
- ) : (
-
- )}
-
-
+
+ Danger Zone
+ {selectedOrganization?.managed_by !== 'vercel-marketplace' ? (
+
+
+
+ ) : (
+
+ )}
+
)
}
diff --git a/apps/studio/components/interfaces/Organization/GeneralSettings/OrganizationDetailsForm.tsx b/apps/studio/components/interfaces/Organization/GeneralSettings/OrganizationDetailsForm.tsx
new file mode 100644
index 00000000000..b264df89b00
--- /dev/null
+++ b/apps/studio/components/interfaces/Organization/GeneralSettings/OrganizationDetailsForm.tsx
@@ -0,0 +1,131 @@
+import { zodResolver } from '@hookform/resolvers/zod'
+import { PermissionAction } from '@supabase/shared-types/out/constants'
+import { useQueryClient } from '@tanstack/react-query'
+import { useEffect } from 'react'
+import { useForm } from 'react-hook-form'
+import { toast } from 'sonner'
+import * as z from 'zod'
+
+import { useParams } from 'common'
+import CopyButton from 'components/ui/CopyButton'
+import { FormActions } from 'components/ui/Forms/FormActions'
+import { useOrganizationUpdateMutation } from 'data/organizations/organization-update-mutation'
+import { invalidateOrganizationsQuery } from 'data/organizations/organizations-query'
+import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
+import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
+import type { ResponseError } from 'types'
+import {
+ Card,
+ CardContent,
+ CardFooter,
+ Form_Shadcn_,
+ FormControl_Shadcn_,
+ FormField_Shadcn_,
+ Input_Shadcn_ as Input,
+ PrePostTab,
+} from 'ui'
+import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
+
+const OrgDetailsSchema = z.object({
+ name: z.string().min(1, 'Organization name is required'),
+})
+
+export const OrganizationDetailsForm = () => {
+ const { slug } = useParams()
+ const queryClient = useQueryClient()
+ const selectedOrganization = useSelectedOrganization()
+ const canUpdateOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations')
+
+ const { mutate: updateOrganization, isLoading: isUpdatingDetails } =
+ useOrganizationUpdateMutation()
+
+ const orgDetailsForm = useForm>({
+ resolver: zodResolver(OrgDetailsSchema),
+ defaultValues: { name: selectedOrganization?.name ?? '' },
+ })
+
+ const onUpdateOrganizationDetails = async (values: z.infer) => {
+ if (!canUpdateOrganization) {
+ return toast.error('You do not have the required permissions to update this organization')
+ }
+ if (!slug) return console.error('Slug is required')
+
+ updateOrganization(
+ { slug, name: values.name },
+ {
+ onSuccess: () => {
+ invalidateOrganizationsQuery(queryClient)
+ toast.success('Successfully updated organization name')
+ },
+ onError: (error: ResponseError) => {
+ toast.error(`Failed to update organization name: ${error.message}`)
+ },
+ }
+ )
+ }
+
+ const permissionsHelperText = !canUpdateOrganization
+ ? "You need additional permissions to manage this organization's settings"
+ : undefined
+
+ useEffect(() => {
+ if (selectedOrganization && !isUpdatingDetails) {
+ orgDetailsForm.reset({ name: selectedOrganization.name ?? '' })
+ }
+ }, [selectedOrganization, orgDetailsForm, isUpdatingDetails])
+
+ return (
+
+
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx b/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx
index c6598e97661..a1c811a09ad 100644
--- a/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx
+++ b/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx
@@ -120,9 +120,7 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr
({
clientSecret: setupIntent ? setupIntent.client_secret! : '',
appearance: getStripeElementsAppearanceOptions(resolvedTheme),
- ...(setupIntent?.pending_subscription_flow_enabled_for_creation === true
- ? { paymentMethodCreation: 'manual' }
- : {}),
+ paymentMethodCreation: 'manual',
}) as const,
[setupIntent, resolvedTheme]
)
@@ -553,9 +551,6 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr
@@ -645,7 +640,6 @@ const NewOrgForm = ({ onPaymentMethodReset, setupIntent, onPlanSelected }: NewOr
paymentIntentConfirmed(paymentIntentConfirmation)
}
onLoadingChange={(loading) => setPaymentConfirmationLoading(loading)}
- paymentMethodId={paymentMethod.id}
onError={(err) => {
toast.error(err.message, { duration: 10_000 })
setNewOrgLoading(false)
diff --git a/apps/studio/components/interfaces/Organization/OrganizationCard.tsx b/apps/studio/components/interfaces/Organization/OrganizationCard.tsx
index dad92b5c257..e636c1e34ba 100644
--- a/apps/studio/components/interfaces/Organization/OrganizationCard.tsx
+++ b/apps/studio/components/interfaces/Organization/OrganizationCard.tsx
@@ -1,9 +1,11 @@
-import { Boxes } from 'lucide-react'
+import { Boxes, Lock } from 'lucide-react'
import Link from 'next/link'
+import { useIsMFAEnabled } from 'common'
import { ActionCard } from 'components/ui/ActionCard'
import { useProjectsQuery } from 'data/projects/projects-query'
import { Organization } from 'types'
+import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
export const OrganizationCard = ({
organization,
@@ -12,17 +14,44 @@ export const OrganizationCard = ({
organization: Organization
href?: string
}) => {
+ const isUserMFAEnabled = useIsMFAEnabled()
const { data: allProjects = [] } = useProjectsQuery()
+
const numProjects = allProjects.filter((x) => x.organization_slug === organization.slug).length
+ const isMfaRequired = organization.organization_requires_mfa
return (
div]:w-full [&>div]:items-center')}
icon={ }
title={organization.name}
- description={`${organization.plan.name} Plan${numProjects > 0 ? `${' '}•${' '}${numProjects} project${numProjects > 1 ? 's' : ''}` : ''}`}
+ description={
+
+
+ {organization.plan.name} Plan
+ {numProjects > 0 && (
+ <>
+ •
+
+ {numProjects} project{numProjects > 1 ? 's' : ''}
+
+ >
+ )}
+
+ {isMfaRequired && (
+
+
+
+
+
+ MFA enforced
+
+
+ )}
+
+ }
/>
)
diff --git a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts
index 07d460f1bce..9d1b56009d0 100644
--- a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts
+++ b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts
@@ -4,7 +4,7 @@ import { AWS_REGIONS, FLY_REGIONS } from 'shared-data'
export function getAvailableRegions(cloudProvider: CloudProvider): Region {
switch (cloudProvider) {
case 'AWS':
- case 'AWS_NEW':
+ case 'AWS_K8S':
return AWS_REGIONS
case 'FLY':
return FLY_REGIONS
diff --git a/apps/studio/components/interfaces/ProjectCreation/SchemaGenerator.tsx b/apps/studio/components/interfaces/ProjectCreation/SchemaGenerator.tsx
index d44f1d7fc24..120428fe922 100644
--- a/apps/studio/components/interfaces/ProjectCreation/SchemaGenerator.tsx
+++ b/apps/studio/components/interfaces/ProjectCreation/SchemaGenerator.tsx
@@ -2,6 +2,7 @@ import { useChat } from 'ai/react'
import { useEffect, useState } from 'react'
import { Markdown } from 'components/interfaces/Markdown'
+import { onErrorChat } from 'components/ui/AIAssistantPanel/AIAssistant.utils'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { BASE_PATH } from 'lib/constants'
import { AiIconAnimation, Button, Label_Shadcn_, Textarea } from 'ui'
@@ -44,6 +45,7 @@ export const SchemaGenerator = ({
api: `${BASE_PATH}/api/ai/onboarding/design`,
id: 'schema-generator',
maxSteps: 7,
+ onError: onErrorChat,
onFinish: () => {
setInput('')
},
diff --git a/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx b/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx
index 8a4cc98cef7..87c29b77990 100644
--- a/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx
+++ b/apps/studio/components/interfaces/Realtime/RealtimeSettings.tsx
@@ -29,8 +29,10 @@ import {
FormField_Shadcn_,
FormMessage_Shadcn_,
Input_Shadcn_,
+ Switch,
} from 'ui'
import { Admonition } from 'ui-patterns'
+import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
const formId = 'realtime-configuration-form'
@@ -70,16 +72,14 @@ export const RealtimeSettings = () => {
// max_channels_per_client: z.coerce.number().min(1).max(10000),
// max_joins_per_second: z.coerce.number().min(1).max(5000),
- // [Filipe] This field is temporarily hidden from the UI
- // allow_public: z.boolean(),
+ allow_public: z.boolean(),
})
const form = useForm>({
resolver: zodResolver(FormSchema),
defaultValues: {
...REALTIME_DEFAULT_CONFIG,
- // [Filipe] This field is temporarily hidden from the UI
- // allow_public: !REALTIME_DEFAULT_CONFIG.private_only,
+ allow_public: !REALTIME_DEFAULT_CONFIG.private_only,
},
})
@@ -87,8 +87,7 @@ export const RealtimeSettings = () => {
if (!projectRef) return console.error('Project ref is required')
updateRealtimeConfig({
ref: projectRef,
- // [Filipe] This field is temporarily hidden from the UI
- // private_only: !data.allow_public,
+ private_only: !data.allow_public,
connection_pool: data.connection_pool,
max_concurrent_users: data.max_concurrent_users,
})
@@ -97,10 +96,9 @@ export const RealtimeSettings = () => {
useEffect(() => {
// [Joshen] Temp typed with any - API typing marks all the properties as nullable,
// but checked with Filipe that they're not supposed to
- // [Filipe] This field is temporarily hidden from the UI
- // if (data) form.reset({ ...data, allow_public: !data.private_only } as any)
-
- if (data) form.reset({ ...data } as any)
+ if (isSuccess) {
+ form.reset({ ...data, allow_public: !data.private_only } as any)
+ }
}, [isSuccess])
return (
@@ -111,10 +109,7 @@ export const RealtimeSettings = () => {
) : (
- {/*
- [Filipe] We're hidding this field until we implement a 'kill all sockets` on change to be triggered in realtime server
- */}
- {/*
+
{
)}
/>
- */}
+
)}
-
+
{label}
{actions}
diff --git a/apps/studio/components/interfaces/Reports/ReportChart.tsx b/apps/studio/components/interfaces/Reports/ReportChart.tsx
new file mode 100644
index 00000000000..4883fc624af
--- /dev/null
+++ b/apps/studio/components/interfaces/Reports/ReportChart.tsx
@@ -0,0 +1,58 @@
+/**
+ * ReportChart
+ *
+ * A wrapper component that uses the useChartData hook to fetch data for a chart
+ * and then passes the data and loading state to the ComposedChartHandler.
+ *
+ * This component acts as a bridge between the data-fetching logic and the
+ * presentational chart component.
+ */
+import LogChartHandler from 'components/ui/Charts/LogChartHandler'
+import { useChartData } from 'hooks/useChartData'
+import type { UpdateDateRange } from 'pages/project/[ref]/reports/database'
+import type { MultiAttribute } from 'components/ui/Charts/ComposedChart.utils'
+
+const ReportChart = ({
+ chart,
+ startDate,
+ endDate,
+ interval,
+ updateDateRange,
+}: {
+ chart: any
+ startDate: string
+ endDate: string
+ interval: string
+ updateDateRange: UpdateDateRange
+}) => {
+ const {
+ data,
+ isLoading: isLoading,
+ chartAttributes,
+ highlightedValue,
+ } = useChartData({
+ attributes: chart.attributes,
+ startDate,
+ endDate,
+ interval,
+ data: undefined,
+ highlightedValue:
+ chart.id === 'client-connections' || chart.id === 'pgbouncer-connections'
+ ? true
+ : chart.showMaxValue,
+ })
+
+ return (
+ 0 ? chartAttributes : chart.attributes) as MultiAttribute[]
+ }
+ data={data}
+ isLoading={isLoading}
+ highlightedValue={highlightedValue as any}
+ updateDateRange={updateDateRange}
+ />
+ )
+}
+export default ReportChart
diff --git a/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover.tsx b/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover.tsx
index 318f03e3546..adbe74201f6 100644
--- a/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover.tsx
+++ b/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover.tsx
@@ -12,6 +12,7 @@ export interface RoleImpersonationPopoverProps {
serviceRoleLabel?: string
variant?: 'regular' | 'connected-on-right' | 'connected-on-left' | 'connected-on-both'
align?: 'center' | 'start' | 'end'
+ disallowAuthenticatedOption?: boolean
}
const RoleImpersonationPopover = ({
@@ -19,6 +20,7 @@ const RoleImpersonationPopover = ({
serviceRoleLabel,
variant = 'regular',
align = 'end',
+ disallowAuthenticatedOption = false,
}: RoleImpersonationPopoverProps) => {
const state = useRoleImpersonationStateSnapshot()
@@ -64,7 +66,10 @@ const RoleImpersonationPopover = ({
side="bottom"
align={align}
>
-
+
)
diff --git a/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationSelector.tsx b/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationSelector.tsx
index 17b121074ca..fa591e1e8a0 100644
--- a/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationSelector.tsx
+++ b/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationSelector.tsx
@@ -10,11 +10,13 @@ import UserImpersonationSelector from './UserImpersonationSelector'
export interface RoleImpersonationSelectorProps {
serviceRoleLabel?: string
padded?: boolean
+ disallowAuthenticatedOption?: boolean
}
const RoleImpersonationSelector = ({
serviceRoleLabel,
padded = true,
+ disallowAuthenticatedOption = false,
}: RoleImpersonationSelectorProps) => {
const state = useRoleImpersonationStateSnapshot()
@@ -81,21 +83,25 @@ const RoleImpersonationSelector = ({
icon={ }
/>
- }
- />
+ {!disallowAuthenticatedOption && (
+ }
+ />
+ )}
{selectedOption === 'service_role' && (
- The default Postgres/superuser role. This has admin privileges.
+ The default Postgres/superuser role.
+ {disallowAuthenticatedOption ? : ' '}
+ This has admin privileges.
It will bypass Row Level Security (RLS) policies.
@@ -103,16 +109,21 @@ const RoleImpersonationSelector = ({
{selectedOption === 'anon' && (
- For "anonymous access". This is the role which the API (PostgREST) will use when a user
+ For "anonymous access".
+ {disallowAuthenticatedOption ? : ' '}
+ This is the role which the API (PostgREST)
- is not logged in. It will respect Row Level Security (RLS) policies.
+ will use when a user is not logged in.
+ {disallowAuthenticatedOption ? : ' '}
+ It will respect Row Level Security (RLS) policies.
)}
{selectedOption === 'authenticated' && (
- For "authenticated access". This is the role which the API (PostgREST) will use when
- a user is logged in. It will respect Row Level Security (RLS) policies.
+ For "authenticated access". This is the role which the API (PostgREST)
+
+ will use when a user is logged in. It will respect Row Level Security (RLS) policies.
)}
diff --git a/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx b/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx
index 2f062081911..6b16ccc1aa3 100644
--- a/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx
+++ b/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx
@@ -3,6 +3,7 @@ import { toast } from 'sonner'
import { useParams } from 'common'
import { useSqlTitleGenerateMutation } from 'data/ai/sql-title-mutation'
+import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
import { getContentById } from 'data/content/content-id-query'
import {
UpsertContentPayload,
@@ -11,12 +12,11 @@ import {
import { Snippet } from 'data/content/sql-folders-query'
import type { SqlSnippet } from 'data/content/sql-snippets-query'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
-import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
+import { useFlag } from 'hooks/ui/useFlag'
import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
import { createTabId, useTabsStateSnapshot } from 'state/tabs'
import { AiIconAnimation, Button, Form, Input, Modal } from 'ui'
-import { useIsSQLEditorTabsEnabled } from '../App/FeaturePreview/FeaturePreviewContext'
import { subscriptionHasHipaaAddon } from '../Billing/Subscription/Subscription.utils'
export interface RenameQueryModalProps {
@@ -34,6 +34,7 @@ const RenameQueryModal = ({
}: RenameQueryModalProps) => {
const { ref } = useParams()
const organization = useSelectedOrganization()
+ const useBedrockAssistant = useFlag('useBedrockAssistant')
const snapV2 = useSqlEditorV2StateSnapshot()
const tabsSnap = useTabsStateSnapshot()
@@ -42,7 +43,6 @@ const RenameQueryModal = ({
{ enabled: visible }
)
const isSQLSnippet = snippet.type === 'sql'
- const isSQLEditorTabsEnabled = useIsSQLEditorTabsEnabled()
const { data: projectSettings } = useProjectSettingsV2Query({ projectRef: ref })
// Customers on HIPAA plans should not have access to Supabase AI
@@ -66,12 +66,12 @@ const RenameQueryModal = ({
const generateTitle = async () => {
if ('content' in snippet && isSQLSnippet) {
- titleSql({ sql: snippet.content.sql })
+ titleSql({ sql: snippet.content.sql, useBedrockAssistant })
} else {
try {
const { content } = await getContentById({ projectRef: ref, id: snippet.id })
if ('sql' in content) {
- titleSql({ sql: content.sql })
+ titleSql({ sql: content.sql, useBedrockAssistant })
}
} catch (error) {
toast.error('Unable to generate title based on query contents')
@@ -112,10 +112,8 @@ const RenameQueryModal = ({
snapV2.renameSnippet({ id, name: nameInput, description: descriptionInput })
- if (isSQLEditorTabsEnabled && ref) {
- const tabId = createTabId('sql', { id })
- tabsSnap.updateTab(tabId, { label: nameInput })
- }
+ const tabId = createTabId('sql', { id })
+ tabsSnap.updateTab(tabId, { label: nameInput })
toast.success('Successfully renamed snippet!')
if (onComplete) onComplete()
diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx
index 054174f05f3..c8f472df446 100644
--- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx
+++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx
@@ -19,11 +19,12 @@ import { useReadReplicasQuery } from 'data/read-replicas/replicas-query'
import { useExecuteSqlMutation } from 'data/sql/execute-sql-mutation'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { isError } from 'data/utils/error-check'
-import { useOrgOptedIntoAiAndHippaProject } from 'hooks/misc/useOrgOptedIntoAi'
+import { useOrgAiOptInLevel } from 'hooks/misc/useOrgOptedIntoAi'
import { useSchemasForAi } from 'hooks/misc/useSchemasForAi'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
-import { BASE_PATH, IS_PLATFORM } from 'lib/constants'
+import { useFlag } from 'hooks/ui/useFlag'
+import { BASE_PATH } from 'lib/constants'
import { formatSql } from 'lib/formatSql'
import { detectOS, uuidv4 } from 'lib/helpers'
import { useProfile } from 'lib/profile'
@@ -51,7 +52,6 @@ import {
TooltipTrigger,
cn,
} from 'ui'
-import { useIsSQLEditorTabsEnabled } from '../App/FeaturePreview/FeaturePreviewContext'
import { useSqlEditorDiff, useSqlEditorPrompt } from './hooks'
import { RunQueryWarningModal } from './RunQueryWarningModal'
import {
@@ -81,6 +81,7 @@ export const SQLEditor = () => {
const os = detectOS()
const router = useRouter()
const { ref, id: urlId } = useParams()
+ const useBedrockAssistant = useFlag('useBedrockAssistant')
const { profile } = useProfile()
const project = useSelectedProject()
@@ -92,10 +93,8 @@ export const SQLEditor = () => {
const snapV2 = useSqlEditorV2StateSnapshot()
const getImpersonatedRoleState = useGetImpersonatedRoleState()
const databaseSelectorState = useDatabaseSelectorStateSnapshot()
- const { isOptedInToAI, isHipaaProjectDisallowed } = useOrgOptedIntoAiAndHippaProject()
+ const { includeSchemaMetadata, isHipaaProjectDisallowed } = useOrgAiOptInLevel()
const [selectedSchemas] = useSchemasForAi(project?.ref!)
- const includeSchemaMetadata = (isOptedInToAI && !isHipaaProjectDisallowed) || !IS_PLATFORM
- const isSQLEditorTabsEnabled = useIsSQLEditorTabsEnabled()
const {
sourceSqlDiff,
@@ -210,17 +209,15 @@ export const SQLEditor = () => {
const setAiTitle = useCallback(
async (id: string, sql: string) => {
try {
- const { title: name } = await generateSqlTitle({ sql })
+ const { title: name } = await generateSqlTitle({ sql, useBedrockAssistant })
snapV2.renameSnippet({ id, name })
- if (isSQLEditorTabsEnabled && ref) {
- const tabId = createTabId('sql', { id })
- tabs.updateTab(tabId, { label: name })
- }
+ const tabId = createTabId('sql', { id })
+ tabs.updateTab(tabId, { label: name })
} catch (error) {
// [Joshen] No error handler required as this happens in the background and not necessary to ping the user
}
},
- [generateSqlTitle, snapV2]
+ [generateSqlTitle, useBedrockAssistant, snapV2]
)
const prettifyQuery = useCallback(async () => {
@@ -464,7 +461,9 @@ export const SQLEditor = () => {
completion,
isLoading: isCompletionLoading,
} = useCompletion({
- api: `${BASE_PATH}/api/ai/sql/complete`,
+ api: useBedrockAssistant
+ ? `${BASE_PATH}/api/ai/sql/complete-v2`
+ : `${BASE_PATH}/api/ai/sql/complete`,
body: {
projectRef: project?.ref,
connectionString: project?.connectionString,
diff --git a/apps/studio/components/interfaces/Settings/API/ApiKeysMoved.tsx b/apps/studio/components/interfaces/Settings/API/ApiKeysMoved.tsx
index 869b3a48efe..bf00d51029c 100644
--- a/apps/studio/components/interfaces/Settings/API/ApiKeysMoved.tsx
+++ b/apps/studio/components/interfaces/Settings/API/ApiKeysMoved.tsx
@@ -19,6 +19,7 @@ export const ApiKeysMoved = () => {
{ name: 'Infrastructure', key: 'infrastructure', url: '#' },
{ name: 'Integrations', key: 'integrations', url: '#' },
{ name: 'API Keys', key: 'api-keys', url: '#', label: 'NEW' },
+ { name: 'JWT Keys', key: 'jwt-keys', url: '#', label: 'NEW' },
{ name: 'Add Ons', key: 'addons', url: '#' },
{
name: 'Vault',
@@ -37,14 +38,19 @@ export const ApiKeysMoved = () => {
return (
-
-
API keys have moved
+
+
API keys and JWT settings have moved
- They can now be found in the new API Keys page
+ They can now be found in their own respective pages.
-
-
Go to API Keys
-
+
+
+ Go to API Keys
+
+
+ Go to JWT Keys
+
+
{/* Menu illustration using the actual ProductMenu component */}
diff --git a/apps/studio/components/interfaces/Settings/API/ServiceList.tsx b/apps/studio/components/interfaces/Settings/API/ServiceList.tsx
index 56b402af109..0b1ed99ab3a 100644
--- a/apps/studio/components/interfaces/Settings/API/ServiceList.tsx
+++ b/apps/studio/components/interfaces/Settings/API/ServiceList.tsx
@@ -1,4 +1,3 @@
-import { JwtSecretUpdateError, JwtSecretUpdateStatus } from '@supabase/shared-types/out/events'
import { useQueryClient } from '@tanstack/react-query'
import { AlertCircle } from 'lucide-react'
import { useEffect, useRef } from 'react'
@@ -9,7 +8,6 @@ import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectConte
import DatabaseSelector from 'components/ui/DatabaseSelector'
import Panel from 'components/ui/Panel'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
-import { useJwtSecretUpdatingStatusQuery } from 'data/config/jwt-secret-updating-status-query'
import { configKeys } from 'data/config/keys'
import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query'
import { useLoadBalancersQuery } from 'data/read-replicas/load-balancers-query'
@@ -17,9 +15,7 @@ import { useReadReplicasQuery } from 'data/read-replicas/replicas-query'
import { PROJECT_STATUS } from 'lib/constants'
import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
import { Badge, Input } from 'ui'
-import { JWT_SECRET_UPDATE_ERROR_MESSAGES } from './API.constants'
import { ApiKeysMoved } from './ApiKeysMoved'
-import JWTSettings from './JWTSettings'
import { PostgrestConfig } from './PostgrestConfig'
const ServiceList = () => {
@@ -32,15 +28,6 @@ const ServiceList = () => {
const { data: databases, isError } = useReadReplicasQuery({ projectRef })
const { data: loadBalancers } = useLoadBalancersQuery({ projectRef })
- const { data } = useJwtSecretUpdatingStatusQuery({ projectRef })
- const jwtSecretUpdateStatus = data?.jwtSecretUpdateStatus
- const jwtSecretUpdateError = data?.jwtSecretUpdateError
-
- const previousJwtSecretUpdateStatus = useRef
()
- const { Failed, Updated, Updating } = JwtSecretUpdateStatus
- const jwtSecretUpdateErrorMessage =
- JWT_SECRET_UPDATE_ERROR_MESSAGES[jwtSecretUpdateError as JwtSecretUpdateError]
-
// Get the API service
const isCustomDomainActive = customDomainData?.customDomain?.status === 'active'
const selectedDatabase = databases?.find((db) => db.identifier === state.selectedDatabaseId)
@@ -54,30 +41,6 @@ const ServiceList = () => {
? loadBalancers?.[0].endpoint ?? ''
: selectedDatabase?.restUrl
- useEffect(() => {
- if (previousJwtSecretUpdateStatus.current === Updating) {
- switch (jwtSecretUpdateStatus) {
- case Updated:
- client.invalidateQueries(configKeys.api(projectRef))
- client.invalidateQueries(configKeys.settings(projectRef))
- client.invalidateQueries(configKeys.postgrest(projectRef))
- toast.success('Successfully updated JWT secret')
- break
- case Failed:
- toast.error(`JWT secret update failed: ${jwtSecretUpdateErrorMessage}`)
- break
- }
- }
-
- previousJwtSecretUpdateStatus.current = jwtSecretUpdateStatus
- }, [jwtSecretUpdateStatus])
-
- useEffect(() => {
- if (source !== undefined) {
- state.setSelectedDatabaseId('load-balancer')
- }
- }, [source])
-
return (
{isLoading ? (
@@ -147,10 +110,6 @@ const ServiceList = () => {
-
-
diff --git a/apps/studio/components/interfaces/Settings/Addons/Addons.tsx b/apps/studio/components/interfaces/Settings/Addons/Addons.tsx
index 2dc1c2d1850..74dc615cf2a 100644
--- a/apps/studio/components/interfaces/Settings/Addons/Addons.tsx
+++ b/apps/studio/components/interfaces/Settings/Addons/Addons.tsx
@@ -32,7 +32,7 @@ import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-que
import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query'
import type { ProjectAddonVariantMeta } from 'data/subscriptions/types'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
-import { useIsOrioleDb, useIsOrioleDbInAws, useProjectByRef } from 'hooks/misc/useSelectedProject'
+import { useIsOrioleDbInAws, useProjectByRef } from 'hooks/misc/useSelectedProject'
import { useFlag } from 'hooks/ui/useFlag'
import { getCloudProviderArchitecture } from 'lib/cloudprovider-utils'
import { BASE_PATH, INSTANCE_MICRO_SPECS, INSTANCE_NANO_SPECS } from 'lib/constants'
@@ -53,7 +53,6 @@ const Addons = () => {
const parentProject = useProjectByRef(selectedProject?.parent_project_ref)
const isBranch = parentProject !== undefined
const isProjectActive = useIsProjectActive()
- const isOrioleDb = useIsOrioleDb()
const isOrioleDbInAws = useIsOrioleDbInAws()
const { data: settings } = useProjectSettingsV2Query({ projectRef })
diff --git a/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx b/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx
index 16e7d30340f..af56adf36cd 100644
--- a/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx
+++ b/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx
@@ -1,4 +1,5 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
+import { ExternalLink } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
@@ -11,13 +12,14 @@ import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query'
import type { AddonVariantId } from 'data/subscriptions/types'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
+import { useIsAwsCloudProvider } from 'hooks/misc/useSelectedProject'
import { formatCurrency } from 'lib/helpers'
-import { ExternalLink } from 'lucide-react'
import { useAddonsPagePanel } from 'state/addons-page'
import { Button, Radio, SidePanel, cn } from 'ui'
import { Admonition } from 'ui-patterns'
const IPv4SidePanel = () => {
+ const isAws = useIsAwsCloudProvider()
const { ref: projectRef } = useParams()
const organization = useSelectedOrganization()
@@ -86,7 +88,7 @@ const IPv4SidePanel = () => {
onCancel={closePanel}
onConfirm={onConfirm}
loading={isLoading || isSubmitting}
- disabled={isFreePlan || isLoading || !hasChanges || isSubmitting || !canUpdateIPv4}
+ disabled={isFreePlan || isLoading || !hasChanges || isSubmitting || !canUpdateIPv4 || !isAws}
tooltip={
isFreePlan
? 'Unable to enable IPv4 on a Free Plan'
@@ -117,6 +119,13 @@ const IPv4SidePanel = () => {
database via a IPv4 address.
+ {!isAws && (
+
+ )}
+
{isPgBouncerEnabled ? (
{
className="col-span-4 !p-0"
name="ipv4"
key={option.identifier}
- disabled={isFreePlan}
+ disabled={isFreePlan || !isAws}
checked={selectedOption === option.identifier}
label={option.name}
value={option.identifier}
diff --git a/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectButton.tsx b/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectButton.tsx
index 427a7d66422..961805f9013 100644
--- a/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectButton.tsx
+++ b/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectButton.tsx
@@ -4,7 +4,7 @@ import { useState } from 'react'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
-import DeleteProjectModal from './DeleteProjectModal'
+import { DeleteProjectModal } from './DeleteProjectModal'
export interface DeleteProjectButtonProps {
type?: 'danger' | 'default'
diff --git a/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal.tsx b/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal.tsx
index bc8c94655cf..966e6d481e6 100644
--- a/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal.tsx
+++ b/apps/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal.tsx
@@ -1,4 +1,3 @@
-import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
@@ -9,13 +8,18 @@ import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectConte
import { useSendDowngradeFeedbackMutation } from 'data/feedback/exit-survey-send'
import { useProjectDeleteMutation } from 'data/projects/project-delete-mutation'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
-import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { Input } from 'ui'
import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal'
-const DeleteProjectModal = ({ visible, onClose }: { visible: boolean; onClose: () => void }) => {
+export const DeleteProjectModal = ({
+ visible,
+ onClose,
+}: {
+ visible: boolean
+ onClose: () => void
+}) => {
const router = useRouter()
const { project } = useProjectContext()
const organization = useSelectedOrganization()
@@ -31,7 +35,25 @@ const DeleteProjectModal = ({ visible, onClose }: { visible: boolean; onClose: (
const isFree = projectPlan === 'free'
const [message, setMessage] = useState('')
- const [selectedReasons, setSelectedReasons] = useState([])
+ const [selectedReason, setSelectedReason] = useState([])
+
+ // Single select for cancellation reason
+ const onSelectCancellationReason = (reason: string) => {
+ setSelectedReason([reason])
+ }
+
+ // Helper to get label for selected reason
+ const getReasonLabel = (reason: string | undefined) => {
+ const found = CANCELLATION_REASONS.find((r) => r.value === reason)
+ return found?.label || 'What can we improve on?'
+ }
+
+ const textareaLabel = getReasonLabel(selectedReason[0])
+
+ const [shuffledReasons] = useState(() => [
+ ...CANCELLATION_REASONS.sort(() => Math.random() - 0.5),
+ { value: 'None of the above' },
+ ])
const { mutate: deleteProject, isLoading: isDeleting } = useProjectDeleteMutation({
onSuccess: async () => {
@@ -41,7 +63,7 @@ const DeleteProjectModal = ({ visible, onClose }: { visible: boolean; onClose: (
orgSlug: organization?.slug,
projectRef,
message,
- reasons: selectedReasons.reduce((a, b) => `${a}- ${b}\n`, ''),
+ reasons: selectedReason.reduce((a, b) => `${a}- ${b}\n`, ''),
exitAction: 'delete',
})
} catch (error) {
@@ -58,122 +80,102 @@ const DeleteProjectModal = ({ visible, onClose }: { visible: boolean; onClose: (
const { mutateAsync: sendExitSurvey, isLoading: isSending } = useSendDowngradeFeedbackMutation()
const isSubmitting = isDeleting || isSending
- useEffect(() => {
- if (visible) {
- setSelectedReasons([])
- setMessage('')
- }
- }, [visible])
-
- const canDeleteProject = useCheckPermissions(PermissionAction.UPDATE, 'projects', {
- resource: {
- project_id: project?.id,
- },
- })
-
- const onSelectCancellationReason = (reason: string) => {
- const existingSelection = selectedReasons.find((x) => x === reason)
- const updatedSelection =
- existingSelection === undefined
- ? selectedReasons.concat([reason])
- : selectedReasons.filter((x) => x !== reason)
- setSelectedReasons(updatedSelection)
- }
-
async function handleDeleteProject() {
if (project === undefined) return
- if (!isFree && selectedReasons.length === 0) {
- return toast.error('Please select at least one reason for deleting your project')
+ if (!isFree && selectedReason.length === 0) {
+ return toast.error('Please select a reason for deleting your project')
}
deleteProject({ projectRef: project.ref, organizationSlug: organization?.slug })
}
+ useEffect(() => {
+ if (visible) {
+ setSelectedReason([])
+ setMessage('')
+ }
+ }, [visible])
+
return (
- <>
- {
- if (!isSubmitting) onClose()
- }}
- >
- {/*
+ {
+ if (!isSubmitting) onClose()
+ }}
+ >
+ {/*
[Joshen] This is basically ExitSurvey.tsx, ideally we have one shared component but the one
in ExitSurvey has a Form wrapped around it already. Will probably need some effort to refactor
but leaving that for the future.
*/}
- {!isFree && (
- <>
-
-
Help us improve.
-
- We always strive to improve Supabase as much as we can. Please let us know the
- reasons you are deleting your project so that we can improve in the future.
-
+ {!isFree && (
+ <>
+
+
+ Help us improve by sharing why you're deleting your project.
+
+
+
+
+ {shuffledReasons.map((option) => {
+ const active = selectedReason[0] === option.value
+ return (
+
+ onSelectCancellationReason(option.value)}
+ />
+ {option.value}
+
+ )
+ })}
-
-
- {CANCELLATION_REASONS.map((option) => {
- const active = selectedReasons.find((x) => x === option)
- return (
-
- onSelectCancellationReason(event.target.value)}
- />
- {option}
-
- )
- })}
-
-
- setMessage(event.target.value)}
- />
-
+
+ {textareaLabel}
+ setMessage(event.target.value)}
+ />
- >
- )}
-
- >
+
+ >
+ )}
+
)
}
-
-export default DeleteProjectModal
diff --git a/apps/studio/components/interfaces/Settings/General/General.tsx b/apps/studio/components/interfaces/Settings/General/General.tsx
index 3675e5a8f2b..9c8aad82053 100644
--- a/apps/studio/components/interfaces/Settings/General/General.tsx
+++ b/apps/studio/components/interfaces/Settings/General/General.tsx
@@ -1,5 +1,5 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
-import { AlertCircle, BarChart2, ChevronRight } from 'lucide-react'
+import { BarChart2 } from 'lucide-react'
import Link from 'next/link'
import { toast } from 'sonner'
@@ -13,15 +13,11 @@ import { useProjectUpdateMutation } from 'data/projects/project-update-mutation'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useProjectByRef } from 'hooks/misc/useSelectedProject'
-import { useFlag } from 'hooks/ui/useFlag'
import {
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
Alert_Shadcn_,
Button,
- CollapsibleContent_Shadcn_,
- CollapsibleTrigger_Shadcn_,
- Collapsible_Shadcn_,
Form,
Input,
WarningIcon,
@@ -125,7 +121,7 @@ const General = () => {
-
+
Restart project
diff --git a/apps/studio/components/interfaces/Settings/General/Infrastructure/PauseProjectButton.tsx b/apps/studio/components/interfaces/Settings/General/Infrastructure/PauseProjectButton.tsx
index 96c1465002a..47daf680895 100644
--- a/apps/studio/components/interfaces/Settings/General/Infrastructure/PauseProjectButton.tsx
+++ b/apps/studio/components/interfaces/Settings/General/Infrastructure/PauseProjectButton.tsx
@@ -14,7 +14,7 @@ import { useProjectPauseMutation } from 'data/projects/project-pause-mutation'
import { setProjectStatus } from 'data/projects/projects-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
-import { useIsAwsNew } from 'hooks/misc/useSelectedProject'
+import { useIsAwsK8sCloudProvider } from 'hooks/misc/useSelectedProject'
import { PROJECT_STATUS } from 'lib/constants'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
@@ -33,9 +33,9 @@ const PauseProjectButton = () => {
'queue_jobs.projects.pause'
)
- const isAwsNew = useIsAwsNew()
+ const isAwsK8s = useIsAwsK8sCloudProvider()
const isFreePlan = organization?.plan.id === 'free'
- const isPaidAndNotAwsNew = !isFreePlan && !isAwsNew
+ const isPaidAndNotAwsK8s = !isFreePlan && !isAwsK8s
const { mutate: pauseProject, isLoading: isPausing } = useProjectPauseMutation({
onSuccess: (_, variables) => {
@@ -53,7 +53,7 @@ const PauseProjectButton = () => {
}
const buttonDisabled =
- isPaidAndNotAwsNew || project === undefined || isPaused || !canPauseProject || !isProjectActive
+ isPaidAndNotAwsK8s || project === undefined || isPaused || !canPauseProject || !isProjectActive
return (
<>
@@ -72,7 +72,7 @@ const PauseProjectButton = () => {
? 'You need additional permissions to pause this project'
: !isProjectActive
? 'Unable to pause project as project is not active'
- : isPaidAndNotAwsNew
+ : isPaidAndNotAwsK8s
? 'Projects on a paid plan will always be running'
: undefined,
},
diff --git a/apps/studio/components/interfaces/Settings/General/Infrastructure/ProjectUpgradeAlert/ProjectUpgradeAlert.tsx b/apps/studio/components/interfaces/Settings/General/Infrastructure/ProjectUpgradeAlert/ProjectUpgradeAlert.tsx
index 5f485beaebf..c3ff46c0ac5 100644
--- a/apps/studio/components/interfaces/Settings/General/Infrastructure/ProjectUpgradeAlert/ProjectUpgradeAlert.tsx
+++ b/apps/studio/components/interfaces/Settings/General/Infrastructure/ProjectUpgradeAlert/ProjectUpgradeAlert.tsx
@@ -187,6 +187,7 @@ const ProjectUpgradeAlert = () => {
[right-sized](https://supabase.com/docs/guides/platform/upgrading#disk-sizing) with the upgrade.`}
/>
)}
+ {/* @ts-ignore */}
{(data?.potential_breaking_changes ?? []).length > 0 && (
diff --git a/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx b/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx
index 75320e24b38..4b00ae86f5d 100644
--- a/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx
+++ b/apps/studio/components/interfaces/Settings/General/Infrastructure/RestartServerButton.tsx
@@ -9,10 +9,12 @@ import {
useIsProjectActive,
useProjectContext,
} from 'components/layouts/ProjectLayout/ProjectContext'
+import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { useProjectRestartMutation } from 'data/projects/project-restart-mutation'
import { useProjectRestartServicesMutation } from 'data/projects/project-restart-services-mutation'
import { setProjectStatus } from 'data/projects/projects-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
+import { useIsAwsK8sCloudProvider } from 'hooks/misc/useSelectedProject'
import { useFlag } from 'hooks/ui/useFlag'
import {
Button,
@@ -20,9 +22,6 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
- Tooltip,
- TooltipContent,
- TooltipTrigger,
cn,
} from 'ui'
import ConfirmModal from 'ui-patterns/Dialogs/ConfirmDialog'
@@ -32,6 +31,7 @@ const RestartServerButton = () => {
const queryClient = useQueryClient()
const { project } = useProjectContext()
const isProjectActive = useIsProjectActive()
+ const isAwsK8s = useIsAwsK8sCloudProvider()
const [serviceToRestart, setServiceToRestart] = useState<'project' | 'database'>()
const projectRef = project?.ref ?? ''
@@ -88,69 +88,68 @@ const RestartServerButton = () => {
return (
<>
-
-
-
-
setServiceToRestart('project')}
- >
- Restart project
-
- {canRestartProject && isProjectActive && !projectRestartDisabled && (
-
-
- }
- disabled={!canRestartProject}
- />
-
-
- {
- setServiceToRestart('database')
- }}
- >
-
-
Fast database reboot
-
- Restarts only the database - faster but may not be able to recover from all
- failure modes
-
-
-
-
-
- )}
-
-
- {((project !== undefined && (!canRestartProject || !isProjectActive)) ||
- projectRestartDisabled) && (
-
- {projectRestartDisabled
- ? 'Project restart is currently disabled'
- : !canRestartProject
- ? 'You need additional permissions to restart this project'
- : !isProjectActive
- ? 'Unable to restart project as project is not active'
- : ''}
-
+
+
setServiceToRestart('project')}
+ tooltip={{
+ content: {
+ side: 'bottom',
+ text: projectRestartDisabled
+ ? 'Project restart is currently disabled'
+ : !canRestartProject
+ ? 'You need additional permissions to restart this project'
+ : !isProjectActive
+ ? 'Unable to restart project as project is not active'
+ : isAwsK8s
+ ? 'Project restart is not supported for AWS (Revamped) projects'
+ : '',
+ },
+ }}
+ >
+ Restart project
+
+ {canRestartProject && isProjectActive && !projectRestartDisabled && (
+
+
+ }
+ disabled={!canRestartProject}
+ />
+
+
+ {
+ setServiceToRestart('database')
+ }}
+ >
+
+
Fast database reboot
+
+ Restarts only the database - faster but may not be able to recover from all
+ failure modes
+
+
+
+
+
)}
-
+
x.organization_id === project?.organization_id
)
const hasOverdueInvoices = overdueInvoices.length > 0 && isNotOnTeamOrEnterprisePlan
+ const isAwsK8s = useIsAwsK8sCloudProvider()
// Opting for useState temporarily as Listbox doesn't seem to work with react-hook-form yet
const [defaultRegion] = Object.entries(AWS_REGIONS).find(
@@ -182,6 +183,7 @@ const DeployNewReplicaPanel = ({
isWalgEnabled &&
currentComputeAddon !== undefined &&
!hasOverdueInvoices &&
+ !isAwsK8s &&
!isProWithSpendCapEnabled
const computeAddons =
@@ -261,6 +263,19 @@ const DeployNewReplicaPanel = ({
/>
+ ) : isAwsK8s ? (
+
+
+
+ Read replicas are not supported for AWS (Revamped) projects
+
+
+
+ Projects provisioned by other cloud providers currently will not be able to use read
+ replicas
+
+
+
) : currentPgVersion < 15 ? (
diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx
index 8f767224235..ca84227d92f 100644
--- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx
+++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx
@@ -19,7 +19,6 @@ import {
AlertTitle_Shadcn_,
Alert_Shadcn_,
Badge,
- Button,
Input,
Tooltip,
TooltipContent,
@@ -27,6 +26,13 @@ import {
} from 'ui'
import { ProjectUpgradeAlert } from '../General/Infrastructure/ProjectUpgradeAlert'
import InstanceConfiguration from './InfrastructureConfiguration/InstanceConfiguration'
+import {
+ DatabaseExtensionsWarning,
+ ObjectsToBeDroppedWarning,
+ ReadReplicasWarning,
+ UnsupportedExtensionsWarning,
+ UserDefinedObjectsInInternalSchemasWarning,
+} from './UpgradeWarnings'
const InfrastructureInfo = () => {
const { ref } = useParams()
@@ -73,6 +79,12 @@ const InfrastructureInfo = () => {
const isInactive = project?.status === 'INACTIVE'
const hasReadReplicas = (databases ?? []).length > 1
+ // @ts-ignore [Bobbie] to be removed after 2025-06-30 prod deploy
+ const hasExtensionDependentObjects = (data?.extension_dependent_objects ?? []).length > 0
+ const hasObjectsToBeDropped = (data?.objects_to_be_dropped ?? []).length > 0
+ const hasUnsupportedExtensions = (data?.unsupported_extensions || []).length > 0
+ const hasObjectsInternalSchema = (data?.user_defined_objects_in_internal_schemas || []).length > 0
+
return (
<>
@@ -181,62 +193,38 @@ const InfrastructureInfo = () => {
>
)}
- {data?.eligible && !hasReadReplicas && }
- {data.eligible && hasReadReplicas && (
-
-
- A new version of Postgres is available for your project
-
-
- You will need to remove all read replicas prior to upgrading your Postgres
- version to the latest available ({latestPgVersion}).
-
-
- )}
- {!data?.eligible && (data?.extension_dependent_objects || []).length > 0 && (
-
-
- A new version of Postgres is available
-
-
-
-
- You'll need to remove the following extensions before upgrading:
-
+ {data.eligible ? (
+ hasReadReplicas ? (
+
+ ) : (
+
+ )
+ ) : null}
-
- {(data?.extension_dependent_objects || []).map((obj) => (
-
- {obj}
-
- ))}
-
-
-
- {projectUpgradeEligibilityData?.potential_breaking_changes?.includes(
- 'pg17_upgrade_unsupported_extensions'
- )
- ? 'These extensions are not supported in newer versions of Supabase Postgres. If you are not using them, it is safe to remove them.'
- : 'Check the docs for which ones might need to be removed.'}
-
-
-
-
-
- )}
+ {!data.eligible ? (
+ hasExtensionDependentObjects ? (
+
+ ) : hasObjectsToBeDropped ? (
+
+ ) : hasUnsupportedExtensions ? (
+
+ ) : hasObjectsInternalSchema ? (
+
+ ) : null
+ ) : null}
>
)}
>
diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/UpgradeWarnings.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/UpgradeWarnings.tsx
new file mode 100644
index 00000000000..4f4e9d8679a
--- /dev/null
+++ b/apps/studio/components/interfaces/Settings/Infrastructure/UpgradeWarnings.tsx
@@ -0,0 +1,185 @@
+import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, Button } from 'ui'
+
+export const ReadReplicasWarning = ({ latestPgVersion }: { latestPgVersion: string }) => {
+ return (
+
+
+ A new version of Postgres is available for your project
+
+
+ You will need to remove all read replicas prior to upgrading your Postgres version to the
+ latest available ({latestPgVersion}).
+
+
+ )
+}
+
+export const DatabaseExtensionsWarning = ({
+ extensions,
+ potentialBreakingChanges,
+}: {
+ extensions: string[]
+ potentialBreakingChanges?: string[]
+}) => {
+ return (
+
+ A new version of Postgres is available
+
+
+
You'll need to remove the following extensions before upgrading:
+
+
+ {extensions.map((obj) => (
+
+ {obj}
+
+ ))}
+
+
+
+ {potentialBreakingChanges?.includes('pg17_upgrade_unsupported_extensions')
+ ? 'These extensions are not supported in newer versions of Supabase Postgres. If you are not using them, it is safe to remove them.'
+ : 'Check the docs for which ones might need to be removed.'}
+
+
+
+
+ )
+}
+
+export const ObjectsToBeDroppedWarning = ({
+ objectsToBeDropped,
+}: {
+ objectsToBeDropped: string[]
+}) => {
+ return (
+
+ A new version of Postgres is available
+
+
+
You'll need to remove the following objects before upgrading:
+
+
+ {objectsToBeDropped.map((obj) => (
+
+ {obj}
+
+ ))}
+
+
+ Check the docs for which objects need to be removed.
+
+
+
+ )
+}
+
+export const UnsupportedExtensionsWarning = ({
+ unsupportedExtensions,
+}: {
+ unsupportedExtensions: string[]
+}) => {
+ return (
+
+ A new version of Postgres is available
+
+
+
You'll need to remove the following extensions before upgrading:
+
+
+ {unsupportedExtensions.map((obj: string) => (
+
+ {obj}
+
+ ))}
+
+
+
+ These extensions are not supported in newer versions of Supabase Postgres. If you are not
+ using them, it is safe to remove them.
+
+
+
+
+ )
+}
+
+export const UserDefinedObjectsInInternalSchemasWarning = ({ objects }: { objects: string[] }) => {
+ return (
+
+ A new version of Postgres is available
+
+
+
+ You'll need to move these objects out of auth/realtime/storage schemas before upgrading:
+
+
+
+ {objects.map((obj: string) => (
+
+ {obj}
+
+ ))}
+
+
+
+ These schemas are Supabase-managed and creating custom objects in them is no longer
+ supported. Check the changelog to see how to move them to your own schemas.
+
+
+
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Settings/Logs/ErrorCodeDialog.tsx b/apps/studio/components/interfaces/Settings/Logs/ErrorCodeDialog.tsx
new file mode 100644
index 00000000000..a14b40e82e6
--- /dev/null
+++ b/apps/studio/components/interfaces/Settings/Logs/ErrorCodeDialog.tsx
@@ -0,0 +1,111 @@
+import {
+ Alert_Shadcn_,
+ AlertDescription_Shadcn_,
+ AlertTitle_Shadcn_,
+ Badge,
+ Button_Shadcn_,
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from 'ui'
+import { useErrorCodesQuery } from 'data/content-api/docs-error-codes-query'
+import { type ErrorCodeQueryQuery, Service } from 'data/graphql/graphql'
+import { AlertTriangle } from 'lucide-react'
+import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
+
+interface ErrorCodeDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ errorCode: string
+ service?: Service
+}
+
+export const ErrorCodeDialog = ({
+ open,
+ onOpenChange,
+ errorCode,
+ service,
+}: ErrorCodeDialogProps) => {
+ const { data, isLoading, isSuccess, refetch } = useErrorCodesQuery(
+ { code: errorCode, service },
+ { enabled: open }
+ )
+
+ return (
+
+
+
+
+ Help for error code {errorCode}
+
+
+ {isLoading && }
+ {isSuccess && }
+ {!isLoading && !isSuccess && }
+
+
+
+
+ )
+}
+
+const LoadingState = () => (
+ <>
+
+
+ >
+)
+
+const SuccessState = ({ data }: { data: ErrorCodeQueryQuery | undefined }) => {
+ const errors = data?.errors?.nodes?.filter((error) => !!error.message)
+ if (!errors || errors.length === 0) {
+ return <>No information found for this error code.>
+ }
+
+ return (
+ <>
+ Possible explanations for this error:
+
+ {errors.map((error) => (
+
+ ))}
+
+ >
+ )
+}
+
+const ErrorExplanation = ({
+ code,
+ service,
+ message,
+}: {
+ code: string
+ service: Service
+ message?: string | null
+}) => {
+ if (!message) return null
+
+ return (
+ <>
+ {service}
+ {message}
+ >
+ )
+}
+
+const ErrorState = ({ refetch }: { refetch?: () => void }) => (
+
+
+ Lookup failed
+
+ Failed to look up error code help info
+ {refetch && (
+
+ Try again
+
+ )}
+
+
+)
diff --git a/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DefaultPreviewSelectionRenderer.tsx b/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DefaultPreviewSelectionRenderer.tsx
index e741d74fe43..fd4de31277c 100644
--- a/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DefaultPreviewSelectionRenderer.tsx
+++ b/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DefaultPreviewSelectionRenderer.tsx
@@ -1,3 +1,4 @@
+import { Service } from 'data/graphql/graphql'
import { useLogsUrlState } from 'hooks/analytics/useLogsUrlState'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
@@ -12,6 +13,7 @@ import {
Separator,
} from 'ui'
import { TimestampInfo } from 'ui-patterns'
+import { ErrorCodeDialog } from '../ErrorCodeDialog'
import type { LogSearchCallback, PreviewLogData } from '../Logs.types'
import { ResponseCodeFormatter } from '../LogsFormatters'
@@ -32,12 +34,18 @@ const PropertyRow = ({
keyName,
value,
dataTestId,
+ path,
}: {
keyName: string
value: any
dataTestId?: string
+ path?: string
}) => {
const { setSearch } = useLogsUrlState()
+ const [showErrorInfo, setShowErrorInfo] = useState(false)
+
+ const service = path?.startsWith('/auth/') ? Service.Auth : undefined
+
const handleSearch: LogSearchCallback = async (event: string, { query }: { query?: string }) => {
setSearch(query || '')
}
@@ -118,88 +126,108 @@ const PropertyRow = ({
}
return (
-
-
-
-
-
- {keyName}
-
+ <>
+
+
+
- {isExpanded ? (
-
- ) : isTimestamp ? (
-
- ) : isStatus ? (
-
-
-
- ) : isMethod ? (
-
-
-
- ) : (
-
{value}
- )}
+
+ {keyName}
+
+
+ {isExpanded ? (
+
+ ) : isTimestamp ? (
+
+ ) : isStatus ? (
+
+
+
+ ) : isMethod ? (
+
+
+
+ ) : (
+
{value}
+ )}
+
-
-
-
- Copy {keyName}
- {!isObject && (
- {
- setIsExpanded(!isExpanded)
- }}
- >
- {isExpanded ? 'Collapse' : 'Expand'} value
-
- )}
- {(isMethod || isUserAgent || isStatus || isPath) && (
- {
- handleSearch('search-input-change', { query: value })
- }}
- >
- Search by {keyName}
-
- )}
- {isSearch
- ? getSearchPairs().map((pair) => (
- {
- handleSearch('search-input-change', { query: pair })
- }}
- >
- Search by {pair}
-
- ))
- : null}
-
-
-
+
+
+ {keyName === 'error_code' && (
+ {
+ setShowErrorInfo(true)
+ }}
+ >
+ More information
+
+ )}
+ Copy {keyName}
+ {!isObject && (
+ {
+ setIsExpanded(!isExpanded)
+ }}
+ >
+ {isExpanded ? 'Collapse' : 'Expand'} value
+
+ )}
+ {(isMethod || isUserAgent || isStatus || isPath) && (
+ {
+ handleSearch('search-input-change', { query: value })
+ }}
+ >
+ Search by {keyName}
+
+ )}
+ {isSearch
+ ? getSearchPairs().map((pair) => (
+ {
+ handleSearch('search-input-change', { query: pair })
+ }}
+ >
+ Search by {pair}
+
+ ))
+ : null}
+
+
+
+ {keyName === 'error_code' && (
+
+ )}
+ >
)
}
const DefaultPreviewSelectionRenderer = ({ log }: { log: PreviewLogData }) => {
const { timestamp, event_message, metadata, id, status, ...rest } = log
+ const path = typeof log.path === 'string' ? log.path : undefined
const log_file = log?.metadata?.[0]?.log_file
return (
@@ -212,7 +240,7 @@ const DefaultPreviewSelectionRenderer = ({ log }: { log: PreviewLogData }) => {
)}
{Object.entries(rest).map(([key, value]) => {
- return
+ return
})}
{log?.event_message && (
diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.types.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.types.ts
index 06ee5653348..ef3597a06fb 100644
--- a/apps/studio/components/interfaces/Settings/Logs/Logs.types.ts
+++ b/apps/studio/components/interfaces/Settings/Logs/Logs.types.ts
@@ -20,7 +20,6 @@ export interface LogsWarning {
linkText?: string
}
export interface LogsEndpointParams {
- project: string // project ref
iso_timestamp_start?: string
iso_timestamp_end?: string
sql?: string
diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts
index 1af9461d3f9..d1f9fbc2273 100644
--- a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts
+++ b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts
@@ -4,12 +4,12 @@ import { get, isEqual } from 'lodash'
import uniqBy from 'lodash/uniqBy'
import { useEffect } from 'react'
+import { IS_PLATFORM } from 'common'
import BackwardIterator from 'components/ui/CodeEditor/Providers/BackwardIterator'
import type { PlanId } from 'data/subscriptions/types'
import logConstants from 'shared-data/logConstants'
import { LogsTableName, SQL_FILTER_TEMPLATES } from './Logs.constants'
import type { Filters, LogData, LogsEndpointParams } from './Logs.types'
-import { IS_PLATFORM } from 'common'
/**
* Convert a micro timestamp from number/string to iso timestamp
diff --git a/apps/studio/components/interfaces/Sidebar.tsx b/apps/studio/components/interfaces/Sidebar.tsx
index afc27de6f8c..bb7625b410a 100644
--- a/apps/studio/components/interfaces/Sidebar.tsx
+++ b/apps/studio/components/interfaces/Sidebar.tsx
@@ -5,7 +5,7 @@ import Link from 'next/link'
import { useRouter } from 'next/router'
import { ComponentProps, ComponentPropsWithoutRef, FC, ReactNode, useEffect } from 'react'
-import { LOCAL_STORAGE_KEYS, useParams } from 'common'
+import { LOCAL_STORAGE_KEYS, useIsMFAEnabled, useParams } from 'common'
import {
generateOtherRoutes,
generateProductRoutes,
@@ -18,6 +18,7 @@ import { useHideSidebar } from 'hooks/misc/useHideSidebar'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useLints } from 'hooks/misc/useLints'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
+import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useFlag } from 'hooks/ui/useFlag'
import { Home } from 'icons'
import { useAppStateSnapshot } from 'state/app-state'
@@ -41,10 +42,7 @@ import {
Sidebar as SidebarPrimitive,
useSidebar,
} from 'ui'
-import {
- useIsAPIDocsSidePanelEnabled,
- useIsSQLEditorTabsEnabled,
-} from './App/FeaturePreview/FeaturePreviewContext'
+import { useIsAPIDocsSidePanelEnabled } from './App/FeaturePreview/FeaturePreviewContext'
export const ICON_SIZE = 32
export const ICON_STROKE_WIDTH = 1.5
@@ -158,10 +156,12 @@ export function SideBarNavLink({
route,
active,
onClick,
+ disabled,
...props
}: {
route: any
active?: boolean
+ disabled?: boolean
onClick?: () => void
} & ComponentPropsWithoutRef
) {
const [sidebarBehaviour] = useLocalStorageQuery(
@@ -170,6 +170,7 @@ export function SideBarNavLink({
)
const buttonProps = {
+ disabled,
tooltip: sidebarBehaviour === 'closed' ? route.label : '',
isActive: active,
className: cn('text-sm', sidebarBehaviour === 'open' ? '!px-2' : ''),
@@ -188,7 +189,7 @@ export function SideBarNavLink({
return (
- {route.link ? (
+ {route.link && !disabled ? (
{content}
@@ -223,7 +224,7 @@ const ProjectLinks = () => {
const { securityLints, errorLints } = useLints()
const showWarehouse = useFlag('warehouse')
- const isSqlEditorTabsEnabled = useIsSQLEditorTabsEnabled()
+ const showUnifiedLogs = useFlag('unifiedLogs')
const activeRoute = router.pathname.split('/')[3]
@@ -239,16 +240,14 @@ const ProjectLinks = () => {
'realtime:all',
])
- const toolRoutes = generateToolRoutes(ref, project, {
- sqlEditorTabs: isSqlEditorTabsEnabled,
- })
+ const toolRoutes = generateToolRoutes(ref, project)
const productRoutes = generateProductRoutes(ref, project, {
auth: authEnabled,
edgeFunctions: edgeFunctionsEnabled,
storage: storageEnabled,
realtime: realtimeEnabled,
})
- const otherRoutes = generateOtherRoutes(ref, project)
+ const otherRoutes = generateOtherRoutes(ref, project, { unifiedLogs: showUnifiedLogs })
const settingsRoutes = generateSettingsRoutes(ref, project)
return (
@@ -351,6 +350,10 @@ const OrganizationLinks = () => {
const router = useRouter()
const { slug } = useParams()
+ const org = useSelectedOrganization()
+ const isUserMFAEnabled = useIsMFAEnabled()
+ const disableAccessMfa = org?.organization_requires_mfa && !isUserMFAEnabled
+
const activeRoute = router.pathname.split('/')[3]
const navMenuItems = [
@@ -398,6 +401,7 @@ const OrganizationLinks = () => {
{navMenuItems.map((item, i) => (
{lastSignIn === type && (
-
-
-
-
- Last used
-
+
+ Last used
+
)}
-
-
- Project metadata (tables, columns, and data types) is being shared with OpenAI
-
-
- setShowAiSettingsModal(true)}
- >
- Change this configuration
-
-
-
- )
-}
-
-export type AiMetadataSkipReason = 'forbidden' | 'no_project'
-
-export function ExcludeSchemaAlert({
- metadataSkipReason,
-}: {
- metadataSkipReason: AiMetadataSkipReason | undefined
-}) {
- const { setShowAiSettingsModal } = useAppStateSnapshot()
-
- return (
-
-
-
- Project metadata (tables, columns, and data types) is not being shared with OpenAI
-
-
- {metadataSkipReason === 'no_project' ? (
- 'Switch to a project to change this setting'
- ) : (
- setShowAiSettingsModal(true)}
- >
- Change this configuration
-
- )}
-
-
- )
-}
diff --git a/apps/studio/components/interfaces/SqlGenerator/SqlGenerator.Commands.tsx b/apps/studio/components/interfaces/SqlGenerator/SqlGenerator.Commands.tsx
deleted file mode 100644
index cf30643e27a..00000000000
--- a/apps/studio/components/interfaces/SqlGenerator/SqlGenerator.Commands.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import { useIsLoggedIn } from 'common'
-import { useAppStateSnapshot } from 'state/app-state'
-import { AiIconAnimation } from 'ui'
-import { BadgeExperimental, useRegisterCommands, useSetQuery } from 'ui-patterns/CommandMenu'
-import { orderCommandSectionsByPriority } from '../App/CommandMenu/ordering'
-import { COMMAND_MENU_SECTIONS } from '../App/CommandMenu/CommandMenu.utils'
-
-export function useGenerateSqlCommand() {
- const isLoggedIn = useIsLoggedIn()
-
- const { setShowGenerateSqlModal } = useAppStateSnapshot()
- const setQuery = useSetQuery()
-
- useRegisterCommands(
- COMMAND_MENU_SECTIONS.QUERY,
- [
- {
- id: 'generate-sql-ai',
- name: 'Run SQL with Supabase AI',
- action: () => {
- setShowGenerateSqlModal(true)
- setQuery('')
- },
- icon: () =>
,
- badge: () =>
,
- },
- ],
- {
- enabled: isLoggedIn,
- orderSection: orderCommandSectionsByPriority,
- sectionMeta: { priority: 2 },
- }
- )
-}
diff --git a/apps/studio/components/interfaces/SqlGenerator/SqlGenerator.tsx b/apps/studio/components/interfaces/SqlGenerator/SqlGenerator.tsx
deleted file mode 100644
index 137a11f4dd6..00000000000
--- a/apps/studio/components/interfaces/SqlGenerator/SqlGenerator.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import dynamic from 'next/dynamic'
-import { memo } from 'react'
-
-import { useCommandMenuInitiated } from 'ui-patterns/CommandMenu'
-
-const LazyGenerateSql = dynamic(() => import('./SqlGeneratorImpl'), { ssr: false })
-
-export const GenerateSql = memo(() => {
- const isInitiated = useCommandMenuInitiated()
- return isInitiated &&
-})
-GenerateSql.displayName = 'GenerateSql'
diff --git a/apps/studio/components/interfaces/SqlGenerator/SqlGenerator.utils.ts b/apps/studio/components/interfaces/SqlGenerator/SqlGenerator.utils.ts
deleted file mode 100644
index 4ca094a049c..00000000000
--- a/apps/studio/components/interfaces/SqlGenerator/SqlGenerator.utils.ts
+++ /dev/null
@@ -1,112 +0,0 @@
-import { stripIndent } from 'common-tags'
-
-type QueryCategory = {
- category: string
- queries: string[]
-}
-
-type SampleQueries = QueryCategory[]
-
-export const SAMPLE_QUERIES: SampleQueries = [
- {
- category: 'Tables',
- queries: [
- 'Create a table that stores a list of cities, and insert 10 rows of sample data into it',
- 'Create tables (with foreign key relationships) for blog posts and comments',
- 'Create tables for employees, reviewers, and employee reviews, with columns for employee ID, reviewer ID, and review text',
- ],
- },
- {
- category: 'Views',
- queries: [
- 'Create a view that shows the total revenue for each customer',
- 'Create a view that shows all orders that were placed in the last week',
- 'Create a view that shows all products that are currently out of stock',
- 'Create a materialized view that shows the customer_orders table the total value of orders in a month',
- ],
- },
- {
- category: 'Indexes',
- queries: [
- 'Create an index on the primary key column of my orders table',
- 'Create a partial index on the orders table:',
- 'Create an index on the customer_id column of the customer_orders table',
- ],
- },
- {
- category: 'Select',
- queries: [
- 'Retrieve a list of employees from the employees table who have a salary greater than $50,000',
- 'Retrieve a list of all employees with the title "Manager" from the employees table',
- 'Retrieve a list of all employees hired in the last 6 months',
- 'Retrieve the department and average salary of each department from the employees table, group by department',
- ],
- },
- {
- category: 'Triggers',
- queries: [
- 'Create a trigger that updates the updated_at column on the orders table with the current time when the row of the orders table is updated',
- 'Create a trigger to add a new user to the users table when someone new registers',
- 'Create a trigger to send an email whenever there is an insert in the orders table',
- 'Create a trigger to delete all orders placed by a customer when that customer is deleted from the customers table',
- ],
- },
- {
- category: 'Row Level Security',
- queries: [
- 'Create an RLS policy that grants only authenticated access to the profiles table',
- 'Create an RLS policy that grants SELECT access to the sales_rep role for the customers table, but denies access to all other roles',
- "Create an RLS policy that grants INSERT access access to the manager role for the employees table, but only for rows where the employee's department_id matches a list of departments that the manager is responsible for",
- ],
- },
- {
- category: 'Functions',
- queries: [
- "Create a function to add a new entry to a user's table when a new user signs up",
- 'Create an a function to calculate the average price of a product in a given category',
- 'Create a function to insert a new order and update the inventory for the ordered products',
- 'Create a function to calculate the total sales for a product given an id',
- ],
- },
-]
-
-export function generatePrompt(prompt: string, definitions?: any) {
- return stripIndent`
- ${
- definitions !== undefined
- ? `
- Given the following Postgres SQL tables:
- ${definitions}
- `
- : ''
- }
-
- Generate a Postgres SQL query based on the following natural language prompt.
- - Only output valid SQL - all explanations must be SQL comments
- - SQL comments should be short
- - Your very last output should be "\`\`\`"
- - For primary keys, always use "integer primary key generated always as identity"
-
- Natural language prompt:
- ${prompt}
-
- Postgres SQL query (markdown SQL only):
- `.trim()
-}
-
-/**
- * Formats a string for use as a title.
- *
- * Removes punctuation and capitalizes each word
- */
-export function formatTitle(value: string) {
- let words = value.replace(/\.$/, '').replace(/['"]/g, '').split(' ')
- words = words.map((word) => {
- // Don't capitalize code
- if (/[._\(\)]+/.test(word)) {
- return word
- }
- return word.charAt(0).toUpperCase() + word.slice(1)
- })
- return words.join(' ')
-}
diff --git a/apps/studio/components/interfaces/SqlGenerator/SqlGeneratorImpl.tsx b/apps/studio/components/interfaces/SqlGenerator/SqlGeneratorImpl.tsx
deleted file mode 100644
index d7a802c8a32..00000000000
--- a/apps/studio/components/interfaces/SqlGenerator/SqlGeneratorImpl.tsx
+++ /dev/null
@@ -1,413 +0,0 @@
-import { AlertTriangle, User } from 'lucide-react'
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import { useEffectOnce } from 'react-use'
-
-import { IS_PLATFORM } from 'common'
-import { useEntityDefinitionsQuery } from 'data/database/entity-definitions-query'
-import { useOrgOptedIntoAiAndHippaProject } from 'hooks/misc/useOrgOptedIntoAi'
-import { useSchemasForAi } from 'hooks/misc/useSchemasForAi'
-import { useSelectedProject } from 'hooks/misc/useSelectedProject'
-import { formatSql } from 'lib/formatSql'
-import { useAppStateSnapshot } from 'state/app-state'
-import {
- AiIconAnimation,
- Button,
- CodeBlock,
- CommandItem_Shadcn_,
- CommandList_Shadcn_,
- Command_Shadcn_,
- Input_Shadcn_,
- Modal,
- StatusIcon,
- TabsContent_Shadcn_,
- TabsList_Shadcn_,
- TabsTrigger_Shadcn_,
- Tabs_Shadcn_,
- cn,
-} from 'ui'
-import {
- BadgeExperimental,
- generateCommandClassNames,
- useHistoryKeys,
- useQuery,
- useSetQuery,
-} from 'ui-patterns/CommandMenu'
-import type { UseAiChatOptions } from 'ui-patterns/CommandMenu/prepackaged/ai'
-import {
- AiWarning,
- MessageRole,
- MessageStatus,
- useAiChat,
-} from 'ui-patterns/CommandMenu/prepackaged/ai'
-import type { AiMetadataSkipReason } from './SqlGenerator.Alerts'
-import { ExcludeSchemaAlert, IncludeSchemaAlert } from './SqlGenerator.Alerts'
-import { SAMPLE_QUERIES, generatePrompt } from './SqlGenerator.utils'
-import { SQLOutputActions } from './SqlOutputActions'
-
-function useSchemaMetadataForAi() {
- const { isOptedInToAI, isHipaaProjectDisallowed } = useOrgOptedIntoAiAndHippaProject()
- const project = useSelectedProject()
-
- const [schemas] = useSchemasForAi(project?.ref!)
- const includeMetadata =
- ((isOptedInToAI && !isHipaaProjectDisallowed) || !IS_PLATFORM) &&
- schemas.length > 0 &&
- !!project
-
- const metadataSkipReason: AiMetadataSkipReason = !project ? 'no_project' : 'forbidden'
-
- const { data } = useEntityDefinitionsQuery(
- {
- schemas: schemas,
- projectRef: project?.ref,
- connectionString: project?.connectionString,
- },
- { enabled: includeMetadata }
- )
-
- const api = useMemo(
- () =>
- includeMetadata
- ? {
- includeMetadata,
- data,
- }
- : { includeMetadata, metadataSkipReason },
- [includeMetadata, data, metadataSkipReason]
- )
-
- return api
-}
-
-function useAiSqlGeneration() {
- const { showGenerateSqlModal } = useAppStateSnapshot()
-
- const schemaMetadata = useSchemaMetadataForAi()
- const definitions = useMemo(
- () => (schemaMetadata.data ?? []).map((def) => def.sql.trim()).join('\n\n'),
- [schemaMetadata]
- )
-
- const query = useQuery()
- const setQuery = useSetQuery()
- const [isLoading, setIsLoading] = useState(false)
-
- const messageTemplate = useCallback
>(
- (message) => generatePrompt(message, schemaMetadata.includeMetadata ? definitions : undefined),
- [schemaMetadata, definitions]
- )
- const { submit, reset, messages, isResponding, hasError } = useAiChat({
- messageTemplate,
- setIsLoading,
- })
-
- useHistoryKeys({
- enable: showGenerateSqlModal && !isResponding,
- stack: messages.filter(({ role }) => role === MessageRole.User).map(({ content }) => content),
- })
-
- const handleSubmit = useCallback(
- (message: string) => {
- setQuery('')
- submit(message)
- },
- [setQuery, submit]
- )
-
- const handleReset = useCallback(() => {
- setQuery('')
- reset()
- }, [setQuery, reset])
-
- useEffectOnce(() => {
- if (query) handleSubmit(query)
- })
-
- return {
- query,
- setQuery,
- isLoading,
- isResponding,
- hasError,
- messages,
- handleSubmit,
- handleReset,
- usesMetadata: schemaMetadata.includeMetadata,
- metadataSkipReason: schemaMetadata.metadataSkipReason,
- }
-}
-
-export default function SqlGeneratorImpl() {
- const { showGenerateSqlModal, setShowGenerateSqlModal } = useAppStateSnapshot()
- const inputRef = useRef(null)
-
- const timeoutHandle = useRef>()
- useEffect(() => () => clearTimeout(timeoutHandle.current), [])
-
- const {
- query,
- setQuery,
- isLoading,
- isResponding,
- hasError,
- messages,
- handleSubmit,
- handleReset,
- usesMetadata,
- metadataSkipReason,
- } = useAiSqlGeneration()
-
- return (
-
- Generate SQL
-
-
- }
- hideClose
- hideFooter
- visible={showGenerateSqlModal}
- onOpenAutoFocus={() => {
- timeoutHandle.current = setTimeout(() => inputRef.current?.focus())
- }}
- onCancel={() => {
- if (messages.length > 0) {
- handleReset()
- timeoutHandle.current = setTimeout(() => inputRef.current?.focus())
- } else {
- setShowGenerateSqlModal(false)
- }
- }}
- className="w-11/12 !max-w-3xl h-4/5 max-h-[800px] grid-rows-[min-content,_1fr] bg-overlay"
- >
-
-
- {messages.length > 0 && }
- {messages.length === 0 && !hasError && (
-
- )}
- {hasError && }
-
-
- {usesMetadata ? (
-
- ) : (
-
- )}
- {
- if (!isLoading || !isResponding) {
- setQuery(e.target.value)
- }
- }}
- onKeyDown={(e) => {
- switch (e.key) {
- case 'Enter':
- if (!query || isLoading || isResponding) {
- return
- }
- return handleSubmit(query)
- default:
- return
- }
- }}
- />
-
-
-
- )
-}
-
-function Messages({
- messages,
- handleReset,
-}: {
- messages: ReturnType['messages']
- handleReset: () => void
-}) {
- const X_PADDING = 'px-4'
-
- const UserAvatar = useCallback(() => {
- return (
-
-
-
- )
- }, [])
-
- return messages.map((message, i) => {
- switch (message.role) {
- case MessageRole.User:
- return (
-
- )
- case MessageRole.Assistant:
- const unformattedAnswer = message.content
- .replace(/```sql/g, '')
- .replace(/```.*/gs, '')
- .replace(/-- End of SQL query\.*/g, '')
- .trim()
-
- const answer =
- message.status === MessageStatus.Complete
- ? formatSql(unformattedAnswer)
- : unformattedAnswer
- const cantHelp = answer.replace(/^-- /, '') === "Sorry, I don't know how to help with that."
-
- return (
-
-
-
- {message.status === MessageStatus.Pending ? (
-
- ) : cantHelp ? (
-
-
-
- Sorry, I don't know how to help with that.
-
-
- Try again?
-
-
- ) : (
-
-
- {answer}
-
-
- {message.status === MessageStatus.Complete && (
-
- )}
-
- )}
-
-
- )
- }
- })
-}
-
-function EmptyState({
- query,
- handleSubmit,
-}: {
- query: string
- handleSubmit: (query: string) => void
-}) {
- const [activeTab, setActiveTab] = useState(SAMPLE_QUERIES[0].category)
-
- useEffect(() => {
- function handleSwitchTab(event: KeyboardEvent) {
- if (query || (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight')) {
- return
- }
-
- const activeSampleIndex = SAMPLE_QUERIES.findIndex(
- (samples) => samples.category === activeTab
- )!
- const nextIndex =
- event.key === 'ArrowRight'
- ? Math.min(activeSampleIndex + 1, SAMPLE_QUERIES.length - 1)
- : Math.max(0, activeSampleIndex - 1)
- setActiveTab(SAMPLE_QUERIES[nextIndex].category)
- }
-
- document.addEventListener('keydown', handleSwitchTab)
- return () => document.removeEventListener('keydown', handleSwitchTab)
- }, [query, activeTab])
-
- return (
- <>
-
- Describe what you need and Supabase AI will try to generate the relevant SQL statements
-
-
- Here are some example prompts to try out:
-
-
- setActiveTab(value)}
- className="focus-visible:ring-0"
- >
-
- {SAMPLE_QUERIES.map((samples) => (
- {samples.category}
- ))}
-
-
- {SAMPLE_QUERIES.map((samples) => (
-
- {samples.queries.map((sampleQuery) => (
- {
- if (!query) {
- handleSubmit(sampleQuery)
- }
- }}
- >
-
- {sampleQuery}
-
- ))}
-
- ))}
-
-
- >
- )
-}
-
-function ErrorMessage({ handleReset }: { handleReset: () => void }) {
- return (
-
-
-
- Sorry, looks like Supabase AI is having a hard time!
-
-
Please try again in a bit.
-
- Try again?
-
-
- )
-}
diff --git a/apps/studio/components/interfaces/SqlGenerator/SqlOutputActions.tsx b/apps/studio/components/interfaces/SqlGenerator/SqlOutputActions.tsx
deleted file mode 100644
index 7a280bd6ae4..00000000000
--- a/apps/studio/components/interfaces/SqlGenerator/SqlOutputActions.tsx
+++ /dev/null
@@ -1,157 +0,0 @@
-import { PermissionAction } from '@supabase/shared-types/out/constants'
-import { codeBlock, stripIndent } from 'common-tags'
-import { Check, Clipboard, Save } from 'lucide-react'
-import { useCallback, useEffect, useState } from 'react'
-import CopyToClipboard from 'react-copy-to-clipboard'
-import { toast } from 'sonner'
-
-import { useParams } from 'common'
-import { createSqlSnippetSkeletonV2 } from 'components/interfaces/SQLEditor/SQLEditor.utils'
-import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
-import { useSelectedProject } from 'hooks/misc/useSelectedProject'
-import { uuidv4 } from 'lib/helpers'
-import { useProfile } from 'lib/profile'
-import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
-import { Button, cn } from 'ui'
-import type { Message } from 'ui-patterns/CommandMenu/prepackaged/ai'
-import { MessageRole, MessageStatus, queryAi } from 'ui-patterns/CommandMenu/prepackaged/ai'
-import { formatTitle } from './SqlGenerator.utils'
-
-const useSaveGeneratedSql = () => {
- const { ref } = useParams()
- const { profile } = useProfile()
- const selectedProject = useSelectedProject()
- const snapV2 = useSqlEditorV2StateSnapshot()
- const canCreateSQLSnippet = useCheckPermissions(PermissionAction.CREATE, 'user_content', {
- resource: { type: 'sql', owner_id: profile?.id },
- subject: { id: profile?.id },
- })
-
- const saveGeneratedSql = useCallback(
- (answer: string, title: string) => {
- if (!ref) return console.error('Project ref is required')
- if (!profile) return console.error('Profile is required')
- if (!selectedProject) return console.error('Project is required')
-
- if (!canCreateSQLSnippet) {
- toast('Unable to save query as you do not have sufficient permissions for this project')
- return
- }
-
- // Remove markdown syntax from returned answer
- answer = answer.replace(/`/g, '').replace(/sql\n/g, '').trim()
-
- const formattedSql = codeBlock`
- -- Note: This query was generated via Supabase AI, please verify the correctness of the
- -- SQL snippet before running it against your database as we are not able to guarantee it
- -- will do exactly what you requested the AI.
- ${answer}
- `
-
- try {
- const snippet = createSqlSnippetSkeletonV2({
- id: uuidv4(),
- name: title || 'Generated query',
- sql: formattedSql,
- owner_id: profile.id,
- project_id: selectedProject.id,
- })
- snapV2.addSnippet({ projectRef: ref, snippet })
- toast.success(`Successfully saved snippet!`)
- } catch (error: any) {
- toast.error(`Failed to create new query: ${error.message}`)
- }
- },
- [canCreateSQLSnippet, profile?.id, ref, selectedProject?.id, snapV2]
- )
-
- return saveGeneratedSql
-}
-
-export interface SQLOutputActionsProps {
- answer: string
- messages: Message[]
- className?: string
-}
-
-export function SQLOutputActions({ answer, messages, className }: SQLOutputActionsProps) {
- const { ref } = useParams()
- const saveGeneratedSql = useSaveGeneratedSql()
-
- const [showCopied, setShowCopied] = useState(false)
- const [isSaving, setIsSaving] = useState(false)
- const [isSaved, setIsSaved] = useState(false)
-
- const onSelectSaveSnippet = async () => {
- setIsSaving(true)
-
- let suggestedTitle
- try {
- suggestedTitle = await queryAi(
- [
- ...messages,
- {
- role: MessageRole.User,
- content: stripIndent`
- Generate a title for the above SQL snippet following all of these rules:
- - The title is only for the last SQL snippet
- - Focus on the main purposes of this snippet
- - Use as few words as possible
- - Title should be nouns, not verbs
- - Do not include word articles (eg. a, the, for, of)
- - Do not use words like "SQL" or "snippet" or "title"
- - Do not output markdown, quotes, etc
- - Do not be too verbose
- `,
- status: MessageStatus.Complete,
- },
- ],
- 10000
- )
- } catch (error) {
- suggestedTitle = ''
- }
-
- const formattedTitle = formatTitle(suggestedTitle)
- await saveGeneratedSql(answer, formattedTitle)
- setIsSaved(true)
- setIsSaving(false)
- }
-
- useEffect(() => {
- if (!showCopied) return
- const timer = setTimeout(() => setShowCopied(false), 2000)
- return () => clearTimeout(timer)
- }, [showCopied])
-
- useEffect(() => {
- if (!isSaved) return
- const timer = setTimeout(() => setIsSaved(false), 2000)
- return () => clearTimeout(timer)
- }, [isSaved])
-
- return (
-
-
- : }
- onClick={() => setShowCopied(true)}
- >
- {showCopied ? 'Copied' : 'Copy SQL'}
-
-
- {ref !== undefined && (
- : }
- onClick={() => onSelectSaveSnippet()}
- >
- {isSaved ? 'Snippet saved!' : 'Save into new snippet'}
-
- )}
-
- )
-}
diff --git a/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx b/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx
index f0c41e6e951..552e59f16e8 100644
--- a/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx
+++ b/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx
@@ -13,7 +13,8 @@ import {
import { useProjectStorageConfigQuery } from 'data/config/project-storage-config-query'
import { useBucketCreateMutation } from 'data/storage/bucket-create-mutation'
import { IS_PLATFORM } from 'lib/constants'
-import { Alert, Button, Collapsible, Form, Input, Listbox, Modal, Toggle, cn } from 'ui'
+import { Button, Collapsible, Form, Input, Listbox, Modal, Toggle, cn } from 'ui'
+import { Admonition } from 'ui-patterns'
export interface CreateBucketModalProps {
visible: boolean
@@ -115,29 +116,36 @@ const CreateBucketModal = ({ visible, onClose }: CreateBucketModalProps) => {
onSubmit={onSubmit}
>
{({ values }: { values: any }) => {
+ const isPublicBucket = values.public
+
return (
<>
-
+
-
+
- {values.public && (
-
+ {isPublicBucket && (
+
Users can read objects in public buckets without any authorization.
@@ -145,7 +153,7 @@ const CreateBucketModal = ({ visible, onClose }: CreateBucketModalProps) => {
Row level security (RLS) policies are still required for other operations
such as object uploads and deletes.
-
+
)}
diff --git a/apps/studio/components/interfaces/Storage/EditBucketModal.tsx b/apps/studio/components/interfaces/Storage/EditBucketModal.tsx
index 53815b39e63..aff15a75ef7 100644
--- a/apps/studio/components/interfaces/Storage/EditBucketModal.tsx
+++ b/apps/studio/components/interfaces/Storage/EditBucketModal.tsx
@@ -3,16 +3,18 @@ import { ChevronDown } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
-import { Alert, Button, Collapsible, Form, Input, Listbox, Modal, Toggle, cn } from 'ui'
+import { Button, Collapsible, Form, Input, Listbox, Modal, Toggle, cn } from 'ui'
import { StorageSizeUnits } from 'components/to-be-cleaned/Storage/StorageSettings/StorageSettings.constants'
import {
convertFromBytes,
convertToBytes,
} from 'components/to-be-cleaned/Storage/StorageSettings/StorageSettings.utils'
+import { InlineLink } from 'components/ui/InlineLink'
import { useProjectStorageConfigQuery } from 'data/config/project-storage-config-query'
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'
export interface EditBucketModalProps {
@@ -84,6 +86,10 @@ const EditBucketModal = ({ visible, bucket, onClose }: EditBucketModalProps) =>
>
-
{productMenu}
diff --git a/apps/studio/components/layouts/ProjectLayout/RestoreFailedState.tsx b/apps/studio/components/layouts/ProjectLayout/RestoreFailedState.tsx
index 6ba94a4833b..bf20923f157 100644
--- a/apps/studio/components/layouts/ProjectLayout/RestoreFailedState.tsx
+++ b/apps/studio/components/layouts/ProjectLayout/RestoreFailedState.tsx
@@ -1,20 +1,16 @@
+import { PermissionAction } from '@supabase/shared-types/out/constants'
import { Download, MoreVertical, Trash } from 'lucide-react'
import Link from 'next/link'
import { useState } from 'react'
import { useParams } from 'common'
-import DeleteProjectModal from 'components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal'
+import { DeleteProjectModal } from 'components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectModal'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
+import { DropdownMenuItemTooltip } from 'components/ui/DropdownMenuItemTooltip'
import { useBackupDownloadMutation } from 'data/database/backup-download-mutation'
import { useDownloadableBackupQuery } from 'data/database/backup-query'
-import {
- Button,
- CriticalIcon,
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from 'ui'
+import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
+import { Button, CriticalIcon, DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from 'ui'
import { useProjectContext } from './ProjectContext'
const RestoreFailedState = () => {
@@ -22,6 +18,10 @@ const RestoreFailedState = () => {
const { project } = useProjectContext()
const [visible, setVisible] = useState(false)
+ const canDeleteProject = useCheckPermissions(PermissionAction.UPDATE, 'projects', {
+ resource: { project_id: project?.id },
+ })
+
const { data } = useDownloadableBackupQuery({ projectRef: ref })
const backups = data?.backups ?? []
@@ -90,9 +90,18 @@ const RestoreFailedState = () => {
} />
- setVisible(true)}
className="items-start gap-x-2"
+ disabled={!canDeleteProject}
+ tooltip={{
+ content: {
+ side: 'right',
+ text: !canDeleteProject
+ ? 'You need additional permissions to delete this project'
+ : undefined,
+ },
+ }}
>
@@ -103,7 +112,7 @@ const RestoreFailedState = () => {
Project cannot be restored once it is deleted
-
+
diff --git a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx
index 6f847292c24..f7f7138e956 100644
--- a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx
+++ b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx
@@ -64,6 +64,13 @@ export const generateSettingsMenu = (
items: [],
label: 'NEW',
},
+ {
+ name: 'JWT Keys',
+ key: 'jwt',
+ url: `/project/${ref}/settings/jwt`,
+ items: [],
+ label: 'NEW',
+ },
]
: []),
{
diff --git a/apps/studio/components/layouts/ReportsLayout/ReportsMenu.tsx b/apps/studio/components/layouts/ReportsLayout/ReportsMenu.tsx
index ce1e4ab1267..1afb9f51a7a 100644
--- a/apps/studio/components/layouts/ReportsLayout/ReportsMenu.tsx
+++ b/apps/studio/components/layouts/ReportsLayout/ReportsMenu.tsx
@@ -18,6 +18,7 @@ import { useProfile } from 'lib/profile'
import { Menu, cn } from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { ReportMenuItem } from './ReportMenuItem'
+import { useFlag } from 'hooks/ui/useFlag'
const ReportsMenu = () => {
const router = useRouter()
@@ -25,6 +26,7 @@ const ReportsMenu = () => {
const { ref, id } = useParams()
const pageKey = (id || router.pathname.split('/')[4]) as string
const storageEnabled = useIsFeatureEnabled('project_storage:all')
+ const authEnabled = useFlag('authreportv2')
const canCreateCustomReport = useCheckPermissions(PermissionAction.CREATE, 'user_content', {
resource: { type: 'report', owner_id: profile?.id },
@@ -97,6 +99,20 @@ const ReportsMenu = () => {
key: 'api-overview',
url: `/project/${ref}/reports/api-overview`,
},
+ ...(authEnabled
+ ? [
+ {
+ name: 'Auth',
+ key: 'auth',
+ url: `/project/${ref}/reports/auth`,
+ },
+ ]
+ : []),
+ {
+ name: 'Database',
+ key: 'database',
+ url: `/project/${ref}/reports/database`,
+ },
...(storageEnabled
? [
{
@@ -106,12 +122,6 @@ const ReportsMenu = () => {
},
]
: []),
-
- {
- name: 'Database',
- key: 'database',
- url: `/project/${ref}/reports/database`,
- },
],
},
]
diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx
index 4dd9bd7ba7c..55b8dee51f7 100644
--- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx
+++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx
@@ -1,5 +1,9 @@
+import { Eye, EyeOffIcon, Heart, Unlock } from 'lucide-react'
+import { useRouter } from 'next/router'
+import { useEffect, useMemo, useState } from 'react'
+import { toast } from 'sonner'
+
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
-import { useIsSQLEditorTabsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import DownloadSnippetModal from 'components/interfaces/SQLEditor/DownloadSnippetModal'
import { MoveQueryModal } from 'components/interfaces/SQLEditor/MoveQueryModal'
import RenameQueryModal from 'components/interfaces/SQLEditor/RenameQueryModal'
@@ -19,10 +23,6 @@ import { useLocalStorage } from 'hooks/misc/useLocalStorage'
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
import { useProfile } from 'lib/profile'
import uuidv4 from 'lib/uuid'
-import { Eye, EyeOffIcon, Heart, Unlock } from 'lucide-react'
-import { useRouter } from 'next/router'
-import { useEffect, useMemo, useState } from 'react'
-import { toast } from 'sonner'
import {
SnippetWithContent,
useSnippetFolders,
@@ -59,7 +59,6 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
const snapV2 = useSqlEditorV2StateSnapshot()
const tabs = useTabsStateSnapshot()
- const isSQLEditorTabsEnabled = useIsSQLEditorTabsEnabled()
const [sectionVisibility, setSectionVisibility] = useLocalStorage(
LOCAL_STORAGE_KEYS.SQL_EDITOR_SECTION_STATE(projectRef ?? ''),
@@ -293,12 +292,10 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
const { mutate: deleteContent, isLoading: isDeleting } = useContentDeleteMutation({
onSuccess: (data) => {
- if (isSQLEditorTabsEnabled) {
- // Update Tabs state - currently unknown how to differentiate between sql and non-sql content
- // so we're just deleting all tabs for with matching IDs
- const tabIds = data.map((id) => createTabId('sql', { id }))
- tabs.removeTabs(tabIds)
- }
+ // Update Tabs state - currently unknown how to differentiate between sql and non-sql content
+ // so we're just deleting all tabs for with matching IDs
+ const tabIds = data.map((id) => createTabId('sql', { id }))
+ tabs.removeTabs(tabIds)
},
onError: (error, data) => {
if (error.message.includes('Contents not found')) {
@@ -541,10 +538,10 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
const sqlEditorTabsCleanup = useSqlEditorTabsCleanup()
useEffect(() => {
- if (isSuccess && isSQLEditorTabsEnabled) {
+ if (isSuccess) {
sqlEditorTabsCleanup({ snippets: allSnippetsInView as any })
}
- }, [allSnippetsInView, isSQLEditorTabsEnabled, isSuccess, sqlEditorTabsCleanup])
+ }, [allSnippetsInView, isSuccess, sqlEditorTabsCleanup])
return (
<>
@@ -572,15 +569,13 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
data={projectSnippetsTreeState}
aria-label="project-level-snippets"
nodeRenderer={({ element, ...props }) => {
- const isOpened =
- isSQLEditorTabsEnabled &&
- Object.values(tabs.tabsMap).some(
- (tab) => tab.metadata?.sqlId === element.metadata?.id
- )
+ const isOpened = Object.values(tabs.tabsMap).some(
+ (tab) => tab.metadata?.sqlId === element.metadata?.id
+ )
const tabId = createTabId('sql', {
id: element?.metadata?.id as unknown as Snippet['id'],
})
- const isPreview = isSQLEditorTabsEnabled && tabs.previewTabId === tabId
+ const isPreview = tabs.previewTabId === tabId
const isActive = !isPreview && element.metadata?.id === id
const isSelected = selectedSnippets.some((x) => x.id === element.metadata?.id)
@@ -656,15 +651,13 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
data={favoritesTreeState}
aria-label="favorite-snippets"
nodeRenderer={({ element, ...props }) => {
- const isOpened =
- isSQLEditorTabsEnabled &&
- Object.values(tabs.tabsMap).some(
- (tab) => tab.metadata?.sqlId === element.metadata?.id
- )
+ const isOpened = Object.values(tabs.tabsMap).some(
+ (tab) => tab.metadata?.sqlId === element.metadata?.id
+ )
const tabId = createTabId('sql', {
id: element?.metadata?.id as unknown as Snippet['id'],
})
- const isPreview = isSQLEditorTabsEnabled && tabs.previewTabId === tabId
+ const isPreview = tabs.previewTabId === tabId
const isActive = !isPreview && element.metadata?.id === id
const isSelected = selectedSnippets.some((x) => x.id === element.metadata?.id)
@@ -746,15 +739,13 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
}}
expandedIds={expandedFolderIds}
nodeRenderer={({ element, ...props }) => {
- const isOpened =
- isSQLEditorTabsEnabled &&
- Object.values(tabs.tabsMap).some(
- (tab) => tab.metadata?.sqlId === element.metadata?.id
- )
+ const isOpened = Object.values(tabs.tabsMap).some(
+ (tab) => tab.metadata?.sqlId === element.metadata?.id
+ )
const tabId = createTabId('sql', {
id: element?.metadata?.id as unknown as Snippet['id'],
})
- const isPreview = isSQLEditorTabsEnabled && tabs.previewTabId === tabId
+ const isPreview = tabs.previewTabId === tabId
const isActive = !isPreview && element.metadata?.id === id
const isSelected = selectedSnippets.some((x) => x.id === element.metadata?.id)
diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx
index 895eaee2bb2..3633fdf5b74 100644
--- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx
+++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx
@@ -1,7 +1,6 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { IS_PLATFORM } from 'common'
import { useParams } from 'common/hooks/useParams'
-import { useIsSQLEditorTabsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useSQLSnippetFolderContentsQuery } from 'data/content/sql-folder-contents-query'
import { Snippet } from 'data/content/sql-folders-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
@@ -83,7 +82,6 @@ export const SQLEditorTreeViewItem = ({
const { profile } = useProfile()
const { className, onClick } = getNodeProps()
const snapV2 = useSqlEditorV2StateSnapshot()
- const isSQLEditorTabsEnabled = useIsSQLEditorTabsEnabled()
const isOwner = profile?.id === element?.metadata.owner_id
const isSharedSnippet = element.metadata.visibility === 'project'
@@ -262,13 +260,12 @@ export const SQLEditorTreeViewItem = ({
{}}
onFocusCapture={(e) => e.stopPropagation()}
>
Open in new tab
diff --git a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx
index d1e8269143b..62cea948bb6 100644
--- a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx
+++ b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx
@@ -10,7 +10,6 @@ import {
MAX_EXPORT_ROW_COUNT_MESSAGE,
} from 'components/grid/components/header/Header'
import { parseSupaTable } from 'components/grid/SupabaseGrid.utils'
-import { useIsTableEditorTabsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import {
formatTableRowsToSQL,
getEntityLintDetails,
@@ -70,11 +69,9 @@ const EntityListItem: ItemRenderer = ({
const snap = useTableEditorStateSnapshot()
const { selectedSchema } = useQuerySchemaState()
- // For tabs preview flag logic
- const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled()
const tabId = createTabId(entity.type, { id: entity.id })
const tabs = useTabsStateSnapshot()
- const isPreview = isTableEditorTabsEnabled ? tabs.previewTabId === tabId : false
+ const isPreview = tabs.previewTabId === tabId
const isOpened = Object.values(tabs.tabsMap).some((tab) => tab.metadata?.tableId === entity.id)
const isActive = Number(id) === entity.id
diff --git a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx
index a306730b114..84bd7f4f689 100644
--- a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx
+++ b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx
@@ -5,7 +5,6 @@ import { useEffect, useMemo, useState } from 'react'
import { useParams } from 'common'
import { useBreakpoint } from 'common/hooks/useBreakpoint'
-import { useIsTableEditorTabsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { ProtectedSchemaModal } from 'components/interfaces/Database/ProtectedSchemaWarning'
import EditorMenuListSkeleton from 'components/layouts/TableEditorLayout/EditorMenuListSkeleton'
import AlertError from 'components/ui/AlertError'
@@ -45,11 +44,10 @@ import EntityListItem from './EntityListItem'
import { TableMenuEmptyState } from './TableMenuEmptyState'
const TableEditorMenu = () => {
- const { ref, id: _id } = useParams()
+ const { id: _id } = useParams()
const id = _id ? Number(_id) : undefined
const snap = useTableEditorStateSnapshot()
const { selectedSchema, setSelectedSchema } = useQuerySchemaState()
- const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled()
const isMobile = useBreakpoint()
const [showModal, setShowModal] = useState(false)
@@ -118,10 +116,10 @@ const TableEditorMenu = () => {
const tableEditorTabsCleanUp = useTableEditorTabsCleanUp()
useEffect(() => {
// Clean up tabs + recent items for any tables that might have been removed outside of the dashboard session
- if (isTableEditorTabsEnabled && entityTypes && !searchText) {
+ if (entityTypes && !searchText) {
tableEditorTabsCleanUp({ schemas: [selectedSchema], entities: entityTypes })
}
- }, [entityTypes, isTableEditorTabsEnabled, searchText, selectedSchema, tableEditorTabsCleanUp])
+ }, [entityTypes, searchText, selectedSchema, tableEditorTabsCleanUp])
return (
<>
diff --git a/apps/studio/components/layouts/Tabs/NewTab.tsx b/apps/studio/components/layouts/Tabs/NewTab.tsx
index ba199aab480..8afcfe3bd4d 100644
--- a/apps/studio/components/layouts/Tabs/NewTab.tsx
+++ b/apps/studio/components/layouts/Tabs/NewTab.tsx
@@ -6,7 +6,6 @@ import { useRouter } from 'next/router'
import { toast } from 'sonner'
import { useParams } from 'common'
-import { TabsUpdateCallout } from 'components/interfaces/App/FeaturePreview/TableEditorTabs'
import { SQL_TEMPLATES } from 'components/interfaces/SQLEditor/SQLEditor.queries'
import { createSqlSnippetSkeletonV2 } from 'components/interfaces/SQLEditor/SQLEditor.utils'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
@@ -114,7 +113,6 @@ export function NewTab() {
return (
-
{actions.map((item, i) => (
diff --git a/apps/studio/components/layouts/Tabs/Tabs.tsx b/apps/studio/components/layouts/Tabs/Tabs.tsx
index 66fb4b9b715..847a3c11d45 100644
--- a/apps/studio/components/layouts/Tabs/Tabs.tsx
+++ b/apps/studio/components/layouts/Tabs/Tabs.tsx
@@ -12,7 +12,6 @@ import { Plus, X } from 'lucide-react'
import { useRouter } from 'next/router'
import { useParams } from 'common'
-import { TabsUpdateTooltip } from 'components/interfaces/App/FeaturePreview/TableEditorTabs'
import { useAppStateSnapshot } from 'state/app-state'
import { editorEntityTypes, useTabsStateSnapshot, type Tab } from 'state/tabs'
import {
@@ -173,43 +172,41 @@ export const EditorTabs = () => {
{/* Non-draggable new tab */}
{hasNewTab && (
- <>
-
+
+
+ New
+
+ {
+ e.preventDefault()
+ e.stopPropagation()
+ }}
+ className="ml-1 opacity-0 group-hover:opacity-100 hover:bg-200 rounded-sm cursor-pointer"
+ onMouseDown={(e) => {
+ e.preventDefault()
+ e.stopPropagation()
+ }}
+ onPointerDown={(e) => {
+ e.preventDefault()
+ e.stopPropagation()
+ handleClose('new')
+ }}
>
-
-
- New
-
- {
- e.preventDefault()
- e.stopPropagation()
- }}
- className="ml-1 opacity-0 group-hover:opacity-100 hover:bg-200 rounded-sm cursor-pointer"
- onMouseDown={(e) => {
- e.preventDefault()
- e.stopPropagation()
- }}
- onPointerDown={(e) => {
- e.preventDefault()
- e.stopPropagation()
- handleClose('new')
- }}
- >
-
- {' '}
-
-
- >
+
+ {' '}
+
+
)}
@@ -235,8 +232,6 @@ export const EditorTabs = () => {
-
-
diff --git a/apps/studio/components/layouts/UnifiedLogsLayout/UnifiedLogsLayout.tsx b/apps/studio/components/layouts/UnifiedLogsLayout/UnifiedLogsLayout.tsx
new file mode 100644
index 00000000000..27ba57f0ce0
--- /dev/null
+++ b/apps/studio/components/layouts/UnifiedLogsLayout/UnifiedLogsLayout.tsx
@@ -0,0 +1,9 @@
+import { PropsWithChildren } from 'react'
+
+import ProjectLayout from '../ProjectLayout/ProjectLayout'
+
+const UnifiedLogsLayout = ({ children }: PropsWithChildren) => {
+ return {children}
+}
+
+export default UnifiedLogsLayout
diff --git a/apps/studio/components/layouts/editors/EditorBaseLayout.tsx b/apps/studio/components/layouts/editors/EditorBaseLayout.tsx
index eafb111c3b0..9f0da404d7f 100644
--- a/apps/studio/components/layouts/editors/EditorBaseLayout.tsx
+++ b/apps/studio/components/layouts/editors/EditorBaseLayout.tsx
@@ -2,10 +2,6 @@ import { usePathname } from 'next/navigation'
import { ComponentProps, ReactNode } from 'react'
import { useParams } from 'common'
-import {
- useIsSQLEditorTabsEnabled,
- useIsTableEditorTabsEnabled,
-} from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useTabsStateSnapshot } from 'state/tabs'
import { cn } from 'ui'
import { ProjectLayoutWithAuth } from '../ProjectLayout/ProjectLayout'
@@ -26,12 +22,6 @@ export const EditorBaseLayout = ({ children, title, product, ...props }: Explore
const editor = useEditorType()
const tabs = useTabsStateSnapshot()
- const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled()
- const isSQLEditorTabsEnabled = useIsSQLEditorTabsEnabled()
-
- const tableEditorTabsEnabled = editor === 'table' && isTableEditorTabsEnabled
- const sqlEditorTabsEnabled = editor === 'sql' && isSQLEditorTabsEnabled
-
const hasNoOpenTabs =
editor === 'table' ? tabs.openTabs.filter((x) => !x.startsWith('sql')).length === 0 : false
const hideTabs =
@@ -40,16 +30,14 @@ export const EditorBaseLayout = ({ children, title, product, ...props }: Explore
return (
- {tableEditorTabsEnabled || sqlEditorTabsEnabled ? (
-
- {hideTabs ? : }
-
- ) : null}
+
+ {hideTabs ? : }
+
{children}
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 e9a20a5e1bd..a268b1aaeff 100644
--- a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/StorageExplorer.tsx
+++ b/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/StorageExplorer.tsx
@@ -63,43 +63,17 @@ const StorageExplorer = ({ bucket }: StorageExplorerProps) => {
const currentFolderIdx = openedFolders.length - 1
const currentFolder = openedFolders[currentFolderIdx]
- if (itemSearchString) {
- if (!currentFolder) {
- // At root of bucket
- await fetchFolderContents({
- bucketId: bucket.id,
- folderId: bucket.id,
- folderName: bucket.name,
- index: -1,
- searchString: itemSearchString,
- })
- } else {
- await fetchFolderContents({
- bucketId: bucket.id,
- folderId: currentFolder.id,
- folderName: currentFolder.name,
- index: currentFolderIdx,
- searchString: itemSearchString,
- })
- }
- } else {
- if (!currentFolder) {
- // At root of bucket
- await fetchFolderContents({
- bucketId: bucket.id,
- folderId: bucket.id,
- folderName: bucket.name,
- index: -1,
- })
- } else {
- await fetchFolderContents({
- bucketId: bucket.id,
- folderId: currentFolder.id,
- folderName: currentFolder.name,
- index: currentFolderIdx,
- })
- }
- }
+ const folderId = !currentFolder ? bucket.id : currentFolder.id
+ const folderName = !currentFolder ? bucket.name : currentFolder.name
+ const index = !currentFolder ? -1 : currentFolderIdx
+
+ await fetchFolderContents({
+ bucketId: bucket.id,
+ folderId,
+ folderName,
+ index,
+ searchString: itemSearchString,
+ })
} else if (view === STORAGE_VIEWS.COLUMNS) {
if (openedFolders.length > 0) {
const paths = openedFolders.map((folder) => folder.name)
@@ -110,6 +84,7 @@ const StorageExplorer = ({ bucket }: StorageExplorerProps) => {
folderId: bucket.id,
folderName: bucket.name,
index: -1,
+ searchString: itemSearchString,
})
}
}
diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx
index 1ebea56eb35..640b511c37c 100644
--- a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx
+++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx
@@ -1,28 +1,24 @@
-import { PermissionAction } from '@supabase/shared-types/out/constants'
import type { Message as MessageType } from 'ai/react'
import { useChat } from 'ai/react'
import { AnimatePresence, motion } from 'framer-motion'
-import { last } from 'lodash'
import { ArrowDown, FileText, Info, RefreshCw, X } from 'lucide-react'
import { useRouter } from 'next/router'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import { toast } from 'sonner'
+import { LOCAL_STORAGE_KEYS } from 'common'
import { useParams, useSearchParamsShallow } from 'common/hooks'
import { Markdown } from 'components/interfaces/Markdown'
-import OptInToOpenAIToggle from 'components/interfaces/Organization/GeneralSettings/OptInToOpenAIToggle'
import { SQL_TEMPLATES } from 'components/interfaces/SQLEditor/SQLEditor.queries'
import { useCheckOpenAIKeyQuery } from 'data/ai/check-api-key-query'
import { constructHeaders } from 'data/fetchers'
-import { useOrganizationUpdateMutation } from 'data/organizations/organization-update-mutation'
import { useTablesQuery } from 'data/tables/tables-query'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
-import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
-import { useOrgOptedIntoAiAndHippaProject } from 'hooks/misc/useOrgOptedIntoAi'
+import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
+import { useOrgAiOptInLevel } from 'hooks/misc/useOrgOptedIntoAi'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
import { useFlag } from 'hooks/ui/useFlag'
-import { BASE_PATH, IS_PLATFORM, OPT_IN_TAGS } from 'lib/constants'
+import { BASE_PATH, IS_PLATFORM } from 'lib/constants'
import uuidv4 from 'lib/uuid'
import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
@@ -36,15 +32,16 @@ import {
TooltipTrigger,
} from 'ui'
import { Admonition, AssistantChatForm, GenericSkeletonLoader } from 'ui-patterns'
-import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { ButtonTooltip } from '../ButtonTooltip'
-import DotGrid from '../DotGrid'
+import { DotGrid } from '../DotGrid'
+import { ErrorBoundary } from '../ErrorBoundary'
+import { onErrorChat } from './AIAssistant.utils'
import { AIAssistantChatSelector } from './AIAssistantChatSelector'
-import AIOnboarding from './AIOnboarding'
-import CollapsibleCodeBlock from './CollapsibleCodeBlock'
+import { AIOnboarding } from './AIOnboarding'
+import { AIOptInModal } from './AIOptInModal'
+import { CollapsibleCodeBlock } from './CollapsibleCodeBlock'
import { Message } from './Message'
import { useAutoScroll } from './hooks'
-import { ErrorBoundary } from '../ErrorBoundary'
const MemoizedMessage = memo(
({
@@ -68,8 +65,7 @@ const MemoizedMessage = memo(
{
const router = useRouter()
const project = useSelectedProject()
- const { isOptedInToAI, isHipaaProjectDisallowed } = useOrgOptedIntoAiAndHippaProject()
const selectedOrganization = useSelectedOrganization()
const { ref, id: entityId } = useParams()
const searchParams = useSearchParamsShallow()
- const includeSchemaMetadata = isOptedInToAI || !IS_PLATFORM
+ const newOrgAiOptIn = useFlag('newOrgAiOptIn')
const disablePrompts = useFlag('disableAssistantPrompts')
+ const useBedrockAssistant = useFlag('useBedrockAssistant')
const { snippets } = useSqlEditorV2StateSnapshot()
const snap = useAiAssistantStateSnapshot()
+ const [updatedOptInSinceMCP] = useLocalStorageQuery(
+ LOCAL_STORAGE_KEYS.AI_ASSISTANT_MCP_OPT_IN,
+ false
+ )
+
const inputRef = useRef(null)
const { ref: scrollContainerRef, isSticky, scrollToEnd } = useAutoScroll()
+ const { aiOptInLevel, isHipaaProjectDisallowed } = useOrgAiOptInLevel()
+ const showMetadataWarning =
+ IS_PLATFORM &&
+ !!selectedOrganization &&
+ ((!useBedrockAssistant && aiOptInLevel === 'disabled') ||
+ (useBedrockAssistant && (aiOptInLevel === 'disabled' || aiOptInLevel === 'schema')))
+
// Add a ref to store the last user message
const lastUserMessageRef = useRef(null)
@@ -129,11 +137,6 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
const { mutate: sendEvent } = useSendEventMutation()
- const handleError = useCallback((error: Error) => {
- const errorMessage = JSON.parse(error.message).message
- toast.error(errorMessage)
- }, [])
-
// Handle completion of the assistant's response
const handleChatFinish = useCallback((message: MessageType) => {
// If we have a user message stored in the ref, save both messages
@@ -156,25 +159,43 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
setMessages,
} = useChat({
id: snap.activeChatId,
- api: `${BASE_PATH}/api/ai/sql/generate-v3`,
+ api: useBedrockAssistant
+ ? `${BASE_PATH}/api/ai/sql/generate-v4`
+ : `${BASE_PATH}/api/ai/sql/generate-v3`,
maxSteps: 5,
// [Alaister] typecast is needed here because valtio returns readonly arrays
// and useChat expects a mutable array
initialMessages: snap.activeChat?.messages as unknown as MessageType[] | undefined,
- body: {
- includeSchemaMetadata,
- projectRef: project?.ref,
- connectionString: project?.connectionString,
- schema: currentSchema,
- table: currentTable?.name,
+ experimental_prepareRequestBody: ({ messages }) => {
+ // [Joshen] Specifically limiting the chat history that get's sent to reduce the
+ // size of the context that goes into the model. This should always be an odd number
+ // as much as possible so that the first message is always the user's
+ const MAX_CHAT_HISTORY = 5
+
+ return JSON.stringify({
+ messages: messages.slice(-MAX_CHAT_HISTORY),
+ aiOptInLevel,
+ projectRef: project?.ref,
+ connectionString: project?.connectionString,
+ schema: currentSchema,
+ table: currentTable?.name,
+ includeSchemaMetadata: !useBedrockAssistant
+ ? !IS_PLATFORM || aiOptInLevel !== 'disabled'
+ : undefined,
+ })
},
- onError: handleError,
+ fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
+ const headers = await constructHeaders()
+ const existingHeaders = new Headers(init?.headers)
+ for (const [key, value] of headers.entries()) {
+ existingHeaders.set(key, value)
+ }
+ return fetch(input, { ...init, headers: existingHeaders })
+ },
+ onError: onErrorChat,
onFinish: handleChatFinish,
})
- const canUpdateOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations')
- const { mutate: updateOrganization, isLoading: isUpdating } = useOrganizationUpdateMutation()
-
const updateMessage = useCallback(
({
messageId,
@@ -207,24 +228,14 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
const hasMessages = chatMessages.length > 0
- const sendMessageToAssistant = async (content: string) => {
- const payload = { role: 'user', createdAt: new Date(), content } as MessageType
- const headerData = await constructHeaders()
+ const sendMessageToAssistant = (content: string) => {
+ const payload = { role: 'user', createdAt: new Date(), content, id: uuidv4() } as MessageType
snap.clearSqlSnippets()
// Store the user message in the ref before appending
lastUserMessageRef.current = payload
- const authorizationHeader = headerData.get('Authorization')
-
- append(
- payload,
- authorizationHeader
- ? {
- headers: { Authorization: authorizationHeader },
- }
- : undefined
- )
+ append(payload)
setValue('')
@@ -247,30 +258,6 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
}
}
- const confirmOptInToShareSchemaData = async () => {
- if (!canUpdateOrganization) {
- return toast.error('You do not have the required permissions to update this organization')
- }
-
- if (!selectedOrganization?.slug) return console.error('Organization slug is required')
-
- const existingOptInTags = selectedOrganization?.opt_in_tags ?? []
-
- const updatedOptInTags = existingOptInTags.includes(OPT_IN_TAGS.AI_SQL)
- ? existingOptInTags
- : [...existingOptInTags, OPT_IN_TAGS.AI_SQL]
-
- updateOrganization(
- { slug: selectedOrganization?.slug, opt_in_tags: updatedOptInTags },
- {
- onSuccess: () => {
- toast.success('Successfully opted-in')
- setIsConfirmOptInModalOpen(false)
- },
- }
- )
- }
-
const handleClearMessages = () => {
snap.clearMessages()
setMessages([])
@@ -336,9 +323,14 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
The Assistant is in Alpha and your prompts might be rate limited.{' '}
- {includeSchemaMetadata
- ? 'Project metadata is being shared to improve Assistant responses.'
- : 'Project metadata is not being shared. Opt in to improve Assistant responses.'}
+ {aiOptInLevel === 'schema_and_log_and_data' &&
+ 'Schema, logs, and query data are being shared to improve Assistant responses.'}
+ {aiOptInLevel === 'schema_and_log' &&
+ 'Schema and logs are being shared to improve Assistant responses.'}
+ {aiOptInLevel === 'schema' &&
+ 'Only schema metadata is being shared to improve Assistant responses.'}
+ {aiOptInLevel === 'disabled' &&
+ 'Project metadata is not being shared. Opt in to improve Assistant responses.'}
@@ -375,16 +367,30 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
- {!includeSchemaMetadata && selectedOrganization && (
+ {showMetadataWarning && (
{!isHipaaProjectDisallowed && (
{
{hasMessages ? (
{renderedMessages}
- {(last(chatMessages)?.role === 'user' ||
- last(chatMessages)?.content?.length === 0) && (
-
-
-
-
Thinking
-
-
- .
-
-
- .
-
-
- .
-
+
+ {isChatLoading && (
+
+
+
Thinking
+
+
+ .
+
+
+ .
+
+
+ .
+
+
-
-
- )}
-
+
+ )}
+
) : snap.suggestions ? (
@@ -473,7 +484,7 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
) : (tables ?? [])?.length > 0 ? (
-
+
) : isApiKeySet ? (
Welcome to Supabase!
@@ -624,7 +635,7 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
onValueChange={(e) => setValue(e.target.value)}
onSubmit={(event) => {
event.preventDefault()
- if (includeSchemaMetadata) {
+ if (aiOptInLevel !== 'disabled') {
const sqlSnippetsString =
snap.sqlSnippets
?.map((snippet: string) => '```sql\n' + snippet + '\n```')
@@ -634,28 +645,18 @@ export const AIAssistant = ({ className }: AIAssistantProps) => {
scrollToEnd()
} else {
sendMessageToAssistant(value)
+ snap.setSqlSnippets([])
+ scrollToEnd()
}
}}
/>
- setIsConfirmOptInModalOpen(false)}
- onConfirm={confirmOptInToShareSchemaData}
- loading={isUpdating}
- >
-
- By opting into sending anonymous data, Supabase AI can improve the answers it shows you.
- This is an organization-wide setting, and affects all projects in your organization.
-
-
-
-
+ />
)
}
diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts
index 60827c734a0..c3ad2cc47fd 100644
--- a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts
+++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts
@@ -1,3 +1,7 @@
+import { toast } from 'sonner'
+import { handleError } from 'data/fetchers'
+import { ResponseError } from 'types'
+
import { authKeys } from 'data/auth/keys'
import { databaseExtensionsKeys } from 'data/database-extensions/keys'
import { databaseIndexesKeys } from 'data/database-indexes/keys'
@@ -6,6 +10,7 @@ import { databaseTriggerKeys } from 'data/database-triggers/keys'
import { databaseKeys } from 'data/database/keys'
import { enumeratedTypesKeys } from 'data/enumerated-types/keys'
import { tableKeys } from 'data/tables/keys'
+import { tryParseJson } from 'lib/helpers'
import { SAFE_FUNCTIONS } from './AiAssistant.constants'
// [Joshen] This is just very basic identification, but possible can extend perhaps
@@ -99,3 +104,21 @@ export const getContextualInvalidationKeys = ({
)[key] ?? []
)
}
+
+export const onErrorChat = (error: Error) => {
+ const parsedError = tryParseJson(error.message)
+
+ try {
+ handleError(parsedError?.error || parsedError || error)
+ } catch (e: any) {
+ if (e instanceof ResponseError) {
+ toast.error(e.message)
+ } else if (e instanceof Error) {
+ toast.error(e.message)
+ } else if (typeof e === 'string') {
+ toast.error(e)
+ } else {
+ toast.error('An unknown error occurred')
+ }
+ }
+}
diff --git a/apps/studio/components/ui/AIAssistantPanel/AIOnboarding.tsx b/apps/studio/components/ui/AIAssistantPanel/AIOnboarding.tsx
index cab0382de7d..8caf871ea3b 100644
--- a/apps/studio/components/ui/AIAssistantPanel/AIOnboarding.tsx
+++ b/apps/studio/components/ui/AIAssistantPanel/AIOnboarding.tsx
@@ -9,11 +9,10 @@ import {
} from 'ui-patterns/InnerSideMenu'
interface AIOnboardingProps {
- setMessages: (messages: any[]) => void
onSendMessage: (message: string) => void
}
-export default function AIOnboarding({ setMessages, onSendMessage }: AIOnboardingProps) {
+export const AIOnboarding = ({ onSendMessage }: AIOnboardingProps) => {
const sendMessageToAssistant = (message: string) => {
onSendMessage(message)
}
diff --git a/apps/studio/components/ui/AIAssistantPanel/AIOptInModal.tsx b/apps/studio/components/ui/AIAssistantPanel/AIOptInModal.tsx
new file mode 100644
index 00000000000..9a12a7c8162
--- /dev/null
+++ b/apps/studio/components/ui/AIAssistantPanel/AIOptInModal.tsx
@@ -0,0 +1,79 @@
+import { PermissionAction } from '@supabase/shared-types/out/constants'
+import { useEffect } from 'react'
+
+import { AIOptInLevelSelector } from 'components/interfaces/Organization/GeneralSettings/AIOptInLevelSelector'
+import { useAIOptInForm } from 'hooks/forms/useAIOptInForm'
+import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
+import { useFlag } from 'hooks/ui/useFlag'
+import {
+ Button,
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogSection,
+ DialogSectionSeparator,
+ DialogTitle,
+ Form_Shadcn_,
+} from 'ui'
+
+interface AIOptInModalProps {
+ visible: boolean
+ onCancel: () => void
+}
+
+export const AIOptInModal = ({ visible, onCancel }: AIOptInModalProps) => {
+ const newOrgAiOptIn = useFlag('newOrgAiOptIn')
+ const { form, onSubmit, isUpdating, currentOptInLevel } = useAIOptInForm(onCancel)
+ const canUpdateOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations')
+
+ const onOpenChange = (open: boolean) => {
+ if (!open) {
+ onCancel()
+ }
+ }
+
+ useEffect(() => {
+ if (visible) {
+ form.reset({ aiOptInLevel: currentOptInLevel })
+ }
+ }, [visible, currentOptInLevel, form])
+
+ return (
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/studio/components/ui/AIAssistantPanel/CollapsibleCodeBlock.tsx b/apps/studio/components/ui/AIAssistantPanel/CollapsibleCodeBlock.tsx
index 0a60ef1055f..92e83becffb 100644
--- a/apps/studio/components/ui/AIAssistantPanel/CollapsibleCodeBlock.tsx
+++ b/apps/studio/components/ui/AIAssistantPanel/CollapsibleCodeBlock.tsx
@@ -1,12 +1,13 @@
import { ChevronDown, ChevronUp, X } from 'lucide-react'
import { useState } from 'react'
+
import { Button, CodeBlock, CodeBlockProps, cn } from 'ui'
interface CollapsibleCodeBlockProps extends CodeBlockProps {
onRemove?: () => void
}
-const CollapsibleCodeBlock = ({ onRemove, ...props }: CollapsibleCodeBlockProps) => {
+export const CollapsibleCodeBlock = ({ onRemove, ...props }: CollapsibleCodeBlockProps) => {
const [isExpanded, setIsExpanded] = useState(false)
const codeString = (props.value || props.children) as string
@@ -57,5 +58,3 @@ const CollapsibleCodeBlock = ({ onRemove, ...props }: CollapsibleCodeBlockProps)
)
}
-
-export default CollapsibleCodeBlock
diff --git a/apps/studio/components/ui/AIAssistantPanel/DisplayBlockRenderer.tsx b/apps/studio/components/ui/AIAssistantPanel/DisplayBlockRenderer.tsx
new file mode 100644
index 00000000000..8d1a94ec670
--- /dev/null
+++ b/apps/studio/components/ui/AIAssistantPanel/DisplayBlockRenderer.tsx
@@ -0,0 +1,138 @@
+import { PermissionAction } from '@supabase/shared-types/out/constants'
+import { Message } from 'ai/react'
+import { useRouter } from 'next/router'
+import { DragEvent, PropsWithChildren, useMemo, useState } from 'react'
+
+import { useParams } from 'common'
+import { ChartConfig } from 'components/interfaces/SQLEditor/UtilityPanel/ChartConfig'
+import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
+import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
+import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
+import { useProfile } from 'lib/profile'
+import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
+import { Badge } from 'ui'
+import { DEFAULT_CHART_CONFIG, QueryBlock } from '../QueryBlock/QueryBlock'
+import { identifyQueryType } from './AIAssistant.utils'
+import { findResultForManualId } from './Message.utils'
+
+interface DisplayBlockRendererProps {
+ messageId: string
+ toolCallId: string
+ manualId?: string
+ initialArgs: {
+ sql: string
+ label?: string
+ view?: 'table' | 'chart'
+ xAxis?: string
+ yAxis?: string
+ runQuery?: boolean
+ }
+ messageParts: Readonly
| undefined
+ isLoading: boolean
+ onResults: (args: { messageId: string; resultId?: string; results: any[] }) => void
+}
+
+export const DisplayBlockRenderer = ({
+ messageId,
+ toolCallId,
+ manualId,
+ initialArgs,
+ messageParts,
+ isLoading,
+ onResults,
+}: PropsWithChildren) => {
+ const router = useRouter()
+ const { ref } = useParams()
+ const { profile } = useProfile()
+ const org = useSelectedOrganization()
+ const snap = useAiAssistantStateSnapshot()
+
+ const { mutate: sendEvent } = useSendEventMutation()
+ const canCreateSQLSnippet = useCheckPermissions(PermissionAction.CREATE, 'user_content', {
+ resource: { type: 'sql', owner_id: profile?.id },
+ subject: { id: profile?.id },
+ })
+
+ const [chartConfig, setChartConfig] = useState(() => ({
+ ...DEFAULT_CHART_CONFIG,
+ view: initialArgs.view === 'chart' ? 'chart' : 'table',
+ xKey: initialArgs.xAxis ?? '',
+ yKey: initialArgs.yAxis ?? '',
+ }))
+
+ const isChart = initialArgs.view === 'chart'
+ const resultId = manualId || toolCallId
+ const liveResultData = useMemo(
+ () => (manualId ? findResultForManualId(messageParts, manualId) : undefined),
+ [messageParts, manualId]
+ )
+ const cachedResults = useMemo(
+ () => snap.getCachedSQLResults({ messageId, snippetId: resultId }),
+ [snap, messageId, resultId]
+ )
+ const displayData = liveResultData ?? cachedResults
+ const isDraggableToReports = canCreateSQLSnippet && router.pathname.endsWith('/reports/[id]')
+ const label = initialArgs.label || 'SQL Results'
+ const sqlQuery = initialArgs.sql
+
+ const handleRunQuery = (queryType: 'select' | 'mutation') => {
+ sendEvent({
+ action: 'assistant_suggestion_run_query_clicked',
+ properties: {
+ queryType,
+ ...(queryType === 'mutation' ? { category: identifyQueryType(sqlQuery) ?? 'unknown' } : {}),
+ },
+ groups: {
+ project: ref ?? 'Unknown',
+ organization: org?.slug ?? 'Unknown',
+ },
+ })
+ }
+
+ const handleUpdateChartConfig = ({
+ chartConfig: updatedValues,
+ }: {
+ chartConfig: Partial
+ }) => {
+ setChartConfig((prev) => ({ ...prev, ...updatedValues }))
+ }
+
+ const handleDragStart = (e: DragEvent) => {
+ e.dataTransfer.setData(
+ 'application/json',
+ JSON.stringify({ label, sql: sqlQuery, config: chartConfig })
+ )
+ }
+
+ return (
+
+
+
+ NEW
+
+ Drag to add this chart into your custom report
+
+ ) : undefined
+ }
+ onResults={(results) => onResults({ messageId, resultId, results })}
+ onRunQuery={handleRunQuery}
+ onUpdateChartConfig={handleUpdateChartConfig}
+ onDragStart={handleDragStart}
+ />
+
+ )
+}
diff --git a/apps/studio/components/ui/AIAssistantPanel/Message.tsx b/apps/studio/components/ui/AIAssistantPanel/Message.tsx
index 8398c86c170..771626f60ea 100644
--- a/apps/studio/components/ui/AIAssistantPanel/Message.tsx
+++ b/apps/studio/components/ui/AIAssistantPanel/Message.tsx
@@ -1,11 +1,21 @@
+import { Message as VercelMessage } from 'ai/react'
import { User } from 'lucide-react'
-import { createContext, PropsWithChildren, useMemo } from 'react'
+import { createContext, PropsWithChildren, ReactNode, useMemo } from 'react'
import ReactMarkdown from 'react-markdown'
import { Components } from 'react-markdown/lib/ast-to-react'
import remarkGfm from 'remark-gfm'
-import { AiIconAnimation, cn, markdownComponents, WarningIcon } from 'ui'
-import { Heading3, InlineCode, Link, ListItem, MarkdownPre, OrderedList } from './MessageMarkdown'
+import { cn, markdownComponents, WarningIcon } from 'ui'
+import { EdgeFunctionBlock } from '../EdgeFunctionBlock/EdgeFunctionBlock'
+import { DisplayBlockRenderer } from './DisplayBlockRenderer'
+import {
+ Heading3,
+ Hyperlink,
+ InlineCode,
+ ListItem,
+ MarkdownPre,
+ OrderedList,
+} from './MessageMarkdown'
interface MessageContextType {
isLoading: boolean
@@ -18,16 +28,16 @@ const baseMarkdownComponents: Partial
= {
li: ListItem,
h3: Heading3,
code: InlineCode,
- a: Link,
+ a: Hyperlink,
+ img: ({ src }) => [Image: {src}] ,
}
interface MessageProps {
id: string
- role: 'function' | 'system' | 'user' | 'assistant' | 'data' | 'tool'
- content?: string
+ message: VercelMessage
isLoading: boolean
readOnly?: boolean
- action?: React.ReactNode
+ action?: ReactNode
variant?: 'default' | 'warning'
onResults: ({
messageId,
@@ -42,15 +52,13 @@ interface MessageProps {
export const Message = function Message({
id,
- role,
- content,
+ message,
isLoading,
readOnly,
action = null,
variant = 'default',
onResults,
}: PropsWithChildren) {
- const isUser = role === 'user'
const allMarkdownComponents: Partial = useMemo(
() => ({
...markdownComponents,
@@ -61,16 +69,26 @@ export const Message = function Message({
),
}),
- []
+ [id, onResults]
)
- if (!content) return null
+ if (!message) {
+ console.error(`Message component received undefined message prop for id: ${id}`)
+ return null
+ }
+
+ const { role, content, parts } = message
+ const isUser = role === 'user'
+
+ const shouldUsePartsRendering = parts && parts.length > 0
+
+ const hasTextContent = content && content.trim().length > 0
return (
- {isUser ? (
+ {isUser && (
- ) : (
-
)}
-
- {content}
-
+
+
+ {shouldUsePartsRendering ? (
+ (() => {
+ const shownLoadingTools = new Set
()
+ return parts.map(
+ (part: NonNullable[number], index: number) => {
+ switch (part.type) {
+ case 'text':
+ return (
+ li]:pl-4 [&_ol>li]:my-0 [&_li>p]:mt-0 space-y-5 [&>*>code]:text-xs [&>*>*>code]:text-xs [&_li]:space-y-4',
+ isUser && 'text-foreground font-semibold'
+ )}
+ remarkPlugins={[remarkGfm]}
+ components={allMarkdownComponents}
+ >
+ {part.text}
+
+ )
+
+ case 'tool-invocation': {
+ const { toolCallId, toolName, args, state } = part.toolInvocation
+ if (state === 'call' || state === 'partial-call') {
+ if (shownLoadingTools.has(toolName)) {
+ // Already shown loading for this toolName in this step
+ return null
+ }
+ shownLoadingTools.add(toolName)
+ return (
+
+ {`Calling ${toolName}...`}
+
+ )
+ }
+ // Only render the result UI for known tools when state is 'result'
+ switch (toolName) {
+ case 'display_query': {
+ return (
+
+ )
+ }
+ case 'display_edge_function': {
+ return (
+
+
+
+ )
+ }
+ default:
+ // For unknown tools, just show nothing for result
+ return null
+ }
+ }
+ case 'reasoning':
+ case 'source':
+ case 'file':
+ return null
+ default:
+ return null
+ }
+ }
+ )
+ })()
+ ) : hasTextContent ? (
+
+ {content}
+
+ ) : (
+ Assistant is thinking...
+ )}
+
diff --git a/apps/studio/components/ui/AIAssistantPanel/Message.utils.ts b/apps/studio/components/ui/AIAssistantPanel/Message.utils.ts
new file mode 100644
index 00000000000..d8e94c8f983
--- /dev/null
+++ b/apps/studio/components/ui/AIAssistantPanel/Message.utils.ts
@@ -0,0 +1,78 @@
+import { Message } from 'ai/react'
+
+type MessagePart = NonNullable
[number]
+
+const extractDataFromSafetyMessage = (text: string): string | null => {
+ const openingTags = [...text.matchAll(//gi)]
+ if (openingTags.length < 2) return null
+
+ const closingTagMatch = text.match(/<\/untrusted-data-[a-z0-9-]+>/i)
+ if (!closingTagMatch) return null
+
+ const secondOpeningEnd = openingTags[1].index! + openingTags[1][0].length
+ const closingStart = text.indexOf(closingTagMatch[0])
+ const content = text.substring(secondOpeningEnd, closingStart)
+
+ return content.replace(/\\n/g, '').replace(/\\"/g, '"').replace(/\n/g, '').trim()
+}
+
+// Helper function to find result data directly from parts array
+export const findResultForManualId = (
+ parts: Readonly | undefined,
+ manualId: string
+): any[] | undefined => {
+ if (!parts) return undefined
+
+ const invocationPart = parts.find(
+ (part: MessagePart) =>
+ part.type === 'tool-invocation' &&
+ 'toolInvocation' in part &&
+ part.toolInvocation.state === 'result' &&
+ 'result' in part.toolInvocation &&
+ part.toolInvocation.result?.manualToolCallId === manualId
+ )
+
+ if (
+ invocationPart &&
+ 'toolInvocation' in invocationPart &&
+ 'result' in invocationPart.toolInvocation &&
+ invocationPart.toolInvocation.result?.content?.[0]?.text
+ ) {
+ try {
+ const rawText = invocationPart.toolInvocation.result.content[0].text
+
+ const extractedData = extractDataFromSafetyMessage(rawText) || rawText
+
+ let parsedData = JSON.parse(extractedData.trim())
+ return Array.isArray(parsedData) ? parsedData : undefined
+ } catch (error) {
+ console.error('Failed to parse tool invocation result data for manualId:', manualId, error)
+ return undefined
+ }
+ }
+ return undefined
+}
+
+// [Joshen] From https://github.com/remarkjs/react-markdown/blob/fda7fa560bec901a6103e195f9b1979dab543b17/lib/index.js#L425
+export function defaultUrlTransform(value: string) {
+ const safeProtocol = /^(https?|ircs?|mailto|xmpp)$/i
+ const colon = value.indexOf(':')
+ const questionMark = value.indexOf('?')
+ const numberSign = value.indexOf('#')
+ const slash = value.indexOf('/')
+
+ if (
+ // If there is no protocol, it’s relative.
+ colon === -1 ||
+ // If the first colon is after a `?`, `#`, or `/`, it’s not a protocol.
+ (slash !== -1 && colon > slash) ||
+ (questionMark !== -1 && colon > questionMark) ||
+ (numberSign !== -1 && colon > numberSign) ||
+ // It is a protocol, it should be allowed.
+ safeProtocol.test(value.slice(0, colon))
+ ) {
+ return value
+ }
+
+ return ''
+}
diff --git a/apps/studio/components/ui/AIAssistantPanel/MessageMarkdown.tsx b/apps/studio/components/ui/AIAssistantPanel/MessageMarkdown.tsx
index 9b482072b50..8575b46fd3a 100644
--- a/apps/studio/components/ui/AIAssistantPanel/MessageMarkdown.tsx
+++ b/apps/studio/components/ui/AIAssistantPanel/MessageMarkdown.tsx
@@ -1,3 +1,4 @@
+import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useRouter } from 'next/router'
import {
DragEvent,
@@ -10,23 +11,38 @@ import {
useRef,
} from 'react'
-import { PermissionAction } from '@supabase/shared-types/out/constants'
import { ChartConfig } from 'components/interfaces/SQLEditor/UtilityPanel/ChartConfig'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
import { useProfile } from 'lib/profile'
+import Link from 'next/link'
import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
import { Dashboards } from 'types'
-import { Badge, cn, CodeBlock, CodeBlockLang } from 'ui'
+import {
+ Badge,
+ Button,
+ cn,
+ CodeBlock,
+ CodeBlockLang,
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogSection,
+ DialogTitle,
+ DialogTrigger,
+} from 'ui'
import { DebouncedComponent } from '../DebouncedComponent'
import { EdgeFunctionBlock } from '../EdgeFunctionBlock/EdgeFunctionBlock'
import { QueryBlock } from '../QueryBlock/QueryBlock'
import { AssistantSnippetProps } from './AIAssistant.types'
import { identifyQueryType } from './AIAssistant.utils'
-import CollapsibleCodeBlock from './CollapsibleCodeBlock'
+import { CollapsibleCodeBlock } from './CollapsibleCodeBlock'
import { MessageContext } from './Message'
+import { defaultUrlTransform } from './Message.utils'
export const OrderedList = memo(({ children }: { children: ReactNode }) => (
{children}
@@ -50,17 +66,63 @@ export const InlineCode = memo(
)
InlineCode.displayName = 'InlineCode'
-export const Link = memo(({ href, children }: { href?: string; children: ReactNode }) => (
-
- {children}
-
-))
-Link.displayName = 'Link'
+export const Hyperlink = memo(({ href, children }: { href?: string; children: ReactNode }) => {
+ const isExternalURL = !href?.startsWith('https://supabase.com/dashboard')
+ const safeUrl = defaultUrlTransform(href ?? '')
+ const isSafeUrl = safeUrl.length > 0
+
+ if (!isSafeUrl) {
+ return {children}
+ }
+
+ return (
+
+
+
+ {children}
+
+
+
+
+ Verify the link before navigating
+
+
+
+
+ This link will take you to the following URL:
+
+ {safeUrl}
+ Are you sure you want to head there?
+
+
+
+
+
+ Cancel
+
+
+
+
+ {isExternalURL ? (
+
+ Head to link
+
+ ) : (
+ Head to link
+ )}
+
+
+
+
+
+ )
+})
+Hyperlink.displayName = 'Hyperlink'
const MemoizedQueryBlock = memo(
({
@@ -243,7 +305,7 @@ export const MarkdownPre = ({
}
return (
-
+
{language === 'edge' ? (
{
- const snap = useAppStateSnapshot()
- const selectedOrganization = useSelectedOrganization()
- const { isOptedInToAI, isHipaaProjectDisallowed } = useOrgOptedIntoAiAndHippaProject()
- const selectedProject = useSelectedProject()
-
- const [selectedSchemas, setSelectedSchemas] = useSchemasForAi(selectedProject?.ref!)
-
- const includeSchemaMetadata = (isOptedInToAI && !isHipaaProjectDisallowed) || !IS_PLATFORM
-
- return (
- snap.setShowAiSettingsModal(false)}
- >
-
-
-
Schemas metadata to be shared with OpenAI
-
0
- ? `${selectedSchemas.length} schema${
- selectedSchemas.length > 1 ? 's' : ''
- } selected`
- : 'No schemas selected'
- }
- disabled={(IS_PLATFORM && !isOptedInToAI) || isHipaaProjectDisallowed}
- selectedSchemas={selectedSchemas}
- onSelectSchemas={setSelectedSchemas}
- />
-
- Metadata includes table names, column names and their corresponding data types in the
- request. This will generate queries that are more relevant to your project.
-
-
- {IS_PLATFORM && (!isOptedInToAI || isHipaaProjectDisallowed) && selectedOrganization && (
-
-
-
- {isHipaaProjectDisallowed
- ? 'Sending data to OpenAI is disabled for HIPAA projects'
- : 'Your organization does not allow sending anonymous data to OpenAI'}
-
-
- This option is only available if your organization has opted-in to sending anonymous
- data to OpenAI and non-HIPAA projects. You may configure your opt-in preferences
- through your organization's settings.
-
-
-
-
- Head to organization settings
-
-
-
-
- )}
-
-
- )
-}
-
-export default AISettingsModal
diff --git a/apps/studio/components/ui/ActionCard.tsx b/apps/studio/components/ui/ActionCard.tsx
index 5f600070348..549e51a2ad7 100644
--- a/apps/studio/components/ui/ActionCard.tsx
+++ b/apps/studio/components/ui/ActionCard.tsx
@@ -1,10 +1,11 @@
+import { JSX, ReactNode } from 'react'
import { Badge, Card, cn } from 'ui'
export const ActionCard = (card: {
icon: JSX.Element
title: string
bgColor?: string
- description?: string
+ description?: string | ReactNode
isBeta?: boolean
className?: string
onClick?: () => void
@@ -28,9 +29,13 @@ export const ActionCard = (card: {
>
{card.icon}
-
+
{card.title}
-
{card.description}
+ {typeof card.description === 'string' ? (
+
{card.description}
+ ) : (
+ card.description
+ )}
diff --git a/apps/studio/components/ui/Charts/ChartHeader.tsx b/apps/studio/components/ui/Charts/ChartHeader.tsx
index 8b2fceca792..e15ee1d9eed 100644
--- a/apps/studio/components/ui/Charts/ChartHeader.tsx
+++ b/apps/studio/components/ui/Charts/ChartHeader.tsx
@@ -39,13 +39,20 @@ const ChartHeader = ({
{title}
{docsUrl && (
-
-
-
+
+
+
+
+
+
+
+ Read docs
+
+
)}
)
diff --git a/apps/studio/components/ui/Charts/ComposedChart.tsx b/apps/studio/components/ui/Charts/ComposedChart.tsx
index 0c36f49f72e..a1d501fd655 100644
--- a/apps/studio/components/ui/Charts/ComposedChart.tsx
+++ b/apps/studio/components/ui/Charts/ComposedChart.tsx
@@ -28,13 +28,9 @@ import {
} from './Charts.constants'
import { CommonChartProps, Datum } from './Charts.types'
import { numberFormatter, useChartSize } from './Charts.utils'
-import {
- calculateTotalChartAggregate,
- CustomLabel,
- CustomTooltip,
- type MultiAttribute,
-} from './ComposedChart.utils'
+import { calculateTotalChartAggregate, CustomLabel, CustomTooltip } from './ComposedChart.utils'
import NoDataPlaceholder from './NoDataPlaceholder'
+import { MultiAttribute } from './ComposedChart.utils'
import { ChartHighlight } from './useChartHighlight'
import { formatBytes } from 'lib/helpers'
@@ -58,6 +54,7 @@ export interface ComposedChartProps extends CommonChartProps {
chartStyle?: string
onChartStyleChange?: (style: string) => void
updateDateRange: any
+ titleTooltip?: string
hideYAxis?: boolean
hideHighlightedValue?: boolean
syncId?: string
@@ -114,6 +111,22 @@ export default function ComposedChart({
const { Container } = useChartSize(size)
+ const day = (value: number | string) => (displayDateInUtc ? dayjs(value).utc() : dayjs(value))
+
+ const formatTimestamp = (ts: unknown) => {
+ if (typeof ts !== 'number' && typeof ts !== 'string') {
+ return ''
+ }
+
+ // Timestamps from auth logs can be in microseconds
+ if (typeof ts === 'number' && ts > 1e14) {
+ return day(ts / 1000).format(customDateFormat)
+ }
+
+ // dayjs can handle ISO strings and millisecond numbers
+ return day(ts).format(customDateFormat)
+ }
+
// Default props
const _XAxisProps = XAxisProps || {
interval: data.length - 2,
@@ -127,18 +140,19 @@ export default function ComposedChart({
width: 0,
}
- const day = (value: number | string) => (displayDateInUtc ? dayjs(value).utc() : dayjs(value))
-
function getHeaderLabel() {
if (!xAxisIsDate) {
if (!focusDataIndex) return highlightedLabel
- return data[focusDataIndex]?.timestamp
+ return data[focusDataIndex]?.[xAxisKey]
}
return (
(focusDataIndex !== null &&
data &&
data[focusDataIndex] !== undefined &&
- day(data[focusDataIndex].timestamp).format(customDateFormat)) ||
+ (() => {
+ const ts = data[focusDataIndex][xAxisKey]
+ return formatTimestamp(ts)
+ })()) ||
highlightedLabel
)
}
@@ -149,13 +163,45 @@ export default function ComposedChart({
color: CHART_COLORS.REFERENCE_LINE,
}
+ const chartData =
+ data && !!data[0]
+ ? Object.entries(data[0])
+ ?.map(([key, value]) => ({
+ name: key,
+ value: value,
+ }))
+ .filter(
+ (att) =>
+ att.name !== 'timestamp' &&
+ att.name !== 'period_start' &&
+ att.name !== maxAttribute?.attribute &&
+ attributes.some((attr) => attr.attribute === att.name && attr.enabled !== false)
+ )
+ .map((att, index) => {
+ const attribute = attributes.find((attr) => attr.attribute === att.name)
+ return {
+ ...att,
+ color: attribute?.color
+ ? resolvedTheme?.includes('dark')
+ ? attribute.color.dark
+ : attribute.color.light
+ : STACKED_CHART_COLORS[index % STACKED_CHART_COLORS.length],
+ }
+ })
+ : []
+
const lastDataPoint = !!data[data.length - 1]
? Object.entries(data[data.length - 1])
.map(([key, value]) => ({
dataKey: key,
value: value as number,
}))
- .filter((entry) => entry.dataKey !== 'timestamp')
+ .filter(
+ (entry) =>
+ entry.dataKey !== 'timestamp' &&
+ entry.dataKey !== 'period_start' &&
+ attributes.some((attr) => attr.attribute === entry.dataKey && attr.enabled !== false)
+ )
: undefined
const referenceLines = attributes.filter((attribute) => attribute?.provider === 'reference-line')
@@ -184,32 +230,17 @@ export default function ComposedChart({
chartHighlight?.coordinates.right &&
chartHighlight?.coordinates.left !== chartHighlight?.coordinates.right
- const chartData =
- data && !!data[0]
- ? Object.entries(data[0])
- ?.map(([key, value], index) => ({
- name: key,
- value: value,
- color: STACKED_CHART_COLORS[index - (1 % STACKED_CHART_COLORS.length)],
- }))
- .filter(
- (att) =>
- att.name !== 'timestamp' &&
- att.name !== maxAttribute?.attribute &&
- !referenceLines.map((a) => a.attribute).includes(att.name)
- )
- : []
-
const stackedAttributes = chartData.filter((att) => !att.name.includes('max'))
const isPercentage = format === '%'
const isRamChart = chartData?.some((att: any) => att.name.toLowerCase().includes('ram_'))
const isDiskSpaceChart = chartData?.some((att: any) =>
att.name.toLowerCase().includes('disk_space_')
)
- const isDiskSizeChart = chartData?.some((att: any) => att.name.toLowerCase().includes('disk_fs_'))
+ const isDBSizeChart = chartData?.some((att: any) =>
+ att.name.toLowerCase().includes('pg_database_size')
+ )
const isNetworkChart = chartData?.some((att: any) => att.name.toLowerCase().includes('network_'))
- const shouldFormatBytes = isRamChart || isDiskSpaceChart || isDiskSizeChart || isNetworkChart
-
+ const shouldFormatBytes = isRamChart || isDiskSpaceChart || isDBSizeChart || isNetworkChart
//*
// Set the y-axis domain
// to the highest value in the chart data for percentage charts
@@ -387,6 +418,7 @@ export default function ComposedChart({
y={line.value}
strokeWidth={1}
{...line}
+ color={line.color?.dark}
strokeDasharray={line.strokeDasharray ?? '3 3'}
label={undefined}
>
@@ -418,13 +450,11 @@ export default function ComposedChart({
className="text-foreground-lighter -mt-9 flex items-center justify-between text-xs"
style={{ marginLeft: YAxisProps?.width }}
>
-
- {xAxisIsDate ? day(data[0]?.timestamp).format(customDateFormat) : data[0]?.timestamp}
-
+ {xAxisIsDate ? formatTimestamp(data[0]?.[xAxisKey]) : data[0]?.[xAxisKey]}
{xAxisIsDate
- ? day(data[data?.length - 1]?.timestamp).format(customDateFormat)
- : data[data?.length - 1]?.timestamp}
+ ? formatTimestamp(data[data.length - 1]?.[xAxisKey])
+ : data[data.length - 1]?.[xAxisKey]}
)}
diff --git a/apps/studio/components/ui/Charts/ComposedChart.utils.tsx b/apps/studio/components/ui/Charts/ComposedChart.utils.tsx
index 95444d52230..831f4b6153a 100644
--- a/apps/studio/components/ui/Charts/ComposedChart.utils.tsx
+++ b/apps/studio/components/ui/Charts/ComposedChart.utils.tsx
@@ -31,13 +31,19 @@ export interface ReportAttributes {
hideHighlightedValue?: boolean
}
-type Provider = 'infra-monitoring' | 'daily-stats' | 'reference-line' | 'combine'
+export type Provider = 'infra-monitoring' | 'daily-stats' | 'mock' | 'reference-line' | 'logs'
export type MultiAttribute = {
attribute: string
provider: Provider
label?: string
- color?: string
+ color?: {
+ light: string
+ dark: string
+ }
+ statusCode?: string
+ grantType?: string
+ providerType?: string
stackId?: string
format?: string
description?: string
@@ -65,6 +71,7 @@ export type MultiAttribute = {
strokeDasharray?: string
className?: string
hide?: boolean
+ enabled?: boolean
}
interface CustomIconProps {
@@ -167,7 +174,7 @@ const CustomTooltip = ({
return (
{getIcon(entry.color, isMax)}
-
+
{attribute?.label || entry.name}
@@ -194,9 +201,12 @@ const CustomTooltip = ({
>
{dayjs(timestamp).format(DateTimeFormats.FULL_SECONDS)}
- {payload.reverse().map((entry: any, index: number) => (
-
- ))}
+ {payload
+ .reverse()
+ .filter((entry: any) => entry.value !== 0)
+ .map((entry: any, index: number) => (
+
+ ))}
{active && showTotal && (
Total
@@ -270,7 +280,7 @@ const CustomLabel = ({ payload, attributes, showMaxValue, onLabelHover }: Custom
{getIcon(entry.name, entry.color)}
diff --git a/apps/studio/components/ui/Charts/LogChartHandler.tsx b/apps/studio/components/ui/Charts/LogChartHandler.tsx
new file mode 100644
index 00000000000..2db174a9d19
--- /dev/null
+++ b/apps/studio/components/ui/Charts/LogChartHandler.tsx
@@ -0,0 +1,268 @@
+import React, { PropsWithChildren, useState, useEffect, useRef } from 'react'
+import { Loader2 } from 'lucide-react'
+import { cn, WarningIcon } from 'ui'
+
+import Panel from 'components/ui/Panel'
+import ComposedChart from './ComposedChart'
+
+import { AnalyticsInterval, DataPoint } from 'data/analytics/constants'
+import { InfraMonitoringAttribute } from 'data/analytics/infra-monitoring-query'
+import { useInfraMonitoringQueries } from 'data/analytics/infra-monitoring-queries'
+import { ProjectDailyStatsAttribute } from 'data/analytics/project-daily-stats-query'
+import { useProjectDailyStatsQueries } from 'data/analytics/project-daily-stats-queries'
+import { useChartHighlight } from './useChartHighlight'
+import { getMockDataForAttribute } from 'data/reports/auth-charts'
+
+import type { ChartData } from './Charts.types'
+import type { UpdateDateRange } from 'pages/project/[ref]/reports/database'
+import type { MultiAttribute } from './ComposedChart.utils'
+
+interface LogChartHandlerProps {
+ id?: string
+ label: string
+ attributes: MultiAttribute[]
+ startDate: string
+ endDate: string
+ interval: string
+ customDateFormat?: string
+ defaultChartStyle?: 'bar' | 'line' | 'stackedAreaLine'
+ hideChartType?: boolean
+ data?: ChartData
+ isLoading?: boolean
+ format?: string
+ highlightedValue?: string | number
+ className?: string
+ showTooltip?: boolean
+ showLegend?: boolean
+ showTotal?: boolean
+ showMaxValue?: boolean
+ updateDateRange: UpdateDateRange
+ valuePrecision?: number
+ isVisible?: boolean
+ titleTooltip?: string
+ docsUrl?: string
+}
+
+/**
+ * Wrapper component that handles intersection observer logic for lazy loading
+ */
+const LazyChartWrapper = ({ children }: PropsWithChildren) => {
+ const [isVisible, setIsVisible] = useState(false)
+ const ref = useRef(null)
+
+ useEffect(() => {
+ const observer = new IntersectionObserver(
+ ([entry]) => {
+ if (entry.isIntersecting) {
+ setIsVisible(true)
+ observer.disconnect()
+ }
+ },
+ {
+ rootMargin: '150px 0px', // Start loading before the component enters viewport
+ threshold: 0,
+ }
+ )
+
+ const currentRef = ref.current
+ if (currentRef) {
+ observer.observe(currentRef)
+ }
+
+ return () => {
+ if (currentRef) {
+ observer.unobserve(currentRef)
+ }
+ }
+ }, [])
+
+ return {React.cloneElement(children as React.ReactElement, { isVisible })}
+}
+
+/**
+ * Controls chart display state. Optionally fetches static chart data if data is not provided.
+ *
+ * If the `data` prop is provided, it will disable automatic chart data fetching and pass the data directly to the chart render.
+ * - loading state can also be provided through the `isLoading` prop, to display loading placeholders. Ignored if `data` key not provided.
+ * - if `isLoading=true` and `data` is `undefined`, loading error message will be shown.
+ *
+ * Provided data must be in the expected chart format.
+ */
+const LogChartHandler = ({
+ label,
+ attributes,
+ customDateFormat,
+ children = null,
+ defaultChartStyle = 'bar',
+ hideChartType = false,
+ data,
+ isLoading,
+ format,
+ highlightedValue,
+ className,
+ showTooltip,
+ showLegend,
+ showMaxValue,
+ showTotal,
+ updateDateRange,
+ valuePrecision,
+ titleTooltip,
+ id,
+ ...otherProps
+}: PropsWithChildren) => {
+ const [chartStyle, setChartStyle] = useState(defaultChartStyle)
+ const chartHighlight = useChartHighlight()
+
+ if (isLoading) {
+ return (
+
+
+ Loading data for {label}
+
+ )
+ }
+
+ if (!data) {
+ return (
+
+
+
Unable to load data for {label}
+
+ )
+ }
+
+ // Rest of the component remains similar, but pass all attributes to charts
+ return (
+
+
+ {children}
+
+
+
+ )
+}
+
+export const useAttributeQueries = (
+ attributes: MultiAttribute[],
+ ref: string | string[] | undefined,
+ startDate: string,
+ endDate: string,
+ interval: AnalyticsInterval,
+ databaseIdentifier: string | undefined,
+ data: ChartData | undefined,
+ isVisible: boolean
+) => {
+ 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),
+ ref,
+ startDate,
+ endDate,
+ interval,
+ databaseIdentifier,
+ data,
+ isVisible
+ )
+ const dailyStatsQueries = useProjectDailyStatsQueries(
+ dailyStatsAttributes.map((attr) => attr.attribute as ProjectDailyStatsAttribute),
+ ref,
+ startDate,
+ endDate,
+ interval,
+ databaseIdentifier,
+ data,
+ isVisible
+ )
+
+ let infraIdx = 0
+ let dailyStatsIdx = 0
+ return attributes
+ .filter((attr) => attr.provider !== 'logs')
+ .map((attr) => {
+ if (attr.provider === 'infra-monitoring') {
+ return {
+ ...infraQueries[infraIdx++],
+ data: { ...infraQueries[infraIdx - 1]?.data, provider: 'infra-monitoring' },
+ }
+ } else if (attr.provider === 'daily-stats') {
+ return {
+ ...dailyStatsQueries[dailyStatsIdx++],
+ data: { ...dailyStatsQueries[dailyStatsIdx - 1]?.data, provider: 'daily-stats' },
+ }
+ } else if (attr.provider === 'mock') {
+ const mockData = getMockDataForAttribute(attr.attribute)
+ return {
+ isLoading: false,
+ data: { ...mockData, provider: 'mock', attribute: attr.attribute },
+ }
+ } else if (attr.provider === 'reference-line') {
+ let value = attr.value || 0
+ return {
+ data: {
+ data: [],
+ attribute: attr.attribute,
+ total: value,
+ maximum: value,
+ totalGrouped: { [attr.attribute]: value },
+ provider: 'reference-line',
+ },
+ isLoading: false,
+ isError: false,
+ }
+ } else {
+ return {
+ isLoading: false,
+ data: undefined,
+ }
+ }
+ })
+}
+
+export default function LazyLogChartHandler(props: LogChartHandlerProps) {
+ return (
+
+
+
+ )
+}
diff --git a/apps/studio/components/ui/CopyButton.tsx b/apps/studio/components/ui/CopyButton.tsx
index 9ac56ba9df6..a45df0d6180 100644
--- a/apps/studio/components/ui/CopyButton.tsx
+++ b/apps/studio/components/ui/CopyButton.tsx
@@ -7,7 +7,6 @@ type CopyButtonBaseProps = {
iconOnly?: boolean
copyLabel?: string
copiedLabel?: string
- onCopy?: () => Promise | string
}
type CopyButtonWithText = CopyButtonBaseProps & {
diff --git a/apps/studio/components/ui/DataTable/DataTable.constants.ts b/apps/studio/components/ui/DataTable/DataTable.constants.ts
new file mode 100644
index 00000000000..da947462782
--- /dev/null
+++ b/apps/studio/components/ui/DataTable/DataTable.constants.ts
@@ -0,0 +1,50 @@
+import { addDays, addHours, endOfDay, startOfDay } from 'date-fns'
+
+import { DatePreset } from './DataTable.types'
+
+export const ARRAY_DELIMITER = ','
+export const SLIDER_DELIMITER = '-'
+export const SPACE_DELIMITER = '_'
+export const RANGE_DELIMITER = '-'
+export const SORT_DELIMITER = '.'
+
+export const LEVELS = ['success', 'warning', 'error', 'info'] as const
+
+export const presets = [
+ {
+ label: 'Today',
+ from: startOfDay(new Date()),
+ to: endOfDay(new Date()),
+ shortcut: 'd', // day
+ },
+ {
+ label: 'Yesterday',
+ from: startOfDay(addDays(new Date(), -1)),
+ to: endOfDay(addDays(new Date(), -1)),
+ shortcut: 'y',
+ },
+ {
+ label: 'Last hour',
+ from: addHours(new Date(), -1),
+ to: new Date(),
+ shortcut: 'h',
+ },
+ {
+ label: 'Last 7 days',
+ from: startOfDay(addDays(new Date(), -7)),
+ to: endOfDay(new Date()),
+ shortcut: 'w',
+ },
+ {
+ label: 'Last 14 days',
+ from: startOfDay(addDays(new Date(), -14)),
+ to: endOfDay(new Date()),
+ shortcut: 'b', // bi-weekly
+ },
+ {
+ label: 'Last 30 days',
+ from: startOfDay(addDays(new Date(), -30)),
+ to: endOfDay(new Date()),
+ shortcut: 'm',
+ },
+] satisfies DatePreset[]
diff --git a/apps/studio/components/ui/DataTable/DataTable.types.ts b/apps/studio/components/ui/DataTable/DataTable.types.ts
new file mode 100644
index 00000000000..9ee5cada849
--- /dev/null
+++ b/apps/studio/components/ui/DataTable/DataTable.types.ts
@@ -0,0 +1,61 @@
+// TODO: we could type the value(!) especially when using enums
+export type Option = {
+ label: string
+ value: string | boolean | number | undefined
+}
+
+export type DatePreset = {
+ label: string
+ from: Date
+ to: Date
+ shortcut: string
+}
+
+export type Input = {
+ type: 'input'
+ options?: Option[]
+}
+
+export type Checkbox = {
+ type: 'checkbox'
+ component?: (props: Option) => JSX.Element | null
+ options?: Option[]
+}
+
+export type Slider = {
+ type: 'slider'
+ min: number
+ max: number
+ // if options is undefined, we will provide all the steps between min and max
+ options?: Option[]
+}
+
+export type Timerange = {
+ type: 'timerange'
+ options?: Option[] // required for TS
+ presets?: DatePreset[]
+}
+
+export type Base = {
+ label: string
+ value: keyof TData
+ /**
+ * Defines if the accordion in the filter bar is open by default
+ */
+ defaultOpen?: boolean
+ /**
+ * Defines if the command input is disabled for this field
+ */
+ commandDisabled?: boolean
+}
+
+export type DataTableCheckboxFilterField = Base & Checkbox
+export type DataTableSliderFilterField = Base & Slider
+export type DataTableInputFilterField = Base & Input
+export type DataTableTimerangeFilterField = Base & Timerange
+
+export type DataTableFilterField =
+ | DataTableCheckboxFilterField
+ | DataTableSliderFilterField
+ | DataTableInputFilterField
+ | DataTableTimerangeFilterField
diff --git a/apps/studio/components/ui/DataTable/DataTable.utils.ts b/apps/studio/components/ui/DataTable/DataTable.utils.ts
new file mode 100644
index 00000000000..12997306dab
--- /dev/null
+++ b/apps/studio/components/ui/DataTable/DataTable.utils.ts
@@ -0,0 +1,129 @@
+import { FilterFn } from '@tanstack/react-table'
+import { isAfter, isBefore, isSameDay } from 'date-fns'
+
+import { LEVELS } from './DataTable.constants'
+
+export function formatCompactNumber(value: number) {
+ if (value >= 100 && value < 1000) {
+ return value.toString() // Keep the number as is if it's in the hundreds
+ } else if (value >= 1000 && value < 1000000) {
+ return (value / 1000).toFixed(1) + 'k' // Convert to 'k' for thousands
+ } else if (value >= 1000000) {
+ return (value / 1000000).toFixed(1) + 'M' // Convert to 'M' for millions
+ } else {
+ return value.toString() // Optionally handle numbers less than 100 if needed
+ }
+}
+
+export function isArrayOfNumbers(arr: any): arr is number[] {
+ if (!Array.isArray(arr)) return false
+ return arr.every((item) => typeof item === 'number')
+}
+
+export function isArrayOfDates(arr: any): arr is Date[] {
+ if (!Array.isArray(arr)) return false
+ return arr.every((item) => item instanceof Date)
+}
+
+export function isArrayOfStrings(arr: any): arr is string[] {
+ if (!Array.isArray(arr)) return false
+ return arr.every((item) => typeof item === 'string')
+}
+
+export function isArrayOfBooleans(arr: any): arr is boolean[] {
+ if (!Array.isArray(arr)) return false
+ return arr.every((item) => typeof item === 'boolean')
+}
+
+export const inDateRange: FilterFn = (row, columnId, value) => {
+ const date = new Date(row.getValue(columnId))
+ const [start, end] = value as Date[]
+
+ if (isNaN(date.getTime())) return false
+
+ // if no end date, check if it's the same day
+ if (!end) return isSameDay(date, start)
+
+ return isAfter(date, start) && isBefore(date, end)
+}
+
+inDateRange.autoRemove = (val: any) => !Array.isArray(val) || !val.length || !isArrayOfDates(val)
+
+export const arrSome: FilterFn = (row, columnId, filterValue) => {
+ if (!Array.isArray(filterValue)) return false
+ return filterValue.some((val) => row.getValue(columnId) === val)
+}
+
+arrSome.autoRemove = (val: any) => !Array.isArray(val) || !val?.length
+
+export function getLevelColor(
+ value: (typeof LEVELS)[number]
+): Record<'text' | 'bg' | 'border', string> {
+ switch (value) {
+ case 'success':
+ return {
+ text: 'text-muted',
+ bg: 'bg-muted',
+ border: 'border-muted',
+ }
+ case 'warning':
+ return {
+ text: 'text-warning',
+ bg: 'bg-warning',
+ border: 'border-warning',
+ }
+ case 'error':
+ return {
+ text: 'text-destructive',
+ bg: 'bg-destructive',
+ border: 'border-destructive',
+ }
+ case 'info':
+ default:
+ return {
+ text: 'text-info',
+ bg: 'bg-info',
+ border: 'border-info',
+ }
+ }
+}
+
+export function getStatusColor(value?: number | string): Record<'text' | 'bg' | 'border', string> {
+ switch (value) {
+ case '1':
+ case 'info':
+ return {
+ text: 'text-blue-500',
+ bg: '',
+ border: 'border-blue-200 dark:border-blue-800',
+ }
+ case '2':
+ case 'success':
+ return {
+ text: 'text-foreground-lighter',
+ bg: '',
+ border: 'border-green-200 dark:border-green-800',
+ }
+ case '4':
+ case 'warning':
+ case 'redirect':
+ return {
+ text: 'text-warning-600 dark:text-warning',
+ bg: 'bg-warning-300 dark:bg-waning-200',
+ border: 'border border-warning-400/50 dark:border-warning-400/50',
+ }
+ case '5':
+ case 'error':
+ return {
+ text: 'text-destructive',
+ bg: 'bg-destructive-300 dark:bg-destructive-300/50',
+ border: 'border border-destructive-400/50 dark:border-destructive-400/50',
+ }
+ default:
+ return {
+ text: 'text-foreground-lighter',
+ bg: '',
+ border: '',
+ }
+ }
+}
diff --git a/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnHeader.tsx b/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnHeader.tsx
new file mode 100644
index 00000000000..7bd07547b55
--- /dev/null
+++ b/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnHeader.tsx
@@ -0,0 +1,54 @@
+import { type Column } from '@tanstack/react-table'
+import { ChevronDown, ChevronUp } from 'lucide-react'
+
+import { Button, cn, type ButtonProps } from 'ui'
+
+interface DataTableColumnHeaderProps extends ButtonProps {
+ column: Column
+ title: string
+}
+
+export const DataTableColumnHeader = ({
+ column,
+ title,
+ className,
+ ...props
+}: DataTableColumnHeaderProps) => {
+ if (!column.getCanSort()) {
+ return {title}
+ }
+
+ return (
+ {
+ column.toggleSorting(undefined)
+ }}
+ className={cn(
+ 'text-xs',
+ 'py-0 px-0 h-7 hover:bg-transparent flex gap-2 items-center justify-between w-full',
+ className
+ )}
+ iconRight={
+
+
+
+
+ }
+ {...props}
+ >
+ {title}
+
+ )
+}
diff --git a/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnLevelIndicator.tsx b/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnLevelIndicator.tsx
new file mode 100644
index 00000000000..c0038b7c3dd
--- /dev/null
+++ b/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnLevelIndicator.tsx
@@ -0,0 +1,17 @@
+import { cn } from 'ui'
+import { LEVELS } from '../DataTable.constants'
+import { getLevelColor } from '../DataTable.utils'
+
+export const DataTableColumnLevelIndicator = ({
+ value,
+ className,
+}: {
+ value: (typeof LEVELS)[number]
+ className?: string
+}) => {
+ return (
+
+ )
+}
diff --git a/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode.tsx b/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode.tsx
new file mode 100644
index 00000000000..175e3a477a8
--- /dev/null
+++ b/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode.tsx
@@ -0,0 +1,35 @@
+import { Minus } from 'lucide-react'
+
+import { cn } from 'ui'
+import { getStatusColor } from '../DataTable.utils'
+
+export const DataTableColumnStatusCode = ({
+ value,
+ level,
+ className,
+}: {
+ value?: number | string
+ level?: string
+ className?: string
+}) => {
+ const colors = getStatusColor(level)
+ if (!value) {
+ return
+ }
+
+ return (
+
+ )
+}
diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckbox.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckbox.tsx
new file mode 100644
index 00000000000..94cca02970c
--- /dev/null
+++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCheckbox.tsx
@@ -0,0 +1,120 @@
+import { Search } from 'lucide-react'
+import { useState } from 'react'
+
+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 DataTableFilterCheckbox({
+ value: _value,
+ options,
+ component,
+}: DataTableCheckboxFilterField) {
+ const value = _value as string
+ const [inputValue, setInputValue] = useState('')
+ const { table, columnFilters, isLoading, isLoadingCounts, getFacetedUniqueValues } =
+ useDataTable()
+
+ const column = table.getColumn(value)
+ // REMINDER: avoid using column?.getFilterValue()
+ const filterValue = columnFilters.find((i) => i.id === value)?.value
+ const facetedValue = getFacetedUniqueValues?.(table, value) || column?.getFacetedUniqueValues()
+
+ const Component = component
+
+ // filter out the options based on the input value
+ const filterOptions = options?.filter(
+ (option) => inputValue === '' || option.label.toLowerCase().includes(inputValue.toLowerCase())
+ )
+
+ // CHECK: it could be filterValue or searchValue
+ 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 (!filterOptions?.length) return null
+
+ return (
+
+ {options && options.length > 4 ? (
+
}
+ containerClassName="h-9 rounded"
+ value={inputValue}
+ onChange={(e) => setInputValue(e.target.value)}
+ />
+ ) : null}
+ {/* FIXME: due to the added max-h and overflow-y-auto, the hover state and border is laying on top of the scroll bar */}
+
+ {filterOptions
+ // TODO: we shoudn't sort the options here, instead filterOptions should be sorted by default
+ // .sort((a, b) => a.label.localeCompare(b.label))
+ .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)
+ ) : 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/DataTableFilterCommand.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCommand.tsx
new file mode 100644
index 00000000000..066f881d895
--- /dev/null
+++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterCommand.tsx
@@ -0,0 +1,404 @@
+import { formatDistanceToNow } from 'date-fns'
+import { LoaderCircle, Search, X } from 'lucide-react'
+import { ParserBuilder } from 'nuqs'
+import { useEffect, useMemo, useRef, useState } from 'react'
+
+import { useLocalStorage } from 'hooks/misc/useLocalStorage'
+import {
+ cn,
+ Command_Shadcn_ as Command,
+ CommandEmpty_Shadcn_ as CommandEmpty,
+ CommandGroup_Shadcn_ as CommandGroup,
+ CommandInput_Shadcn_ as CommandInput,
+ CommandItem_Shadcn_ as CommandItem,
+ CommandList_Shadcn_ as CommandList,
+ CommandSeparator_Shadcn_ as CommandSeparator,
+ Separator,
+} from 'ui'
+import type { DataTableFilterField } from '../DataTable.types'
+import { formatCompactNumber } from '../DataTable.utils'
+import { Kbd } from '../primitives/Kbd'
+import { useDataTable } from '../providers/DataTableProvider'
+import {
+ columnFiltersParser,
+ getFieldOptions,
+ getFilterValue,
+ getWordByCaretPosition,
+ replaceInputByFieldType,
+} from './DataTableFilters.utils'
+
+// FIXME: there is an issue on cmdk if I wanna only set a single slider value...
+
+interface DataTableFilterCommandProps {
+ // TODO: maybe use generics for the parser
+ searchParamsParser: Record>
+ placeholder?: string
+}
+
+export function DataTableFilterCommand({
+ searchParamsParser,
+ placeholder = 'Search data table...',
+}: DataTableFilterCommandProps) {
+ const { table, isLoading, filterFields: _filterFields, getFacetedUniqueValues } = useDataTable()
+ const columnFilters = table.getState().columnFilters
+ const inputRef = useRef(null)
+ const [open, setOpen] = useState(false)
+ const [currentWord, setCurrentWord] = useState('')
+ const filterFields = useMemo(
+ () => _filterFields?.filter((i) => !i.commandDisabled),
+ [_filterFields]
+ )
+ const columnParser = useMemo(
+ () => columnFiltersParser({ searchParamsParser, filterFields }),
+ [searchParamsParser, filterFields]
+ )
+ const [inputValue, setInputValue] = useState(columnParser.serialize(columnFilters))
+ const [lastSearches, setLastSearches] = useLocalStorage<
+ {
+ search: string
+ timestamp: number
+ }[]
+ >('data-table-command', [])
+
+ const trimmedInputValue = inputValue.trim()
+
+ // [Joshen] Temporarily disabling as this conflicts with our current CMD K behaviour
+ // useHotKey(() => setOpen((open) => !open), 'k')
+
+ useEffect(() => {
+ // TODO: we could check for ARRAY_DELIMITER or SLIDER_DELIMITER to auto-set filter when typing
+ if (currentWord !== '' && open) return
+ // reset
+ if (currentWord !== '' && !open) setCurrentWord('')
+ // avoid recursion
+ if (trimmedInputValue === '' && !open) return
+
+ const searchParams = columnParser.parse(inputValue)
+
+ const currentFilters = table.getState().columnFilters
+ const currentEnabledFilters = currentFilters.filter((filter) => {
+ 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]
+ table.getColumn(key)?.setFilterValue(value)
+ }
+ const currentFiltersToReset = currentEnabledFilters.filter((filter) => {
+ return !(filter.id in searchParams)
+ })
+ for (const filter of currentFiltersToReset) {
+ table.getColumn(filter.id)?.setFilterValue(undefined)
+ }
+
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [inputValue, open, currentWord])
+
+ useEffect(() => {
+ // REMINDER: only update the input value if the command is closed (avoids jumps while open)
+ if (!open) {
+ setInputValue(columnParser.serialize(columnFilters))
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [columnFilters, filterFields, open])
+
+ useEffect(() => {
+ if (open) {
+ inputRef?.current?.focus()
+ }
+ }, [open])
+
+ return (
+
+
setOpen(true)}
+ >
+ {isLoading ? (
+
+ ) : (
+
+ )}
+
+ {trimmedInputValue ? inputValue : placeholder}
+
+ {/* [Joshen] Temporarily disabling as this conflicts with existing CMD K shortcut */}
+ {/*
+ ⌘
+ K
+ */}
+
+
+
div]:border-none bg',
+ open ? 'visible' : 'hidden'
+ )}
+ filter={(value, search, keywords) =>
+ getFilterValue({ value, search, keywords, currentWord })
+ }
+ >
+ {
+ if (e.key === 'Escape') inputRef?.current?.blur()
+ }}
+ onBlur={() => {
+ setOpen(false)
+ // FIXME: doesnt reflect the jumps
+ // FIXME: will save non-existing searches
+ // TODO: extract into function
+ const search = inputValue.trim()
+ if (!search) return
+ const timestamp = Date.now()
+ const searchIndex = lastSearches.findIndex((item) => item.search === search)
+ if (searchIndex !== -1) {
+ lastSearches[searchIndex].timestamp = timestamp
+ setLastSearches(lastSearches)
+ return
+ }
+ setLastSearches([...lastSearches, { search, timestamp }])
+ return
+ }}
+ onInput={(e) => {
+ const caretPosition = e.currentTarget?.selectionStart || -1
+ const value = e.currentTarget?.value || ''
+ const word = getWordByCaretPosition({ value, caretPosition })
+ setCurrentWord(word)
+ }}
+ placeholder={placeholder}
+ className="text-foreground"
+ />
+
+
+ {/* default height is 300px but in case of more, we'd like to tease the user */}
+
+
+ {filterFields.map((field) => {
+ if (typeof field.value !== 'string') return null
+ if (inputValue.includes(`${field.value}:`)) return null
+ // TBD: should we handle this in the component?
+ return (
+ {
+ e.preventDefault()
+ e.stopPropagation()
+ }}
+ onSelect={(value) => {
+ setInputValue((prev) => {
+ if (currentWord.trim() === '') {
+ const input = `${prev}${value}`
+ return `${input}:`
+ }
+ // lots of cheat
+ const isStarting = currentWord === prev
+ const prefix = isStarting ? '' : ' '
+ const input = prev.replace(`${prefix}${currentWord}`, `${prefix}${value}`)
+ return `${input}:`
+ })
+ setCurrentWord(`${value}:`)
+ }}
+ className="group"
+ >
+ {field.value}
+
+
+ )
+ })}
+
+
+
+ {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()
+
+ 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,
+ })}
+
+ {
+ 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"
+ >
+
+
+
+ )
+ })}
+
+ No results found.
+
+
+
+
+ Use ↑ ↓ to navigate
+
+
+ Enter to query
+
+
+ Esc to close
+
+
+
+ Union: regions:a,b
+
+
+ Range: p95:59-340
+
+
+ {lastSearches.length ? (
+
{
+ e.preventDefault()
+ e.stopPropagation()
+ }}
+ onClick={() => setLastSearches([])}
+ >
+ Clear suggestions
+
+ ) : null}
+
+
+
+
+
+ )
+}
+
+function CommandItemSuggestions({ field }: { field: DataTableFilterField }) {
+ const { table, getFacetedMinMaxValues, getFacetedUniqueValues } = useDataTable()
+ const value = field.value as string
+ switch (field.type) {
+ case 'checkbox': {
+ return (
+
+ {getFacetedUniqueValues
+ ? Array.from(getFacetedUniqueValues(table, value)?.keys() || [])
+ .map((value) => `[${value}]`)
+ .join(' ')
+ : field.options?.map(({ value }) => `[${value}]`).join(' ')}
+
+ )
+ }
+ case 'slider': {
+ const [min, max] = getFacetedMinMaxValues?.(table, value) || [field.min, field.max]
+ return (
+
+ [{min} - {max}]
+
+ )
+ }
+ case '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
new file mode 100644
index 00000000000..c5030af0153
--- /dev/null
+++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControls.tsx
@@ -0,0 +1,74 @@
+import {
+ Accordion_Shadcn_ as Accordion,
+ AccordionContent_Shadcn_ as AccordionContent,
+ AccordionItem_Shadcn_ as AccordionItem,
+ AccordionTrigger_Shadcn_ as AccordionTrigger,
+} from 'ui'
+
+import { DataTableFilterCheckbox } from './DataTableFilterCheckbox'
+import { DataTableFilterInput } from './DataTableFilterInput'
+import { DataTableFilterResetButton } from './DataTableFilterResetButton'
+import { DataTableFilterSlider } from './DataTableFilterSlider'
+import { DataTableFilterTimerange } from './DataTableFilterTimerange'
+
+import { useDataTable } from '../providers/DataTableProvider'
+
+// 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()
+ return (
+ defaultOpen)
+ ?.map(({ value }) => value as string)}
+ >
+ {filterFields?.map((field) => {
+ const value = field.value as string
+ return (
+
+
+
+
+
{field.label}
+ {value !== field.label.toLowerCase() && !field.commandDisabled ? (
+
+ {value}
+
+ ) : null}
+
+
+
+
+
+ {/* REMINDER: avoid the focus state to be cut due to overflow-hidden */}
+ {/* REMINDER: need to move within here because of accordion height animation */}
+
+ {(() => {
+ switch (field.type) {
+ case 'checkbox': {
+ return
+ }
+ case 'slider': {
+ return
+ }
+ case 'input': {
+ return
+ }
+ case 'timerange': {
+ return
+ }
+ }
+ })()}
+
+
+
+ )
+ })}
+
+ )
+}
diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControlsDrawer.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControlsDrawer.tsx
new file mode 100644
index 00000000000..835d77cc54e
--- /dev/null
+++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterControlsDrawer.tsx
@@ -0,0 +1,75 @@
+import { VisuallyHidden } from '@radix-ui/react-visually-hidden'
+import { FilterIcon } from 'lucide-react'
+import { useRef } from 'react'
+
+import { useHotKey } from 'hooks/ui/useHotKey'
+import {
+ Button,
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from 'ui'
+import { useMediaQuery } from '../hooks/useMediaQuery'
+import { Kbd } from '../primitives/Kbd'
+import { DataTableFilterControls } from './DataTableFilterControls'
+
+export function DataTableFilterControlsDrawer() {
+ const triggerButtonRef = useRef(null)
+ const isMobile = useMediaQuery('(max-width: 640px)')
+
+ useHotKey(() => {
+ triggerButtonRef.current?.click()
+ }, 'b')
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ Toggle controls with{' '}
+
+ ⌘
+ B
+
+
+
+
+
+
+
+
+ Filters
+ Adjust your table filters
+
+
+
+
+
+
+
+
+ Close
+
+
+
+
+
+ )
+}
diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterInput.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterInput.tsx
new file mode 100644
index 00000000000..a41cc5d9656
--- /dev/null
+++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterInput.tsx
@@ -0,0 +1,52 @@
+import { Search } from 'lucide-react'
+import { useEffect, useState } from 'react'
+
+import { Label_Shadcn_ as Label } from 'ui'
+import type { DataTableInputFilterField } from '../DataTable.types'
+import { useDebounce } from '../hooks/useDebounce'
+import { InputWithAddons } from '../primitives/InputWithAddons'
+import { useDataTable } from '../providers/DataTableProvider'
+
+function getFilter(filterValue: unknown) {
+ return typeof filterValue === 'string' ? filterValue : null
+}
+
+export function DataTableFilterInput({ value: _value }: DataTableInputFilterField) {
+ const value = _value as string
+ const { table, columnFilters } = useDataTable()
+ const column = table.getColumn(value)
+ const filterValue = columnFilters.find((i) => i.id === value)?.value
+ const filters = getFilter(filterValue)
+ const [input, setInput] = useState(filters)
+
+ const debouncedInput = useDebounce(input, 500)
+
+ useEffect(() => {
+ const newValue = debouncedInput?.trim() === '' ? null : debouncedInput
+ if (debouncedInput === null) return
+ column?.setFilterValue(newValue)
+ }, [debouncedInput])
+
+ useEffect(() => {
+ if (debouncedInput?.trim() !== filters) {
+ setInput(filters)
+ }
+ }, [filters])
+
+ return (
+
+
+ {value}
+
+ }
+ containerClassName="h-9 rounded"
+ name={value}
+ id={value}
+ value={input || ''}
+ onChange={(e) => setInput(e.target.value)}
+ />
+
+ )
+}
diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterResetButton.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterResetButton.tsx
new file mode 100644
index 00000000000..2279eb48b17
--- /dev/null
+++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterResetButton.tsx
@@ -0,0 +1,35 @@
+import { X } from 'lucide-react'
+
+import { Button } from 'ui'
+import type { DataTableFilterField } from '../DataTable.types'
+import { useDataTable } from '../providers/DataTableProvider'
+
+export function DataTableFilterResetButton({ value: _value }: DataTableFilterField) {
+ const { columnFilters, table } = useDataTable()
+ const value = _value as string
+ const column = table.getColumn(value)
+ const filterValue = columnFilters.find((f) => f.id === value)?.value
+ const filters = filterValue ? (Array.isArray(filterValue) ? filterValue : [filterValue]) : []
+
+ if (filters.length === 0) return null
+
+ return (
+ }
+ className="h-5 rounded-full px-1.5 py-1 font-mono text-[10px] [&>span]:-translate-y-[0.6px] space-x-1"
+ onClick={(e) => {
+ e.stopPropagation()
+ column?.setFilterValue(undefined)
+ }}
+ onKeyDown={(e) => {
+ e.stopPropagation()
+ if (e.code === 'Enter') {
+ column?.setFilterValue(undefined)
+ }
+ }}
+ >
+ {filters.length}
+
+ )
+}
diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterSlider.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterSlider.tsx
new file mode 100644
index 00000000000..8882c6fe91d
--- /dev/null
+++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterSlider.tsx
@@ -0,0 +1,99 @@
+import { useEffect, useState } from 'react'
+
+import { Label_Shadcn_ as Label } from 'ui'
+import type { DataTableSliderFilterField } from '../DataTable.types'
+import { isArrayOfNumbers } from '../DataTable.utils'
+import { useDebounce } from '../hooks/useDebounce'
+import { InputWithAddons } from '../primitives/InputWithAddons'
+import { Slider } from '../primitives/Slider'
+import { useDataTable } from '../providers/DataTableProvider'
+
+function getFilter(filterValue: unknown) {
+ return typeof filterValue === 'number'
+ ? [filterValue, filterValue]
+ : Array.isArray(filterValue) && isArrayOfNumbers(filterValue)
+ ? filterValue.length === 1
+ ? [filterValue[0], filterValue[0]]
+ : filterValue
+ : null
+}
+
+// TODO: discuss if we even need the `defaultMin` and `defaultMax`
+export function DataTableFilterSlider({
+ value: _value,
+ min: defaultMin,
+ max: defaultMax,
+}: DataTableSliderFilterField) {
+ const value = _value as string
+ const { table, columnFilters, getFacetedMinMaxValues } = useDataTable()
+ const column = table.getColumn(value)
+ const filterValue = columnFilters.find((i) => i.id === value)?.value
+ const filters = getFilter(filterValue)
+ const [input, setInput] = useState(filters)
+ const [min, max] = getFacetedMinMaxValues?.(table, value) ||
+ column?.getFacetedMinMaxValues() || [defaultMin, defaultMax]
+
+ const debouncedInput = useDebounce(input, 500)
+
+ useEffect(() => {
+ if (debouncedInput?.length === 2) {
+ column?.setFilterValue(debouncedInput)
+ }
+ }, [debouncedInput])
+
+ useEffect(() => {
+ if (debouncedInput?.length !== 2) {
+ } else if (!filters) {
+ setInput(null)
+ } else if (debouncedInput[0] !== filters[0] || debouncedInput[1] !== filters[1]) {
+ setInput(filters)
+ }
+ }, [filters])
+
+ return (
+
+
+
+
+ Min.
+
+ setInput((prev) => [Number(e.target.value), prev?.[1] || max])}
+ />
+
+
+
+ Max.
+
+ setInput((prev) => [prev?.[0] || min, Number(e.target.value)])}
+ />
+
+
+
setInput(values)}
+ />
+
+ )
+}
diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterTimerange.tsx b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterTimerange.tsx
new file mode 100644
index 00000000000..9981ed31e61
--- /dev/null
+++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilterTimerange.tsx
@@ -0,0 +1,39 @@
+import { useMemo } from 'react'
+import type { DateRange } from 'react-day-picker'
+
+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,
+}: 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 date: DateRange | undefined = useMemo(
+ () =>
+ filterValue instanceof Date
+ ? { from: filterValue, to: undefined }
+ : Array.isArray(filterValue) && isArrayOfDates(filterValue)
+ ? { from: filterValue?.[0], to: filterValue?.[1] }
+ : undefined,
+ [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])
+ }
+ if (date.to && date.from) {
+ column?.setFilterValue([date.from, date.to])
+ }
+ }
+
+ return
+}
diff --git a/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilters.utils.ts b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilters.utils.ts
new file mode 100644
index 00000000000..c65b6106b07
--- /dev/null
+++ b/apps/studio/components/ui/DataTable/DataTableFilters/DataTableFilters.utils.ts
@@ -0,0 +1,259 @@
+import { ColumnFiltersState } from '@tanstack/react-table'
+import { ParserBuilder } from 'nuqs'
+
+import { ARRAY_DELIMITER, RANGE_DELIMITER, SLIDER_DELIMITER } from '../DataTable.constants'
+import type { DataTableFilterField } from '../DataTable.types'
+import { isArrayOfDates } from '../DataTable.utils'
+
+/**
+ * Extracts the word from the given string at the specified caret position.
+ */
+export function getWordByCaretPosition({
+ value,
+ caretPosition,
+}: {
+ value: string
+ caretPosition: number
+}) {
+ let start = caretPosition
+ let end = caretPosition
+
+ while (start > 0 && value[start - 1] !== ' ') start--
+ while (end < value.length && value[end] !== ' ') end++
+
+ const word = value.substring(start, end)
+ return word
+}
+
+export function replaceInputByFieldType({
+ prev,
+ currentWord,
+ optionValue,
+ value,
+ field,
+}: {
+ prev: string
+ currentWord: string
+ optionValue?: string | number | boolean | undefined // FIXME: use DataTableFilterField["options"][number];
+ value: string
+ field: DataTableFilterField
+}) {
+ switch (field.type) {
+ case 'checkbox': {
+ if (currentWord.includes(ARRAY_DELIMITER)) {
+ const words = currentWord.split(ARRAY_DELIMITER)
+ words[words.length - 1] = `${optionValue}`
+ const input = prev.replace(currentWord, words.join(ARRAY_DELIMITER))
+ return `${input.trim()} `
+ }
+ }
+ case 'slider': {
+ if (currentWord.includes(SLIDER_DELIMITER)) {
+ const words = currentWord.split(SLIDER_DELIMITER)
+ words[words.length - 1] = `${optionValue}`
+ const input = prev.replace(currentWord, words.join(SLIDER_DELIMITER))
+ return `${input.trim()} `
+ }
+ }
+ case 'timerange': {
+ if (currentWord.includes(RANGE_DELIMITER)) {
+ const words = currentWord.split(RANGE_DELIMITER)
+ words[words.length - 1] = `${optionValue}`
+ const input = prev.replace(currentWord, words.join(RANGE_DELIMITER))
+ return `${input.trim()} `
+ }
+ }
+ default: {
+ const input = prev.replace(currentWord, value)
+ return `${input.trim()} `
+ }
+ }
+}
+
+export function getFieldOptions({ field }: { field: DataTableFilterField }) {
+ switch (field.type) {
+ case 'slider': {
+ return field.options?.length
+ ? field.options
+ .map(({ value }) => value)
+ .sort((a, b) => Number(a) - Number(b))
+ .filter(notEmpty)
+ : Array.from({ length: field.max - field.min + 1 }, (_, i) => field.min + i) || []
+ }
+ default: {
+ return field.options?.map(({ value }) => value).filter(notEmpty) || []
+ }
+ }
+}
+
+export function getFilterValue({
+ value,
+ search,
+ currentWord,
+}: {
+ value: string
+ search: string
+ keywords?: string[] | undefined
+ currentWord: string
+}): number {
+ /**
+ * @example value "suggestion:public:true regions,ams,gru,fra"
+ */
+ if (value.startsWith('suggestion:')) {
+ const rawValue = value.toLowerCase().replace('suggestion:', '')
+ if (rawValue.includes(search)) return 1
+ return 0
+ }
+
+ /** */
+ if (value.toLowerCase().includes(currentWord.toLowerCase())) return 1
+
+ /**
+ * @example checkbox [filter, query] = ["regions", "ams,gru,fra"]
+ * @example slider [filter, query] = ["p95", "0-3000"]
+ * @example input [filter, query] = ["name", "api"]
+ */
+ const [filter, query] = currentWord.toLowerCase().split(':')
+ if (query && value.startsWith(`${filter}:`)) {
+ if (query.includes(ARRAY_DELIMITER)) {
+ /**
+ * array of n elements
+ * @example queries = ["ams", "gru", "fra"]
+ */
+ const queries = query.split(ARRAY_DELIMITER)
+ const rawValue = value.toLowerCase().replace(`${filter}:`, '')
+ if (queries.some((item, i) => item === rawValue && i !== queries.length - 1)) return 0
+ if (queries.some((item) => rawValue.includes(item))) return 1
+ }
+ if (query.includes(SLIDER_DELIMITER)) {
+ /**
+ * range between 2 elements
+ * @example queries = ["0", "3000"]
+ */
+ const queries = query.split(SLIDER_DELIMITER)
+ const rawValue = value.toLowerCase().replace(`${filter}:`, '')
+
+ const rawValueAsNumber = Number.parseInt(rawValue)
+ const queryAsNumber = Number.parseInt(queries[0])
+
+ if (queryAsNumber < rawValueAsNumber) {
+ if (rawValue.includes(queries[1])) return 1
+ return 0
+ }
+ return 0
+ }
+ const rawValue = value.toLowerCase().replace(`${filter}:`, '')
+ if (rawValue.includes(query)) return 1
+ }
+ return 0
+}
+
+export function getFieldValueByType({
+ field,
+ value,
+}: {
+ field?: DataTableFilterField
+ value: unknown
+}) {
+ if (!field) return null
+
+ switch (field.type) {
+ case 'slider': {
+ if (Array.isArray(value)) {
+ return value.join(SLIDER_DELIMITER)
+ }
+ return value
+ }
+ case 'checkbox': {
+ if (Array.isArray(value)) {
+ return value.join(ARRAY_DELIMITER)
+ }
+ // REMINER: inversed logic
+ if (typeof value === 'string') {
+ return value.split(ARRAY_DELIMITER)
+ }
+ return value
+ }
+ case 'timerange': {
+ if (Array.isArray(value)) {
+ if (isArrayOfDates(value)) {
+ return value.map((date) => date.getTime()).join(RANGE_DELIMITER)
+ }
+ return value.join(RANGE_DELIMITER)
+ }
+ if (value instanceof Date) {
+ return value.getTime()
+ }
+ return value
+ }
+ default: {
+ return value
+ }
+ }
+}
+
+export function notEmpty(value: TValue | null | undefined): value is TValue {
+ return value !== null && value !== undefined
+}
+
+export function columnFiltersParser({
+ searchParamsParser,
+ filterFields,
+}: {
+ searchParamsParser: Record>
+ filterFields: DataTableFilterField[]
+}) {
+ return {
+ parse: (inputValue: string) => {
+ // Use regex to properly extract field:value pairs
+ // This properly handles spaces within values
+ const filterPairs: Record = {}
+
+ // This regex properly extracts field:value pairs where:
+ // 1. Values can contain spaces (both normal spaces and URL-encoded + characters)
+ // 2. It finds word characters followed by a colon as the field name
+ // 3. It then captures everything (including spaces and + characters) until
+ // it finds another field:value pattern or the end of string
+ // This solves the issue with values like "edge function" or "edge+function" in URLs
+ 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()}`)
+ }
+ }
+
+ const searchParams = Object.entries(filterPairs).reduce(
+ (prev, [key, value]) => {
+ const parser = searchParamsParser[key]
+ if (!parser) return prev
+
+ prev[key] = parser.parse(value)
+ return prev
+ },
+ {} as Record
+ )
+
+ return searchParams
+ },
+ serialize: (columnFilters: ColumnFiltersState) => {
+ const values = columnFilters.reduce((prev, curr) => {
+ const { commandDisabled } = filterFields?.find((field) => curr.id === field.value) || {
+ commandDisabled: true,
+ } // if column filter is not found, disable the command by default
+ const parser = searchParamsParser[curr.id]
+
+ if (commandDisabled || !parser) return prev
+
+ return `${prev}${curr.id}:${parser.serialize(curr.value)} `
+ }, '')
+
+ return values
+ },
+ }
+}
diff --git a/apps/studio/components/ui/DataTable/DataTableHeaderLayout.tsx b/apps/studio/components/ui/DataTable/DataTableHeaderLayout.tsx
new file mode 100644
index 00000000000..5222d38f14c
--- /dev/null
+++ b/apps/studio/components/ui/DataTable/DataTableHeaderLayout.tsx
@@ -0,0 +1,35 @@
+import { forwardRef, PropsWithChildren, useEffect, useRef } from 'react'
+import { cn } from 'ui'
+
+export const DataTableHeaderLayout = forwardRef<
+ HTMLDivElement,
+ PropsWithChildren<{
+ setTopBarHeight: (height: number) => void
+ }>
+>(({ setTopBarHeight, ...props }, ref) => {
+ const topBarRef = useRef(null)
+
+ useEffect(() => {
+ const observer = new ResizeObserver(() => {
+ const rect = topBarRef.current?.getBoundingClientRect()
+ if (rect) {
+ setTopBarHeight(rect.height)
+ }
+ })
+
+ const topBar = topBarRef.current
+ if (!topBar) return
+
+ observer.observe(topBar)
+ return () => observer.unobserve(topBar)
+ }, [topBarRef])
+
+ return (
+
+ )
+})
+DataTableHeaderLayout.displayName = 'DataTableHeaderLayout'
diff --git a/apps/studio/components/ui/DataTable/DataTableInfinite.tsx b/apps/studio/components/ui/DataTable/DataTableInfinite.tsx
new file mode 100644
index 00000000000..8dec475c052
--- /dev/null
+++ b/apps/studio/components/ui/DataTable/DataTableInfinite.tsx
@@ -0,0 +1,224 @@
+import { type FetchNextPageOptions } from '@tanstack/react-query'
+import type { ColumnDef, Row, Table as TTable, VisibilityState } from '@tanstack/react-table'
+import { flexRender } from '@tanstack/react-table'
+import { LoaderCircle } from 'lucide-react'
+import { useQueryState } from 'nuqs'
+import { Fragment, memo, ReactNode, UIEvent, useCallback, useRef } from 'react'
+
+import { useHotKey } from 'hooks/ui/useHotKey'
+import { Button, cn } from 'ui'
+import { formatCompactNumber } from './DataTable.utils'
+import { useDataTable } from './providers/DataTableProvider'
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './Table'
+
+// TODO: add a possible chartGroupBy
+export interface DataTableInfiniteProps {
+ columns: ColumnDef[]
+ defaultColumnVisibility?: VisibilityState
+ totalRows?: number
+ filterRows?: number
+ totalRowsFetched?: number
+ isFetching?: boolean
+ isLoading?: boolean
+ hasNextPage?: boolean
+ fetchNextPage: (options?: FetchNextPageOptions | undefined) => Promise
+ renderLiveRow?: (props?: { row: Row }) => ReactNode
+ setColumnOrder: (columnOrder: string[]) => void
+ setColumnVisibility: (columnVisibility: VisibilityState) => void
+
+ // [Joshen] See if we can type this properly
+ searchParamsParser: any
+}
+
+// [Joshen] JFYI this component is NOT virtualized and hence will struggle handling many data points
+export function DataTableInfinite({
+ columns,
+ defaultColumnVisibility = {},
+ fetchNextPage,
+ hasNextPage,
+ totalRows = 0,
+ filterRows = 0,
+ totalRowsFetched = 0,
+ renderLiveRow,
+ setColumnOrder,
+ setColumnVisibility,
+ searchParamsParser,
+}: DataTableInfiniteProps) {
+ const { table, isLoading, isFetching } = useDataTable()
+ const tableRef = useRef(null)
+
+ const headerGroups = table.getHeaderGroups()
+ const headers = headerGroups[0].headers
+ const rows = table.getRowModel().rows ?? []
+
+ const onScroll = useCallback(
+ (e: UIEvent) => {
+ const onPageBottom =
+ Math.ceil(e.currentTarget.scrollTop + e.currentTarget.clientHeight) >=
+ e.currentTarget.scrollHeight
+
+ if (onPageBottom && !isFetching && totalRows > totalRowsFetched) {
+ fetchNextPage()
+ }
+ },
+ [fetchNextPage, isFetching, totalRows, totalRowsFetched]
+ )
+
+ useHotKey(() => {
+ setColumnOrder([])
+ setColumnVisibility(defaultColumnVisibility)
+ }, 'u')
+
+ return (
+
+
+
+ {headers.map((header) => {
+ const sort = header.column.getIsSorted()
+ const canResize = header.column.getCanResize()
+ const onResize = header.getResizeHandler()
+ const headerClassName = (header.column.columnDef.meta as any)?.headerClassName
+
+ return (
+
+ {header.isPlaceholder
+ ? null
+ : flexRender(header.column.columnDef.header, header.getContext())}
+ {canResize && (
+ header.column.resetSize()}
+ onMouseDown={onResize}
+ onTouchStart={onResize}
+ className={cn(
+ 'user-select-none absolute -right-2 top-0 z-10 flex h-full w-4 cursor-col-resize touch-none justify-center',
+ 'before:absolute before:inset-y-0 before:w-px before:translate-x-px before:bg-border'
+ )}
+ />
+ )}
+
+ )
+ })}
+
+
+
+ {rows.length ? (
+ rows.map((row) => (
+ // REMINDER: if we want to add arrow navigation https://github.com/TanStack/table/discussions/2752#discussioncomment-192558
+
+ {renderLiveRow?.({ row: row as any })}
+
+
+ ))
+ ) : (
+
+ {renderLiveRow?.()}
+
+
+ {isLoading ? 'Retrieving logs...' : 'No results'}
+
+
+
+ )}
+
+
+ {hasNextPage || isFetching || isLoading ? (
+
+
fetchNextPage()}
+ size="small"
+ type="outline"
+ icon={isFetching ? : null}
+ >
+ Load more
+
+
+ Showing{' '}
+
+ {formatCompactNumber(totalRowsFetched)}
+ {' '}
+ of {formatCompactNumber(totalRows)} {' '}
+ rows
+
+
+ ) : (
+
+ No more data to load (
+ {formatCompactNumber(filterRows)} of{' '}
+ {formatCompactNumber(totalRows)} {' '}
+ rows)
+
+ )}
+
+
+
+
+ )
+}
+
+/**
+ * REMINDER: this is the heaviest component in the table if lots of rows
+ * Some other components are rendered more often necessary, but are fixed size (not like rows that can grow in height)
+ * e.g. DataTableFilterControls, DataTableFilterCommand, DataTableToolbar, DataTableHeader
+ */
+
+function DataTableRow({
+ row,
+ table,
+ selected,
+ searchParamsParser,
+}: {
+ row: Row
+ table: TTable
+ selected?: boolean
+ searchParamsParser: any
+}) {
+ useQueryState('live', searchParamsParser.live)
+ const rowClassName = (table.options.meta as any)?.getRowClassName?.(row)
+ const cells = row.getVisibleCells()
+
+ return (
+ row.toggleSelected()}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault()
+ row.toggleSelected()
+ }
+ }}
+ className={cn(rowClassName)}
+ >
+ {cells.map((cell) => {
+ const cellClassName = (cell.column.columnDef.meta as any)?.cellClassName
+ return (
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+
+ )
+ })}
+
+ )
+}
+
+// [Joshen] Using MemoizedRow will cause the column visibility to break as the rows aren't getting re-rendered
+const MemoizedRow = memo(
+ DataTableRow,
+ (prev, next) => prev.row.id === next.row.id && prev.selected === next.selected
+) as typeof DataTableRow
diff --git a/apps/studio/components/ui/DataTable/DataTableResetButton.tsx b/apps/studio/components/ui/DataTable/DataTableResetButton.tsx
new file mode 100644
index 00000000000..06a80799f08
--- /dev/null
+++ b/apps/studio/components/ui/DataTable/DataTableResetButton.tsx
@@ -0,0 +1,37 @@
+import { X } from 'lucide-react'
+
+import { useHotKey } from 'hooks/ui/useHotKey'
+import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from 'ui'
+import { Kbd } from './primitives/Kbd'
+import { useDataTable } from './providers/DataTableProvider'
+
+export function DataTableResetButton() {
+ const { table } = useDataTable()
+ useHotKey(table.resetColumnFilters, 'Escape')
+
+ return (
+
+
+
+ table.resetColumnFilters()}
+ icon={ }
+ >
+ Reset
+
+
+
+
+ Reset filters with{' '}
+
+ ⌘
+ Esc
+
+
+
+
+
+ )
+}
diff --git a/apps/studio/components/ui/DataTable/DataTableSheetDetails.tsx b/apps/studio/components/ui/DataTable/DataTableSheetDetails.tsx
new file mode 100644
index 00000000000..922d38bc3ee
--- /dev/null
+++ b/apps/studio/components/ui/DataTable/DataTableSheetDetails.tsx
@@ -0,0 +1,135 @@
+import { ChevronDown, ChevronUp, X } from 'lucide-react'
+import { ReactNode, useCallback, useEffect, useMemo } from 'react'
+
+import {
+ Button,
+ cn,
+ Separator,
+ Skeleton,
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from 'ui'
+import { Kbd } from './primitives/Kbd'
+import { useDataTable } from './providers/DataTableProvider'
+
+export interface DataTableSheetDetailsProps {
+ title?: ReactNode
+ titleClassName?: string
+ children?: ReactNode
+}
+
+export function DataTableSheetDetails({
+ title,
+ titleClassName,
+ children,
+}: DataTableSheetDetailsProps) {
+ const { table, rowSelection, isLoading } = useDataTable()
+
+ const selectedRowKey = Object.keys(rowSelection)?.[0]
+
+ const selectedRow = useMemo(() => {
+ if (isLoading && !selectedRowKey) return
+ return table.getCoreRowModel().flatRows.find((row) => row.id === selectedRowKey)
+ }, [selectedRowKey, isLoading])
+
+ const index = table.getCoreRowModel().flatRows.findIndex((row) => row.id === selectedRow?.id)
+
+ const nextId = useMemo(() => table.getCoreRowModel().flatRows[index + 1]?.id, [index, isLoading])
+
+ const prevId = useMemo(() => table.getCoreRowModel().flatRows[index - 1]?.id, [index, isLoading])
+
+ const onPrev = useCallback(() => {
+ if (prevId) table.setRowSelection({ [prevId]: true })
+ }, [prevId, isLoading])
+
+ const onNext = useCallback(() => {
+ if (nextId) table.setRowSelection({ [nextId]: true })
+ }, [nextId, isLoading])
+
+ useEffect(() => {
+ const down = (e: KeyboardEvent) => {
+ if (!selectedRowKey) return
+
+ // REMINDER: prevent dropdown navigation inside of sheet to change row selection
+ const activeElement = document.activeElement
+ const isMenuActive = activeElement?.closest('[role="menu"]')
+
+ if (isMenuActive) return
+
+ if (e.key === 'ArrowUp') {
+ e.preventDefault()
+ onPrev()
+ }
+ if (e.key === 'ArrowDown') {
+ e.preventDefault()
+ onNext()
+ }
+ }
+
+ document.addEventListener('keydown', down)
+ return () => document.removeEventListener('keydown', down)
+ }, [selectedRowKey, onNext, onPrev])
+
+ return (
+
+
+
+ {isLoading && !selectedRowKey ? : title}
+
+
+
+
+
+ }
+ />
+
+
+
+ Navigate ↑
+
+
+
+
+
+
+
+ }
+ />
+
+
+
+ Navigate ↓
+
+
+
+
+
+
+
table.resetRowSelection()}
+ className="px-1"
+ icon={ }
+ />
+
+
+
+ {children}
+
+ )
+}
diff --git a/apps/studio/components/ui/DataTable/DataTableSheetRowAction.tsx b/apps/studio/components/ui/DataTable/DataTableSheetRowAction.tsx
new file mode 100644
index 00000000000..d1cec2acbfe
--- /dev/null
+++ b/apps/studio/components/ui/DataTable/DataTableSheetRowAction.tsx
@@ -0,0 +1,186 @@
+import { Table } from '@tanstack/react-table'
+import { endOfDay, endOfHour, startOfDay, startOfHour } from 'date-fns'
+import {
+ CalendarClock,
+ CalendarDays,
+ CalendarSearch,
+ ChevronLeft,
+ ChevronRight,
+ Copy,
+ Equal,
+ Search,
+} from 'lucide-react'
+import { ComponentPropsWithRef } from 'react'
+
+import { DataTableFilterField } from 'components/ui/DataTable/DataTable.types'
+import { useCopyToClipboard } from 'hooks/ui/useCopyToClipboard'
+import {
+ cn,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from 'ui'
+
+interface DataTableSheetRowActionProps>
+ extends ComponentPropsWithRef {
+ fieldValue: TFields['value']
+ filterFields: TFields[]
+ value: string | number
+ table: Table
+}
+
+export function DataTableSheetRowAction>({
+ fieldValue,
+ filterFields,
+ value,
+ children,
+ className,
+ table,
+ onKeyDown,
+ ...props
+}: DataTableSheetRowActionProps) {
+ const { copy, isCopied } = useCopyToClipboard()
+ const field = filterFields.find((field) => field.value === fieldValue)
+ const column = table.getColumn(fieldValue.toString())
+
+ if (!field || !column) return null
+
+ function renderOptions() {
+ if (!field) return null
+ switch (field.type) {
+ case 'checkbox':
+ return (
+ {
+ const filterValue = column?.getFilterValue() as undefined | Array
+ const newValue = filterValue?.includes(value)
+ ? filterValue
+ : [...(filterValue || []), value]
+
+ column?.setFilterValue(newValue)
+ }}
+ className="flex items-center gap-2"
+ >
+
+ Include
+
+ )
+ case 'input':
+ return (
+ column?.setFilterValue(value)}
+ className="flex items-center gap-2"
+ >
+
+ Include
+
+ )
+ case 'slider':
+ return (
+
+ column?.setFilterValue([0, value])}
+ className="flex items-center gap-2"
+ >
+ {/* FIXME: change icon as it is not clear */}
+
+ Less or equal than
+
+ column?.setFilterValue([value, 5000])}
+ className="flex items-center gap-2"
+ >
+ {/* FIXME: change icon as it is not clear */}
+
+ Greater or equal than
+
+ column?.setFilterValue([value])}
+ className="flex items-center gap-2"
+ >
+
+ Equal to
+
+
+ )
+ case 'timerange':
+ const date = new Date(value)
+ return (
+
+ column?.setFilterValue([date])}
+ className="flex items-center gap-2"
+ >
+
+ Exact timestamp
+
+ {
+ const start = startOfHour(date)
+ const end = endOfHour(date)
+ column?.setFilterValue([start, end])
+ }}
+ className="flex items-center gap-2"
+ >
+
+ Same hour
+
+ {
+ const start = startOfDay(date)
+ const end = endOfDay(date)
+ column?.setFilterValue([start, end])
+ }}
+ className="flex items-center gap-2"
+ >
+
+ Same day
+
+
+ )
+ default:
+ return null
+ }
+ }
+
+ return (
+
+ {
+ if (e.key === 'ArrowDown') {
+ // REMINDER: default behavior is to open the dropdown menu
+ // But because we use it to navigate between rows, we need to prevent it
+ // and only use "Enter" to select the option
+ e.preventDefault()
+ }
+ onKeyDown?.(e)
+ }}
+ {...props}
+ >
+ {children}
+ {isCopied ? (
+ Value copied
+ ) : null}
+
+
+ {renderOptions()}
+
+ copy(String(value), { timeout: 1000 })}
+ className="flex items-center gap-2"
+ >
+
+ Copy value
+
+
+
+ )
+}
diff --git a/apps/studio/components/ui/DataTable/DataTableSideBarLayout.tsx b/apps/studio/components/ui/DataTable/DataTableSideBarLayout.tsx
new file mode 100644
index 00000000000..5898566d139
--- /dev/null
+++ b/apps/studio/components/ui/DataTable/DataTableSideBarLayout.tsx
@@ -0,0 +1,52 @@
+import { CSSProperties, ReactNode, useMemo } from 'react'
+
+import { cn } from 'ui'
+import { useDataTable } from './providers/DataTableProvider'
+
+interface DataTableSideBarLayoutProps {
+ children: ReactNode
+ className?: string
+ topBarHeight?: number
+}
+
+export function DataTableSideBarLayout({
+ children,
+ className,
+ topBarHeight = 0,
+}: DataTableSideBarLayoutProps) {
+ const { table } = useDataTable()
+
+ /**
+ * https://tanstack.com/table/v8/docs/guide/column-sizing#advanced-column-resizing-performance
+ * Instead of calling `column.getSize()` on every render for every header
+ * and especially every data cell (very expensive),
+ * we will calculate all column sizes at once at the root table level in a useMemo
+ * and pass the column sizes down as CSS variables to the element.
+ */
+ const columnSizeVars = useMemo(() => {
+ const headers = table.getFlatHeaders()
+ const colSizes: { [key: string]: string } = {}
+ for (let i = 0; i < headers.length; i++) {
+ const header = headers[i]!
+ // REMINDER: replace "." with "-" to avoid invalid CSS variable name (e.g. "timing.dns" -> "timing-dns")
+ colSizes[`--header-${header.id.replace('.', '-')}-size`] = `${header.getSize()}px`
+ colSizes[`--col-${header.column.id.replace('.', '-')}-size`] = `${header.column.getSize()}px`
+ }
+ return colSizes
+ }, [
+ // TODO: check if we need this
+ table.getState().columnSizingInfo,
+ table.getState().columnSizing,
+ table.getState().columnVisibility,
+ ])
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/apps/studio/components/ui/DataTable/DataTableToolbar.tsx b/apps/studio/components/ui/DataTable/DataTableToolbar.tsx
new file mode 100644
index 00000000000..906e89dee00
--- /dev/null
+++ b/apps/studio/components/ui/DataTable/DataTableToolbar.tsx
@@ -0,0 +1,82 @@
+import { PanelLeftClose } from 'lucide-react'
+import { ReactNode, useMemo } from 'react'
+
+import { useHotKey } from 'hooks/ui/useHotKey'
+import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from 'ui'
+import { formatCompactNumber } from './DataTable.utils'
+import { DataTableFilterControlsDrawer } from './DataTableFilters/DataTableFilterControlsDrawer'
+import { DataTableResetButton } from './DataTableResetButton'
+import { DataTableViewOptions } from './DataTableViewOptions'
+import { Kbd } from './primitives/Kbd'
+import { useControls } from './providers/ControlsProvider'
+import { useDataTable } from './providers/DataTableProvider'
+
+interface DataTableToolbarProps {
+ renderActions?: () => ReactNode
+}
+
+export function DataTableToolbar({ renderActions }: DataTableToolbarProps) {
+ const { table, isLoading, columnFilters } = useDataTable()
+ const { open, setOpen } = useControls()
+ const filters = table.getState().columnFilters
+
+ useHotKey(() => setOpen((prev) => !prev), 'b')
+
+ const rows = useMemo(
+ () => ({
+ total: table.getCoreRowModel().rows.length,
+ filtered: table.getFilteredRowModel().rows.length,
+ }),
+ [isLoading, columnFilters]
+ )
+
+ return (
+
+
+
+
+
+ }
+ onClick={() => setOpen((prev) => !prev)}
+ className="hidden sm:flex"
+ >
+ {open ? 'Hide' : 'Show'} Controls
+
+
+
+
+ Toggle controls with{' '}
+
+ ⌘
+ B
+
+
+
+
+
+
+
+
+
+
+ {formatCompactNumber(rows.filtered)} of{' '}
+ {formatCompactNumber(rows.total)} row(s){' '}
+ filtered
+
+
+ {formatCompactNumber(rows.filtered)} {' '}
+ row(s)
+
+
+
+
+ {filters.length ? : null}
+ {renderActions?.()}
+
+
+
+ )
+}
diff --git a/apps/studio/components/ui/DataTable/DataTableViewOptions.tsx b/apps/studio/components/ui/DataTable/DataTableViewOptions.tsx
new file mode 100644
index 00000000000..24fc9a6641a
--- /dev/null
+++ b/apps/studio/components/ui/DataTable/DataTableViewOptions.tsx
@@ -0,0 +1,95 @@
+import { GripVertical, Settings2 } from 'lucide-react'
+import { useMemo, useState } from 'react'
+
+import {
+ Button,
+ Checkbox_Shadcn_,
+ Command_Shadcn_ as Command,
+ CommandEmpty_Shadcn_ as CommandEmpty,
+ CommandGroup_Shadcn_ as CommandGroup,
+ CommandInput_Shadcn_ as CommandInput,
+ CommandItem_Shadcn_ as CommandItem,
+ CommandList_Shadcn_ as CommandList,
+ Popover_Shadcn_ as Popover,
+ PopoverContent_Shadcn_ as PopoverContent,
+ PopoverTrigger_Shadcn_ as PopoverTrigger,
+} from 'ui'
+import { Sortable, SortableDragHandle, SortableItem } from './primitives/Sortable'
+import { useDataTable } from './providers/DataTableProvider'
+
+export function DataTableViewOptions() {
+ const { table, enableColumnOrdering } = useDataTable()
+ const [open, setOpen] = useState(false)
+ const [drag, setDrag] = useState(false)
+ const [search, setSearch] = useState('')
+
+ const columnOrder = table.getState().columnOrder
+
+ const sortedColumns = useMemo(
+ () =>
+ table.getAllColumns().sort((a, b) => {
+ return columnOrder.indexOf(a.id) - columnOrder.indexOf(b.id)
+ }),
+ [columnOrder]
+ )
+
+ return (
+
+
+ }
+ />
+
+
+
+
+
+ No option found.
+
+ ({ id: c.id }))}
+ onValueChange={(items) => table.setColumnOrder(items.map((c) => c.id))}
+ overlay={
}
+ onDragStart={() => setDrag(true)}
+ onDragEnd={() => setDrag(false)}
+ onDragCancel={() => setDrag(false)}
+ >
+ {sortedColumns
+ .filter(
+ (column) => typeof column.accessorFn !== 'undefined' && column.getCanHide()
+ )
+ .map((column) => (
+
+ column.toggleVisibility(!column.getIsVisible())}
+ className="capitalize p-1"
+ disabled={drag}
+ >
+
+ {(column.columnDef.meta as any)?.label || column.id}
+ {enableColumnOrdering && !search ? (
+
+
+
+ ) : null}
+
+
+ ))}
+
+
+
+
+
+
+ )
+}
diff --git a/apps/studio/components/ui/DataTable/DatePickerWithRange.tsx b/apps/studio/components/ui/DataTable/DatePickerWithRange.tsx
new file mode 100644
index 00000000000..4b035239642
--- /dev/null
+++ b/apps/studio/components/ui/DataTable/DatePickerWithRange.tsx
@@ -0,0 +1,267 @@
+import { format } from 'date-fns'
+import { Calendar as CalendarIcon } from 'lucide-react'
+import { HTMLAttributes, useEffect, useState } from 'react'
+import type { DateRange } from 'react-day-picker'
+
+import {
+ Button,
+ Calendar,
+ cn,
+ Input,
+ Label_Shadcn_ as Label,
+ Popover_Shadcn_ as Popover,
+ PopoverContent_Shadcn_ as PopoverContent,
+ PopoverSeparator_Shadcn_ as PopoverSeparator,
+ PopoverTrigger_Shadcn_ as PopoverTrigger,
+ Select_Shadcn_ as Select,
+ SelectContent_Shadcn_ as SelectContent,
+ SelectGroup_Shadcn_ as SelectGroup,
+ SelectItem_Shadcn_ as SelectItem,
+ SelectLabel_Shadcn_ as SelectLabel,
+ SelectTrigger_Shadcn_ as SelectTrigger,
+ SelectValue_Shadcn_ as SelectValue,
+} from 'ui'
+import { presets as defaultPresets } from './DataTable.constants'
+import type { DatePreset } from './DataTable.types'
+import { useDebounce } from './hooks/useDebounce'
+import { kdbClassName } from './primitives/Kbd'
+
+interface DatePickerWithRangeProps extends HTMLAttributes {
+ date: DateRange | undefined
+ setDate: (date: DateRange | undefined) => void
+ presets?: DatePreset[]
+}
+
+// [Joshen] This might be better placed in ui instead of DataTable since it could be reusable
+export function DatePickerWithRange({
+ className,
+ date,
+ setDate,
+ presets = defaultPresets,
+}: DatePickerWithRangeProps) {
+ const [open, setOpen] = useState(false)
+
+ useEffect(() => {
+ const down = (e: KeyboardEvent) => {
+ if (!open) return
+
+ presets.map((preset) => {
+ if (preset.shortcut === e.key) {
+ setDate({ from: preset.from, to: preset.to })
+ }
+ })
+ }
+ document.addEventListener('keydown', down)
+ return () => document.removeEventListener('keydown', down)
+ }, [setDate, presets, open])
+
+ return (
+
+
+
+ }
+ >
+ {date?.from ? (
+ date.to ? (
+
+ {format(date.from, 'LLL dd, y')} - {format(date.to, 'LLL dd, y')}
+
+ ) : (
+ format(date.from, 'LLL dd, y')
+ )
+ ) : (
+ Pick a date
+ )}
+
+
+
+
+
+
+
+
+
+ )
+}
+
+function DatePresets({
+ selected,
+ onSelect,
+ presets,
+}: {
+ selected: DateRange | undefined
+ onSelect: (date: DateRange | undefined) => void
+ presets: DatePreset[]
+}) {
+ return (
+
+
Date Range
+
+ {presets.map(({ label, shortcut, from, to }) => {
+ const isActive = selected?.from === from && selected?.to === to
+ return (
+
onSelect({ from, to })}
+ className="text-left"
+ >
+
+ {label}
+ {shortcut}
+
+
+ )
+ })}
+
+
+ )
+}
+
+function DatePresetsSelect({
+ selected,
+ onSelect,
+ presets,
+}: {
+ selected: DateRange | undefined
+ onSelect: (date: DateRange | undefined) => void
+ presets: DatePreset[]
+}) {
+ function findPreset(from?: Date, to?: Date) {
+ return presets.find((p) => p.from === from && p.to === to)?.shortcut
+ }
+ const [value, setValue] = useState(findPreset(selected?.from, selected?.to))
+
+ useEffect(() => {
+ const preset = findPreset(selected?.from, selected?.to)
+ if (preset === value) return
+ setValue(preset)
+ }, [selected, presets])
+
+ return (
+ {
+ const preset = presets.find((p) => p.shortcut === v)
+ if (preset) {
+ onSelect({ from: preset.from, to: preset.to })
+ }
+ }}
+ >
+
+
+
+
+
+ Date Presets
+ {presets.map(({ label, shortcut }) => {
+ return (
+
+ {label}
+
+ {shortcut}
+
+
+ )
+ })}
+
+
+
+ )
+}
+
+// REMINDER: We can add min max date range validation https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/datetime-local#setting_maximum_and_minimum_dates_and_times
+function CustomDateRange({
+ selected,
+ onSelect,
+}: {
+ selected: DateRange | undefined
+ onSelect: (date: DateRange | undefined) => void
+}) {
+ const [dateFrom, setDateFrom] = useState(selected?.from)
+ const [dateTo, setDateTo] = useState(selected?.to)
+ const debounceDateFrom = useDebounce(dateFrom, 1000)
+ const debounceDateTo = useDebounce(dateTo, 1000)
+
+ const formatDateForInput = (date: Date | undefined): string => {
+ if (!date) return ''
+ const utcDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000)
+ return utcDate.toISOString().slice(0, 16)
+ }
+
+ useEffect(() => {
+ onSelect({ from: debounceDateFrom, to: debounceDateTo })
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [debounceDateFrom, debounceDateTo])
+
+ return (
+
+ )
+}
diff --git a/apps/studio/components/ui/DataTable/FilterSideBar.tsx b/apps/studio/components/ui/DataTable/FilterSideBar.tsx
new file mode 100644
index 00000000000..d5be1d62a3d
--- /dev/null
+++ b/apps/studio/components/ui/DataTable/FilterSideBar.tsx
@@ -0,0 +1,28 @@
+import { cn } from 'ui'
+import { DataTableFilterControls } from './DataTableFilters/DataTableFilterControls'
+import { DataTableResetButton } from './DataTableResetButton'
+import { useDataTable } from './providers/DataTableProvider'
+
+export function FilterSideBar() {
+ const { table } = useDataTable()
+
+ return (
+
+
+
+
Filters
+
{table.getState().columnFilters.length ? : null}
+
+
+
+
+
+
+ )
+}
diff --git a/apps/studio/components/ui/DataTable/LiveButton.tsx b/apps/studio/components/ui/DataTable/LiveButton.tsx
new file mode 100644
index 00000000000..ebb1c3f04e2
--- /dev/null
+++ b/apps/studio/components/ui/DataTable/LiveButton.tsx
@@ -0,0 +1,71 @@
+import type { FetchPreviousPageOptions } from '@tanstack/react-query'
+import { CirclePause, CirclePlay } from 'lucide-react'
+import { useQueryStates } from 'nuqs'
+import { useEffect } from 'react'
+
+import { useHotKey } from 'hooks/ui/useHotKey'
+import { Button, cn } from 'ui'
+import { useDataTable } from './providers/DataTableProvider'
+
+const REFRESH_INTERVAL = 10_000
+
+interface LiveButtonProps {
+ searchParamsParser: any
+ fetchPreviousPage?: (options?: FetchPreviousPageOptions | undefined) => Promise
+}
+
+export function LiveButton({ fetchPreviousPage, searchParamsParser }: LiveButtonProps) {
+ const [{ live, date, sort }, setSearch] = useQueryStates(searchParamsParser)
+ const { table } = useDataTable()
+ useHotKey(handleClick, 'j')
+
+ useEffect(() => {
+ let timeoutId: NodeJS.Timeout
+
+ async function fetchData() {
+ if (live) {
+ await fetchPreviousPage?.()
+ timeoutId = setTimeout(fetchData, REFRESH_INTERVAL)
+ } else {
+ clearTimeout(timeoutId)
+ }
+ }
+
+ fetchData()
+
+ return () => {
+ clearTimeout(timeoutId)
+ }
+ }, [live, fetchPreviousPage])
+
+ // REMINDER: make sure to reset live when date is set
+ // TODO: test properly
+ useEffect(() => {
+ if ((date || sort) && live) {
+ setSearch((prev) => ({ ...prev, live: null }))
+ }
+ }, [date, sort])
+
+ function handleClick() {
+ setSearch((prev) => ({
+ ...prev,
+ live: !prev.live,
+ date: null,
+ sort: null,
+ }))
+ table.getColumn('date')?.setFilterValue(undefined)
+ table.resetSorting()
+ }
+
+ return (
+ : }
+ >
+ Live
+
+ )
+}
diff --git a/apps/studio/components/ui/DataTable/LiveRow.tsx b/apps/studio/components/ui/DataTable/LiveRow.tsx
new file mode 100644
index 00000000000..76c63acd8be
--- /dev/null
+++ b/apps/studio/components/ui/DataTable/LiveRow.tsx
@@ -0,0 +1,18 @@
+import { TableCell, TableRow } from 'ui'
+import { DataTableColumnLevelIndicator } from './DataTableColumn/DataTableColumnLevelIndicator'
+
+export function LiveRow({ colSpan }: { colSpan: number }) {
+ return (
+
+
+
+
+
+ Live Mode
+
+
+ )
+}
diff --git a/apps/studio/components/ui/DataTable/RefreshButton.tsx b/apps/studio/components/ui/DataTable/RefreshButton.tsx
new file mode 100644
index 00000000000..2adee376572
--- /dev/null
+++ b/apps/studio/components/ui/DataTable/RefreshButton.tsx
@@ -0,0 +1,26 @@
+import { LoaderCircle, RefreshCcw } from 'lucide-react'
+import { Button } from 'ui'
+
+interface RefreshButtonProps {
+ isLoading: boolean
+ onRefresh: () => void
+}
+
+export const RefreshButton = ({ isLoading, onRefresh }: RefreshButtonProps) => {
+ return (
+
+ ) : (
+
+ )
+ }
+ />
+ )
+}
diff --git a/apps/studio/components/ui/DataTable/Table.tsx b/apps/studio/components/ui/DataTable/Table.tsx
new file mode 100644
index 00000000000..efd78e2613a
--- /dev/null
+++ b/apps/studio/components/ui/DataTable/Table.tsx
@@ -0,0 +1,106 @@
+import { ComponentPropsWithRef, forwardRef } from 'react'
+import { cn } from 'ui'
+import {
+ Table as ShadcnTable,
+ TableBody as ShadcnTableBody,
+ TableCaption as ShadcnTableCaption,
+ TableCell as ShadcnTableCell,
+ TableFooter as ShadcnTableFooter,
+ TableHead as ShadcnTableHead,
+ TableHeader as ShadcnTableHeader,
+ TableRow as ShadcnTableRow,
+} from 'ui/src/components/shadcn/ui/table'
+
+// Only create a custom component for Table with the added props
+const Table = forwardRef>(
+ ({ className, onScroll, ...props }, ref) => (
+
+ )
+)
+Table.displayName = 'Table'
+
+const TableHeader = forwardRef<
+ HTMLTableSectionElement,
+ ComponentPropsWithRef
+>(({ className, ...props }, ref) => (
+
+))
+TableHeader.displayName = 'TableHeader'
+
+const TableBody = forwardRef<
+ HTMLTableSectionElement,
+ ComponentPropsWithRef
+>(({ className, ...props }, ref) => (
+
+))
+TableBody.displayName = 'TableBody'
+
+const TableFooter = forwardRef<
+ HTMLTableSectionElement,
+ ComponentPropsWithRef
+>(({ className, ...props }, ref) => (
+
+))
+TableFooter.displayName = 'TableFooter'
+
+const TableRow = forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ )
+)
+TableRow.displayName = 'TableRow'
+
+const TableHead = forwardRef>(
+ ({ className, ...props }, ref) => (
+ .cursor-col-resize]:last:opacity-0',
+ 'text-muted-foreground h-8 px-2 text-left align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
+ className
+ )}
+ {...props}
+ />
+ )
+)
+TableHead.displayName = 'TableHead'
+
+const TableCell = forwardRef>(
+ ({ className, ...props }, ref) => (
+ [role=checkbox]]:translate-y-[2px] truncate', className)}
+ {...props}
+ />
+ )
+)
+TableCell.displayName = 'TableCell'
+
+const TableCaption = forwardRef<
+ HTMLTableCaptionElement,
+ ComponentPropsWithRef
+>(({ className, ...props }, ref) => (
+
+))
+TableCaption.displayName = 'TableCaption'
+
+export { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow }
diff --git a/apps/studio/components/ui/DataTable/TimelineChart.tsx b/apps/studio/components/ui/DataTable/TimelineChart.tsx
new file mode 100644
index 00000000000..76acf6daf25
--- /dev/null
+++ b/apps/studio/components/ui/DataTable/TimelineChart.tsx
@@ -0,0 +1,166 @@
+import { format } from 'date-fns'
+import { useMemo, useState } from 'react'
+import { Bar, BarChart, CartesianGrid, ReferenceArea, XAxis } from 'recharts'
+import type { CategoricalChartFunc } from 'recharts/types/chart/generateCategoricalChart'
+
+import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, cn } from 'ui'
+import { useDataTable } from './providers/DataTableProvider'
+
+export type BaseChartSchema = { timestamp: number; [key: string]: number }
+export const description = 'A stacked bar chart'
+
+interface TimelineChartProps {
+ className?: string
+ /**
+ * The table column id to filter by - needs to be a type of `timerange` (e.g. "date").
+ * TBD: if using keyof TData to be closer to the data table props
+ */
+ columnId: string
+ /**
+ * Same data as of the InfiniteQueryMeta.
+ */
+ data: TChart[]
+ chartConfig: ChartConfig
+}
+
+export function TimelineChart({
+ data,
+ className,
+ columnId,
+ chartConfig,
+}: TimelineChartProps) {
+ const { table } = useDataTable()
+ const [refAreaLeft, setRefAreaLeft] = useState(null)
+ const [refAreaRight, setRefAreaRight] = useState(null)
+ const [isSelecting, setIsSelecting] = useState(false)
+
+ // REMINDER: date has to be a string for tooltip label to work - don't ask me why
+ const chart = useMemo(
+ () =>
+ data.map((item) => ({
+ ...item,
+ [columnId]: new Date(item.timestamp).toString(),
+ })),
+ [data]
+ )
+
+ const timerange = useMemo(() => {
+ if (data.length === 0) return { interval: 0, period: undefined }
+ const first = data[0].timestamp
+ const last = data[data.length - 1].timestamp
+ const interval = Math.abs(first - last) // in ms
+ return { interval, period: calculatePeriod(interval) }
+ }, [data])
+
+ const handleMouseDown: CategoricalChartFunc = (e) => {
+ if (e.activeLabel) {
+ setRefAreaLeft(e.activeLabel)
+ setIsSelecting(true)
+ }
+ }
+
+ const handleMouseMove: CategoricalChartFunc = (e) => {
+ if (isSelecting && e.activeLabel) {
+ setRefAreaRight(e.activeLabel)
+ }
+ }
+
+ const handleMouseUp: CategoricalChartFunc = (e) => {
+ if (refAreaLeft && refAreaRight) {
+ const [left, right] = [refAreaLeft, refAreaRight].sort(
+ (a, b) => new Date(a).getTime() - new Date(b).getTime()
+ )
+ table.getColumn(columnId)?.setFilterValue([new Date(left), new Date(right)])
+ }
+ setRefAreaLeft(null)
+ setRefAreaRight(null)
+ setIsSelecting(false)
+ }
+
+ return (
+
+
+
+ {
+ const date = new Date(value)
+ if (isNaN(date.getTime())) return 'N/A'
+ if (timerange.period === '10m') {
+ return format(date, 'HH:mm:ss')
+ } else if (timerange.period === '1d') {
+ return format(date, 'HH:mm')
+ } else if (timerange.period === '1w') {
+ return format(date, 'LLL dd HH:mm')
+ }
+ return format(date, 'LLL dd, y')
+ }}
+ />
+ {
+ const date = new Date(value)
+ if (isNaN(date.getTime())) return 'N/A'
+ if (timerange.period === '10m') {
+ return format(date, 'LLL dd, HH:mm:ss')
+ }
+ return format(date, 'LLL dd, y HH:mm')
+ }}
+ />
+ }
+ />
+ {/* TODO: we could use the `{timestamp, ...rest} = data[0]` to dynamically create the bars but that would mean the order can be very much random */}
+
+
+
+ {refAreaLeft && refAreaRight && (
+
+ )}
+
+
+ )
+}
+
+// TODO: check what's a good abbreviation for month vs. minutes
+function calculatePeriod(interval: number): '10m' | '1d' | '1w' | '1mo' {
+ if (interval <= 1000 * 60 * 10) {
+ // less than 10 minutes
+ return '10m'
+ } else if (interval <= 1000 * 60 * 60 * 24) {
+ // less than 1 day
+ return '1d'
+ } else if (interval <= 1000 * 60 * 60 * 24 * 7) {
+ // less than 1 week
+ return '1w'
+ }
+ return '1mo' // defaults to 1 month
+}
diff --git a/apps/studio/components/ui/DataTable/hooks/README.md b/apps/studio/components/ui/DataTable/hooks/README.md
new file mode 100644
index 00000000000..a517e071125
--- /dev/null
+++ b/apps/studio/components/ui/DataTable/hooks/README.md
@@ -0,0 +1,3 @@
+## DataTable/hooks
+
+Ideally all these shift to top level lib/hooks folder as these could be reused / are duplicates to what we already have
diff --git a/apps/studio/components/ui/DataTable/hooks/useComposedRefs.ts b/apps/studio/components/ui/DataTable/hooks/useComposedRefs.ts
new file mode 100644
index 00000000000..392968ad00a
--- /dev/null
+++ b/apps/studio/components/ui/DataTable/hooks/useComposedRefs.ts
@@ -0,0 +1,32 @@
+import { Ref, useCallback } from 'react'
+
+type PossibleRef = Ref | undefined
+
+/**
+ * Set a given ref to a given value
+ * This utility takes care of different types of refs: callback refs and RefObject(s)
+ */
+function setRef(ref: PossibleRef, value: T) {
+ if (typeof ref === 'function') {
+ ref(value)
+ } else if (ref !== null && ref !== undefined) {
+ ;(ref as React.MutableRefObject).current = value
+ }
+}
+
+/**
+ * A utility to compose multiple refs together
+ * Accepts callback refs and RefObject(s)
+ */
+export function composeRefs(...refs: PossibleRef[]) {
+ return (node: T) => refs.forEach((ref) => setRef(ref, node))
+}
+
+/**
+ * A custom hook that composes multiple refs
+ * Accepts callback refs and RefObject(s)
+ */
+export function useComposedRefs(...refs: PossibleRef[]) {
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ return useCallback(composeRefs(...refs), refs)
+}
diff --git a/apps/studio/components/ui/DataTable/hooks/useDebounce.ts b/apps/studio/components/ui/DataTable/hooks/useDebounce.ts
new file mode 100644
index 00000000000..fc9515530a8
--- /dev/null
+++ b/apps/studio/components/ui/DataTable/hooks/useDebounce.ts
@@ -0,0 +1,16 @@
+import { useEffect, useState } from 'react'
+
+// consider using https://github.com/xnimorz/use-debounce
+export function useDebounce(value: T, delay?: number): T {
+ const [debouncedValue, setDebouncedValue] = useState(value)
+
+ useEffect(() => {
+ const timer = setTimeout(() => setDebouncedValue(value), delay ?? 500)
+
+ return () => {
+ clearTimeout(timer)
+ }
+ }, [value, delay])
+
+ return debouncedValue
+}
diff --git a/apps/studio/components/ui/DataTable/hooks/useMediaQuery.ts b/apps/studio/components/ui/DataTable/hooks/useMediaQuery.ts
new file mode 100644
index 00000000000..8551e7e97ef
--- /dev/null
+++ b/apps/studio/components/ui/DataTable/hooks/useMediaQuery.ts
@@ -0,0 +1,19 @@
+import { useEffect, useState } from 'react'
+
+export function useMediaQuery(query: string) {
+ const [value, setValue] = useState(false)
+
+ useEffect(() => {
+ function onChange(event: MediaQueryListEvent) {
+ setValue(event.matches)
+ }
+
+ const result = matchMedia(query)
+ result.addEventListener('change', onChange)
+ setValue(result.matches)
+
+ return () => result.removeEventListener('change', onChange)
+ }, [query])
+
+ return value
+}
diff --git a/apps/studio/components/ui/DataTable/primitives/InputWithAddons.tsx b/apps/studio/components/ui/DataTable/primitives/InputWithAddons.tsx
new file mode 100644
index 00000000000..bc08b4fe97f
--- /dev/null
+++ b/apps/studio/components/ui/DataTable/primitives/InputWithAddons.tsx
@@ -0,0 +1,42 @@
+import { cn } from 'ui'
+
+import { forwardRef, InputHTMLAttributes, ReactNode } from 'react'
+
+export interface InputWithAddonsProps extends InputHTMLAttributes {
+ leading?: ReactNode
+ trailing?: ReactNode
+ containerClassName?: string
+}
+
+export const InputWithAddons = forwardRef(
+ ({ leading, trailing, containerClassName, className, ...props }, ref) => {
+ return (
+
+ {leading ? (
+
+ {leading}
+
+ ) : null}
+
+ {trailing ? (
+
{trailing}
+ ) : null}
+
+ )
+ }
+)
+InputWithAddons.displayName = 'InputWithAddons'
diff --git a/apps/studio/components/ui/DataTable/primitives/Kbd.tsx b/apps/studio/components/ui/DataTable/primitives/Kbd.tsx
new file mode 100644
index 00000000000..550bed2e290
--- /dev/null
+++ b/apps/studio/components/ui/DataTable/primitives/Kbd.tsx
@@ -0,0 +1,21 @@
+// Copy Pasta from: https://github.com/sadmann7/shadcn-table/blob/main/src/components/kbd.tsx#L54
+import { ComponentPropsWithoutRef, forwardRef } from 'react'
+
+import { cn } from 'ui'
+
+export const kdbClassName = cn(
+ 'select-none rounded border px-1.5 py-px font-mono text-[0.7rem] font-normal font-mono shadow-sm disabled:opacity-50',
+ 'bg-background text-foreground'
+)
+
+export const Kbd = forwardRef>(
+ ({ children, className, ...props }, ref) => {
+ return (
+
+ {children}
+
+ )
+ }
+)
+
+Kbd.displayName = 'Kbd'
diff --git a/apps/studio/components/ui/DataTable/primitives/Slider.tsx b/apps/studio/components/ui/DataTable/primitives/Slider.tsx
new file mode 100644
index 00000000000..b5d90eff3a6
--- /dev/null
+++ b/apps/studio/components/ui/DataTable/primitives/Slider.tsx
@@ -0,0 +1,34 @@
+// Props to https://github.com/shadcn-ui/ui/issues/885#issuecomment-2059600641
+// @mildtomato - consider using this as the main Slider component in packages/ui
+
+import * as SliderPrimitive from '@radix-ui/react-slider'
+import { ComponentPropsWithoutRef, ElementRef, forwardRef, Fragment } from 'react'
+
+import { cn } from 'ui'
+
+const Slider = forwardRef<
+ ElementRef,
+ ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => {
+ const initialValue = Array.isArray(props.value) ? props.value : [props.min, props.max]
+
+ return (
+
+
+
+
+ {initialValue.map((_, index) => (
+
+
+
+ ))}
+
+ )
+})
+Slider.displayName = SliderPrimitive.Root.displayName
+
+export { Slider }
diff --git a/apps/studio/components/ui/DataTable/primitives/Sortable.tsx b/apps/studio/components/ui/DataTable/primitives/Sortable.tsx
new file mode 100644
index 00000000000..24b45191a95
--- /dev/null
+++ b/apps/studio/components/ui/DataTable/primitives/Sortable.tsx
@@ -0,0 +1,330 @@
+import type {
+ DndContextProps,
+ DraggableSyntheticListeners,
+ DropAnimation,
+ UniqueIdentifier,
+} from '@dnd-kit/core'
+import {
+ closestCenter,
+ defaultDropAnimationSideEffects,
+ DndContext,
+ DragOverlay,
+ KeyboardSensor,
+ MouseSensor,
+ TouchSensor,
+ useSensor,
+ useSensors,
+} from '@dnd-kit/core'
+import {
+ restrictToHorizontalAxis,
+ restrictToParentElement,
+ restrictToVerticalAxis,
+} from '@dnd-kit/modifiers'
+import {
+ arrayMove,
+ horizontalListSortingStrategy,
+ SortableContext,
+ useSortable,
+ verticalListSortingStrategy,
+ type SortableContextProps,
+} from '@dnd-kit/sortable'
+import { CSS } from '@dnd-kit/utilities'
+import { Slot, type SlotProps } from '@radix-ui/react-slot'
+import { createContext, forwardRef, useContext, useMemo, useState } from 'react'
+import { createPortal } from 'react-dom'
+
+import { Button, cn, type ButtonProps } from 'ui'
+import { composeRefs } from '../hooks/useComposedRefs'
+
+const orientationConfig = {
+ vertical: {
+ modifiers: [restrictToVerticalAxis, restrictToParentElement],
+ strategy: verticalListSortingStrategy,
+ },
+ horizontal: {
+ modifiers: [restrictToHorizontalAxis, restrictToParentElement],
+ strategy: horizontalListSortingStrategy,
+ },
+ mixed: {
+ modifiers: [restrictToParentElement],
+ strategy: undefined,
+ },
+}
+
+interface SortableProps extends DndContextProps {
+ /**
+ * An array of data items that the sortable component will render.
+ * @example
+ * value={[
+ * { id: 1, name: 'Item 1' },
+ * { id: 2, name: 'Item 2' },
+ * ]}
+ */
+ value: TData[]
+
+ /**
+ * An optional callback function that is called when the order of the data items changes.
+ * It receives the new array of items as its argument.
+ * @example
+ * onValueChange={(items) => console.log(items)}
+ */
+ onValueChange?: (items: TData[]) => void
+
+ /**
+ * An optional callback function that is called when an item is moved.
+ * It receives an event object with `activeIndex` and `overIndex` properties, representing the original and new positions of the moved item.
+ * This will override the default behavior of updating the order of the data items.
+ * @type (event: { activeIndex: number; overIndex: number }) => void
+ * @example
+ * onMove={(event) => console.log(`Item moved from index ${event.activeIndex} to index ${event.overIndex}`)}
+ */
+ onMove?: (event: { activeIndex: number; overIndex: number }) => void
+
+ /**
+ * A collision detection strategy that will be used to determine the closest sortable item.
+ * @default closestCenter
+ * @type DndContextProps["collisionDetection"]
+ */
+ collisionDetection?: DndContextProps['collisionDetection']
+
+ /**
+ * An array of modifiers that will be used to modify the behavior of the sortable component.
+ * @default
+ * [restrictToVerticalAxis, restrictToParentElement]
+ * @type Modifier[]
+ */
+ modifiers?: DndContextProps['modifiers']
+
+ /**
+ * A sorting strategy that will be used to determine the new order of the data items.
+ * @default verticalListSortingStrategy
+ * @type SortableContextProps["strategy"]
+ */
+ strategy?: SortableContextProps['strategy']
+
+ /**
+ * Specifies the axis for the drag-and-drop operation. It can be "vertical", "horizontal", or "both".
+ * @default "vertical"
+ * @type "vertical" | "horizontal" | "mixed"
+ */
+ orientation?: 'vertical' | 'horizontal' | 'mixed'
+
+ /**
+ * An optional React node that is rendered on top of the sortable component.
+ * It can be used to display additional information or controls.
+ * @default null
+ * @type React.ReactNode | null
+ * @example
+ * overlay={ }
+ */
+ overlay?: React.ReactNode | null
+}
+
+function Sortable({
+ value,
+ onValueChange,
+ onDragStart,
+ onDragEnd,
+ onDragCancel,
+ collisionDetection = closestCenter,
+ modifiers,
+ strategy,
+ onMove,
+ orientation = 'vertical',
+ overlay,
+ children,
+ ...props
+}: SortableProps) {
+ const [activeId, setActiveId] = useState(null)
+ const sensors = useSensors(
+ useSensor(MouseSensor),
+ useSensor(TouchSensor),
+ useSensor(KeyboardSensor)
+ )
+
+ const config = orientationConfig[orientation]
+
+ return (
+ {
+ setActiveId(event.active.id)
+ onDragStart?.(event)
+ }}
+ onDragEnd={(event) => {
+ const { active, over } = event
+ if (over && active.id !== over?.id) {
+ const activeIndex = value.findIndex((item) => item.id === active.id)
+ const overIndex = value.findIndex((item) => item.id === over.id)
+
+ if (onMove) {
+ onMove({ activeIndex, overIndex })
+ } else {
+ onValueChange?.(arrayMove(value, activeIndex, overIndex))
+ }
+ }
+ setActiveId(null)
+ onDragEnd?.(event)
+ }}
+ onDragCancel={(event) => {
+ setActiveId?.(null)
+ onDragCancel?.(event)
+ }}
+ collisionDetection={collisionDetection}
+ {...props}
+ >
+
+ {children}
+
+ {overlay
+ ? // https://docs.dndkit.com/api-documentation/draggable/drag-overlay#portals
+ createPortal(
+ {overlay} ,
+ document.body
+ )
+ : null}
+
+ )
+}
+
+const dropAnimationOpts: DropAnimation = {
+ sideEffects: defaultDropAnimationSideEffects({
+ styles: {
+ active: {
+ opacity: '0.4',
+ },
+ },
+ }),
+}
+
+interface SortableOverlayProps extends React.ComponentPropsWithRef {
+ activeId?: UniqueIdentifier | null
+}
+
+const SortableOverlay = forwardRef(
+ ({ activeId, dropAnimation = dropAnimationOpts, children, ...props }, ref) => {
+ return (
+
+ {activeId ? (
+
+ {children}
+
+ ) : null}
+
+ )
+ }
+)
+SortableOverlay.displayName = 'SortableOverlay'
+
+interface SortableItemContextProps {
+ attributes: React.HTMLAttributes
+ listeners: DraggableSyntheticListeners | undefined
+ isDragging?: boolean
+}
+
+const SortableItemContext = createContext({
+ attributes: {},
+ listeners: undefined,
+ isDragging: false,
+})
+
+function useSortableItem() {
+ const context = useContext(SortableItemContext)
+
+ if (!context) {
+ throw new Error('useSortableItem must be used within a SortableItem')
+ }
+
+ return context
+}
+
+interface SortableItemProps extends SlotProps {
+ /**
+ * The unique identifier of the item.
+ * @example "item-1"
+ * @type UniqueIdentifier
+ */
+ value: UniqueIdentifier
+
+ /**
+ * Specifies whether the item should act as a trigger for the drag-and-drop action.
+ * @default false
+ * @type boolean | undefined
+ */
+ asTrigger?: boolean
+
+ /**
+ * Merges the item's props into its immediate child.
+ * @default false
+ * @type boolean | undefined
+ */
+ asChild?: boolean
+}
+
+const SortableItem = forwardRef(
+ ({ value, asTrigger, asChild, className, ...props }, ref) => {
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
+ id: value,
+ })
+
+ const context = useMemo(
+ () => ({
+ attributes,
+ listeners,
+ isDragging,
+ }),
+ [attributes, listeners, isDragging]
+ )
+ const style: React.CSSProperties = {
+ opacity: isDragging ? 0.5 : 1,
+ transform: CSS.Translate.toString(transform),
+ transition,
+ }
+
+ const Comp = asChild ? Slot : 'div'
+
+ return (
+
+ )}
+ style={style}
+ {...(asTrigger ? attributes : {})}
+ {...(asTrigger ? listeners : {})}
+ {...props}
+ />
+
+ )
+ }
+)
+SortableItem.displayName = 'SortableItem'
+
+interface SortableDragHandleProps extends ButtonProps {
+ withHandle?: boolean
+}
+
+const SortableDragHandle = forwardRef(
+ ({ className, ...props }, ref) => {
+ const { attributes, listeners, isDragging } = useSortableItem()
+
+ return (
+
+ )
+ }
+)
+SortableDragHandle.displayName = 'SortableDragHandle'
+
+export { Sortable, SortableDragHandle, SortableItem, SortableOverlay }
diff --git a/apps/studio/components/ui/DataTable/providers/ControlsProvider.tsx b/apps/studio/components/ui/DataTable/providers/ControlsProvider.tsx
new file mode 100644
index 00000000000..ccdf799e8f1
--- /dev/null
+++ b/apps/studio/components/ui/DataTable/providers/ControlsProvider.tsx
@@ -0,0 +1,36 @@
+import { useLocalStorage } from 'hooks/misc/useLocalStorage'
+import { createContext, Dispatch, SetStateAction, useContext } from 'react'
+
+interface ControlsContextType {
+ open: boolean
+ setOpen: Dispatch>
+}
+
+export const ControlsContext = createContext(null)
+
+export function ControlsProvider({ children }: { children: React.ReactNode }) {
+ const [open, setOpen] = useLocalStorage('data-table-controls', true)
+
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+export function useControls() {
+ const context = useContext(ControlsContext)
+
+ if (!context) {
+ throw new Error('useControls must be used within a ControlsProvider')
+ }
+
+ return context as ControlsContextType
+}
diff --git a/apps/studio/components/ui/DataTable/providers/DataTableProvider.tsx b/apps/studio/components/ui/DataTable/providers/DataTableProvider.tsx
new file mode 100644
index 00000000000..ff1abe4f0cb
--- /dev/null
+++ b/apps/studio/components/ui/DataTable/providers/DataTableProvider.tsx
@@ -0,0 +1,81 @@
+import type {
+ ColumnDef,
+ ColumnFiltersState,
+ PaginationState,
+ RowSelectionState,
+ SortingState,
+ Table,
+ VisibilityState,
+} from '@tanstack/react-table'
+import { createContext, ReactNode, useContext, useMemo } from 'react'
+
+import { DataTableFilterField } from '../DataTable.types'
+import { ControlsProvider } from './ControlsProvider'
+
+// REMINDER: read about how to move controlled state out of the useReactTable hook
+// https://github.com/TanStack/table/discussions/4005#discussioncomment-7303569
+
+interface DataTableStateContextType {
+ columnFilters: ColumnFiltersState
+ sorting: SortingState
+ rowSelection: RowSelectionState
+ columnOrder: string[]
+ columnVisibility: VisibilityState
+ pagination: PaginationState
+ enableColumnOrdering: boolean
+}
+
+interface DataTableBaseContextType {
+ table: Table
+ filterFields: DataTableFilterField[]
+ columns: ColumnDef[]
+ isFetching?: boolean
+ isLoading?: boolean
+ isLoadingCounts?: boolean
+ getFacetedUniqueValues?: (table: Table, columnId: string) => Map
+ getFacetedMinMaxValues?: (table: Table, columnId: string) => undefined | [number, number]
+}
+
+interface DataTableContextType
+ extends DataTableStateContextType,
+ DataTableBaseContextType {}
+
+export const DataTableContext = createContext | null>(null)
+
+export function DataTableProvider({
+ children,
+ ...props
+}: Partial &
+ DataTableBaseContextType & {
+ children: ReactNode
+ }) {
+ const value = useMemo(
+ () => ({
+ ...props,
+ columnFilters: props.columnFilters ?? [],
+ sorting: props.sorting ?? [],
+ rowSelection: props.rowSelection ?? {},
+ columnOrder: props.columnOrder ?? [],
+ columnVisibility: props.columnVisibility ?? {},
+ pagination: props.pagination ?? { pageIndex: 0, pageSize: 10 },
+ enableColumnOrdering: props.enableColumnOrdering ?? false,
+ }),
+ [props]
+ )
+
+ return (
+
+ {children}
+
+ )
+}
+
+export function useDataTable() {
+ const context = useContext(DataTableContext)
+
+ if (!context) {
+ throw new Error('useDataTable must be used within a DataTableProvider')
+ }
+
+ return context as DataTableContextType
+}
diff --git a/apps/studio/components/ui/DatabaseSelector.tsx b/apps/studio/components/ui/DatabaseSelector.tsx
index 2a9f04da1a2..f3f5a968cfe 100644
--- a/apps/studio/components/ui/DatabaseSelector.tsx
+++ b/apps/studio/components/ui/DatabaseSelector.tsx
@@ -36,6 +36,7 @@ interface DatabaseSelectorProps {
buttonProps?: ButtonProps
onSelectId?: (id: string) => void // Optional callback
onCreateReplicaClick?: () => void
+ portal?: boolean
}
const DatabaseSelector = ({
@@ -45,6 +46,7 @@ const DatabaseSelector = ({
onSelectId = noop,
buttonProps,
onCreateReplicaClick = noop,
+ portal = true,
}: DatabaseSelectorProps) => {
const router = useRouter()
const { ref: projectRef } = useParams()
@@ -110,7 +112,7 @@ const DatabaseSelector = ({
-
+
{additionalOptions.length > 0 && (
diff --git a/apps/studio/components/ui/DotGrid.tsx b/apps/studio/components/ui/DotGrid.tsx
index 6e0fdbdccf1..03b52f87c06 100644
--- a/apps/studio/components/ui/DotGrid.tsx
+++ b/apps/studio/components/ui/DotGrid.tsx
@@ -6,7 +6,7 @@ interface DotGridProps {
count: number
}
-const DotGrid = ({ rows, columns, count }: DotGridProps) => {
+export const DotGrid = ({ rows, columns, count }: DotGridProps) => {
const container = {
hidden: { opacity: 1 },
visible: {
@@ -65,5 +65,3 @@ const DotGrid = ({ rows, columns, count }: DotGridProps) => {
)
}
-
-export default DotGrid
diff --git a/apps/studio/components/ui/EditorPanel/EditorPanel.tsx b/apps/studio/components/ui/EditorPanel/EditorPanel.tsx
index acfb5394781..ee70fab8c8f 100644
--- a/apps/studio/components/ui/EditorPanel/EditorPanel.tsx
+++ b/apps/studio/components/ui/EditorPanel/EditorPanel.tsx
@@ -12,9 +12,10 @@ import Results from 'components/interfaces/SQLEditor/UtilityPanel/Results'
import { SqlRunButton } from 'components/interfaces/SQLEditor/UtilityPanel/RunButton'
import { useSqlTitleGenerateMutation } from 'data/ai/sql-title-mutation'
import { QueryResponseError, useExecuteSqlMutation } from 'data/sql/execute-sql-mutation'
-import { useOrgOptedIntoAiAndHippaProject } from 'hooks/misc/useOrgOptedIntoAi'
+import { useOrgAiOptInLevel } from 'hooks/misc/useOrgOptedIntoAi'
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
-import { BASE_PATH, IS_PLATFORM } from 'lib/constants'
+import { useFlag } from 'hooks/ui/useFlag'
+import { BASE_PATH } from 'lib/constants'
import { uuidv4 } from 'lib/helpers'
import { useProfile } from 'lib/profile'
import { useAppStateSnapshot } from 'state/app-state'
@@ -55,8 +56,8 @@ export const EditorPanel = ({ onChange }: EditorPanelProps) => {
const { profile } = useProfile()
const snapV2 = useSqlEditorV2StateSnapshot()
const { mutateAsync: generateSqlTitle } = useSqlTitleGenerateMutation()
- const { isOptedInToAI, isHipaaProjectDisallowed } = useOrgOptedIntoAiAndHippaProject()
- const includeSchemaMetadata = (isOptedInToAI && !isHipaaProjectDisallowed) || !IS_PLATFORM
+ const { includeSchemaMetadata } = useOrgAiOptInLevel()
+ const useBedrockAssistant = useFlag('useBedrockAssistant')
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState()
@@ -236,7 +237,10 @@ export const EditorPanel = ({ onChange }: EditorPanelProps) => {
try {
setIsSaving(true)
- const { title: name } = await generateSqlTitle({ sql: currentValue })
+ const { title: name } = await generateSqlTitle({
+ sql: currentValue,
+ useBedrockAssistant,
+ })
const snippet = createSqlSnippetSkeletonV2({
id: uuidv4(),
name,
@@ -276,7 +280,11 @@ export const EditorPanel = ({ onChange }: EditorPanelProps) => {
language="pgsql"
value={currentValue}
onChange={handleChange}
- aiEndpoint={`${BASE_PATH}/api/ai/sql/complete`}
+ aiEndpoint={
+ useBedrockAssistant
+ ? `${BASE_PATH}/api/ai/sql/complete-v2`
+ : `${BASE_PATH}/api/ai/sql/complete`
+ }
aiMetadata={{
projectRef: project?.ref,
connectionString: project?.connectionString,
diff --git a/apps/studio/components/ui/GroupsTelemetry.tsx b/apps/studio/components/ui/GroupsTelemetry.tsx
index 1c05ec91cb5..d138179ec2e 100644
--- a/apps/studio/components/ui/GroupsTelemetry.tsx
+++ b/apps/studio/components/ui/GroupsTelemetry.tsx
@@ -8,7 +8,6 @@ import { useSendGroupsIdentifyMutation } from 'data/telemetry/send-groups-identi
import { useSendGroupsResetMutation } from 'data/telemetry/send-groups-reset-mutation'
import { usePrevious } from 'hooks/deprecated'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
-import { useAppStateSnapshot } from 'state/app-state'
import { IS_PLATFORM } from 'lib/constants'
const getAnonId = async (id: string) => {
diff --git a/apps/studio/components/ui/InfoPill.tsx b/apps/studio/components/ui/InfoPill.tsx
new file mode 100644
index 00000000000..be2b35973bc
--- /dev/null
+++ b/apps/studio/components/ui/InfoPill.tsx
@@ -0,0 +1,61 @@
+import React from 'react'
+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
+ label: string
+}
+
+interface InfoPillProps {
+ label: string
+ icon: React.ReactNode
+ title: string
+ description: string
+ links?: Link[]
+}
+
+export const InfoPill: React.FC = ({ label, icon, title, description, links }) => {
+ return (
+
+
+
+ {icon} {label}
+
+
+
+
+ {React.cloneElement(icon as React.ReactElement, {
+ className: 'w-5 h-5',
+ strokeWidth: 1.5,
+ })}
+
+
+
{title}
+
{description}
+ {links && links.length > 0 && (
+
+ )}
+
+
+
+ )
+}
diff --git a/apps/studio/components/ui/ProjectSettings/ToggleLegacyApiKeys.tsx b/apps/studio/components/ui/ProjectSettings/ToggleLegacyApiKeys.tsx
index 0c17e4a676f..a24bc09ef89 100644
--- a/apps/studio/components/ui/ProjectSettings/ToggleLegacyApiKeys.tsx
+++ b/apps/studio/components/ui/ProjectSettings/ToggleLegacyApiKeys.tsx
@@ -1,63 +1,68 @@
+import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useState } from 'react'
import { toast } from 'sonner'
-import { PermissionAction } from '@supabase/shared-types/out/constants'
-import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
+import { useParams } from 'common'
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 { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
-import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, CriticalIcon } from 'ui'
import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal'
+import Panel from '../Panel'
export const ToggleLegacyApiKeysPanel = () => {
- const { project } = useProjectContext()
+ const { ref: projectRef } = useParams()
const [isConfirmOpen, setIsConfirmOpen] = useState(false)
const { data: legacyAPIKeysStatusData, isSuccess: isLegacyAPIKeysStatusSuccess } =
- useLegacyAPIKeysStatusQuery({ projectRef: project!.ref })
+ useLegacyAPIKeysStatusQuery({ projectRef })
const { can: canUpdateAPIKeys, isSuccess: isPermissionsSuccess } =
useAsyncCheckProjectPermissions(PermissionAction.SECRETS_WRITE, '*')
+ const { enabled: isLegacyKeysEnabled } = legacyAPIKeysStatusData || {}
+
if (!(isLegacyAPIKeysStatusSuccess && isPermissionsSuccess)) {
return null
}
return (
-
-
-
- {legacyAPIKeysStatusData.enabled
- ? 'Disabling your legacy API keys may cause your applications to break.'
- : 'Re-enabling your legacy API keys may expose your applications to security risks.'}
-
-
- {legacyAPIKeysStatusData.enabled
- ? 'Make sure you are no longer using your legacy API keys before proceeding.'
- : "Make sure you've tested your RLS policies."}
-
-
- setIsConfirmOpen(true)}
- disabled={!canUpdateAPIKeys}
- tooltip={{
- content: {
- side: 'bottom',
- text: !canUpdateAPIKeys
- ? 'You need additional permissions to enable or disable JWT-based API keys'
- : undefined,
- },
- }}
- >
- {legacyAPIKeysStatusData.enabled
- ? 'Disable JWT-based API keys'
- : 'Re-enable JWT-based API keys'}
-
-
-
+
+
+
+
+
+ {isLegacyKeysEnabled ? 'Disable legacy API keys' : 'Re-enabling legacy API keys'}
+
+
+ {isLegacyKeysEnabled
+ ? 'Make sure you are no longer using your legacy API keys before proceeding.'
+ : 'Ensure that your RLS policies are in place prior to re-enabling legacy keys.'}
+
+
+
+ setIsConfirmOpen(true)}
+ disabled={!canUpdateAPIKeys}
+ tooltip={{
+ content: {
+ side: 'bottom',
+ text: !canUpdateAPIKeys
+ ? 'You need additional permissions to enable or disable JWT-based API keys'
+ : undefined,
+ },
+ }}
+ >
+ {legacyAPIKeysStatusData.enabled
+ ? 'Disable JWT-based API keys'
+ : 'Re-enable JWT-based API keys'}
+
+
+
+
+
void
legacyAPIKeysStatusData: { enabled: boolean }
}) => {
- const { project } = useProjectContext()
+ const { ref: projectRef } = useParams()
+ const { enabled: isLegacyKeysEnabled } = legacyAPIKeysStatusData || {}
const { mutate: toggleLegacyAPIKey, isLoading: isTogglingLegacyAPIKey } =
useToggleLegacyAPIKeysMutation()
@@ -86,7 +92,7 @@ const ToggleApiKeysModal = ({
const enabled = !legacyAPIKeysStatusData?.enabled
toggleLegacyAPIKey(
- { projectRef: project!.ref, enabled },
+ { projectRef, enabled },
{
onSuccess: () => {
toast.success(
@@ -102,27 +108,37 @@ const ToggleApiKeysModal = ({
return (
onClose()}
onConfirm={onToggleLegacyAPIKeysEnabled}
- title={
- legacyAPIKeysStatusData.enabled ? 'Disable JWT-based keys' : 'Re-enable JWT-based keys'
- }
- confirmString={legacyAPIKeysStatusData.enabled ? 'disable' : 're-enable'}
- confirmLabel={`Yes, ${legacyAPIKeysStatusData.enabled ? 'disable' : 're-enable'} anon and service_role`}
- confirmPlaceholder={legacyAPIKeysStatusData.enabled ? 'disable' : 're-enable'}
+ title={isLegacyKeysEnabled ? 'Disable JWT-based keys' : 'Re-enable JWT-based keys'}
+ confirmString={isLegacyKeysEnabled ? 'disable' : 're-enable'}
+ confirmLabel={`Confirm to ${isLegacyKeysEnabled ? 'disable' : 're-enable'} anon and service_role`}
+ confirmPlaceholder={isLegacyKeysEnabled ? 'disable' : 're-enable'}
loading={isTogglingLegacyAPIKey}
- variant={legacyAPIKeysStatusData.enabled ? 'destructive' : 'default'}
+ variant={isLegacyKeysEnabled ? 'destructive' : 'default'}
alert={
- legacyAPIKeysStatusData.enabled
+ isLegacyKeysEnabled
? {
- title: 'Disabling can cause downtime!',
- description: `If you disable your anon and service_role keys while they are in use, your applications will stop functioning. All API endpoints will receive HTTP 401 Unauthorized. Make sure you are no longer using them before proceeding.`,
+ title: 'Ensure legacy keys are no longer in use before disabling',
+ description: (
+
+ Disabling anon and service_role keys while they are in
+ use will cause downtime for your application. Ensure they are no longer in use
+ before proceeding.
+
+ ),
}
: {
- title: 'Prefer publishable and secret keys',
- description:
- 'While re-enabling anon and service_role keys makes sense in some cases, a better and more secure alternative is the publishable or secret key. Consider using those before proceeding!',
+ title: 'Publishable and secret keys are preferred',
+ description: (
+
+ Re-enabling anon and service_role keys may be
+ appropriate in certain cases, but using a publishable and secret key is more
+ secure. We recommend against re-enabling legacy API keys
+
+ ),
}
}
/>
diff --git a/apps/studio/components/ui/QueryBlock/QueryBlock.tsx b/apps/studio/components/ui/QueryBlock/QueryBlock.tsx
index 99a885bec36..afa9fc56408 100644
--- a/apps/studio/components/ui/QueryBlock/QueryBlock.tsx
+++ b/apps/studio/components/ui/QueryBlock/QueryBlock.tsx
@@ -424,7 +424,7 @@ export const QueryBlock = ({
|
diff --git a/apps/studio/csp.js b/apps/studio/csp.js
new file mode 100644
index 00000000000..f4ddabfcc76
--- /dev/null
+++ b/apps/studio/csp.js
@@ -0,0 +1,187 @@
+const API_URL = process.env.NEXT_PUBLIC_API_URL
+ ? new URL(process.env.NEXT_PUBLIC_API_URL).origin
+ : ''
+const SUPABASE_URL = process.env.SUPABASE_URL ? new URL(process.env.SUPABASE_URL).origin : ''
+const GOTRUE_URL = process.env.NEXT_PUBLIC_GOTRUE_URL
+ ? new URL(process.env.NEXT_PUBLIC_GOTRUE_URL).origin
+ : ''
+const SUPABASE_PROJECTS_URL = 'https://*.supabase.co'
+const SUPABASE_PROJECTS_URL_WS = 'wss://*.supabase.co'
+
+// construct the URL for the Websocket Local URLs
+let SUPABASE_LOCAL_PROJECTS_URL_WS = ''
+if (SUPABASE_URL) {
+ const url = new URL(SUPABASE_URL)
+ const wsUrl = `${url.hostname}:${url.port}`
+ SUPABASE_LOCAL_PROJECTS_URL_WS = `ws://${wsUrl} wss://${wsUrl}`
+}
+
+// Needed to test docs search in local dev
+const SUPABASE_DOCS_PROJECT_URL = process.env.NEXT_PUBLIC_SUPABASE_URL
+ ? new URL(process.env.NEXT_PUBLIC_SUPABASE_URL).origin
+ : ''
+
+// Needed to test docs content API in local dev
+const SUPABASE_CONTENT_API_URL = process.env.NEXT_PUBLIC_CONTENT_API_URL
+ ? new URL(process.env.NEXT_PUBLIC_CONTENT_API_URL).origin
+ : ''
+
+const SUPABASE_STAGING_PROJECTS_URL = 'https://*.supabase.red'
+const SUPABASE_STAGING_PROJECTS_URL_WS = 'wss://*.supabase.red'
+const SUPABASE_COM_URL = 'https://supabase.com'
+const CLOUDFLARE_CDN_URL = 'https://cdnjs.cloudflare.com'
+const HCAPTCHA_SUBDOMAINS_URL = 'https://*.hcaptcha.com'
+const HCAPTCHA_ASSET_URL = 'https://newassets.hcaptcha.com'
+const HCAPTCHA_JS_URL = 'https://js.hcaptcha.com'
+const CONFIGCAT_URL = 'https://cdn-global.configcat.com'
+const CONFIGCAT_PROXY_URL = ['staging', 'local'].includes(process.env.NEXT_PUBLIC_ENVIRONMENT ?? '')
+ ? 'https://configcat.supabase.green'
+ : 'https://configcat.supabase.com'
+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'
+const GITHUB_USER_CONTENT_URL = 'https://raw.githubusercontent.com'
+const GITHUB_USER_AVATAR_URL = 'https://avatars.githubusercontent.com'
+const GOOGLE_USER_AVATAR_URL = 'https://lh3.googleusercontent.com'
+
+// This is a custom domain for Stape, which isused for GTM servers
+const STAPE_URL = 'https://ss.supabase.com'
+
+const VERCEL_LIVE_URL = 'https://vercel.live'
+const SENTRY_URL =
+ 'https://*.ingest.sentry.io https://*.ingest.us.sentry.io https://*.ingest.de.sentry.io'
+const SUPABASE_ASSETS_URL =
+ process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging'
+ ? 'https://frontend-assets.supabase.green'
+ : 'https://frontend-assets.supabase.com'
+
+const USERCENTRICS_URLS = 'https://*.usercentrics.eu'
+const USERCENTRICS_APP_URL = 'https://app.usercentrics.eu'
+
+// used by vercel live preview
+const PUSHER_URL = 'https://*.pusher.com'
+const PUSHER_URL_WS = 'wss://*.pusher.com'
+
+module.exports.getCSP = function getCSP() {
+ const DEFAULT_SRC_URLS = [
+ API_URL,
+ SUPABASE_URL,
+ GOTRUE_URL,
+ SUPABASE_LOCAL_PROJECTS_URL_WS,
+ SUPABASE_PROJECTS_URL,
+ SUPABASE_PROJECTS_URL_WS,
+ HCAPTCHA_SUBDOMAINS_URL,
+ CONFIGCAT_URL,
+ CONFIGCAT_PROXY_URL,
+ STRIPE_SUBDOMAINS_URL,
+ STRIPE_NETWORK_URL,
+ CLOUDFLARE_URL,
+ ONE_ONE_ONE_ONE_URL,
+ VERCEL_INSIGHTS_URL,
+ GITHUB_API_URL,
+ GITHUB_USER_CONTENT_URL,
+ SUPABASE_ASSETS_URL,
+ USERCENTRICS_URLS,
+ STAPE_URL,
+ ]
+ const SCRIPT_SRC_URLS = [
+ CLOUDFLARE_CDN_URL,
+ HCAPTCHA_JS_URL,
+ STRIPE_JS_URL,
+ SUPABASE_ASSETS_URL,
+ STAPE_URL,
+ ]
+ const FRAME_SRC_URLS = [HCAPTCHA_ASSET_URL, STRIPE_JS_URL, STAPE_URL]
+ const IMG_SRC_URLS = [
+ SUPABASE_URL,
+ SUPABASE_COM_URL,
+ SUPABASE_PROJECTS_URL,
+ GITHUB_USER_AVATAR_URL,
+ GOOGLE_USER_AVATAR_URL,
+ SUPABASE_ASSETS_URL,
+ USERCENTRICS_APP_URL,
+ STAPE_URL,
+ ]
+ const STYLE_SRC_URLS = [CLOUDFLARE_CDN_URL, SUPABASE_ASSETS_URL]
+ const FONT_SRC_URLS = [CLOUDFLARE_CDN_URL, SUPABASE_ASSETS_URL]
+
+ const isDevOrStaging =
+ process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview' ||
+ process.env.NEXT_PUBLIC_ENVIRONMENT === 'local' ||
+ process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging'
+
+ const defaultSrcDirective = [
+ `default-src 'self'`,
+ ...DEFAULT_SRC_URLS,
+ ...(isDevOrStaging
+ ? [
+ SUPABASE_STAGING_PROJECTS_URL,
+ SUPABASE_STAGING_PROJECTS_URL_WS,
+ VERCEL_LIVE_URL,
+ SUPABASE_DOCS_PROJECT_URL,
+ SUPABASE_CONTENT_API_URL,
+ ]
+ : []),
+ PUSHER_URL_WS,
+ SENTRY_URL,
+ ].join(' ')
+
+ const imgSrcDirective = [
+ `img-src 'self'`,
+ `blob:`,
+ `data:`,
+ ...IMG_SRC_URLS,
+ ...(isDevOrStaging ? [SUPABASE_STAGING_PROJECTS_URL, VERCEL_URL] : []),
+ ].join(' ')
+
+ const scriptSrcDirective = [
+ `script-src 'self'`,
+ `'unsafe-eval'`,
+ `'unsafe-inline'`,
+ ...SCRIPT_SRC_URLS,
+ VERCEL_LIVE_URL,
+ PUSHER_URL,
+ ].join(' ')
+
+ const frameSrcDirective = [`frame-src 'self'`, ...FRAME_SRC_URLS, VERCEL_LIVE_URL].join(' ')
+
+ const styleSrcDirective = [
+ `style-src 'self'`,
+ `'unsafe-inline'`,
+ ...STYLE_SRC_URLS,
+ VERCEL_LIVE_URL,
+ ].join(' ')
+
+ const fontSrcDirective = [`font-src 'self'`, ...FONT_SRC_URLS, VERCEL_LIVE_URL].join(' ')
+
+ const workerSrcDirective = [`worker-src 'self'`, `blob:`, `data:`].join(' ')
+
+ const cspDirectives = [
+ defaultSrcDirective,
+ imgSrcDirective,
+ scriptSrcDirective,
+ frameSrcDirective,
+ styleSrcDirective,
+ fontSrcDirective,
+ workerSrcDirective,
+ `object-src 'none'`,
+ `base-uri 'self'`,
+ `form-action 'self'`,
+ `frame-ancestors 'none'`,
+ `block-all-mixed-content`,
+ ...(process.env.NEXT_PUBLIC_IS_PLATFORM === 'true' &&
+ process.env.NEXT_PUBLIC_ENVIRONMENT === 'prod'
+ ? [`upgrade-insecure-requests`]
+ : []),
+ ]
+
+ const csp = cspDirectives.join('; ') + ';'
+
+ // Replace newline characters and spaces
+ return csp.replace(/\s{2,}/g, ' ').trim()
+}
diff --git a/apps/studio/data/ai/sql-cron-mutation.ts b/apps/studio/data/ai/sql-cron-mutation.ts
new file mode 100644
index 00000000000..8257a8a9993
--- /dev/null
+++ b/apps/studio/data/ai/sql-cron-mutation.ts
@@ -0,0 +1,66 @@
+import { useMutation, UseMutationOptions } from '@tanstack/react-query'
+import { toast } from 'sonner'
+
+import { constructHeaders, fetchHandler } from 'data/fetchers'
+import { BASE_PATH } from 'lib/constants'
+import { ResponseError } from 'types'
+
+export type SqlCronGenerateResponse = string
+
+export type SqlCronGenerateVariables = {
+ prompt: string
+ useBedrockAssistant?: boolean
+}
+
+export async function generateSqlCron({ prompt, useBedrockAssistant }: SqlCronGenerateVariables) {
+ const url = useBedrockAssistant
+ ? `${BASE_PATH}/api/ai/sql/cron-v2`
+ : `${BASE_PATH}/api/ai/sql/cron`
+
+ const headers = await constructHeaders({ 'Content-Type': 'application/json' })
+ const response = await fetchHandler(url, {
+ headers,
+ method: 'POST',
+ body: JSON.stringify({ prompt }),
+ })
+
+ let body: any
+
+ try {
+ body = await response.json()
+ } catch {}
+
+ if (!response.ok) {
+ throw new ResponseError(body?.message, response.status)
+ }
+
+ return body as SqlCronGenerateResponse
+}
+
+type SqlCronGenerateData = Awaited>
+
+export const useSqlCronGenerateMutation = ({
+ onSuccess,
+ onError,
+ ...options
+}: Omit<
+ UseMutationOptions,
+ 'mutationFn'
+> = {}) => {
+ return useMutation(
+ (vars) => generateSqlCron(vars),
+ {
+ async onSuccess(data, variables, context) {
+ await onSuccess?.(data, variables, context)
+ },
+ async onError(data, variables, context) {
+ if (onError === undefined) {
+ toast.error(`Failed to generate cron expression: ${data.message}`)
+ } else {
+ onError(data, variables, context)
+ }
+ },
+ ...options,
+ }
+ )
+}
diff --git a/apps/studio/data/ai/sql-title-mutation.ts b/apps/studio/data/ai/sql-title-mutation.ts
index 6b6868698f4..ba28a87eeb7 100644
--- a/apps/studio/data/ai/sql-title-mutation.ts
+++ b/apps/studio/data/ai/sql-title-mutation.ts
@@ -12,11 +12,16 @@ export type SqlTitleGenerateResponse = {
export type SqlTitleGenerateVariables = {
sql: string
+ useBedrockAssistant?: boolean
}
-export async function generateSqlTitle({ sql }: SqlTitleGenerateVariables) {
+export async function generateSqlTitle({ sql, useBedrockAssistant }: SqlTitleGenerateVariables) {
+ const url = useBedrockAssistant
+ ? `${BASE_PATH}/api/ai/sql/title-v2`
+ : `${BASE_PATH}/api/ai/sql/title`
+
const headers = await constructHeaders({ 'Content-Type': 'application/json' })
- const response = await fetchHandler(`${BASE_PATH}/api/ai/sql/title`, {
+ const response = await fetchHandler(url, {
headers,
method: 'POST',
body: JSON.stringify({
diff --git a/apps/studio/data/config/project-settings-v2-query.ts b/apps/studio/data/config/project-settings-v2-query.ts
index b76d50f85f9..9b8d65e071d 100644
--- a/apps/studio/data/config/project-settings-v2-query.ts
+++ b/apps/studio/data/config/project-settings-v2-query.ts
@@ -63,6 +63,9 @@ export const useProjectSettingsV2Query = (
)
}
+/**
+ * @deprecated Use api-keys-query instead!
+ */
export const getAPIKeys = (settings?: ProjectSettings) => {
const anonKey = (settings?.service_api_keys ?? []).find((x) => x.tags === 'anon')
const serviceKey = (settings?.service_api_keys ?? []).find((x) => x.tags === 'service_role')
diff --git a/apps/studio/data/content-api/docs-error-codes-query.ts b/apps/studio/data/content-api/docs-error-codes-query.ts
new file mode 100644
index 00000000000..381c655222b
--- /dev/null
+++ b/apps/studio/data/content-api/docs-error-codes-query.ts
@@ -0,0 +1,43 @@
+import { useQuery, type UseQueryOptions } from '@tanstack/react-query'
+import { graphql } from 'data/graphql'
+import { executeGraphQL } from 'data/graphql/execute'
+import { Service } from 'data/graphql/graphql'
+import { contentApiKeys } from './keys'
+
+const ErrorCodeQuery = graphql(`
+ query ErrorCodeQuery($code: String!, $service: Service) {
+ errors(code: $code, service: $service) {
+ nodes {
+ code
+ service
+ message
+ }
+ }
+ }
+`)
+
+interface Variables {
+ code: string
+ service?: Service
+}
+
+async function getErrorCodeDescriptions({ code, service }: Variables, signal?: AbortSignal) {
+ return await executeGraphQL(ErrorCodeQuery, { variables: { code, service }, signal })
+}
+
+type ErrorCodeDescriptionsData = Awaited>
+type ErrorCodeDescriptionsError = unknown
+
+export const useErrorCodesQuery = (
+ variables: Variables,
+ {
+ enabled = true,
+ ...options
+ }: UseQueryOptions = {}
+) => {
+ return useQuery(
+ contentApiKeys.errorCodes(variables),
+ ({ signal }) => getErrorCodeDescriptions(variables, signal),
+ { enabled, ...options }
+ )
+}
diff --git a/apps/studio/data/content-api/keys.ts b/apps/studio/data/content-api/keys.ts
new file mode 100644
index 00000000000..80d437700a0
--- /dev/null
+++ b/apps/studio/data/content-api/keys.ts
@@ -0,0 +1,4 @@
+export const contentApiKeys = {
+ errorCodes: ({ code, service }: { code: string; service?: string }) =>
+ ['content-api', 'error-codes', { code, service }] as const,
+}
diff --git a/apps/studio/data/database-indexes/index-delete-mutation.ts b/apps/studio/data/database-indexes/index-delete-mutation.ts
index 13bd3e835da..47dea94fe6d 100644
--- a/apps/studio/data/database-indexes/index-delete-mutation.ts
+++ b/apps/studio/data/database-indexes/index-delete-mutation.ts
@@ -9,14 +9,16 @@ export type DatabaseIndexDeleteVariables = {
projectRef: string
connectionString?: string | null
name: string
+ schema: string
}
export async function deleteDatabaseIndex({
projectRef,
connectionString,
name,
+ schema,
}: DatabaseIndexDeleteVariables) {
- const sql = `drop index if exists "${name}"`
+ const sql = `drop index if exists "${schema}"."${name}"`
const { result } = await executeSql({
projectRef,
diff --git a/apps/studio/data/feedback/support-ticket-send.ts b/apps/studio/data/feedback/support-ticket-send.ts
index 45ed494472e..137215c244f 100644
--- a/apps/studio/data/feedback/support-ticket-send.ts
+++ b/apps/studio/data/feedback/support-ticket-send.ts
@@ -52,7 +52,7 @@ export async function sendSupportTicket({
},
})
- if (error) handleError(error)
+ if (error) handleError(error, { alwaysCapture: true })
return data
}
diff --git a/apps/studio/data/fetchers.ts b/apps/studio/data/fetchers.ts
index d3849c3e83e..9c369ee5504 100644
--- a/apps/studio/data/fetchers.ts
+++ b/apps/studio/data/fetchers.ts
@@ -128,8 +128,18 @@ export const {
OPTIONS: options,
} = client
-export const handleError = (error: unknown): never => {
+type HandleErrorOptions = {
+ alwaysCapture?: boolean
+}
+
+export const handleError = (
+ error: unknown,
+ options: HandleErrorOptions = { alwaysCapture: false }
+): never => {
if (error && typeof error === 'object') {
+ if (options.alwaysCapture) {
+ Sentry.captureException(error)
+ }
const errorMessage =
'msg' in error && typeof error.msg === 'string'
? error.msg
diff --git a/apps/studio/data/graphql/execute.ts b/apps/studio/data/graphql/execute.ts
new file mode 100644
index 00000000000..3305e1ba5d6
--- /dev/null
+++ b/apps/studio/data/graphql/execute.ts
@@ -0,0 +1,36 @@
+import { handleError } from 'data/fetchers'
+import type { TypedDocumentString } from './graphql'
+
+const CONTENT_API_URL = process.env.NEXT_PUBLIC_CONTENT_API_URL!
+
+export async function executeGraphQL(
+ query: TypedDocumentString,
+ { variables, signal }: { variables?: TVariables; signal?: AbortSignal }
+) {
+ try {
+ const response = await fetch(CONTENT_API_URL, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ query,
+ variables,
+ }),
+ signal,
+ })
+
+ if (!response.ok) {
+ throw new Error('Failed network response from Content API')
+ }
+
+ const { data, errors } = await response.json()
+ if (errors) {
+ throw errors
+ }
+
+ return data as TResult
+ } catch (err) {
+ handleError(err)
+ }
+}
diff --git a/apps/studio/data/graphql/fragment-masking.ts b/apps/studio/data/graphql/fragment-masking.ts
new file mode 100644
index 00000000000..494960811fa
--- /dev/null
+++ b/apps/studio/data/graphql/fragment-masking.ts
@@ -0,0 +1,84 @@
+/* eslint-disable */
+import { ResultOf, DocumentTypeDecoration } from '@graphql-typed-document-node/core'
+import { Incremental, TypedDocumentString } from './graphql'
+
+export type FragmentType> =
+ TDocumentType extends DocumentTypeDecoration
+ ? [TType] extends [{ ' $fragmentName'?: infer TKey }]
+ ? TKey extends string
+ ? { ' $fragmentRefs'?: { [key in TKey]: TType } }
+ : never
+ : never
+ : never
+
+// return non-nullable if `fragmentType` is non-nullable
+export function useFragment(
+ _documentNode: DocumentTypeDecoration,
+ fragmentType: FragmentType>
+): TType
+// return nullable if `fragmentType` is undefined
+export function useFragment(
+ _documentNode: DocumentTypeDecoration,
+ fragmentType: FragmentType> | undefined
+): TType | undefined
+// return nullable if `fragmentType` is nullable
+export function useFragment(
+ _documentNode: DocumentTypeDecoration,
+ fragmentType: FragmentType> | null
+): TType | null
+// return nullable if `fragmentType` is nullable or undefined
+export function useFragment(
+ _documentNode: DocumentTypeDecoration,
+ fragmentType: FragmentType> | null | undefined
+): TType | null | undefined
+// return array of non-nullable if `fragmentType` is array of non-nullable
+export function useFragment(
+ _documentNode: DocumentTypeDecoration,
+ fragmentType: Array>>
+): Array
+// return array of nullable if `fragmentType` is array of nullable
+export function useFragment(
+ _documentNode: DocumentTypeDecoration,
+ fragmentType: Array>> | null | undefined
+): Array | null | undefined
+// return readonly array of non-nullable if `fragmentType` is array of non-nullable
+export function useFragment(
+ _documentNode: DocumentTypeDecoration,
+ fragmentType: ReadonlyArray>>
+): ReadonlyArray
+// return readonly array of nullable if `fragmentType` is array of nullable
+export function useFragment(
+ _documentNode: DocumentTypeDecoration,
+ fragmentType: ReadonlyArray>> | null | undefined
+): ReadonlyArray | null | undefined
+export function useFragment(
+ _documentNode: DocumentTypeDecoration,
+ fragmentType:
+ | FragmentType>
+ | Array