-
}
- onClick={handleBackToList}
- tooltip={{ content: { side: 'bottom', text: 'Back to list' } }}
- />
-
-
- {selectedItem?.title}
-
- {selectedItem && (
-
- {severityLabels[selectedItem.severity]}
-
- )}
-
-
}
- onClick={handleClose}
- tooltip={{ content: { side: 'bottom', text: 'Close Advisor Center' } }}
- />
-
{selectedItem ? (
-
+
) : (
@@ -218,118 +295,40 @@ export const AdvisorPanel = () => {
>
) : (
<>
-
-
-
-
-
- All
-
-
- Security
-
-
- Performance
-
-
-
-
- setSeverityFilters(values as AdvisorSeverity[])}
- />
- }
- onClick={handleClose}
- tooltip={{ content: { side: 'bottom', text: 'Close Advisor Center' } }}
- />
-
-
-
+
{
+ notificationFilterStatuses
+ .filter((status) => !values.includes(status))
+ .forEach((status) => setNotificationFilters(status, 'status'))
+ values
+ .filter((status) => !notificationFilterStatuses.includes(status))
+ .forEach((status) => setNotificationFilters(status, 'status'))
+ }}
+ hasProjectRef={hasProjectRef}
+ onClose={handleClose}
+ isPlatform={IS_PLATFORM}
+ />
- {isLoading ? (
-
-
-
- ) : isError ? (
-
-
-
Error loading advisories
-
Please try again later.
-
- ) : filteredItems.length === 0 ? (
-
0}
- onClearFilters={clearSeverityFilters}
- />
- ) : (
- <>
-
- {filteredItems.map((item) => {
- const SeverityIcon = tabIconMap[item.tab]
- const severityClass = severityColorClasses[item.severity]
- return (
-
-
-
- )
- })}
-
- {severityFilters.length > 0 && hiddenItemsCount > 0 && (
-
-
-
- )}
- >
- )}
+
>
)}
)
}
-
-interface AdvisorDetailProps {
- item: AdvisorItem
- projectRef: string
-}
-
-const AdvisorDetail = ({ item, projectRef }: AdvisorDetailProps) => {
- if (item.source === 'lint') {
- const lint = item.original as Lint
- return (
-
-
-
- )
- }
-}
diff --git a/apps/studio/components/ui/AdvisorPanel/AdvisorPanelBody.tsx b/apps/studio/components/ui/AdvisorPanel/AdvisorPanelBody.tsx
new file mode 100644
index 0000000000000..7b00fce3e6aa3
--- /dev/null
+++ b/apps/studio/components/ui/AdvisorPanel/AdvisorPanelBody.tsx
@@ -0,0 +1,157 @@
+import dayjs from 'dayjs'
+import { AlertTriangle, ChevronRight, Gauge, Inbox, Shield } from 'lucide-react'
+
+import { Notification } from 'data/notifications/notifications-v2-query'
+import { AdvisorSeverity, AdvisorTab } from 'state/advisor-state'
+import { Button, cn } from 'ui'
+import { GenericSkeletonLoader } from 'ui-patterns'
+import { AdvisorItem } from './AdvisorPanelHeader'
+import { EmptyAdvisor } from './EmptyAdvisor'
+
+const NoProjectNotice = () => {
+ return (
+
+
+
+
Project required
+
+ Select a project to view security and performance advisories
+
+
+
+ )
+}
+
+const tabIconMap: Record
, React.ElementType> = {
+ security: Shield,
+ performance: Gauge,
+ messages: Inbox,
+}
+
+const severityColorClasses: Record = {
+ critical: 'text-destructive',
+ warning: 'text-warning',
+ info: 'text-foreground-light',
+}
+
+interface AdvisorPanelBodyProps {
+ isLoading: boolean
+ isError: boolean
+ filteredItems: AdvisorItem[]
+ activeTab: AdvisorTab
+ severityFilters: AdvisorSeverity[]
+ onItemClick: (item: AdvisorItem) => void
+ onClearFilters: () => void
+ hiddenItemsCount: number
+ hasAnyFilters: boolean
+ hasProjectRef?: boolean
+}
+
+export const AdvisorPanelBody = ({
+ isLoading,
+ isError,
+ filteredItems,
+ activeTab,
+ severityFilters,
+ onItemClick,
+ onClearFilters,
+ hiddenItemsCount,
+ hasAnyFilters,
+ hasProjectRef = true,
+}: AdvisorPanelBodyProps) => {
+ // Show notice if no project ref and trying to view project-specific tabs
+ if (!hasProjectRef && activeTab !== 'messages') {
+ return
+ }
+
+ if (isLoading) {
+ return (
+
+
+
+ )
+ }
+
+ if (isError) {
+ return (
+
+
+
Error loading advisories
+
Please try again later.
+
+ )
+ }
+
+ if (filteredItems.length === 0) {
+ return (
+
+ )
+ }
+
+ return (
+ <>
+
+ {filteredItems.map((item) => {
+ const SeverityIcon = tabIconMap[item.tab]
+ const severityClass = severityColorClasses[item.severity]
+ const isNotification = item.source === 'notification'
+ const notification = isNotification ? (item.original as Notification) : null
+ const isUnread = notification?.status === 'new'
+
+ return (
+
+
+
+ )
+ })}
+
+ {severityFilters.length > 0 && hiddenItemsCount > 0 && (
+
+
+
+ )}
+ >
+ )
+}
diff --git a/apps/studio/components/ui/AdvisorPanel/AdvisorPanelHeader.tsx b/apps/studio/components/ui/AdvisorPanel/AdvisorPanelHeader.tsx
new file mode 100644
index 0000000000000..ae060dddb9355
--- /dev/null
+++ b/apps/studio/components/ui/AdvisorPanel/AdvisorPanelHeader.tsx
@@ -0,0 +1,77 @@
+import dayjs from 'dayjs'
+import { ChevronLeft, X } from 'lucide-react'
+
+import { ButtonTooltip } from 'components/ui/ButtonTooltip'
+import { AdvisorItemSource, AdvisorSeverity } from 'state/advisor-state'
+import { Badge } from 'ui'
+
+export type AdvisorItem = {
+ id: string
+ title: string
+ severity: AdvisorSeverity
+ createdAt?: number
+ tab: 'security' | 'performance' | 'messages'
+ source: AdvisorItemSource
+ original: any
+}
+
+export const severityBadgeVariants: Record =
+ {
+ critical: 'destructive',
+ warning: 'warning',
+ info: 'default',
+ }
+
+export const severityLabels: Record = {
+ critical: 'Critical',
+ warning: 'Warning',
+ info: 'Info',
+}
+
+interface AdvisorPanelHeaderProps {
+ selectedItem: AdvisorItem | undefined
+ onBack: () => void
+ onClose: () => void
+}
+
+export const AdvisorPanelHeader = ({ selectedItem, onBack, onClose }: AdvisorPanelHeaderProps) => {
+ return (
+
+
}
+ onClick={onBack}
+ tooltip={{ content: { side: 'bottom', text: 'Back to list' } }}
+ />
+
+
+ {selectedItem?.title?.replace(/[`\\]/g, '')}
+ {selectedItem?.createdAt && (
+
+ {(() => {
+ const insertedAt = selectedItem.createdAt
+ const daysFromNow = dayjs().diff(dayjs(insertedAt), 'day')
+ const formattedTimeFromNow = dayjs(insertedAt).fromNow()
+ const formattedInsertedAt = dayjs(insertedAt).format('MMM DD, YYYY')
+ return daysFromNow > 1 ? formattedInsertedAt : formattedTimeFromNow
+ })()}
+
+ )}
+
+ {selectedItem && (
+
+ {severityLabels[selectedItem.severity]}
+
+ )}
+
+
}
+ onClick={onClose}
+ tooltip={{ content: { side: 'bottom', text: 'Close Advisor Center' } }}
+ />
+
+ )
+}
diff --git a/apps/studio/components/ui/AdvisorPanel/EmptyAdvisor.tsx b/apps/studio/components/ui/AdvisorPanel/EmptyAdvisor.tsx
index 3096b0b19a9c1..49c1647a6aec7 100644
--- a/apps/studio/components/ui/AdvisorPanel/EmptyAdvisor.tsx
+++ b/apps/studio/components/ui/AdvisorPanel/EmptyAdvisor.tsx
@@ -33,9 +33,9 @@ export const EmptyAdvisor = ({ activeTab, hasFilters, onClearFilters }: EmptyAdv
case 'performance':
return 'Congrats! There are no performance issues detected for this project'
case 'messages':
- return 'There are no messages for this project'
+ return 'Messages alert you of upcoming changes or potential issues with your project'
default:
- return 'Congrats! There are no issues detected for this project'
+ return 'Congrats! There are no issues detected'
}
}
diff --git a/apps/studio/components/ui/AdvisorPanel/NotificationDetail.tsx b/apps/studio/components/ui/AdvisorPanel/NotificationDetail.tsx
new file mode 100644
index 0000000000000..cb8cda263e41e
--- /dev/null
+++ b/apps/studio/components/ui/AdvisorPanel/NotificationDetail.tsx
@@ -0,0 +1,113 @@
+import { Archive, ArchiveRestoreIcon, ExternalLink } from 'lucide-react'
+import Link from 'next/link'
+import { Button } from 'ui'
+import { Markdown } from 'components/interfaces/Markdown'
+import { Notification, NotificationData } from 'data/notifications/notifications-v2-query'
+import { useProjectDetailQuery } from 'data/projects/project-detail-query'
+import { useOrganizationsQuery } from 'data/organizations/organizations-query'
+
+interface NotificationDetailProps {
+ notification: Notification
+ onUpdateStatus: (id: string, status: 'archived' | 'seen') => void
+}
+
+export const NotificationDetail = ({ notification, onUpdateStatus }: NotificationDetailProps) => {
+ const data = notification.data as NotificationData
+
+ const { data: project } = useProjectDetailQuery({ ref: data.project_ref })
+ const { data: organizations } = useOrganizationsQuery()
+
+ const organization =
+ data.org_slug !== undefined
+ ? organizations?.find((org) => org.slug === data.org_slug)
+ : project !== undefined
+ ? organizations?.find((org) => org.id === project.organization_id)
+ : undefined
+
+ const onButtonAction = (type?: string) => {
+ // [Joshen] Implement accordingly - BE team will need to give us a heads up on this
+ console.log('Action', type)
+ }
+
+ return (
+
+ {(project !== undefined || organization !== undefined) && (
+ <>
+
Context
+
+ {organization !== undefined && (
+
+ {organization.name}
+
+ )}
+ {project !== undefined && (
+
+ {project.name}
+
+ )}
+
+ >
+ )}
+
+ {data.message !== undefined && (
+ <>
+
Message
+
+ >
+ )}
+
+
Actions
+
+ {(data.actions ?? []).map((action, idx) => {
+ const key = `${notification.id}-action-${idx}`
+ if (action.url !== undefined) {
+ const url = action.url.includes('[ref]')
+ ? action.url.replace('[ref]', project?.ref ?? '_')
+ : action.url.includes('[slug]')
+ ? action.url.replace('[slug]', organization?.slug ?? '_')
+ : action.url
+ return (
+ } asChild>
+
+ {action.label}
+
+
+ )
+ } else if (action.action_type !== undefined) {
+ return (
+
+ )
+ } else {
+ return null
+ }
+ })}
+ {notification.status === 'archived' ? (
+ }
+ onClick={() => onUpdateStatus(notification.id, 'seen')}
+ >
+ Unarchive
+
+ ) : (
+ }
+ onClick={() => onUpdateStatus(notification.id, 'archived')}
+ >
+ Archive
+
+ )}
+
+
+ )
+}
diff --git a/apps/studio/components/ui/FilterPopover.tsx b/apps/studio/components/ui/FilterPopover.tsx
index 4ae0b2365a5e0..6ac0bf7ae4688 100644
--- a/apps/studio/components/ui/FilterPopover.tsx
+++ b/apps/studio/components/ui/FilterPopover.tsx
@@ -30,6 +30,7 @@ interface FilterPopoverProps {
maxHeightClass?: string
clearButtonText?: string
className?: string
+ isMinimized?: boolean
onSaveFilters: (options: string[]) => void
// [Joshen] These props are to support async data with infinite loading if applicable
@@ -60,6 +61,7 @@ export const FilterPopover = >({
className,
maxHeightClass = 'h-[205px]',
clearButtonText = 'Clear',
+ isMinimized = false,
onSaveFilters,
search,
@@ -127,14 +129,20 @@ export const FilterPopover = >({
>
{name}
- {activeOptions.length > 0 && :}
- {activeOptions.length >= 3 ? (
-
- {formattedOptions[0]} and {activeOptions.length - 1} others
-
- ) : activeOptions.length > 0 ? (
- {formattedOptions.join(', ')}
- ) : null}
+ {activeOptions.length > 0 && (
+ <>
+ :
+ {isMinimized ? (
+ {activeOptions.length}
+ ) : activeOptions.length >= 3 ? (
+
+ {formattedOptions[0]} and {activeOptions.length - 1} others
+
+ ) : (
+ {formattedOptions.join(', ')}
+ )}
+ >
+ )}
diff --git a/apps/studio/data/etl/create-destination-pipeline-mutation.ts b/apps/studio/data/etl/create-destination-pipeline-mutation.ts
index a313280119d0d..78476e137d7d9 100644
--- a/apps/studio/data/etl/create-destination-pipeline-mutation.ts
+++ b/apps/studio/data/etl/create-destination-pipeline-mutation.ts
@@ -16,7 +16,7 @@ export type BigQueryDestinationConfig = {
export type IcebergDestinationConfig = {
projectRef: string
warehouseName: string
- namespace: string
+ namespace?: string
catalogToken: string
s3AccessKeyId: string
s3SecretAccessKey: string
@@ -59,6 +59,7 @@ async function createDestinationPipeline(
if ('bigQuery' in destinationConfig) {
const { projectId, datasetId, serviceAccountKey, maxStalenessMins } = destinationConfig.bigQuery
+
destination_config = {
big_query: {
project_id: projectId,
@@ -70,19 +71,20 @@ async function createDestinationPipeline(
} else if ('iceberg' in destinationConfig) {
const {
projectRef: icebergProjectRef,
- warehouseName,
namespace,
+ warehouseName,
catalogToken,
s3AccessKeyId,
s3SecretAccessKey,
s3Region,
} = destinationConfig.iceberg
+
destination_config = {
iceberg: {
supabase: {
+ namespace,
project_ref: icebergProjectRef,
warehouse_name: warehouseName,
- namespace: namespace,
catalog_token: catalogToken,
s3_access_key_id: s3AccessKeyId,
s3_secret_access_key: s3SecretAccessKey,
diff --git a/apps/studio/data/etl/destinations-query.ts b/apps/studio/data/etl/destinations-query.ts
index 9c73561f751d6..961c49ebc7a09 100644
--- a/apps/studio/data/etl/destinations-query.ts
+++ b/apps/studio/data/etl/destinations-query.ts
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query'
import { get, handleError } from 'data/fetchers'
import type { ResponseError, UseCustomQueryOptions } from 'types'
import { replicationKeys } from './keys'
+import { checkReplicationFeatureFlagRetry } from './utils'
type ReplicationDestinationsParams = { projectRef?: string }
@@ -36,5 +37,8 @@ export const useReplicationDestinationsQuery = fetchReplicationDestinations({ projectRef }, signal),
enabled: enabled && typeof projectRef !== 'undefined',
+ refetchOnMount: false,
+ refetchOnWindowFocus: false,
+ retry: checkReplicationFeatureFlagRetry,
...options,
})
diff --git a/apps/studio/data/etl/pipelines-query.ts b/apps/studio/data/etl/pipelines-query.ts
index 5e9ed35d57a00..7d1074409e159 100644
--- a/apps/studio/data/etl/pipelines-query.ts
+++ b/apps/studio/data/etl/pipelines-query.ts
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query'
import { get, handleError } from 'data/fetchers'
import type { ResponseError, UseCustomQueryOptions } from 'types'
import { replicationKeys } from './keys'
+import { checkReplicationFeatureFlagRetry } from './utils'
type ReplicationPipelinesParams = { projectRef?: string }
@@ -16,10 +17,8 @@ async function fetchReplicationPipelines(
params: { path: { ref: projectRef } },
signal,
})
- if (error) {
- handleError(error)
- }
+ if (error) handleError(error)
return data
}
@@ -37,5 +36,8 @@ export const useReplicationPipelinesQuery = (
queryKey: replicationKeys.pipelines(projectRef),
queryFn: ({ signal }) => fetchReplicationPipelines({ projectRef }, signal),
enabled: enabled && typeof projectRef !== 'undefined',
+ refetchOnMount: false,
+ refetchOnWindowFocus: false,
+ retry: checkReplicationFeatureFlagRetry,
...options,
})
diff --git a/apps/studio/data/etl/create-publication-mutation.ts b/apps/studio/data/etl/publication-create-mutation.ts
similarity index 100%
rename from apps/studio/data/etl/create-publication-mutation.ts
rename to apps/studio/data/etl/publication-create-mutation.ts
diff --git a/apps/studio/data/etl/publication-update-mutation.ts b/apps/studio/data/etl/publication-update-mutation.ts
new file mode 100644
index 0000000000000..7eb3789895e0a
--- /dev/null
+++ b/apps/studio/data/etl/publication-update-mutation.ts
@@ -0,0 +1,66 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { toast } from 'sonner'
+
+import { handleError, post } from 'data/fetchers'
+import type { ResponseError, UseCustomMutationOptions } from 'types'
+import { replicationKeys } from './keys'
+
+export type UpdatePublicationParams = {
+ projectRef: string
+ sourceId: number
+ publicationName: string
+ tables: { schema: string; name: string }[]
+}
+
+async function updatePublication(
+ { projectRef, sourceId, publicationName, tables }: UpdatePublicationParams,
+ signal?: AbortSignal
+) {
+ if (!projectRef) throw new Error('projectRef is required')
+
+ const { data, error } = await post(
+ '/platform/replication/{ref}/sources/{source_id}/publications/{publication_name}',
+ {
+ params: { path: { ref: projectRef, source_id: sourceId, publication_name: publicationName } },
+ body: { tables },
+ signal,
+ }
+ )
+ if (error) {
+ handleError(error)
+ }
+
+ return data
+}
+
+type UpdatePublicationData = Awaited>
+
+export const useUpdatePublicationMutation = ({
+ onSuccess,
+ onError,
+ ...options
+}: Omit<
+ UseCustomMutationOptions,
+ 'mutationFn'
+> = {}) => {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: (vars) => updatePublication(vars),
+ async onSuccess(data, variables, context) {
+ const { projectRef, sourceId } = variables
+ await queryClient.invalidateQueries({
+ queryKey: replicationKeys.publications(projectRef, sourceId),
+ })
+ await onSuccess?.(data, variables, context)
+ },
+ async onError(data, variables, context) {
+ if (onError === undefined) {
+ toast.error(`Failed to update publication: ${data.message}`)
+ } else {
+ onError(data, variables, context)
+ }
+ },
+ ...options,
+ })
+}
diff --git a/apps/studio/data/etl/sources-query.ts b/apps/studio/data/etl/sources-query.ts
index 8561af72a1d41..54e5a9429806b 100644
--- a/apps/studio/data/etl/sources-query.ts
+++ b/apps/studio/data/etl/sources-query.ts
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query'
import { get, handleError } from 'data/fetchers'
import type { ResponseError, UseCustomQueryOptions } from 'types'
import { replicationKeys } from './keys'
+import { checkReplicationFeatureFlagRetry } from './utils'
type ReplicationSourcesParams = { projectRef?: string }
@@ -36,5 +37,8 @@ export const useReplicationSourcesQuery = (
queryKey: replicationKeys.sources(projectRef),
queryFn: ({ signal }) => fetchReplicationSources({ projectRef }, signal),
enabled: enabled && typeof projectRef !== 'undefined',
+ refetchOnMount: false,
+ refetchOnWindowFocus: false,
+ retry: checkReplicationFeatureFlagRetry,
...options,
})
diff --git a/apps/studio/data/etl/tables-query.ts b/apps/studio/data/etl/tables-query.ts
index 0635055aba39a..bcfa3a9f79ec0 100644
--- a/apps/studio/data/etl/tables-query.ts
+++ b/apps/studio/data/etl/tables-query.ts
@@ -21,7 +21,7 @@ async function fetchReplicationTables(
handleError(error)
}
- return data
+ return data.tables
}
export type ReplicationTablesData = Awaited>
diff --git a/apps/studio/data/etl/utils.ts b/apps/studio/data/etl/utils.ts
new file mode 100644
index 0000000000000..76d48cda2b13d
--- /dev/null
+++ b/apps/studio/data/etl/utils.ts
@@ -0,0 +1,22 @@
+import { MAX_RETRY_FAILURE_COUNT } from 'data/query-client'
+import { ResponseError } from 'types'
+
+export const checkReplicationFeatureFlagRetry = (
+ failureCount: number,
+ error: ResponseError
+): boolean => {
+ const isFeatureFlagRequiredError =
+ error instanceof ResponseError &&
+ error.code === 503 &&
+ error.message.includes('feature flag is required')
+
+ if (isFeatureFlagRequiredError) {
+ return false
+ }
+
+ if (failureCount < MAX_RETRY_FAILURE_COUNT) {
+ return true
+ }
+
+ return false
+}
diff --git a/apps/studio/data/organizations/organization-billing-subscription-preview.ts b/apps/studio/data/organizations/organization-billing-subscription-preview.ts
index e8e2e2d4903bf..de845902c8fcd 100644
--- a/apps/studio/data/organizations/organization-billing-subscription-preview.ts
+++ b/apps/studio/data/organizations/organization-billing-subscription-preview.ts
@@ -88,21 +88,4 @@ export const useOrganizationBillingSubscriptionPreview = <
queryFn: () => previewOrganizationBillingSubscription({ organizationSlug, tier }),
enabled: enabled && typeof organizationSlug !== 'undefined' && typeof tier !== 'undefined',
...options,
- retry: (failureCount, error) => {
- // Don't retry on 400s
- if (
- typeof error === 'object' &&
- error !== null &&
- 'code' in error &&
- (error as any).code === 400
- ) {
- return false
- }
-
- if (failureCount < 3) {
- return true
- }
-
- return false
- },
})
diff --git a/apps/studio/data/projects/project-transfer-preview-query.ts b/apps/studio/data/projects/project-transfer-preview-query.ts
index 3f7d7be8bb7f1..71624745a4da2 100644
--- a/apps/studio/data/projects/project-transfer-preview-query.ts
+++ b/apps/studio/data/projects/project-transfer-preview-query.ts
@@ -1,8 +1,8 @@
import { useQuery } from '@tanstack/react-query'
import { handleError, post } from 'data/fetchers'
-import { projectKeys } from './keys'
import { UseCustomQueryOptions } from 'types'
+import { projectKeys } from './keys'
export type ProjectTransferPreviewVariables = {
projectRef?: string
@@ -44,21 +44,4 @@ export const useProjectTransferPreviewQuery = {
- // Don't retry on 400s
- if (
- typeof error === 'object' &&
- error !== null &&
- 'code' in error &&
- (error as any).code === 400
- ) {
- return false
- }
-
- if (failureCount < 3) {
- return true
- }
-
- return false
- },
})
diff --git a/apps/studio/data/query-client.ts b/apps/studio/data/query-client.ts
index e539a31e24e7f..3587efc79485f 100644
--- a/apps/studio/data/query-client.ts
+++ b/apps/studio/data/query-client.ts
@@ -18,6 +18,8 @@ const SKIP_RETRY_PATHNAME_MATCHERS = [
'/v1/projects/:ref/analytics/endpoints/logs.all',
].map((pathname) => match(pathname))
+export const MAX_RETRY_FAILURE_COUNT = 3
+
let queryClient: QueryClient | undefined
export function getQueryClient() {
@@ -55,7 +57,7 @@ export function getQueryClient() {
return false
}
- if (failureCount < 3) {
+ if (failureCount < MAX_RETRY_FAILURE_COUNT) {
return true
}
diff --git a/apps/studio/data/storage/buckets-query.ts b/apps/studio/data/storage/buckets-query.ts
index 0cb30a07c197f..158777e2e7b67 100644
--- a/apps/studio/data/storage/buckets-query.ts
+++ b/apps/studio/data/storage/buckets-query.ts
@@ -2,9 +2,10 @@ import { useQuery } from '@tanstack/react-query'
import { components } from 'api-types'
import { get, handleError } from 'data/fetchers'
+import { MAX_RETRY_FAILURE_COUNT } from 'data/query-client'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { PROJECT_STATUS } from 'lib/constants'
-import type { ResponseError, UseCustomQueryOptions } from 'types'
+import { ResponseError, type UseCustomQueryOptions } from 'types'
import { storageKeys } from './keys'
export type BucketsVariables = { projectRef?: string }
@@ -41,15 +42,11 @@ export const useBucketsQuery = (
enabled: enabled && typeof projectRef !== 'undefined' && isActive,
...options,
retry: (failureCount, error) => {
- if (
- typeof error === 'object' &&
- error !== null &&
- error.message.includes('Missing tenant config')
- ) {
+ if (error instanceof ResponseError && error.message.includes('Missing tenant config')) {
return false
}
- if (failureCount < 3) {
+ if (failureCount < MAX_RETRY_FAILURE_COUNT) {
return true
}
diff --git a/apps/studio/data/storage/iceberg-namespace-create-mutation.ts b/apps/studio/data/storage/iceberg-namespace-create-mutation.ts
index ce1a74696f3bc..9181128a1b5ee 100644
--- a/apps/studio/data/storage/iceberg-namespace-create-mutation.ts
+++ b/apps/studio/data/storage/iceberg-namespace-create-mutation.ts
@@ -90,7 +90,7 @@ export const useIcebergNamespaceCreateMutation = ({
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
- if ((data.message = 'Request failed with status code 409')) {
+ if (data.message === 'Request failed with status code 409') {
toast.error(`A namespace named ${variables.namespace} already exists in the catalog.`)
return
}
diff --git a/apps/studio/state/advisor-state.ts b/apps/studio/state/advisor-state.ts
index a5839330a32ec..809bdfb81807c 100644
--- a/apps/studio/state/advisor-state.ts
+++ b/apps/studio/state/advisor-state.ts
@@ -2,11 +2,19 @@ import { proxy, snapshot, useSnapshot } from 'valtio'
export type AdvisorTab = 'all' | 'security' | 'performance' | 'messages'
export type AdvisorSeverity = 'critical' | 'warning' | 'info'
+export type AdvisorItemSource = 'lint' | 'notification'
const initialState = {
activeTab: 'all' as AdvisorTab,
- severityFilters: ['critical'] as AdvisorSeverity[],
+ severityFilters: ['critical', 'warning'] as AdvisorSeverity[],
selectedItemId: undefined as string | undefined,
+ selectedItemSource: undefined as AdvisorItemSource | undefined,
+ // Notification filters
+ notificationFilterStatuses: [] as string[],
+ notificationFilterPriorities: [] as string[],
+ get numNotificationFiltersApplied() {
+ return [...this.notificationFilterStatuses, ...this.notificationFilterPriorities].length
+ },
}
export const advisorState = proxy({
@@ -20,14 +28,44 @@ export const advisorState = proxy({
clearSeverityFilters() {
advisorState.severityFilters = []
},
- setSelectedItemId(id: string | undefined) {
+ setSelectedItem(id: string | undefined, source?: AdvisorItemSource) {
advisorState.selectedItemId = id
+ advisorState.selectedItemSource = source
},
- focusItem({ id, tab }: { id: string; tab?: AdvisorTab }) {
+ focusItem({ id, tab, source }: { id: string; tab?: AdvisorTab; source?: AdvisorItemSource }) {
if (tab) {
advisorState.activeTab = tab
}
advisorState.selectedItemId = id
+ advisorState.selectedItemSource = source
+ },
+ setNotificationFilters: (value: string, type: 'status' | 'priority') => {
+ switch (type) {
+ case 'status':
+ if (advisorState.notificationFilterStatuses.includes(value)) {
+ advisorState.notificationFilterStatuses = advisorState.notificationFilterStatuses.filter(
+ (x) => x !== value
+ )
+ } else {
+ advisorState.notificationFilterStatuses = advisorState.notificationFilterStatuses.concat([
+ value,
+ ])
+ }
+ break
+ case 'priority':
+ if (advisorState.notificationFilterPriorities.includes(value)) {
+ advisorState.notificationFilterPriorities =
+ advisorState.notificationFilterPriorities.filter((x) => x !== value)
+ } else {
+ advisorState.notificationFilterPriorities =
+ advisorState.notificationFilterPriorities.concat([value])
+ }
+ break
+ }
+ },
+ resetNotificationFilters() {
+ advisorState.notificationFilterStatuses = []
+ advisorState.notificationFilterPriorities = []
},
reset() {
Object.assign(advisorState, initialState)
diff --git a/apps/studio/state/notifications.ts b/apps/studio/state/notifications.ts
deleted file mode 100644
index 4a1c0be56bb6d..0000000000000
--- a/apps/studio/state/notifications.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-import { proxy, snapshot, useSnapshot } from 'valtio'
-
-export const notificationsState = proxy({
- filterStatuses: [] as string[],
- filterPriorities: [] as string[],
- filterOrganizations: [] as string[],
- filterProjects: [] as string[],
- get numFiltersApplied() {
- return [
- ...this.filterStatuses,
- ...this.filterPriorities,
- ...this.filterOrganizations,
- ...this.filterProjects,
- ].length
- },
- setFilters: (value: string, type: 'status' | 'priority' | 'organizations' | 'projects') => {
- switch (type) {
- case 'status':
- if (notificationsState.filterStatuses.includes(value)) {
- notificationsState.filterStatuses = notificationsState.filterStatuses.filter(
- (x) => x !== value
- )
- } else {
- notificationsState.filterStatuses = notificationsState.filterStatuses.concat([value])
- }
- break
- case 'priority':
- if (notificationsState.filterPriorities.includes(value)) {
- notificationsState.filterPriorities = notificationsState.filterPriorities.filter(
- (x) => x !== value
- )
- } else {
- notificationsState.filterPriorities = notificationsState.filterPriorities.concat([value])
- }
- break
- case 'organizations':
- if (notificationsState.filterOrganizations.includes(value)) {
- notificationsState.filterOrganizations = notificationsState.filterOrganizations.filter(
- (x) => x !== value
- )
- } else {
- notificationsState.filterOrganizations = notificationsState.filterOrganizations.concat([
- value,
- ])
- }
- break
- case 'projects':
- if (notificationsState.filterProjects.includes(value)) {
- notificationsState.filterProjects = notificationsState.filterProjects.filter(
- (x) => x !== value
- )
- } else {
- notificationsState.filterProjects = notificationsState.filterProjects.concat([value])
- }
- break
- }
- },
- resetFilters: () => {
- notificationsState.filterStatuses = []
- notificationsState.filterPriorities = []
- notificationsState.filterOrganizations = []
- notificationsState.filterProjects = []
- },
-})
-
-export const getNotificationsStateSnapshot = () => snapshot(notificationsState)
-
-export const useNotificationsStateSnapshot = (options?: Parameters[1]) =>
- useSnapshot(notificationsState, options)
diff --git a/apps/studio/state/sidebar-manager-state.tsx b/apps/studio/state/sidebar-manager-state.tsx
index e0e48404090a4..5c12856a94bc7 100644
--- a/apps/studio/state/sidebar-manager-state.tsx
+++ b/apps/studio/state/sidebar-manager-state.tsx
@@ -1,6 +1,7 @@
import { LOCAL_STORAGE_KEYS } from 'common/constants'
+import useLatest from 'hooks/misc/useLatest'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
-import { ReactNode, useEffect } from 'react'
+import { ReactNode, useEffect, useRef } from 'react'
import { proxy, snapshot, useSnapshot } from 'valtio'
type SidebarHandlers = {
@@ -143,26 +144,26 @@ export const useRegisterSidebar = (
id: string,
component: () => ReactNode,
handlers: SidebarHandlers = {},
- hotKey?: string
+ hotKey?: string,
+ enabled?: boolean
) => {
const [isSidebarHotkeyEnabled] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.HOTKEY_SIDEBAR(id),
true
)
- useEffect(() => {
- const { registerSidebar, unregisterSidebar, sidebars } = sidebarManagerState
+ const componentRef = useLatest(component)
+ const handlersRef = useLatest(handlers)
- if (!sidebars[id]) {
- registerSidebar(id, component, handlers)
+ useEffect(() => {
+ if (enabled) {
+ sidebarManagerState.registerSidebar(id, () => componentRef.current(), handlersRef.current)
}
return () => {
- if (sidebars[id]) {
- unregisterSidebar(id)
- }
+ sidebarManagerState.unregisterSidebar(id)
}
- }, [id])
+ }, [id, enabled])
useEffect(() => {
if (!hotKey) return
diff --git a/packages/common/enabled-features/enabled-features.json b/packages/common/enabled-features/enabled-features.json
index 30a32b68b201a..e86431605becd 100644
--- a/packages/common/enabled-features/enabled-features.json
+++ b/packages/common/enabled-features/enabled-features.json
@@ -52,6 +52,7 @@
"docs:contribution": true,
"docs:fdw": true,
"docs:footer": true,
+ "docs:navigation_dropdown_links_home": true,
"docs:self-hosting": true,
"docs:framework_quickstarts": true,
"docs:full_getting_started": true,
diff --git a/packages/common/enabled-features/enabled-features.schema.json b/packages/common/enabled-features/enabled-features.schema.json
index e737c7c36d77e..3c4520832aa24 100644
--- a/packages/common/enabled-features/enabled-features.schema.json
+++ b/packages/common/enabled-features/enabled-features.schema.json
@@ -181,6 +181,10 @@
"type": "boolean",
"description": "Enable footer on docs site"
},
+ "docs:navigation_dropdown_links_home": {
+ "type": "boolean",
+ "description": "Should the navigation dropdown homepage link go to Supabase.com or the dashboard"
+ },
"docs:framework_quickstarts": {
"type": "boolean",
"description": "Enable framework quickstarts documentation"
diff --git a/packages/common/telemetry.tsx b/packages/common/telemetry.tsx
index 64f77f1d503da..c4f8c4fc17cdc 100644
--- a/packages/common/telemetry.tsx
+++ b/packages/common/telemetry.tsx
@@ -172,6 +172,9 @@ export const PageTelemetry = ({
// Handle initial page telemetry event
const hasSentInitialPageTelemetryRef = useRef(false)
+ // Track previous pathname for App Router to detect actual changes
+ const previousAppPathnameRef = useRef(null)
+
// Initialize PostHog client when consent is accepted
useEffect(() => {
if (hasAcceptedConsent && IS_PLATFORM) {
@@ -239,10 +242,13 @@ export const PageTelemetry = ({
// For app router
if (router !== null) return
- // Wait until we've sent the initial page telemetry event
- if (appPathname && !hasSentInitialPageTelemetryRef.current) {
+ // Only track if pathname actually changed (not initial mount)
+ if (appPathname && previousAppPathnameRef.current !== null && previousAppPathnameRef.current !== appPathname) {
sendPageTelemetry()
}
+
+ // Update previous pathname
+ previousAppPathnameRef.current = appPathname
}, [appPathname, router, sendPageTelemetry])
useEffect(() => {
diff --git a/packages/ui/src/components/shadcn/ui/badge.tsx b/packages/ui/src/components/shadcn/ui/badge.tsx
index 343c1a5c80479..0c32c9352db64 100644
--- a/packages/ui/src/components/shadcn/ui/badge.tsx
+++ b/packages/ui/src/components/shadcn/ui/badge.tsx
@@ -4,7 +4,7 @@ import * as React from 'react'
import { cn } from '../../../lib/utils/cn'
const badgeVariants = cva(
- 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs bg-opacity-10',
+ 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs bg-opacity-10 whitespace-nowrap',
{
variants: {
variant: {
diff --git a/packages/ui/src/components/shadcn/ui/table.tsx b/packages/ui/src/components/shadcn/ui/table.tsx
index 33fa0c8a9dbbe..fefe89672164d 100644
--- a/packages/ui/src/components/shadcn/ui/table.tsx
+++ b/packages/ui/src/components/shadcn/ui/table.tsx
@@ -68,7 +68,7 @@ const TableHead = React.forwardRef<
|