+ )}
{isLoading || isLoadingPermissions ? (
@@ -210,34 +271,8 @@ export const AuditLogs = () => {
) : null}
- {isError ? (
- error.message.endsWith(logsUpgradeError) ? (
-
-
-
-
-
- Organization Audit Logs are not available on Free or Pro plans
-
-
-
- Upgrade to Team or Enterprise to view up to 28 days of Audit Logs for your
- organization.
-
-
-
-
-
-
- Upgrade subscription
-
-
-
-
- ) : error.message.includes('range exceeded') ? (
+ {isError &&
+ (isRangeExceededError ? (
Date range too large
@@ -248,8 +283,7 @@ export const AuditLogs = () => {
) : (
- )
- ) : null}
+ ))}
{isSuccess && (
<>
diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingBreakdown.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingBreakdown.tsx
index 9bbcfec1b4c..29019e4f2b5 100644
--- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingBreakdown.tsx
+++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingBreakdown.tsx
@@ -71,22 +71,24 @@ const BillingBreakdown = () => {
{selectedOrganization?.managed_by === MANAGED_BY.AWS_MARKETPLACE ? (
<>
- You'll receive two invoices from AWS Marketplace: one on the 3rd of{' '}
- {billingCycleEnd.format('MMMM')} for your usage in{' '}
- {billingCycleStart.format('MMMM')} and one on {billingCycleEnd.format('MMMM DD')}{' '}
- for the fixed subscription fee.
+
+ AWS Marketplace sends two invoices each month: one for your fixed subscription
+ fee, billed on the day you subscribed, and one for your usage charges from the
+ previous month, billed by the 3rd.
+
+
+
+ For a more detailed breakdown, visit the{' '}
+ usage page.
+
>
) : (
<>
Your upcoming invoice (excluding credits) will continue to update until the end of
- your billing cycle on {billingCycleEnd.format('MMMM DD')}.
+ your billing cycle on {billingCycleEnd.format('MMMM DD')}. For a more detailed
+ breakdown, visit the
usage page.
>
)}
- <>
- {' '}
- For a more detailed breakdown, visit the{' '}
-
usage page.
- >
diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingSettings.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingSettings.tsx
index 884b39bee9e..bcb1c3f0f0b 100644
--- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingSettings.tsx
+++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingSettings.tsx
@@ -57,7 +57,7 @@ export const BillingSettings = () => {
- {org?.plan.id !== 'free' && (
+ {org && org.plan.id !== 'free' && (
diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/CreditBalance.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/CreditBalance.tsx
index bfc48ccab37..3da418ea629 100644
--- a/apps/studio/components/interfaces/Organization/BillingSettings/CreditBalance.tsx
+++ b/apps/studio/components/interfaces/Organization/BillingSettings/CreditBalance.tsx
@@ -55,7 +55,9 @@ const CreditBalance = () => {
{isPermissionsLoaded && !canReadSubscriptions ? (
) : (
- }>
+ }
+ >
{isError && (
diff --git a/apps/studio/components/interfaces/Organization/OrganizationCard.tsx b/apps/studio/components/interfaces/Organization/OrganizationCard.tsx
index 0e3c77246e9..f9cb258318c 100644
--- a/apps/studio/components/interfaces/Organization/OrganizationCard.tsx
+++ b/apps/studio/components/interfaces/Organization/OrganizationCard.tsx
@@ -3,7 +3,7 @@ import Link from 'next/link'
import { useIsMFAEnabled } from 'common'
import { ActionCard } from 'components/ui/ActionCard'
-import { useProjectsQuery } from 'data/projects/projects-query'
+import { useOrgProjectsInfiniteQuery } from 'data/projects/org-projects-infinite-query'
import { Organization } from 'types'
import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
@@ -15,10 +15,8 @@ export const OrganizationCard = ({
href?: string
}) => {
const isUserMFAEnabled = useIsMFAEnabled()
- const { data } = useProjectsQuery()
- const allProjects = data?.projects ?? []
-
- const numProjects = allProjects.filter((x) => x.organization_slug === organization.slug).length
+ const { data } = useOrgProjectsInfiniteQuery({ slug: organization.slug })
+ const numProjects = data?.pages[0].pagination.count ?? 0
const isMfaRequired = organization.organization_requires_mfa
return (
diff --git a/apps/studio/components/interfaces/Organization/SSO/SSOConfig.tsx b/apps/studio/components/interfaces/Organization/SSO/SSOConfig.tsx
index 7bd3b6df393..98a38c1b195 100644
--- a/apps/studio/components/interfaces/Organization/SSO/SSOConfig.tsx
+++ b/apps/studio/components/interfaces/Organization/SSO/SSOConfig.tsx
@@ -157,7 +157,7 @@ export const SSOConfig = () => {
return (
-
+
{!!plan && !canSetupSSOConfig ? (
{
Usage
-
+
{isLoadingSubscription || isLoadingPermissions ? (
diff --git a/apps/studio/components/interfaces/Realtime/Inspector/AnimatedCursors.tsx b/apps/studio/components/interfaces/Realtime/Inspector/AnimatedCursors.tsx
new file mode 100644
index 00000000000..de4bd3abaed
--- /dev/null
+++ b/apps/studio/components/interfaces/Realtime/Inspector/AnimatedCursors.tsx
@@ -0,0 +1,61 @@
+import { useState, useEffect } from 'react'
+import { motion } from 'framer-motion'
+import { MousePointer2 } from 'lucide-react'
+
+export const AnimatedCursors = () => {
+ const [cursor1Position, setCursor1Position] = useState({ x: 20, y: 20 })
+ const [cursor2Position, setCursor2Position] = useState({ x: 180, y: 80 })
+
+ useEffect(() => {
+ const animateCursors = () => {
+ const newCursor1Position = {
+ x: Math.random() * 160 + 20,
+ y: Math.random() * 80 + 20,
+ }
+ const newCursor2Position = {
+ x: Math.random() * 160 + 20,
+ y: Math.random() * 80 + 20,
+ }
+
+ setCursor1Position(newCursor1Position)
+ setCursor2Position(newCursor2Position)
+ }
+
+ const initialTimer = setTimeout(animateCursors, 1000)
+
+ const interval = setInterval(animateCursors, 3000)
+
+ return () => {
+ clearTimeout(initialTimer)
+ clearInterval(interval)
+ }
+ }, [])
+
+ return (
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Realtime/Inspector/EmptyRealtime.tsx b/apps/studio/components/interfaces/Realtime/Inspector/EmptyRealtime.tsx
new file mode 100644
index 00000000000..8bed6a1596c
--- /dev/null
+++ b/apps/studio/components/interfaces/Realtime/Inspector/EmptyRealtime.tsx
@@ -0,0 +1,101 @@
+import { AiIconAnimation, Button, Card, cn } from 'ui'
+import Link from 'next/link'
+import { AnimatedCursors } from './AnimatedCursors'
+import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
+
+/**
+ * Acts as a container component for the entire log display
+ */
+export const EmptyRealtime = ({ projectRef }: { projectRef: string }) => {
+ const aiSnap = useAiAssistantStateSnapshot()
+
+ const handleCreateTriggerWithAssistant = () => {
+ aiSnap.newChat({
+ name: `Realtime`,
+ open: true,
+ initialInput: `Help me set up a realtime experience for my project`,
+ })
+ }
+
+ return (
+
+
+
+
+
Create realtime experiences
+
+ Send your first realtime message from your database, application code or edge function
+
+
}
+ onClick={handleCreateTriggerWithAssistant}
+ >
+ Set up realtime for me
+
+
+
+
+
+
+
+ 1
+
+
Broadcast messages
+
+
+ Send messages to a channel from your client application or database via triggers.
+
+
+ Create a trigger
+
+
+
+
+
+
+ 2
+
+
Write policies
+
+
+ Set up Row Level Security policies to control who can see messages within a channel
+
+
+ Write a policy
+
+
+
+
+
+
+ 3
+
+
Subscribe to a channel
+
+
+ Receive realtime messages in your application by listening to a channel
+
+
+
+ Read the guide
+
+
+
+
+
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Realtime/Inspector/index.tsx b/apps/studio/components/interfaces/Realtime/Inspector/index.tsx
index 28b616b7735..116ce210093 100644
--- a/apps/studio/components/interfaces/Realtime/Inspector/index.tsx
+++ b/apps/studio/components/interfaces/Realtime/Inspector/index.tsx
@@ -1,5 +1,7 @@
import { useParams } from 'common'
-import { useState } from 'react'
+import { useState, useEffect } from 'react'
+import { motion } from 'framer-motion'
+import { MousePointer2 } from 'lucide-react'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
@@ -7,6 +9,7 @@ import { Header } from './Header'
import MessagesTable from './MessagesTable'
import { SendMessageModal } from './SendMessageModal'
import { RealtimeConfig, useRealtimeMessages } from './useRealtimeMessages'
+import { EmptyRealtime } from './EmptyRealtime'
/**
* Acts as a container component for the entire log display
@@ -40,12 +43,16 @@ export const RealtimeInspector = () => {
- 0}
- enabled={realtimeConfig.enabled}
- data={logData}
- showSendMessage={() => setSendMessageShown(true)}
- />
+ {(logData ?? []).length > 0 ? (
+ 0}
+ enabled={realtimeConfig.enabled}
+ data={logData}
+ showSendMessage={() => setSendMessageShown(true)}
+ />
+ ) : (
+
+ )}
diff --git a/apps/studio/components/interfaces/Reports/v2/ReportChartUpsell.tsx b/apps/studio/components/interfaces/Reports/v2/ReportChartUpsell.tsx
index 779aa17ee33..d5bd366bf00 100644
--- a/apps/studio/components/interfaces/Reports/v2/ReportChartUpsell.tsx
+++ b/apps/studio/components/interfaces/Reports/v2/ReportChartUpsell.tsx
@@ -1,8 +1,7 @@
+import { LogChartHandler } from 'components/ui/Charts/LogChartHandler'
import Link from 'next/link'
import { useRef, useState } from 'react'
-import { LogChartHandler } from 'components/ui/Charts/LogChartHandler'
-import { ReportConfig } from 'data/reports/v2/reports.types'
import { Button, Card, cn } from 'ui'
export function ReportChartUpsell({
@@ -78,7 +77,6 @@ export function ReportChartUpsell({
label={''}
startDate={startDate}
endDate={endDate}
- interval={'1d'}
data={demoData as any}
isLoading={false}
highlightedValue={0}
diff --git a/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx b/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx
index 33fddb3c71e..7b59187c6d7 100644
--- a/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx
+++ b/apps/studio/components/interfaces/Settings/API/PostgrestConfig.tsx
@@ -55,7 +55,7 @@ import { HardenAPIModal } from './HardenAPIModal'
const formSchema = z
.object({
dbSchema: z.array(z.string()),
- dbExtraSearchPath: z.string(),
+ dbExtraSearchPath: z.array(z.string()),
maxRows: z.number().max(1000000, "Can't be more than 1,000,000"),
dbPool: z
.number()
@@ -84,15 +84,26 @@ export const PostgrestConfig = () => {
const [showModal, setShowModal] = useState(false)
- const { data: config, isError, isLoading } = useProjectPostgrestConfigQuery({ projectRef })
+ const {
+ data: config,
+ isError,
+ isLoading: isLoadingConfig,
+ } = useProjectPostgrestConfigQuery({ projectRef })
const { data: extensions } = useDatabaseExtensionsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
- const { data: schemas, isLoading: isLoadingSchemas } = useSchemasQuery({
+ const {
+ data: allSchemas = [],
+ isLoading: isLoadingSchemas,
+ isSuccess: isSuccessSchemas,
+ } = useSchemasQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
+
+ const isLoading = isLoadingConfig || isLoadingSchemas
+
const { mutate: updatePostgrestConfig, isLoading: isUpdating } =
useProjectPostgrestConfigUpdateMutation({
onSuccess: () => {
@@ -112,7 +123,10 @@ export const PostgrestConfig = () => {
const defaultValues = {
dbSchema,
maxRows: config?.max_rows,
- dbExtraSearchPath: config?.db_extra_search_path,
+ dbExtraSearchPath: (config?.db_extra_search_path ?? '')
+ .split(',')
+ .map((x) => x.trim())
+ .filter((x) => x.length > 0 && allSchemas.find((y) => y.name === x)),
dbPool: config?.db_pool,
}
@@ -122,9 +136,9 @@ export const PostgrestConfig = () => {
defaultValues,
})
- const schema =
- schemas
- ?.filter((x) => {
+ const schemas =
+ allSchemas
+ .filter((x) => {
const find = indexOf(hiddenSchema, x.name)
if (find < 0) return x
})
@@ -149,20 +163,20 @@ export const PostgrestConfig = () => {
projectRef,
dbSchema: values.dbSchema.join(', '),
maxRows: values.maxRows,
- dbExtraSearchPath: values.dbExtraSearchPath,
+ dbExtraSearchPath: values.dbExtraSearchPath.join(','),
dbPool: values.dbPool ? values.dbPool : null,
})
}
useEffect(() => {
- if (config) {
+ if (config && isSuccessSchemas) {
/**
* Checks if enableDataApi should be enabled or disabled
* based on the db_schema value being empty string
*/
resetForm()
}
- }, [config])
+ }, [config, isSuccessSchemas])
const isDataApiEnabledInForm = form.getValues('enableDataApi')
@@ -277,21 +291,16 @@ export const PostgrestConfig = () => {
/>
- {schema.length <= 0 ? (
+ {schemas.length <= 0 ? (
no
) : (
- <>
- {schema.map((x) => (
-
- {x.name}
-
- ))}
- >
+ schemas.map((x) => (
+
+ {x.name}
+
+ ))
)}
@@ -341,15 +350,43 @@ export const PostgrestConfig = () => {
className="w-full px-8 py-8"
layout="horizontal"
label="Extra search path"
- description="Extra schemas to add to the search path of every request. Multiple schemas must be comma-separated."
+ description="Extra schemas to add to the search path of every request."
>
-
-
+
+
+ ) : (
+
-
+ >
+
+
+
+ {allSchemas.length <= 0 ? (
+
+ no
+
+ ) : (
+ allSchemas.map((x) => (
+
+ {x.name}
+
+ ))
+ )}
+
+
+
+ )}
)}
diff --git a/apps/studio/components/interfaces/Settings/API/ServiceList.tsx b/apps/studio/components/interfaces/Settings/API/ServiceList.tsx
index 34145807e93..2b16008778b 100644
--- a/apps/studio/components/interfaces/Settings/API/ServiceList.tsx
+++ b/apps/studio/components/interfaces/Settings/API/ServiceList.tsx
@@ -3,7 +3,6 @@ import { AlertCircle } from 'lucide-react'
import { useParams } from 'common'
import { ScaffoldSection } from 'components/layouts/Scaffold'
import DatabaseSelector from 'components/ui/DatabaseSelector'
-import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query'
import { useLoadBalancersQuery } from 'data/read-replicas/load-balancers-query'
import { useReadReplicasQuery } from 'data/read-replicas/replicas-query'
@@ -13,6 +12,7 @@ import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
import { Alert_Shadcn_, AlertTitle_Shadcn_, Badge, Card, CardContent, CardHeader } from 'ui'
import { Input } from 'ui-patterns/DataInputs/Input'
import { FormLayout } from 'ui-patterns/form/Layout/FormLayout'
+import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
import { PostgrestConfig } from './PostgrestConfig'
export const ServiceList = () => {
@@ -65,7 +65,10 @@ export const ServiceList = () => {
{isLoading || isLoadingDatabases ? (
-
+
+
+
+
) : isError ? (
diff --git a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx
index ff61476ee0c..f3ee01982a2 100644
--- a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx
+++ b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx
@@ -5,7 +5,10 @@ import { useFlag, useParams } from 'common'
import { FormHeader } from 'components/ui/Forms/FormHeader'
import Panel from 'components/ui/Panel'
import UpgradeToPro from 'components/ui/UpgradeToPro'
-import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query'
+import {
+ type CustomDomainsData,
+ useCustomDomainsQuery,
+} from 'data/custom-domains/custom-domains-query'
import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import CustomDomainActivate from './CustomDomainActivate'
@@ -14,7 +17,7 @@ import CustomDomainVerify from './CustomDomainVerify'
import CustomDomainsConfigureHostname from './CustomDomainsConfigureHostname'
import CustomDomainsShimmerLoader from './CustomDomainsShimmerLoader'
-const CustomDomainConfig = () => {
+export const CustomDomainConfig = () => {
const { ref } = useParams()
const { data: organization } = useSelectedOrganizationQuery()
@@ -30,6 +33,7 @@ const CustomDomainConfig = () => {
isLoading: isCustomDomainsLoading,
isError,
isSuccess,
+ status: customDomainStatus,
} = useCustomDomainsQuery(
{ projectRef: ref },
{
@@ -44,7 +48,7 @@ const CustomDomainConfig = () => {
}
)
- const { status, customDomain } = customDomainData || {}
+ const { status } = customDomainData || {}
return (
@@ -99,7 +103,7 @@ const CustomDomainConfig = () => {
) : (
- {isSuccess && (
+ {isSuccess ? (
{(status === '1_not_started' ||
status === '2_initiated' ||
@@ -116,6 +120,11 @@ const CustomDomainConfig = () => {
)}
+ ) : (
+
)}
)}
@@ -123,4 +132,15 @@ const CustomDomainConfig = () => {
)
}
-export default CustomDomainConfig
+interface CustomDomainConfigFallthroughProps {
+ fetchStatus: 'error' | 'success' | 'loading'
+ data: CustomDomainsData | undefined
+}
+
+function CustomDomainConfigFallthrough({ fetchStatus, data }: CustomDomainConfigFallthroughProps) {
+ console.error(`Failing to display UI for custom domains:
+Fetch status: ${fetchStatus}
+Custom domain status: ${data?.status}`)
+
+ return null
+}
diff --git a/apps/studio/components/interfaces/Settings/General/index.ts b/apps/studio/components/interfaces/Settings/General/index.ts
index a211542638e..daebfc2f9e4 100644
--- a/apps/studio/components/interfaces/Settings/General/index.ts
+++ b/apps/studio/components/interfaces/Settings/General/index.ts
@@ -1,5 +1,5 @@
export { default as ComplianceConfig } from './ComplianceConfig/ProjectComplianceMode'
-export { default as CustomDomainConfig } from './CustomDomainConfig/CustomDomainConfig'
+export { CustomDomainConfig } from './CustomDomainConfig/CustomDomainConfig'
export { default as DeleteProjectButton } from './DeleteProjectPanel/DeleteProjectButton'
export { default as General } from './General'
export { default as TransferProjectButton } from './TransferProjectPanel/TransferProjectButton'
diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx
index 1ff808b8614..343fbc73e6a 100644
--- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx
+++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx
@@ -95,7 +95,7 @@ const InfrastructureInfo = () => {
Service Versions
- Information on your provisioned instance.
+ Service versions and upgrade eligibility for your provisioned instance.
diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/UpgradeWarnings.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/UpgradeWarnings.tsx
index 1e147771370..81874dbe478 100644
--- a/apps/studio/components/interfaces/Settings/Infrastructure/UpgradeWarnings.tsx
+++ b/apps/studio/components/interfaces/Settings/Infrastructure/UpgradeWarnings.tsx
@@ -24,36 +24,37 @@ export const ObjectsToBeDroppedWarning = ({
}: {
objectsToBeDropped: string[]
}) => {
+ const { ref } = useParams()
return (
-
- A new version of Postgres is available
+
+ A newer version of Postgres is available
-
-
The following objects have to be removed before upgrading:
+ <>
+
+ The following objects are not supported and must be removed before upgrading.{' '}
+
+ Learn more
+
+
-
+ {/* Old */}
+
{objectsToBeDropped.map((obj) => (
{obj}
))}
-
- Check the docs for which objects need to be removed.
-
+ >
)
@@ -73,15 +74,15 @@ export const UnsupportedExtensionsWarning = ({
<>
The following extensions are not supported in newer versions of Postgres and must be
- removed before you can upgrade.{' '}
+ removed before upgrading.{' '}
Learn more
- .
@@ -107,15 +108,13 @@ export const UnsupportedExtensionsWarning = ({
export const UserDefinedObjectsInInternalSchemasWarning = ({ objects }: { objects: string[] }) => {
return (
-
- A new version of Postgres is available
+
+ A newer version of Postgres is available
- You'll need to move these objects out of auth/realtime/storage schemas before upgrading:
+ The following objects must be removed from the auth/realtime/storage schemas before
+ upgrading:
diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts
index 1d61aeefbce..8f26529dc49 100644
--- a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts
+++ b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts
@@ -640,7 +640,7 @@ function getErrorCondition(table: LogsTableName): string {
case 'postgres_logs':
return "parsed.error_severity IN ('ERROR', 'FATAL', 'PANIC')"
case 'auth_logs':
- return "metadata.level = 'error' OR metadata.status >= 400"
+ return "metadata.level = 'error' OR SAFE_CAST(metadata.status AS INT64) >= 400"
case 'function_edge_logs':
return 'response.status_code >= 500'
case 'function_logs':
diff --git a/apps/studio/components/interfaces/Sidebar.tsx b/apps/studio/components/interfaces/Sidebar.tsx
index 63c0ee2600e..72eea15c4c9 100644
--- a/apps/studio/components/interfaces/Sidebar.tsx
+++ b/apps/studio/components/interfaces/Sidebar.tsx
@@ -46,6 +46,7 @@ import {
useIsNewStorageUIEnabled,
useUnifiedLogsPreview,
} from './App/FeaturePreview/FeaturePreviewContext'
+import { useFlag } from 'common'
export const ICON_SIZE = 32
export const ICON_STROKE_WIDTH = 1.5
@@ -244,12 +245,15 @@ const ProjectLinks = () => {
'realtime:all',
])
+ const authOverviewPageEnabled = useFlag('authOverviewPage')
+
const toolRoutes = generateToolRoutes(ref, project)
const productRoutes = generateProductRoutes(ref, project, {
auth: authEnabled,
edgeFunctions: edgeFunctionsEnabled,
storage: storageEnabled,
realtime: realtimeEnabled,
+ authOverviewPage: authOverviewPageEnabled,
isStorageV2,
})
const otherRoutes = generateOtherRoutes(ref, project, {
diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets.tsx
new file mode 100644
index 00000000000..2da65106257
--- /dev/null
+++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets.tsx
@@ -0,0 +1,6 @@
+import { EmptyBucketState } from './EmptyBucketState'
+
+export const AnalyticsBuckets = () => {
+ // Placeholder component - will be implemented in a later PR
+ return
+}
diff --git a/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx b/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx
index f1f8bdf3053..2586ce15220 100644
--- a/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx
+++ b/apps/studio/components/interfaces/Storage/CreateBucketModal.tsx
@@ -1,7 +1,7 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { snakeCase } from 'lodash'
-import { Edit } from 'lucide-react'
+import { Plus } from 'lucide-react'
import { useRouter } from 'next/router'
import { useState } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
@@ -51,6 +51,7 @@ import {
} from 'ui'
import { Admonition } from 'ui-patterns/admonition'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
+import { useIsNewStorageUIEnabled } from '../App/FeaturePreview/FeaturePreviewContext'
import { inverseValidBucketNameRegex, validBucketNameRegex } from './CreateBucketModal.utils'
import { convertFromBytes, convertToBytes } from './StorageSettings/StorageSettings.utils'
@@ -95,10 +96,23 @@ const formId = 'create-storage-bucket-form'
export type CreateBucketForm = z.infer
-export const CreateBucketModal = () => {
+interface CreateBucketModalProps {
+ buttonSize?: 'tiny' | 'small'
+ buttonType?: 'default' | 'primary'
+ buttonClassName?: string
+ label?: string
+}
+
+export const CreateBucketModal = ({
+ buttonSize = 'tiny',
+ buttonType = 'default',
+ buttonClassName,
+ label = 'New bucket',
+}: CreateBucketModalProps) => {
const router = useRouter()
const { ref } = useParams()
const { data: org } = useSelectedOrganizationQuery()
+ const isStorageV2 = useIsNewStorageUIEnabled()
const [visible, setVisible] = useState(false)
const [selectedUnit, setSelectedUnit] = useState(StorageSizeUnits.MB)
@@ -189,7 +203,7 @@ export const CreateBucketModal = () => {
setSelectedUnit(StorageSizeUnits.MB)
setVisible(false)
- router.push(`/project/${ref}/storage/buckets/${values.name}`)
+ if (!isStorageV2) router.push(`/project/${ref}/storage/buckets/${values.name}`)
} catch (error: any) {
// Handle specific error cases for inline display
const errorMessage = error.message?.toLowerCase() || ''
@@ -228,8 +242,10 @@ export const CreateBucketModal = () => {
}
+ size={buttonSize}
+ type={buttonType}
+ className={buttonClassName}
+ icon={ }
disabled={!canCreateBuckets}
style={{ justifyContent: 'start' }}
onClick={() => setVisible(true)}
@@ -242,7 +258,7 @@ export const CreateBucketModal = () => {
},
}}
>
- New bucket
+ {label}
@@ -281,59 +297,61 @@ export const CreateBucketModal = () => {
)}
/>
- (
-
-
- field.onChange(v)}
- >
-
- {IS_PLATFORM && (
+ {!isStorageV2 && (
+ (
+
+
+ field.onChange(v)}
+ >
- <>
-
- Stores Iceberg files and is optimized for analytical workloads.
-
+ />
+ {IS_PLATFORM && (
+
+ <>
+
+ Stores Iceberg files and is optimized for analytical workloads.
+
- {icebergCatalogEnabled ? null : (
-
-
-
- This feature is currently in alpha and not yet enabled for your
- project. Sign up{' '}
-
- here
-
- .
-
-
- )}
- >
-
- )}
-
-
-
- )}
- />
+ {icebergCatalogEnabled ? null : (
+
+
+
+ This feature is currently in alpha and not yet enabled for
+ your project. Sign up{' '}
+
+ here
+
+ .
+
+
+ )}
+ >
+
+ )}
+
+
+
+ )}
+ />
+ )}
diff --git a/apps/studio/components/interfaces/Storage/EmptyBucketModal.tsx b/apps/studio/components/interfaces/Storage/EmptyBucketModal.tsx
index 3b3416834a9..597ae9ea7c5 100644
--- a/apps/studio/components/interfaces/Storage/EmptyBucketModal.tsx
+++ b/apps/studio/components/interfaces/Storage/EmptyBucketModal.tsx
@@ -35,7 +35,7 @@ export const EmptyBucketModal = ({ visible, bucket, onClose }: EmptyBucketModalP
folderName: bucket.name,
index: -1,
})
- toast.success(`Successfully deleted bucket ${bucket!.name}`)
+ toast.success(`Successfully emptied bucket ${bucket!.name}`)
onClose()
},
})
diff --git a/apps/studio/components/interfaces/Storage/EmptyBucketState.tsx b/apps/studio/components/interfaces/Storage/EmptyBucketState.tsx
new file mode 100644
index 00000000000..3b4e1a61a65
--- /dev/null
+++ b/apps/studio/components/interfaces/Storage/EmptyBucketState.tsx
@@ -0,0 +1,25 @@
+import { CreateBucketModal } from './CreateBucketModal'
+import { BUCKET_TYPES } from './Storage.constants'
+
+interface EmptyBucketStateProps {
+ bucketType: keyof typeof BUCKET_TYPES
+}
+
+export const EmptyBucketState = ({ bucketType }: EmptyBucketStateProps) => {
+ const config = BUCKET_TYPES[bucketType]
+
+ return (
+
+
+
Create {config.label}
+
{config.valueProp}
+
+
+ {/* [Joshen] We can render the individual bucket modals here instead - where each modal has its own trigger */}
+ {/* [Danny] CreateBucketModal needs to be split into CreateFileBucketModal, CreateAnalyticsBucketModal, CreateVectorsBucketModal */}
+ {bucketType === 'files' && (
+
+ )}
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Storage/FilesBuckets.tsx b/apps/studio/components/interfaces/Storage/FilesBuckets.tsx
new file mode 100644
index 00000000000..acd46b2c4b2
--- /dev/null
+++ b/apps/studio/components/interfaces/Storage/FilesBuckets.tsx
@@ -0,0 +1,239 @@
+import { Edit, FolderOpen, MoreVertical, Search, Trash2 } from 'lucide-react'
+import Link from 'next/link'
+import { useRouter } from 'next/router'
+import { useState } from 'react'
+
+import { useParams } from 'common'
+import { ScaffoldSection } from 'components/layouts/Scaffold'
+import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
+import { useProjectStorageConfigQuery } from 'data/config/project-storage-config-query'
+import { Bucket, useBucketsQuery } from 'data/storage/buckets-query'
+import { useStoragePolicyCounts } from 'hooks/storage/useStoragePolicyCounts'
+import { IS_PLATFORM } from 'lib/constants'
+import { formatBytes } from 'lib/helpers'
+import {
+ Badge,
+ Button,
+ Card,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from 'ui'
+import { Input } from 'ui-patterns/DataInputs/Input'
+import { CreateBucketModal } from './CreateBucketModal'
+import { DeleteBucketModal } from './DeleteBucketModal'
+import { EditBucketModal } from './EditBucketModal'
+import { EmptyBucketModal } from './EmptyBucketModal'
+import { EmptyBucketState } from './EmptyBucketState'
+
+export const FilesBuckets = () => {
+ const router = useRouter()
+ const { ref } = useParams()
+
+ const [modal, setModal] = useState<'edit' | 'empty' | 'delete' | null>(null)
+ const [selectedBucket, setSelectedBucket] = useState()
+ const [filterString, setFilterString] = useState('')
+
+ const { data } = useProjectStorageConfigQuery({ projectRef: ref }, { enabled: IS_PLATFORM })
+ const { data: buckets = [], isLoading: isLoadingBuckets } = useBucketsQuery({ projectRef: ref })
+ const { getPolicyCount, isLoading: isLoadingPolicies } = useStoragePolicyCounts(buckets)
+
+ const formattedGlobalUploadLimit = formatBytes(data?.fileSizeLimit ?? 0)
+
+ const isLoading = isLoadingBuckets || isLoadingPolicies
+ const filesBuckets = buckets
+ .filter((bucket) => !('type' in bucket) || bucket.type === 'STANDARD')
+ .filter((bucket) =>
+ filterString.length === 0
+ ? true
+ : bucket.name.toLowerCase().includes(filterString.toLowerCase())
+ )
+ return (
+ <>
+ {!isLoading &&
+ buckets.filter((bucket) => !('type' in bucket) || bucket.type === 'STANDARD').length === 0 ? (
+
+ ) : (
+
+
+ setFilterString(e.target.value)}
+ icon={ }
+ />
+
+
+
+ {isLoading ? (
+
+ ) : (
+
+
+
+
+ Name
+ Policies
+ File size limit
+ Allowed MIME types
+
+
+
+
+ {filesBuckets.length === 0 && filterString.length > 0 && (
+
+
+ No results found
+
+ Your search for "{filterString}" did not return any results
+
+
+
+ )}
+ {filesBuckets.map((bucket) => (
+ {
+ const url = `/project/${ref}/storage/files/buckets/${bucket.id}`
+ if (event.metaKey) window.open(url, '_blank')
+ else router.push(url)
+ }}
+ >
+
+
+
{bucket.name}
+ {bucket.public &&
Public }
+
+
+
+
+ {getPolicyCount(bucket.name)}
+
+
+
+
+ {bucket.file_size_limit
+ ? formatBytes(bucket.file_size_limit)
+ : `Unset (${formattedGlobalUploadLimit})`}
+
+
+
+
+
+ {bucket.allowed_mime_types ? bucket.allowed_mime_types.join(', ') : 'Any'}
+
+
+
+
+
+
+ e.stopPropagation()}
+ >
+ View files
+
+
+
+
+ }
+ onClick={(e) => e.stopPropagation()}
+ />
+
+
+ {
+ e.stopPropagation()
+ setModal('edit')
+ setSelectedBucket(bucket)
+ }}
+ >
+
+ Edit bucket
+
+
+ {
+ e.stopPropagation()
+ setModal('empty')
+ setSelectedBucket(bucket)
+ }}
+ >
+
+ Empty bucket
+
+
+ {
+ e.stopPropagation()
+ setModal('delete')
+ setSelectedBucket(bucket)
+ }}
+ >
+
+ Delete bucket
+
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+ )}
+
+ {selectedBucket && (
+ <>
+ setModal(null)}
+ />
+ setModal(null)}
+ />
+ setModal(null)}
+ />
+ >
+ )}
+ >
+ )
+}
diff --git a/apps/studio/components/interfaces/Storage/Storage.constants.ts b/apps/studio/components/interfaces/Storage/Storage.constants.ts
index 9dc6cae7b37..b2c75e211ac 100644
--- a/apps/studio/components/interfaces/Storage/Storage.constants.ts
+++ b/apps/studio/components/interfaces/Storage/Storage.constants.ts
@@ -65,20 +65,25 @@ export const CONTEXT_MENU_KEYS = {
export const BUCKET_TYPES = {
files: {
displayName: 'Files',
+ label: 'a file bucket',
description: 'General file storage for most types of digital content.',
+ valueProp: 'Store images, videos, documents, and any other file type.',
docsUrl: `${DOCS_URL}/guides/storage/buckets/fundamentals`,
},
analytics: {
displayName: 'Analytics',
+ label: 'an analytics bucket',
description: 'Purpose-built storage for analytical workloads.',
+ valueProp: 'Store large datasets for analytics and reporting.',
docsUrl: `${DOCS_URL}/guides/storage/analytics/introduction`,
},
vectors: {
displayName: 'Vectors',
+ label: 'a vector bucket',
description: 'Purpose-built storage for vector data.',
+ valueProp: 'Store, index, and query your vector embeddings at scale.',
docsUrl: `${DOCS_URL}/guides/storage/vectors`,
},
}
-
export const BUCKET_TYPE_KEYS = Object.keys(BUCKET_TYPES) as Array
export const DEFAULT_BUCKET_TYPE: keyof typeof BUCKET_TYPES = 'files'
diff --git a/apps/studio/components/interfaces/Storage/Storage.utils.ts b/apps/studio/components/interfaces/Storage/Storage.utils.ts
index 36f64ef075f..51254750652 100644
--- a/apps/studio/components/interfaces/Storage/Storage.utils.ts
+++ b/apps/studio/components/interfaces/Storage/Storage.utils.ts
@@ -1,4 +1,6 @@
-import { groupBy, difference } from 'lodash'
+import { difference, groupBy } from 'lodash'
+import { useRouter } from 'next/router'
+
import { STORAGE_CLIENT_LIBRARY_MAPPINGS } from './Storage.constants'
import type { StoragePolicyFormField } from './Storage.types'
@@ -188,3 +190,8 @@ export const createSQLPolicies = (
export const applyBucketIdToTemplateDefinition = (definition: string, bucketId: any) => {
return definition.replace('{bucket_id}', `'${bucketId}'`)
}
+
+export const useStorageV2Page = () => {
+ const router = useRouter()
+ return router.pathname.split('/')[4] as undefined | 'files' | 'analytics' | 'vectors' | 's3'
+}
diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeader.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeader.tsx
index 72135eb819e..7025cf213cd 100644
--- a/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeader.tsx
+++ b/apps/studio/components/interfaces/Storage/StorageExplorer/FileExplorerHeader.tsx
@@ -16,7 +16,10 @@ import {
} from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
-import { useIsAPIDocsSidePanelEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
+import {
+ useIsAPIDocsSidePanelEnabled,
+ useIsNewStorageUIEnabled,
+} from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { APIDocsButton } from 'components/ui/APIDocsButton'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
@@ -148,6 +151,7 @@ export const FileExplorerHeader = ({
const debounceDuration = 300
const snap = useStorageExplorerStateSnapshot()
const isNewAPIDocsEnabled = useIsAPIDocsSidePanelEnabled()
+ const isNewStorageUIEnabled = useIsNewStorageUIEnabled()
const [pathString, setPathString] = useState('')
const [searchString, setSearchString] = useState('')
@@ -286,12 +290,12 @@ export const FileExplorerHeader = ({
>
{/* Navigation */}
- {breadcrumbs.length > 0 && (
+ {breadcrumbs.length > 1 && (
}
size="tiny"
type="text"
- className={`${breadcrumbs.length > 1 ? 'opacity-100' : 'opacity-25'} px-1`}
+ className="opacity-100 px-1"
disabled={backDisabled}
onClick={() => {
setIsEditingPath(false)
@@ -330,20 +334,37 @@ export const FileExplorerHeader = ({
/>
) : snap.view === STORAGE_VIEWS.COLUMNS ? (
-
- ) : (
+ breadcrumbs.length > 1 ? (
+
+ ) : isNewStorageUIEnabled ? (
+
+
+ } onClick={togglePathEdit}>
+ Navigate
+
+
+
+ ) : (
+
+ )
+ ) : breadcrumbs.length > 1 ? (
- )}
+ ) : null}
{/* Actions */}
diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.tsx b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.tsx
index d3616e59a65..e827c71f24d 100644
--- a/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.tsx
+++ b/apps/studio/components/interfaces/Storage/StorageExplorer/StorageExplorer.tsx
@@ -53,9 +53,6 @@ export const StorageExplorer = ({ bucket }: StorageExplorerProps) => {
// Things like showing results from a search filter is "temporary", hence we use react state to manage
const [itemSearchString, setItemSearchString] = useState('')
- // Requires a fixed height to ensure that explorer is constrained to the viewport
- const fileExplorerHeight = window.innerHeight - 122
-
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => {
const fetchContents = async () => {
@@ -178,7 +175,7 @@ export const StorageExplorer = ({ bucket }: StorageExplorerProps) => {
) : (
)}
-
+
{
const { ref, bucketId } = useParams()
const { data: projectDetails } = useSelectedProjectQuery()
const snap = useStorageExplorerStateSnapshot()
+
+ const showMigrationCallout = useFlag('storageMigrationCallout')
+ const { data: config } = useProjectStorageConfigQuery(
+ { projectRef: ref },
+ { enabled: showMigrationCallout }
+ )
+ const isListV2UpgradeAvailable =
+ !!config && !config.capabilities.list_v2 && config.external.upstreamTarget === 'main'
+
const isBranch = projectDetails?.parent_project_ref !== undefined
const [searchText, setSearchText] = useState('')
@@ -159,7 +170,12 @@ export const StorageMenu = () => {
{IS_PLATFORM && (
- Settings
+
+
Settings
+ {isListV2UpgradeAvailable && (
+
Upgrade available
+ )}
+
)}
diff --git a/apps/studio/components/interfaces/Storage/StorageMenuV2.tsx b/apps/studio/components/interfaces/Storage/StorageMenuV2.tsx
index e5b8e8592ef..b3ef2cc665a 100644
--- a/apps/studio/components/interfaces/Storage/StorageMenuV2.tsx
+++ b/apps/studio/components/interfaces/Storage/StorageMenuV2.tsx
@@ -1,29 +1,48 @@
-import { useParams } from 'common'
import Link from 'next/link'
+
+import { IS_PLATFORM, useParams } from 'common'
import { Menu } from 'ui'
-import { BUCKET_TYPES, BUCKET_TYPE_KEYS, DEFAULT_BUCKET_TYPE } from './Storage.constants'
+import { BUCKET_TYPES, BUCKET_TYPE_KEYS } from './Storage.constants'
+import { useStorageV2Page } from './Storage.utils'
export const StorageMenuV2 = () => {
- const { ref, bucketType } = useParams()
- const selectedBucketType = bucketType || DEFAULT_BUCKET_TYPE
+ const { ref } = useParams()
+ const page = useStorageV2Page()
return (
-
-
-
Bucket Types} />
+
+
+
+
Manage} />
- {BUCKET_TYPE_KEYS.map((bucketTypeKey) => {
- const isSelected = selectedBucketType === bucketTypeKey
- const config = BUCKET_TYPES[bucketTypeKey]
+ {BUCKET_TYPE_KEYS.map((bucketTypeKey) => {
+ const isSelected = page === bucketTypeKey
+ const config = BUCKET_TYPES[bucketTypeKey]
- return (
-
-
- {config.displayName}
-
-
- )
- })}
+ return (
+
+
+ {config.displayName}
+
+
+ )
+ })}
+
+
+ {IS_PLATFORM && (
+ <>
+
+
+
Configuration} />
+
+
+
+ S3
+
+
+
+ >
+ )}
)
diff --git a/apps/studio/components/interfaces/Storage/StorageSettings/S3Connection.tsx b/apps/studio/components/interfaces/Storage/StorageSettings/S3Connection.tsx
index 1c3d302beea..4be54f62c57 100644
--- a/apps/studio/components/interfaces/Storage/StorageSettings/S3Connection.tsx
+++ b/apps/studio/components/interfaces/Storage/StorageSettings/S3Connection.tsx
@@ -118,13 +118,22 @@ export const S3Connection = () => {
- S3 Connection
+ Connection
- Connect to your bucket using any S3-compatible service via the S3 protocol
+ Connect to your bucket using any S3-compatible service via the S3 protocol.
+
+ {isErrorStorageConfig && (
+
+ )}
+