diff --git a/.gitignore b/.gitignore index 4105694af0a2f..25662fb0017c2 100644 --- a/.gitignore +++ b/.gitignore @@ -116,6 +116,10 @@ next-env.d.ts .idea .vercel +# AI assistant local files +.serena/ +CLAUDE.md + #include template .env file for docker-compose !docker/.env !docker/supabase/.env diff --git a/apps/design-system/content/docs/components/table.mdx b/apps/design-system/content/docs/components/table.mdx index ff05bebe235e7..ab2cc6bf5dc55 100644 --- a/apps/design-system/content/docs/components/table.mdx +++ b/apps/design-system/content/docs/components/table.mdx @@ -1,44 +1,16 @@ --- title: Table -description: A responsive table component. +description: A responsive table component for basic data. component: true -source: - shadcn: true --- - - -## Installation - - - - - CLI - Manual - - - -```bash -npx shadcn-ui@latest add table -``` - - +Table presents basic tabular data presentation within a [Card](/docs/components/card). - - - - -Copy and paste the following code into your project. - - - -Update the import paths to match your project setup. - - + - +Columns will naturally take the width of their content. Width specification is therefore not required unless you require columns to span a specific width. An example use-case may be when placing multiple Table instances on the one page, where columns on each table should optically align. - +`TableHead` includes a `whitespace-nowrap` utility class to prevent its text from wrapping. `TableCell` must have any custom width specified, as there are legitimate use cases for both wrapping (or truncated) text. ## Usage @@ -59,7 +31,7 @@ import { A list of your recent invoices. - Invoice + Invoice Status Method Amount diff --git a/apps/design-system/registry/default/example/table-demo.tsx b/apps/design-system/registry/default/example/table-demo.tsx index d3893e1864982..6ee6dad665a74 100644 --- a/apps/design-system/registry/default/example/table-demo.tsx +++ b/apps/design-system/registry/default/example/table-demo.tsx @@ -1,4 +1,5 @@ import { + Card, Table, TableBody, TableCaption, @@ -14,74 +15,87 @@ const invoices = [ invoice: 'INV001', paymentStatus: 'Paid', totalAmount: '$250.00', - paymentMethod: 'Credit Card', + paymentMethod: 'Credit card', + description: 'Website design services', }, { invoice: 'INV002', paymentStatus: 'Pending', totalAmount: '$150.00', paymentMethod: 'PayPal', + description: 'Monthly subscription fee', }, { invoice: 'INV003', paymentStatus: 'Unpaid', totalAmount: '$350.00', - paymentMethod: 'Bank Transfer', + paymentMethod: 'Bank transfer', + description: 'Consulting hours', }, { invoice: 'INV004', paymentStatus: 'Paid', totalAmount: '$450.00', - paymentMethod: 'Credit Card', + paymentMethod: 'Credit card', + description: 'Software license renewal', }, { invoice: 'INV005', paymentStatus: 'Paid', totalAmount: '$550.00', paymentMethod: 'PayPal', + description: 'Custom development work', }, { invoice: 'INV006', paymentStatus: 'Pending', totalAmount: '$200.00', - paymentMethod: 'Bank Transfer', + paymentMethod: 'Bank transfer', + description: 'Hosting and maintenance', }, { invoice: 'INV007', paymentStatus: 'Unpaid', totalAmount: '$300.00', - paymentMethod: 'Credit Card', + paymentMethod: 'Credit card', + description: 'Training session package', }, ] export default function TableDemo() { return ( - - A list of your recent invoices. - - - Invoice - Status - Method - Amount - - - - {invoices.map((invoice) => ( - - {invoice.invoice} - {invoice.paymentStatus} - {invoice.paymentMethod} - {invoice.totalAmount} + +
+ A list of your recent invoices + + + Invoice + Status + Method + Description + Amount - ))} - - - - Total - $2,500.00 - - -
+
+ + {invoices.map((invoice) => ( + + {invoice.invoice} + {invoice.paymentStatus} + {invoice.paymentMethod} + + {invoice.description} + + {invoice.totalAmount} + + ))} + + + + Total + $2,250.00 + + + + ) } diff --git a/apps/docs/components/Navigation/NavigationMenu/TopNavDropdown.tsx b/apps/docs/components/Navigation/NavigationMenu/TopNavDropdown.tsx index 7d7999c63c8c8..bb656aa3b0d80 100644 --- a/apps/docs/components/Navigation/NavigationMenu/TopNavDropdown.tsx +++ b/apps/docs/components/Navigation/NavigationMenu/TopNavDropdown.tsx @@ -20,18 +20,25 @@ import { themes, } from 'ui' import MenuIconPicker from './MenuIconPicker' +import { isFeatureEnabled } from 'common' const menu = [ [ - { - label: 'Supabase.com', - icon: 'home', - href: 'https://supabase.com', - otherProps: { - target: '_blank', - rel: 'noreferrer noopener', - }, - }, + isFeatureEnabled('docs:navigation_dropdown_links_home') + ? { + label: 'Supabase.com', + icon: 'home', + href: 'https://supabase.com', + otherProps: { + target: '_blank', + rel: 'noreferrer noopener', + }, + } + : { + label: 'Dashboard', + icon: 'home', + href: '../dashboard', + }, { label: 'GitHub', icon: 'github', diff --git a/apps/docs/components/Navigation/NavigationMenu/useDropdownMenu.tsx b/apps/docs/components/Navigation/NavigationMenu/useDropdownMenu.tsx index ab0fb45827e31..ececa37033a04 100644 --- a/apps/docs/components/Navigation/NavigationMenu/useDropdownMenu.tsx +++ b/apps/docs/components/Navigation/NavigationMenu/useDropdownMenu.tsx @@ -1,8 +1,8 @@ 'use client' import type { User } from '@supabase/supabase-js' -import { LogOut, Globe, LifeBuoy, Settings, UserIcon, Database } from 'lucide-react' -import { logOut } from 'common' +import { isFeatureEnabled, logOut } from 'common' +import { Database, Globe, Home, LifeBuoy, LogOut, Settings, UserIcon } from 'lucide-react' import type { menuItem } from 'ui-patterns/AuthenticatedDropdownMenu' import { IconGitHub } from './MenuIcons' @@ -27,15 +27,21 @@ const useDropdownMenu = (user: User | null) => { }, ], [ - { - label: 'Supabase.com', - icon: Globe, - href: 'https://supabase.com', - otherProps: { - target: '_blank', - rel: 'noreferrer noopener', - }, - }, + isFeatureEnabled('docs:navigation_dropdown_links_home') + ? { + label: 'Supabase.com', + icon: Globe, + href: 'https://supabase.com', + otherProps: { + target: '_blank', + rel: 'noreferrer noopener', + }, + } + : { + label: 'Dashboard', + icon: Home, + href: '../dashboard', + }, { label: 'GitHub', icon: IconGitHub as any, diff --git a/apps/studio/components/interfaces/Database/ETL/Destinations.tsx b/apps/studio/components/interfaces/Database/ETL/Destinations.tsx index 7432e6cd173fc..dca4d6fdd74fe 100644 --- a/apps/studio/components/interfaces/Database/ETL/Destinations.tsx +++ b/apps/studio/components/interfaces/Database/ETL/Destinations.tsx @@ -117,8 +117,6 @@ export const Destinations = () => {
- {(isSourcesLoading || isDestinationsLoading) && } - {(isSourcesError || isDestinationsError) && ( { /> )} - {replicationNotEnabled ? ( + {isSourcesLoading || isDestinationsLoading ? ( + + ) : replicationNotEnabled ? (

Stream data to external destinations in real-time

diff --git a/apps/studio/components/interfaces/Database/ETL/NewPublicationPanel.tsx b/apps/studio/components/interfaces/Database/ETL/NewPublicationPanel.tsx index a83ce63f42d00..72d8d7e71947b 100644 --- a/apps/studio/components/interfaces/Database/ETL/NewPublicationPanel.tsx +++ b/apps/studio/components/interfaces/Database/ETL/NewPublicationPanel.tsx @@ -5,7 +5,7 @@ import { toast } from 'sonner' import { z } from 'zod' import { useParams } from 'common' -import { useCreatePublicationMutation } from 'data/etl/create-publication-mutation' +import { useCreatePublicationMutation } from 'data/etl/publication-create-mutation' import { useReplicationTablesQuery } from 'data/etl/tables-query' import { Button, @@ -139,7 +139,7 @@ export const NewPublicationPanel = ({ visible, sourceId, onClose }: NewPublicati - {tables?.tables.map((table) => ( + {tables?.map((table) => ( { {isSuccess && publications.map((x) => ( - - {x.name} - {/* [Joshen] Making this tooltip very specific for these 2 publications */} - {['supabase_realtime', 'supabase_realtime_messages_publication'].includes( - x.name - ) && ( - - - - - - {x.name === 'supabase_realtime' - ? 'This publication is managed by Supabase and handles Postgres changes' - : x.name === 'supabase_realtime_messages_publication' - ? 'This publication is managed by Supabase and handles broadcasts from the database' - : undefined} - - - )} + +
+ {x.name} + {/* [Joshen] Making this tooltip very specific for these 2 publications */} + {['supabase_realtime', 'supabase_realtime_messages_publication'].includes( + x.name + ) && ( + + + + + + {x.name === 'supabase_realtime' + ? 'Managed by Supabase and handles Postgres changes' + : x.name === 'supabase_realtime_messages_publication' + ? 'Managed by Supabase and handles broadcasts from the database' + : undefined} + + + )} +
{x.id} {publicationEvents.map((event) => ( diff --git a/apps/studio/components/interfaces/Database/Publications/PublicationsTableItem.tsx b/apps/studio/components/interfaces/Database/Publications/PublicationsTableItem.tsx index c5aec787a996d..62b689f984384 100644 --- a/apps/studio/components/interfaces/Database/Publications/PublicationsTableItem.tsx +++ b/apps/studio/components/interfaces/Database/Publications/PublicationsTableItem.tsx @@ -78,8 +78,8 @@ export const PublicationsTableItem = ({ return ( {table.name} - {table.schema} - + {table.schema} + {table.comment} diff --git a/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx b/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx index d3e77df1c6329..f543e5469dc26 100644 --- a/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx +++ b/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx @@ -102,7 +102,7 @@ export const PublicationsTables = () => { Name Schema - Description + Description {/* We've disabled All tables toggle for publications. See https://github.com/supabase/supabase/pull/7233. diff --git a/apps/studio/components/interfaces/Database/Tables/TableList.tsx b/apps/studio/components/interfaces/Database/Tables/TableList.tsx index f88f13ebe16b6..cfaa15e552c09 100644 --- a/apps/studio/components/interfaces/Database/Tables/TableList.tsx +++ b/apps/studio/components/interfaces/Database/Tables/TableList.tsx @@ -20,6 +20,7 @@ import { useRouter } from 'next/router' import { useState } from 'react' import { useParams } from 'common' +import { LOAD_TAB_FROM_CACHE_PARAM } from 'components/grid/SupabaseGrid.utils' import AlertError from 'components/ui/AlertError' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { DropdownMenuItemTooltip } from 'components/ui/DropdownMenuItemTooltip' @@ -63,7 +64,6 @@ import { } from 'ui' import { ProtectedSchemaWarning } from '../ProtectedSchemaWarning' import { formatAllEntities } from './Tables.utils' -import { LOAD_TAB_FROM_CACHE_PARAM } from 'components/grid/SupabaseGrid.utils' interface TableListProps { onAddTable: () => void @@ -325,7 +325,7 @@ export const TableList = ({ Size (Estimated) - + Realtime Enabled @@ -457,11 +457,11 @@ export const TableList = ({ {(realtimePublication?.tables ?? []).find( (table) => table.id === x.id ) ? ( -
+
) : ( -
+
)} diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecret.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecret.tsx index 9e9aaf20ecc28..235f0284c584a 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecret.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecret.tsx @@ -37,7 +37,7 @@ const EdgeFunctionSecret = ({ secret, onSelectDelete }: EdgeFunctionSecretProps) displayAs="utc" utcTimestamp={secret.updated_at} labelFormat="DD MMM YYYY HH:mm:ss (ZZ)" - className="!text-sm text-foreground-light" + className="!text-sm text-foreground-light whitespace-nowrap" /> ) : ( '-' diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionsListItem.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionsListItem.tsx index 9056d0f55ccb5..358c8fc2ad6b3 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionsListItem.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionsListItem.tsx @@ -37,9 +37,7 @@ export const EdgeFunctionsListItem = ({ function: item }: EdgeFunctionsListItemP className="cursor-pointer" > -
-

{item.name}

-
+

{item.name}

@@ -80,7 +78,7 @@ export const EdgeFunctionsListItem = ({ function: item }: EdgeFunctionsListItemP - +

{dayjs(item.updated_at).fromNow()}

diff --git a/apps/studio/components/interfaces/Home/AdvisorWidget.tsx b/apps/studio/components/interfaces/Home/AdvisorWidget.tsx index b6212222f1aeb..931c4265c90ab 100644 --- a/apps/studio/components/interfaces/Home/AdvisorWidget.tsx +++ b/apps/studio/components/interfaces/Home/AdvisorWidget.tsx @@ -49,7 +49,7 @@ export const AdvisorWidget = () => { ) const snap = useAiAssistantStateSnapshot() const { openSidebar } = useSidebarManagerSnapshot() - const { setSelectedItemId } = useAdvisorStateSnapshot() + const { setSelectedItem } = useAdvisorStateSnapshot() const securityLints = useMemo( () => (lints ?? []).filter((lint: Lint) => lint.categories.includes('SECURITY')), @@ -80,10 +80,10 @@ export const AdvisorWidget = () => { const handleLintClick = useCallback( (lint: Lint) => { - setSelectedItemId(lint.cache_key) + setSelectedItem(lint.cache_key, 'lint') openSidebar(SIDEBAR_KEYS.ADVISOR_PANEL) }, - [setSelectedItemId, openSidebar] + [setSelectedItem, openSidebar] ) const totalIssues = diff --git a/apps/studio/components/interfaces/HomeNew/AdvisorSection.tsx b/apps/studio/components/interfaces/HomeNew/AdvisorSection.tsx index 3bccc7f130b0f..ffcc9d52752a9 100644 --- a/apps/studio/components/interfaces/HomeNew/AdvisorSection.tsx +++ b/apps/studio/components/interfaces/HomeNew/AdvisorSection.tsx @@ -30,7 +30,7 @@ export const AdvisorSection = ({ showEmptyState = false }: { showEmptyState?: bo const { mutate: sendEvent } = useSendEventMutation() const { data: organization } = useSelectedOrganizationQuery() const { openSidebar } = useSidebarManagerSnapshot() - const { setSelectedItemId } = useAdvisorStateSnapshot() + const { setSelectedItem } = useAdvisorStateSnapshot() const errorLints: Lint[] = useMemo(() => { return lints?.filter((lint) => lint.level === LINTER_LEVELS.ERROR) ?? [] @@ -67,7 +67,7 @@ export const AdvisorSection = ({ showEmptyState = false }: { showEmptyState?: bo const handleCardClick = useCallback( (lint: Lint) => { - setSelectedItemId(lint.cache_key) + setSelectedItem(lint.cache_key, 'lint') openSidebar(SIDEBAR_KEYS.ADVISOR_PANEL) if (projectRef && organization?.slug) { sendEvent({ @@ -84,7 +84,7 @@ export const AdvisorSection = ({ showEmptyState = false }: { showEmptyState?: bo }) } }, - [sendEvent, setSelectedItemId, openSidebar, projectRef, organization, totalErrors] + [sendEvent, setSelectedItem, openSidebar, projectRef, organization, totalErrors] ) if (showEmptyState) { diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/ConnectTablesDialog.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/ConnectTablesDialog.tsx index 95ef50311d939..1ec27e28f3e3a 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/ConnectTablesDialog.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/ConnectTablesDialog.tsx @@ -1,29 +1,31 @@ import { zodResolver } from '@hookform/resolvers/zod' +import { AnimatePresence, motion } from 'framer-motion' import { snakeCase } from 'lodash' -import { Plus } from 'lucide-react' -import { useState } from 'react' +import { Loader2, Plus } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' import { toast } from 'sonner' import z from 'zod' -import { useParams } from 'common' +import { useFlag, useParams } from 'common' import { convertKVStringArrayToJson } from 'components/interfaces/Integrations/Wrappers/Wrappers.utils' -import { getCatalogURI } from 'components/interfaces/Storage/StorageSettings/StorageSettings.utils' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query' import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCreateDestinationPipelineMutation } from 'data/etl/create-destination-pipeline-mutation' -import { useCreatePublicationMutation } from 'data/etl/create-publication-mutation' +import { useCreateTenantSourceMutation } from 'data/etl/create-tenant-source-mutation' +import { useCreatePublicationMutation } from 'data/etl/publication-create-mutation' +import { useUpdatePublicationMutation } from 'data/etl/publication-update-mutation' import { useReplicationSourcesQuery } from 'data/etl/sources-query' import { useStartPipelineMutation } from 'data/etl/start-pipeline-mutation' -import { useIcebergNamespaceCreateMutation } from 'data/storage/iceberg-namespace-create-mutation' -import { useTablesQuery } from 'data/tables/tables-query' +import { useReplicationTablesQuery } from 'data/etl/tables-query' import { getDecryptedValues } from 'data/vault/vault-secret-decrypted-value-query' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { Button, Dialog, DialogContent, + DialogDescription, DialogFooter, DialogHeader, DialogSection, @@ -33,10 +35,13 @@ import { Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, + Progress, } from 'ui' +import { Admonition } from 'ui-patterns' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { MultiSelector } from 'ui-patterns/multi-select' import { getAnalyticsBucketPublicationName } from './AnalyticsBucketDetails.utils' +import { useAnalyticsBucketAssociatedEntities } from './useAnalyticsBucketAssociatedEntities' import { useAnalyticsBucketWrapperInstance } from './useAnalyticsBucketWrapperInstance' /** @@ -52,24 +57,100 @@ import { useAnalyticsBucketWrapperInstance } from './useAnalyticsBucketWrapperIn * - Error handling due to multiple async processes */ +const formId = 'connect-tables-form' const FormSchema = z.object({ - tables: z.array(z.string()).min(1, 'At least one table is required'), + tables: z.array(z.string()).min(1, 'Select at least one table'), }) -const formId = 'connect-tables-form' -const isEnabled = false // Kill switch if we wanna hold off supporting connecting tables - type ConnectTablesForm = z.infer -export const ConnectTablesDialog = ({ bucketId }: { bucketId?: string }) => { +enum PROGRESS_STAGE { + CREATE_PUBLICATION = 'CREATE_PUBLICATION', + CREATE_PIPELINE = 'CREATE_PIPELINE', + START_PIPELINE = 'START_PIPELINE', + UPDATE_PUBLICATION = 'CREATE_REPLICATION', +} + +const PROGRESS_INDICATORS = { + CREATE: [ + { + step: PROGRESS_STAGE.CREATE_PUBLICATION, + getDescription: (numTables: number) => + `Creating replication publication with ${numTables} table${numTables > 1 ? 's' : ''}...`, + }, + { step: PROGRESS_STAGE.CREATE_PIPELINE, description: `Creating replication pipeline` }, + { step: PROGRESS_STAGE.START_PIPELINE, description: `Starting replication pipeline` }, + ], + UPDATE: [ + { + step: PROGRESS_STAGE.UPDATE_PUBLICATION, + getDescription: (numTables: number) => + `Updating replication publication with ${numTables} table${numTables > 1 ? 's' : ''}...`, + }, + { step: PROGRESS_STAGE.START_PIPELINE, description: 'Restarting replication pipeline...' }, + ], +} + +interface ConnectTablesDialogProps { + onSuccessConnectTables: (tables: { schema: string; name: string }[]) => void +} + +export const ConnectTablesDialog = ({ onSuccessConnectTables }: ConnectTablesDialogProps) => { + const { ref: projectRef, bucketId } = useParams() + const [visible, setVisible] = useState(false) + + const isEnabled = useFlag('storageAnalyticsVector') // Kill switch if we wanna hold off supporting connecting tables + + const { sourceId, pipeline, publication } = useAnalyticsBucketAssociatedEntities({ + projectRef, + bucketId, + }) + const isEditingExistingPublication = !!publication && !!pipeline + + return ( + + + } + onClick={() => setVisible(true)} + tooltip={{ content: { side: 'bottom', text: !isEnabled ? 'Coming soon' : undefined } }} + > + {isEditingExistingPublication ? 'Add tables' : 'Connect tables'} + + + + {!sourceId ? ( + setVisible(false)} /> + ) : ( + setVisible(false)} + onSuccessConnectTables={onSuccessConnectTables} + /> + )} + + ) +} + +export const ConnectTablesDialogContent = ({ + visible, + onClose, + onSuccessConnectTables, +}: ConnectTablesDialogProps & { visible: boolean; onClose: () => void }) => { + const { ref: projectRef, bucketId } = useParams() + const { data: project } = useSelectedProjectQuery() + + const [isConnecting, setIsConnecting] = useState(false) + const [connectingStep, setConnectingStep] = useState() + const form = useForm({ resolver: zodResolver(FormSchema), defaultValues: { tables: [] }, }) - - const [visible, setVisible] = useState(false) - const { ref: projectRef } = useParams() - const { data: project } = useSelectedProjectQuery() + const { tables: selectedTables } = form.watch() const { data: wrapperInstance } = useAnalyticsBucketWrapperInstance({ bucketId: bucketId }) const wrapperValues = convertKVStringArrayToJson(wrapperInstance?.server_options ?? []) @@ -78,52 +159,54 @@ export const ConnectTablesDialog = ({ bucketId }: { bucketId?: string }) => { const { data: apiKeys } = useAPIKeysQuery({ projectRef, reveal: true }) const { serviceKey } = getKeys(apiKeys) - const { data: tables } = useTablesQuery({ + const { sourceId, pipeline, publication } = useAnalyticsBucketAssociatedEntities({ projectRef, - connectionString: project?.connectionString, - includeColumns: false, + bucketId, }) + const isEditingExistingPublication = !!publication && !!pipeline - const { data: sourcesData } = useReplicationSourcesQuery({ projectRef }) - const sourceId = sourcesData?.sources.find((s) => s.name === projectRef)?.id - - const { mutateAsync: createNamespace, isLoading: isCreatingNamespace } = - useIcebergNamespaceCreateMutation() - - const { mutateAsync: createPublication, isLoading: isCreatingPublication } = - useCreatePublicationMutation() - - const { mutateAsync: createDestinationPipeline, isLoading: creatingDestinationPipeline } = - useCreateDestinationPipelineMutation({ - onSuccess: () => {}, - }) + const { data: tables } = useReplicationTablesQuery({ projectRef, sourceId }) + const { mutateAsync: createPublication } = useCreatePublicationMutation() + const { mutateAsync: updatePublication } = useUpdatePublicationMutation() + const { mutateAsync: createDestinationPipeline } = useCreateDestinationPipelineMutation() const { mutateAsync: startPipeline } = useStartPipelineMutation() - const isConnecting = isCreatingNamespace || creatingDestinationPipeline || isCreatingPublication - - const onSubmit: SubmitHandler = async (values) => { - // [Joshen] Currently creates the destination for the analytics bucket here - // Which also involves creating a namespace + publication - // Publication name is automatically generated as {bucketId}_publication - // Destination name is automatically generated as {bucketId}_destination + const progressIndicator = useMemo( + () => (isEditingExistingPublication ? PROGRESS_INDICATORS.UPDATE : PROGRESS_INDICATORS.CREATE), + // [Joshen] This is to prevent the progressIndicator from flipping to UPDATE in the middle of CREATE + // since the publication and pipelines are getting created in the middle of CREATE + // eslint-disable-next-line react-hooks/exhaustive-deps + [isConnecting] + ) + const totalProgressSteps = progressIndicator.length + const currentStep = progressIndicator.findIndex((x) => x.step === connectingStep) + 1 + const progressDescription = progressIndicator.find((x) => x.step === connectingStep) + const onSubmitNewPublication: SubmitHandler = async (values) => { if (!projectRef) return console.error('Project ref is required') - if (!sourceId) return toast.error('Source ID is required') if (!bucketId) return toast.error('Bucket ID is required') + if (!sourceId) return toast.error('Replication has not been enabled for your project') try { + setIsConnecting(true) + + // Step 1: Create publication + setConnectingStep(PROGRESS_STAGE.CREATE_PUBLICATION) const publicationName = getAnalyticsBucketPublicationName(bucketId) + const publicationTables = values.tables.map((table) => { + const [schema, name] = table.split('.') + return { schema, name } + }) await createPublication({ projectRef, sourceId, name: publicationName, - tables: values.tables.map((table) => { - const [schema, name] = table.split('.') - return { schema, name } - }), + tables: publicationTables, }) + // Step 2: Create destination pipeline + setConnectingStep(PROGRESS_STAGE.CREATE_PIPELINE) const keysToDecrypt = Object.entries(wrapperValues) .filter(([name]) => ['vault_aws_access_key_id', 'vault_aws_secret_access_key'].includes(name) @@ -141,29 +224,15 @@ export const ConnectTablesDialog = ({ bucketId }: { bucketId?: string }) => { const s3SecretAccessKey = decryptedValues[wrapperValues['vault_aws_secret_access_key']] const s3Region = projectSettings?.region ?? '' - const protocol = projectSettings?.app_config?.protocol ?? 'https' - const endpoint = - projectSettings?.app_config?.storage_endpoint || projectSettings?.app_config?.endpoint - const catalogUri = getCatalogURI(project?.ref ?? '', protocol, endpoint) - const namespace = `${bucketId}_namespace` - await createNamespace({ - catalogUri, - warehouse: warehouseName, - token: catalogToken, - namespace, - }) - const icebergConfiguration = { projectRef, warehouseName, - namespace, catalogToken, s3AccessKeyId, s3SecretAccessKey, s3Region, } const destinationName = `${snakeCase(bucketId)}_destination` - const { pipeline_id: pipelineId } = await createDestinationPipeline({ projectRef, destinationName, @@ -172,104 +241,241 @@ export const ConnectTablesDialog = ({ bucketId }: { bucketId?: string }) => { pipelineConfig: { publicationName }, }) - // Pipeline can start behind the scenes, don't need to await - startPipeline({ projectRef, pipelineId }) + // Step 3: Start the destination pipeline + setConnectingStep(PROGRESS_STAGE.START_PIPELINE) + await startPipeline({ projectRef, pipelineId }) + + onSuccessConnectTables?.(publicationTables) toast.success(`Connected ${values.tables.length} tables to Analytics bucket!`) form.reset() - setVisible(false) + onClose() } catch (error: any) { // [Joshen] JFYI there's several async processes here so if something goes wrong midway - we need to figure out how to roll back cleanly // e.g publication gets created, but namespace creation fails -> should the old publication get deleted? // Another question is probably whether all of these step by step logic should be at the API level instead of client level // Same issue present within DestinationPanel - it's alright for now as we do an Alpha but this needs to be addressed before GA toast.error(`Failed to connect tables: ${error.message}`) + } finally { + setIsConnecting(false) + setConnectingStep(undefined) } } - const handleClose = () => { - form.reset() - setVisible(false) + const onSubmitUpdatePublication: SubmitHandler = async (values) => { + if (!projectRef) return console.error('Project ref is required') + if (!sourceId) return toast.error('Replication has not been enabled on this project') + if (!bucketId) return toast.error('Bucket ID is required') + if (!publication) return toast.error('Unable to find existing publication') + if (!pipeline) return toast.error('Unable to find existing pipeline') + + try { + setIsConnecting(true) + + const tablesToBeAdded = values.tables.map((table) => { + const [schema, name] = table.split('.') + return { schema, name } + }) + setConnectingStep(PROGRESS_STAGE.UPDATE_PUBLICATION) + const publicationTables = publication.tables.concat(tablesToBeAdded) + await updatePublication({ + projectRef, + sourceId, + publicationName: publication.name, + tables: publicationTables, + }) + + setConnectingStep(PROGRESS_STAGE.START_PIPELINE) + await startPipeline({ projectRef, pipelineId: pipeline.id }) + + onSuccessConnectTables?.(tablesToBeAdded) + toast.success('Successfully updated connected tables! Pipeline is being restarted') + onClose() + } catch (error: any) { + toast.error(`Failed to update tables: ${error.message}`) + } finally { + setIsConnecting(false) + setConnectingStep(undefined) + } } - return ( - { - if (!open) handleClose() - }} - > - - } - onClick={() => setVisible(true)} - tooltip={{ content: { side: 'bottom', text: !isEnabled ? 'Coming soon' : undefined } }} - > - Connect tables - - + const onSubmit: SubmitHandler = async (values) => { + if (isEditingExistingPublication) { + onSubmitUpdatePublication(values) + } else { + onSubmitNewPublication(values) + } + } - - - Connect tables - + useEffect(() => { + form.reset() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [visible]) - + return ( + + + + {isEditingExistingPublication ? 'Connect more tables' : 'Connect tables'} + + - -
- -

- Select the database tables to send data from. A destination analytics table will be - created for each, and data will replicate automatically. -

-
- - - ( - - - - - - - {tables?.map((table) => ( + + + + + +

+ Select the database tables to send data from. A destination analytics table will be + created for each, and data will replicate automatically. +

+
+ + + ( + + + + + + + {tables?.map((table) => { + const alreadyConnected = (publication?.tables ?? []).some( + (x) => x.schema === table.schema && x.name === table.name + ) + return ( - {`${table.schema}.${table.name}`} + {`${table.schema}.${table.name}`} + {alreadyConnected && Connected to analytics bucket} - ))} - - - - - - )} - /> + ) + })} +
+
+
+
+
+ )} + /> +
+ +
+ + + {isConnecting && !!progressDescription && ( + + + +
+

+ {'getDescription' in progressDescription + ? progressDescription.getDescription?.(selectedTables.length) + : progressDescription.description} +

+ +
+
- - +
+ )} +
- - - + + +
+ ) +} + +const EnableReplicationDialogContent = ({ onClose }: { onClose: () => void }) => { + const { ref: projectRef } = useParams() + const enablePgReplicate = useFlag('enablePgReplicate') + const { error } = useReplicationSourcesQuery({ projectRef }) + const noAccessToReplication = + !enablePgReplicate || error?.message.includes('feature flag is required') + + const { mutateAsync: createTenantSource, isLoading: creatingTenantSource } = + useCreateTenantSourceMutation() + + const onEnableReplication = async () => { + if (!projectRef) return console.error('Project ref is required') + await createTenantSource({ projectRef }) + } + + return ( + + + Database replication needs to be enabled + + Replication is used to sync data from your Postgres tables + + + + + + {noAccessToReplication ? ( +

+ Access to database replication is currently not available yet for public use. If + you're interested, do reach out to us via support! +

+ ) : ( + <> +

+ This feature is in active development and may change as we gather feedback. + Availability and behavior can evolve while in Alpha. +

+

+ Pricing has not been finalized yet. You can enable replication now; we’ll announce + pricing later and notify you before any charges apply. +

+ + )} +
+
+ + + {!noAccessToReplication && ( + - -
-
+ )} + + ) } diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceRow.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceRow.tsx deleted file mode 100644 index a64264be2b158..0000000000000 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceRow.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import { RefreshCw, SquareArrowOutUpRight } from 'lucide-react' -import { useMemo, useState } from 'react' - -import type { WrapperMeta } from 'components/interfaces/Integrations/Wrappers/Wrappers.types' -import { FormattedWrapperTable } from 'components/interfaces/Integrations/Wrappers/Wrappers.utils' -import { ImportForeignSchemaDialog } from 'components/interfaces/Storage/ImportForeignSchemaDialog' -import { ButtonTooltip } from 'components/ui/ButtonTooltip' -import { useFDWImportForeignSchemaMutation } from 'data/fdw/fdw-import-foreign-schema-mutation' -import { FDW } from 'data/fdw/fdws-query' -import { useIcebergNamespaceTablesQuery } from 'data/storage/iceberg-namespace-tables-query' -import { BASE_PATH } from 'lib/constants' -import { Button, cn, TableCell, TableRow } from 'ui' -import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' - -type NamespaceRowProps = { - bucketName: string - namespace: string - schema: string - tables: (FormattedWrapperTable & { id: number })[] - token: string - wrapperInstance: FDW - wrapperValues: Record - wrapperMeta: WrapperMeta -} - -export const NamespaceRow = ({ - bucketName, - namespace, - schema, - tables, - token, - wrapperInstance, - wrapperValues, - wrapperMeta, -}: NamespaceRowProps) => { - const { data: project } = useSelectedProjectQuery() - const [importForeignSchemaShown, setImportForeignSchemaShown] = useState(false) - - const { data: tablesData, isLoading: isLoadingNamespaceTables } = useIcebergNamespaceTablesQuery( - { - catalogUri: wrapperValues.catalog_uri, - warehouse: wrapperValues.warehouse, - token: token, - namespace: namespace, - }, - { enabled: !!token } - ) - - const { mutateAsync: importForeignSchema, isLoading: isImportingForeignSchema } = - useFDWImportForeignSchemaMutation() - - const rescanNamespace = async () => { - await importForeignSchema({ - projectRef: project?.ref, - connectionString: project?.connectionString, - serverName: wrapperInstance.server_name, - sourceSchema: namespace, - targetSchema: schema, - }) - } - - const missingTables = useMemo(() => { - return (tablesData || []).filter( - (t) => !tables.find((table) => table.table.split('.')[1] === t) - ) - }, [tablesData, tables]) - - let scanTooltip = useMemo(() => { - if (isImportingForeignSchema) return 'Scanning for new tables...' - if (isLoadingNamespaceTables) return 'Loading tables...' - if (missingTables.length > 0) return `Found ${missingTables.length} new tables` - if (tables.length === 0) return 'No tables found' - return 'All tables are up to date' - }, [isImportingForeignSchema, isLoadingNamespaceTables, missingTables.length, tables.length]) - - return ( - - {namespace} - - {schema ?? 'No schema'} - - - {tablesData ? `${tables.length}/${tablesData.length} connected tables` : ``} - - -
- 0 ? 'primary' : 'default'} - icon={} - loading={isImportingForeignSchema || isLoadingNamespaceTables} - onClick={() => (schema ? rescanNamespace() : setImportForeignSchemaShown(true))} - disabled={missingTables.length === 0} - tooltip={{ content: { text: scanTooltip } }} - > - Sync - - {schema ? ( - - ) : ( - } - disabled - tooltip={{ - content: { text: 'There are no tables connected.' }, - }} - > - Open in Table Editor - - )} -
-
- setImportForeignSchemaShown(false)} - /> -
- ) -} diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/TableRowComponent.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/TableRowComponent.tsx new file mode 100644 index 0000000000000..6a1f43de7afc4 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/NamespaceWithTables/TableRowComponent.tsx @@ -0,0 +1,240 @@ +import { snakeCase } from 'lodash' +import { MoreVertical, Pause, Play } from 'lucide-react' +import Link from 'next/link' +import { useState } from 'react' +import { toast } from 'sonner' + +import { useParams } from 'common' +import { useUpdatePublicationMutation } from 'data/etl/publication-update-mutation' +import { useStartPipelineMutation } from 'data/etl/start-pipeline-mutation' +import { useReplicationTablesQuery } from 'data/etl/tables-query' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { SqlEditor } from 'icons' +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + TableCell, + TableRow, +} from 'ui' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { useAnalyticsBucketAssociatedEntities } from '../useAnalyticsBucketAssociatedEntities' + +export const TableRowComponent = ({ + index, + table, + schema, + isLoading, +}: { + index: number + table: { id: number; name: string; isConnected: boolean } + schema: string + isLoading?: boolean +}) => { + const { ref: projectRef, bucketId } = useParams() + const { data: project } = useSelectedProjectQuery() + + const [showStopReplicationModal, setShowStopReplicationModal] = useState(false) + const [showStartReplicationModal, setShowStartReplicationModal] = useState(false) + const [isUpdatingReplication, setIsUpdatingReplication] = useState(false) + + const { sourceId, publication, pipeline } = useAnalyticsBucketAssociatedEntities({ + projectRef, + bucketId, + }) + + const { data: tables } = useReplicationTablesQuery({ projectRef, sourceId }) + + const { mutateAsync: updatePublication } = useUpdatePublicationMutation() + const { mutateAsync: startPipeline } = useStartPipelineMutation() + + const isReplicating = publication?.tables.find( + (x) => table.name === snakeCase(`${x.schema}.${x.name}_changelog`) + ) + + const onConfirmStopReplication = async () => { + if (!projectRef) return console.error('Project ref is required') + if (!bucketId) return console.error('Bucket ID is required') + if (!sourceId) return toast.error('Source ID is required') + if (!publication) return toast.error('Unable to find existing publication') + if (!pipeline) return toast.error('Unable to find existing pipeline') + + try { + setIsUpdatingReplication(true) + // [Joshen ALPHA] Assumption here is that all the namespace tables have _changelog as suffix + // May need to update if that assumption falls short (e.g for those dealing with iceberg APIs directly) + const updatedTables = publication.tables.filter( + (x) => table.name !== snakeCase(`${x.schema}.${x.name}_changelog`) + ) + await updatePublication({ + projectRef, + sourceId, + publicationName: publication.name, + tables: updatedTables, + }) + await startPipeline({ projectRef, pipelineId: pipeline.id }) + setShowStopReplicationModal(false) + toast.success('Successfully stopped replication for table! Pipeline is being restarted.') + } catch (error: any) { + toast.error(`Failed to stop replication for table: ${error.message}`) + } finally { + setIsUpdatingReplication(false) + } + } + + const onConfirmStartReplication = async () => { + if (!projectRef) return console.error('Project ref is required') + if (!bucketId) return console.error('Bucket ID is required') + if (!sourceId) return toast.error('Source ID is required') + if (!publication) return toast.error('Unable to find existing publication') + if (!pipeline) return toast.error('Unable to find existing pipeline') + + // [Joshen ALPHA] This has potential to be flaky - we should see how we can get the table name and schema better + const pgTable = tables?.find((t) => snakeCase(`${t.schema}.${t.name}_changelog`) === table.name) + if (!pgTable) return toast.error('Unable to find corresponding Postgres table') + + try { + setIsUpdatingReplication(true) + const updatedTables = publication.tables.concat([ + { schema: pgTable.schema, name: pgTable.name }, + ]) + await updatePublication({ + projectRef, + sourceId, + publicationName: publication.name, + tables: updatedTables, + }) + await startPipeline({ projectRef, pipelineId: pipeline.id }) + setShowStartReplicationModal(false) + toast.success('Successfully stopped replication for table! Pipeline is being restarted.') + } catch (error: any) { + toast.error(`Failed to stop replication for table: ${error.message}`) + } finally { + setIsUpdatingReplication(false) + } + } + + return ( + <> + + {table.name} + {!!publication && ( + +
+
+ + +
+ + {isLoading && !isReplicating + ? '-' + : isReplicating + ? 'Replicating' + : 'Not replicating'} + +
+
+ )} + + {table.isConnected && ( + + <> + + + + - - - )}
+ + {tablesToPoll.length > 0 && } + Table name - Status + {!!publication && ( + + Replication Status + + )} @@ -311,11 +234,11 @@ export const NamespaceWithTables = ({ ) : ( - allTables.map(({ name, isConnected }, index) => ( + allTables.map((table, index) => ( diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/index.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/index.tsx index d01c01c55819f..a9efb065f22e6 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/index.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/index.tsx @@ -1,8 +1,8 @@ import { uniq } from 'lodash' -import { SquarePlus } from 'lucide-react' +import { Loader2, SquarePlus } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useParams } from 'common' import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integrations.constants' @@ -38,6 +38,7 @@ import { DeleteAnalyticsBucketModal } from '../DeleteAnalyticsBucketModal' import { ConnectTablesDialog } from './ConnectTablesDialog' import { NamespaceWithTables } from './NamespaceWithTables' import { SimpleConfigurationDetails } from './SimpleConfigurationDetails' +import { useAnalyticsBucketAssociatedEntities } from './useAnalyticsBucketAssociatedEntities' import { useAnalyticsBucketWrapperInstance } from './useAnalyticsBucketWrapperInstance' import { useIcebergWrapperExtension } from './useIcebergWrapper' @@ -55,14 +56,38 @@ export const AnalyticBucketDetails = () => { const bucket = _bucket as undefined | AnalyticsBucket const [modal, setModal] = useState<'delete' | null>(null) + // [Joshen] Namespaces are now created asynchronously when the pipeline is started, so long poll after + // updating connected tables until namespaces are updated + // Namespace would just be the schema (Which is currently limited to public) + // Wrapper table would be {schema}_{table}_changelog + const [tablesToPoll, setTablesToPoll] = useState<{ schema: string; name: string }[]>([]) + const [pollIntervalNamespaces, setPollIntervalNamespaces] = useState(0) + const [pollIntervalNamespaceTables, setPollIntervalNamespaceTables] = useState(0) /** The wrapper instance is the wrapper that is installed for this Analytics bucket. */ const { data: wrapperInstance, isLoading } = useAnalyticsBucketWrapperInstance({ bucketId: bucket?.id, }) + const { pipeline } = useAnalyticsBucketAssociatedEntities({ + projectRef, + bucketId: bucket?.id, + }) + const wrapperValues = convertKVStringArrayToJson(wrapperInstance?.server_options ?? []) const integration = INTEGRATIONS.find((i) => i.id === 'iceberg_wrapper' && i.type === 'wrapper') const wrapperMeta = (integration?.type === 'wrapper' && integration.meta) as WrapperMeta + const state = isLoading + ? 'loading' + : extensionState === 'installed' + ? wrapperInstance + ? 'added' + : 'missing' + : extensionState + + const wrapperTables = useMemo(() => { + if (!wrapperInstance) return [] + return formatWrapperTables(wrapperInstance, wrapperMeta!) + }, [wrapperInstance, wrapperMeta]) const { data: extensionsData } = useDatabaseExtensionsQuery({ projectRef: project?.ref, @@ -85,14 +110,23 @@ export const AnalyticBucketDetails = () => { warehouse: wrapperValues.warehouse, token: token!, }, - { enabled: isSuccessToken } - ) - - const wrapperTables = useMemo(() => { - if (!wrapperInstance) return [] + { + enabled: isSuccessToken, + refetchInterval: (data) => { + if (pollIntervalNamespaces === 0) return false + if (tablesToPoll.length > 0) { + const schemas = [...new Set(tablesToPoll.map((x) => x.schema))] + const hasSchemaMissingFromNamespace = !schemas.some((x) => (data ?? []).includes(x)) - return formatWrapperTables(wrapperInstance, wrapperMeta!) - }, [wrapperInstance, wrapperMeta]) + if (!hasSchemaMissingFromNamespace) { + setPollIntervalNamespaces(0) + return false + } + } + return pollIntervalNamespaces + }, + } + ) const namespaces = useMemo(() => { const fdwNamespaces = wrapperTables.map((t) => t.table.split('.')[0]) as string[] @@ -110,13 +144,11 @@ export const AnalyticBucketDetails = () => { }) }, [wrapperTables, namespacesData]) - const state = isLoading - ? 'loading' - : extensionState === 'installed' - ? wrapperInstance - ? 'added' - : 'missing' - : extensionState + useEffect(() => { + if (pollIntervalNamespaces === 0 && pollIntervalNamespaceTables === 0) { + setTablesToPoll([]) + } + }, [pollIntervalNamespaces, pollIntervalNamespaceTables]) return ( <> @@ -160,22 +192,68 @@ export const AnalyticBucketDetails = () => { Analytics tables stored in this bucket - {namespaces.length > 0 && } +
+ {!!pipeline && ( + + )} + {namespaces.length > 0 && ( + { + setTablesToPoll(tables) + setPollIntervalNamespaces(4000) + setPollIntervalNamespaceTables(4000) + }} + /> + )} +
{isLoadingNamespaces || isLoading ? ( ) : namespaces.length === 0 ? ( - + <> + {tablesToPoll.length > 0 ? ( + + ) : ( + + )} + ) : (
{namespaces.map(({ namespace, schema, tables }) => ( @@ -190,6 +268,9 @@ export const AnalyticBucketDetails = () => { wrapperInstance={wrapperInstance} wrapperValues={wrapperValues} wrapperMeta={wrapperMeta} + tablesToPoll={tablesToPoll} + pollIntervalNamespaceTables={pollIntervalNamespaceTables} + setPollIntervalNamespaceTables={setPollIntervalNamespaceTables} /> ))}
diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/useAnalyticsBucketAssociatedEntities.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/useAnalyticsBucketAssociatedEntities.tsx index c869502e2b095..41a3c6aa3343a 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/useAnalyticsBucketAssociatedEntities.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/useAnalyticsBucketAssociatedEntities.tsx @@ -1,13 +1,14 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { WrapperMeta } from 'components/interfaces/Integrations/Wrappers/Wrappers.types' -import { useFDWDeleteMutation } from 'data/fdw/fdw-delete-mutation' -import { FDW } from 'data/fdw/fdws-query' +import { useReplicationPipelinesQuery } from 'data/etl/pipelines-query' import { ReplicationPublication, useReplicationPublicationsQuery, } from 'data/etl/publications-query' import { useReplicationSourcesQuery } from 'data/etl/sources-query' +import { useFDWDeleteMutation } from 'data/fdw/fdw-delete-mutation' +import { FDW } from 'data/fdw/fdws-query' import { useS3AccessKeyDeleteMutation } from 'data/storage/s3-access-key-delete-mutation' import { S3AccessKey, useStorageCredentialsQuery } from 'data/storage/s3-access-key-query' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' @@ -57,7 +58,10 @@ export const useAnalyticsBucketAssociatedEntities = ( (p) => p.name === getAnalyticsBucketPublicationName(bucketId ?? '') ) - return { icebergWrapper, icebergWrapperMeta, s3AccessKey, publication } + const { data: pipelines } = useReplicationPipelinesQuery({ projectRef }) + const pipeline = pipelines?.pipelines.find((x) => x.config.publication_name === publication?.name) + + return { icebergWrapper, icebergWrapperMeta, s3AccessKey, sourceId, publication, pipeline } } export const useAnalyticsBucketDeleteCleanUp = () => { diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/useAnalyticsBucketWrapperInstance.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/useAnalyticsBucketWrapperInstance.tsx index b685e212e8717..39e3f234c9c52 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/useAnalyticsBucketWrapperInstance.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/useAnalyticsBucketWrapperInstance.tsx @@ -6,12 +6,12 @@ import { getWrapperMetaForWrapper, wrapperMetaComparator, } from 'components/interfaces/Integrations/Wrappers/Wrappers.utils' -import { useFDWsQuery } from 'data/fdw/fdws-query' +import { type FDW, useFDWsQuery } from 'data/fdw/fdws-query' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' export const useAnalyticsBucketWrapperInstance = ( { bucketId }: { bucketId?: string }, - options?: { enabled?: boolean } + options?: { enabled?: boolean; refetchInterval?: (data: FDW[] | undefined) => number | false } ) => { const { data: project, isLoading: isLoadingProject } = useSelectedProjectQuery() @@ -21,7 +21,11 @@ export const useAnalyticsBucketWrapperInstance = ( projectRef: project?.ref, connectionString: project?.connectionString, }, - { enabled: defaultEnabled && !!bucketId } + { + enabled: defaultEnabled && !!bucketId, + refetchInterval: (data) => + !!options?.refetchInterval ? options.refetchInterval(data) : false, + } ) const icebergWrapper = useMemo(() => { diff --git a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketModal.tsx b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketModal.tsx index 2e2afe50d1309..54210e3590203 100644 --- a/apps/studio/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/AnalyticsBuckets/CreateAnalyticsBucketModal.tsx @@ -164,8 +164,8 @@ export const CreateAnalyticsBucketModal = ({ groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' }, }) - toast.success(`Created bucket “${values.name}”`) form.reset() + toast.success(`Created bucket “${values.name}”`) setVisible(false) router.push(`/project/${ref}/storage/analytics/buckets/${values.name}`) } catch (error: any) { diff --git a/apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.tsx b/apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.tsx index 6efe28e4835f4..17f536eef04a3 100644 --- a/apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.tsx +++ b/apps/studio/components/interfaces/Storage/ImportForeignSchemaDialog.tsx @@ -36,11 +36,14 @@ export const ImportForeignSchemaDialog = ({ onClose, circumstance = 'fresh', }: ImportForeignSchemaDialogProps) => { - const { data: project } = useSelectedProjectQuery() const { ref } = useParams() + const { data: project } = useSelectedProjectQuery() const [loading, setLoading] = useState(false) const [createSchemaSheetOpen, setCreateSchemaSheetOpen] = useState(false) + const { data: schemas } = useSchemasQuery({ projectRef: project?.ref! }) + + const { mutateAsync: createSchema } = useSchemaCreateMutation() const { mutateAsync: importForeignSchema } = useFDWImportForeignSchemaMutation({}) const { mutateAsync: updateFDW } = useFDWUpdateMutation({ onSuccess: () => { @@ -49,8 +52,6 @@ export const ImportForeignSchemaDialog = ({ }, }) - const { data: schemas } = useSchemasQuery({ projectRef: project?.ref! }) - const FormSchema = z.object({ bucketName: z.string().trim(), sourceNamespace: z.string().trim(), @@ -73,12 +74,10 @@ export const ImportForeignSchemaDialog = ({ defaultValues: { bucketName, sourceNamespace: namespace, - targetSchema: '', + targetSchema: `fdw_analytics_${namespace}`, }, }) - const { mutateAsync: createSchema } = useSchemaCreateMutation() - const onSubmit: SubmitHandler> = async (values) => { const serverName = `${snakeCase(values.bucketName)}_fdw_server` @@ -150,7 +149,7 @@ export const ImportForeignSchemaDialog = ({ form.reset({ bucketName, sourceNamespace: namespace, - targetSchema: '', + targetSchema: `fdw_analytics_${namespace}`, }) } }, [visible, form, bucketName, namespace]) diff --git a/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesBucketRow.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesBucketRow.tsx index a80d9d4762396..95ebf325cd8e0 100644 --- a/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesBucketRow.tsx +++ b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesBucketRow.tsx @@ -1,8 +1,8 @@ import { PostgresPolicy } from '@supabase/postgres-meta' import { noop } from 'lodash' -import { Bucket } from 'data/storage/buckets-query' import { PolicyRow } from 'components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow' +import { Bucket } from 'data/storage/buckets-query' import { Bucket as BucketIcon } from 'icons' import { @@ -40,11 +40,13 @@ export const StoragePoliciesBucketRow = ({ }: StoragePoliciesBucketRowProps) => { return ( - -
+ +
- {label} - {bucket?.public && Public} +
+ {label} + {bucket?.public && Public} +
- - Description - - - Access key ID - - - Created at - + Name + Key ID + Created at diff --git a/apps/studio/components/interfaces/Storage/StorageSettings/StorageCredItem.tsx b/apps/studio/components/interfaces/Storage/StorageSettings/StorageCredItem.tsx index fb24091c0c5b7..36a0eff15eb20 100644 --- a/apps/studio/components/interfaces/Storage/StorageSettings/StorageCredItem.tsx +++ b/apps/studio/components/interfaces/Storage/StorageSettings/StorageCredItem.tsx @@ -53,7 +53,9 @@ export const StorageCredItem = ({ - {daysSince(created_at)} + + {daysSince(created_at)} + {canRemoveAccessKey && ( diff --git a/apps/studio/components/layouts/AppLayout/AdvisorButton.tsx b/apps/studio/components/layouts/AppLayout/AdvisorButton.tsx index 6b3c3c3fccc91..7dd855ae65da3 100644 --- a/apps/studio/components/layouts/AppLayout/AdvisorButton.tsx +++ b/apps/studio/components/layouts/AppLayout/AdvisorButton.tsx @@ -1,14 +1,11 @@ import { Lightbulb } from 'lucide-react' - -import { useParams } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useProjectLintsQuery } from 'data/lint/lint-query' import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state' import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import { cn } from 'ui' -export const AdvisorButton = () => { - const { ref: projectRef } = useParams() +export const AdvisorButton = ({ projectRef }: { projectRef?: string }) => { const { toggleSidebar, activeSidebar } = useSidebarManagerSnapshot() const { data: lints } = useProjectLintsQuery({ projectRef }) diff --git a/apps/studio/components/layouts/DefaultLayout.tsx b/apps/studio/components/layouts/DefaultLayout.tsx index c2ed5bcdeae41..47229e4acbaba 100644 --- a/apps/studio/components/layouts/DefaultLayout.tsx +++ b/apps/studio/components/layouts/DefaultLayout.tsx @@ -8,10 +8,11 @@ import { Sidebar } from 'components/interfaces/Sidebar' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { useCheckLatestDeploy } from 'hooks/use-check-latest-deploy' import { useAppStateSnapshot } from 'state/app-state' -import { SidebarProvider } from 'ui' +import { ResizablePanel, ResizablePanelGroup, SidebarProvider } from 'ui' import { LayoutHeader } from './ProjectLayout/LayoutHeader/LayoutHeader' import MobileNavigationBar from './ProjectLayout/NavigationBar/MobileNavigationBar' import { ProjectContextProvider } from './ProjectLayout/ProjectContext' +import { LayoutSidebar } from './ProjectLayout/LayoutSidebar' import { LayoutSidebarProvider } from './ProjectLayout/LayoutSidebar/LayoutSidebarProvider' export interface DefaultLayoutProps { @@ -55,8 +56,8 @@ const DefaultLayout = ({ return ( - - + +
{/* Top Banner */} @@ -75,13 +76,22 @@ const DefaultLayout = ({
{/* Sidebar - Only show for project pages, not account pages */} {!router.pathname.startsWith('/account') && } - {/* Main Content */} -
{children}
+ {/* Main Content with Layout Sidebar */} + + +
{children}
+
+ +
-
-
+ +
) } diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx index 8c7765ca2aaae..b8e99afb3e4e2 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx @@ -27,8 +27,6 @@ import { HelpPopover } from './HelpPopover' import { HomeIcon } from './HomeIcon' import { LocalVersionPopover } from './LocalVersionPopover' import MergeRequestButton from './MergeRequestButton' -import { NotificationsPopoverV2 } from './NotificationsPopoverV2/NotificationsPopover' -import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state' import { AdvisorButton } from 'components/layouts/AppLayout/AdvisorButton' const LayoutHeaderDivider = ({ className, ...props }: React.HTMLProps) => ( @@ -212,11 +210,10 @@ export const LayoutHeader = ({
- + {!!projectRef && ( <> - @@ -229,10 +226,10 @@ export const LayoutHeader = ({ <>
+ {!!projectRef && ( <> - diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationRow.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationRow.tsx deleted file mode 100644 index 574b9aa472128..0000000000000 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationRow.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import dayjs from 'dayjs' -import { Archive, ArchiveRestoreIcon, ExternalLink } from 'lucide-react' -import Link from 'next/link' -import { useEffect } from 'react' -import { useInView } from 'react-intersection-observer' -import { Button, cn } from 'ui' - -import { Markdown } from 'components/interfaces/Markdown' -import { ButtonTooltip } from 'components/ui/ButtonTooltip' -import { useVirtualizerContext } from 'components/ui/InfiniteList' -import { NotificationData, type NotificationsData } from 'data/notifications/notifications-v2-query' -import { useProjectDetailQuery } from 'data/projects/project-detail-query' -import type { Organization } from 'types' -import { CriticalIcon, WarningIcon } from 'ui' - -interface NotificationRowProps { - index: number - item: NotificationsData[number] - getOrganizationById: (id: number) => Organization - getOrganizationBySlug: (slug: string) => Organization - onUpdateNotificationStatus: (id: string, status: 'archived' | 'seen') => void - queueMarkRead: (id: string) => void -} - -const NotificationRow = ({ - index, - item: notification, - getOrganizationById, - getOrganizationBySlug, - onUpdateNotificationStatus, - queueMarkRead, -}: NotificationRowProps) => { - const { virtualizer } = useVirtualizerContext() - const { ref: viewRef, inView } = useInView() - - const { status, priority } = notification - const data = notification.data as NotificationData - - const { data: project } = useProjectDetailQuery({ ref: data.project_ref }) - - const organization = - data.org_slug !== undefined - ? getOrganizationBySlug(data.org_slug) - : project !== undefined - ? getOrganizationById(project.organization_id) - : undefined - - const daysFromNow = dayjs().diff(dayjs(notification.inserted_at), 'day') - const formattedTimeFromNow = dayjs(notification.inserted_at).fromNow() - const formattedInsertedAt = dayjs(notification.inserted_at).format('MMM DD, YYYY') - - const onButtonAction = (type?: string) => { - // [Joshen] Implement accordingly - BE team will need to give us a heads up on this - console.log('Action', type) - } - - useEffect(() => { - if (inView && notification.status === 'new') { - queueMarkRead(notification.id) - } - }, [inView]) - - return ( -
-
- {(project !== undefined || organization !== undefined) && ( -
- {organization !== undefined && ( - - {organization.name} - - )} - {organization !== undefined && project !== undefined && ( - - - - - - )} - {project !== undefined && ( - - {project.name} - - )} -
- )} -
-

- {data.title}{' '} - - {daysFromNow > 1 ? formattedInsertedAt : formattedTimeFromNow} - -

-
- {data.message !== undefined && ( - - )} - {(data.actions ?? []).length > 0 && ( -
- {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 ( - - ) - } else if (action.action_type !== undefined) { - return ( - - ) - } else { - return null - } - })} -
- )} -
-
- {priority === 'Warning' && } - {priority === 'Critical' && } - {notification.status === 'archived' ? ( - - } - className="p-1.5 group-hover:opacity-100 opacity-0 transition rounded-full" - onClick={() => onUpdateNotificationStatus(notification.id, 'seen')} - tooltip={{ content: { text: 'Unarchive', side: 'bottom' } }} - /> - ) : ( - } - className="p-1.5 group-hover:opacity-100 opacity-0 transition rounded-full" - onClick={() => onUpdateNotificationStatus(notification.id, 'archived')} - tooltip={{ content: { text: 'Archive', side: 'bottom' } }} - /> - )} -
-
- ) -} - -export default NotificationRow diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsFilter.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsFilter.tsx deleted file mode 100644 index 1cea7a375c84b..0000000000000 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsFilter.tsx +++ /dev/null @@ -1,245 +0,0 @@ -import { RotateCcw, Settings2Icon, X } from 'lucide-react' -import { useMemo, useState } from 'react' -import { - Button, - Checkbox_Shadcn_, - CommandEmpty_Shadcn_, - CommandGroup_Shadcn_, - CommandInput_Shadcn_, - CommandItem_Shadcn_, - CommandList_Shadcn_, - CommandSeparator_Shadcn_, - Command_Shadcn_, - DropdownMenuLabel, - Label_Shadcn_, - PopoverContent_Shadcn_, - PopoverTrigger_Shadcn_, - Popover_Shadcn_, - ScrollArea, - cn, -} from 'ui' - -import { CommandGroup } from '@ui/components/shadcn/ui/command' -import { useDebounce } from '@uidotdev/usehooks' -import { useOrganizationsQuery } from 'data/organizations/organizations-query' -import { useProjectsInfiniteQuery } from 'data/projects/projects-infinite-query' -import { useNotificationsStateSnapshot } from 'state/notifications' -import { CriticalIcon, WarningIcon } from 'ui' -import { Input } from 'ui-patterns/DataInputs/Input' - -// [Joshen] Opting to not use infinite loading for projects in this UI specifically -// since the UX feels quite awkward having infinite loading for just a specific section in this Popover - -export const NotificationsFilter = ({ activeTab }: { activeTab: 'inbox' | 'archived' }) => { - const [open, setOpen] = useState(false) - const snap = useNotificationsStateSnapshot() - - const [search, setSearch] = useState('') - const debouncedSearch = useDebounce(search, 500) - - const { data: organizations } = useOrganizationsQuery() - const { data } = useProjectsInfiniteQuery( - { search: search.length === 0 ? search : debouncedSearch }, - { keepPreviousData: true, enabled: open } - ) - const projects = useMemo(() => data?.pages.flatMap((page) => page.projects), [data?.pages]) || [] - const projectCount = data?.pages[0].pagination.count ?? 0 - const pageLimit = data?.pages[0].pagination.limit ?? 0 - - return ( - - - - - - - - - No filters found that match your search - - - - - Status - { - snap.setFilters('unread', 'status') - }} - > - - - Unread - - - - - - - - Priority - { - snap.setFilters('Warning', 'priority') - }} - className="flex items-center gap-x-2" - > - - - - Warning - - - { - snap.setFilters('Critical', 'priority') - }} - > - - - - Critical - - - - - - - - Organizations - {(organizations ?? []).map((org) => ( - { - snap.setFilters(org.slug, 'organizations') - }} - > - - - {org.name} - - - ))} - - - - - - Projects - {/* - [Joshen] Adding a separate search input field here for projects as the - top level CommandInput doesn't work well with a mix of sync and async data - */} -
- setSearch(e.target.value)} - actions={ - search.length > 0 ? ( - setSearch('')} - /> - ) : null - } - /> -
- {(projects ?? []).map((project) => ( - { - snap.setFilters(project.ref, 'projects') - }} - > - - - {project.name} - - - ))} - {projectCount > pageLimit && ( -

- Not all projects are shown here. Try searching to find a specific project. -

- )} -
-
- - - - - snap.resetFilters()} - className="flex gap-x-2 items-center" - > - - Reset filters - - -
-
-
-
- ) -} diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover.tsx deleted file mode 100644 index 81db3d3d2c17e..0000000000000 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopoverV2/NotificationsPopover.tsx +++ /dev/null @@ -1,269 +0,0 @@ -import { ArchiveIcon, InboxIcon } from 'lucide-react' -import { useMemo, useRef, useState } from 'react' -import { toast } from 'sonner' - -import AlertError from 'components/ui/AlertError' -import { ButtonTooltip } from 'components/ui/ButtonTooltip' -import { InfiniteListDefault } from 'components/ui/InfiniteList' -import ShimmeringLoader, { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' -import { useNotificationsArchiveAllMutation } from 'data/notifications/notifications-v2-archive-all-mutation' -import { useNotificationsV2Query } from 'data/notifications/notifications-v2-query' -import { useNotificationsSummaryQuery } from 'data/notifications/notifications-v2-summary-query' -import { useNotificationsV2UpdateMutation } from 'data/notifications/notifications-v2-update-mutation' -import { useOrganizationsQuery } from 'data/organizations/organizations-query' -import { useNotificationsStateSnapshot } from 'state/notifications' -import { - Button, - PopoverContent_Shadcn_, - PopoverTrigger_Shadcn_, - Popover_Shadcn_, - TabsList_Shadcn_, - TabsTrigger_Shadcn_, - Tabs_Shadcn_, - cn, -} from 'ui' -import NotificationRow from './NotificationRow' -import { NotificationsFilter } from './NotificationsFilter' - -export const NotificationsPopoverV2 = () => { - const [open, setOpen] = useState(false) - const [activeTab, setActiveTab] = useState<'inbox' | 'archived'>('inbox') - - const snap = useNotificationsStateSnapshot() - - // Storing in ref as no re-rendering required - const markedRead = useRef([]) - - const { data: organizations } = useOrganizationsQuery({ enabled: open }) - const { - data, - error, - isLoading, - isError, - isSuccess, - hasNextPage, - isFetchingNextPage, - fetchNextPage, - } = useNotificationsV2Query( - { - status: - activeTab === 'archived' - ? 'archived' - : snap.filterStatuses.includes('unread') - ? 'new' - : undefined, - filters: { - priority: snap.filterPriorities, - organizations: snap.filterOrganizations, - projects: snap.filterProjects, - }, - }, - { enabled: open } - ) - const { data: summary } = useNotificationsSummaryQuery() - const { mutate: updateNotifications } = useNotificationsV2UpdateMutation() - const { mutate: archiveAllNotifications, isLoading: isArchiving } = - useNotificationsArchiveAllMutation({ - onSuccess: () => toast.success('Successfully archived all notifications'), - }) - - const notifications = useMemo(() => data?.pages.flatMap((page) => page) ?? [], [data?.pages]) - const hasNewNotifications = summary?.unread_count ?? 0 > 0 - const hasWarning = summary?.has_warning - const hasCritical = summary?.has_critical - - const markNotificationsRead = () => { - if (markedRead.current.length > 0) { - updateNotifications({ ids: markedRead.current, status: 'seen' }) - } - } - - return ( - { - setOpen(open) - if (!open) markNotificationsRead() - }} - > - - - - {hasCritical && ( -
-
-
- )} - {hasWarning && !hasCritical && ( -
-
-
- )} - {!!hasNewNotifications && !hasCritical && !hasWarning && ( -
-
-
- )} -
- } - /> - - -
-

Notifications

-
- { - setActiveTab(tab as 'inbox' | 'archived') - if (tab === 'archived' && snap.filterStatuses.includes('unread')) { - snap.setFilters('unread', 'status') - } - }} - value={activeTab} - > -
- - - Inbox -
9 ? 'px-0.5 w-auto' : 'w-4' - )} - > - {summary?.unread_count} -
-
- - Archived - -
- -
-
-
-
-
- {isLoading && ( -
- -
- )} - {isError && ( -
- -
- )} - {isSuccess && ( -
- {notifications.length > 0 && - !(activeTab === 'archived' && snap.filterStatuses.includes('unread')) ? ( - ( -
- -
- )} - itemProps={{ - getOrganizationById: (id: number) => - organizations?.find((org) => org.id === id)!, - getOrganizationBySlug: (slug: string) => - organizations?.find((org) => org.slug === slug)!, - onUpdateNotificationStatus: (id: string, status: 'archived' | 'seen') => { - updateNotifications({ ids: [id], status }) - }, - queueMarkRead: (id: string) => { - if (markedRead.current && !markedRead.current.includes(id)) { - markedRead.current.push(id) - } - }, - }} - getItemSize={() => 56} - hasNextPage={hasNextPage} - isLoadingNextPage={isFetchingNextPage} - onLoadNextPage={() => fetchNextPage()} - /> - ) : ( -
- -
-

- {activeTab === 'archived' - ? `No archived notifications${ - snap.numFiltersApplied > 0 - ? ` based on the ${snap.numFiltersApplied} filter${ - snap.numFiltersApplied > 1 ? 's' : '' - } applied` - : '' - }` - : snap.numFiltersApplied > 0 - ? `No notifications based on the ${snap.numFiltersApplied} filter${ - snap.numFiltersApplied > 1 ? 's' : '' - } applied` - : 'All caught up'} -

-

- {activeTab === 'archived' - ? 'Notifications that you have previously archived will be shown here' - : 'You will be notified here for any notices on your organizations and projects'} -

-
-
- )} -
- )} -
- {notifications.length > 0 && activeTab === 'inbox' && ( -
- -
- )} -
- - ) -} diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx index 85b8d86d5ea0b..ca5af49f6b3f9 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx @@ -1,13 +1,12 @@ import { useRouter } from 'next/router' import { PropsWithChildren, useEffect } from 'react' - -import { AdvisorPanel } from 'components/ui/AdvisorPanel/AdvisorPanel' +import { useRegisterSidebar, useSidebarManagerSnapshot } from 'state/sidebar-manager-state' import { AIAssistant } from 'components/ui/AIAssistantPanel/AIAssistant' import { EditorPanel } from 'components/ui/EditorPanel/EditorPanel' -import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' +import { AdvisorPanel } from 'components/ui/AdvisorPanel/AdvisorPanel' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { useRegisterSidebar, useSidebarManagerSnapshot } from 'state/sidebar-manager-state' +import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' +import { useSendEventMutation } from 'data/telemetry/send-event-mutation' export const SIDEBAR_KEYS = { AI_ASSISTANT: 'ai-assistant', @@ -15,16 +14,14 @@ export const SIDEBAR_KEYS = { ADVISOR_PANEL: 'advisor-panel', } as const -// LayoutSidebars are meant to be used within a project, but rendered within DefaultLayout -// to prevent unnecessary registering / unregistering of sidebars with every route change export const LayoutSidebarProvider = ({ children }: PropsWithChildren) => { const { data: project } = useSelectedProjectQuery() const { data: org } = useSelectedOrganizationQuery() const { mutate: sendEvent } = useSendEventMutation() - useRegisterSidebar(SIDEBAR_KEYS.AI_ASSISTANT, () => , {}, 'i') - useRegisterSidebar(SIDEBAR_KEYS.EDITOR_PANEL, () => , {}, 'e') - useRegisterSidebar(SIDEBAR_KEYS.ADVISOR_PANEL, () => ) + useRegisterSidebar(SIDEBAR_KEYS.AI_ASSISTANT, () => , {}, 'i', !!project) + useRegisterSidebar(SIDEBAR_KEYS.EDITOR_PANEL, () => , {}, 'e', !!project) + useRegisterSidebar(SIDEBAR_KEYS.ADVISOR_PANEL, () => , {}, undefined, true) const router = useRouter() const { openSidebar, activeSidebar } = useSidebarManagerSnapshot() diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.test.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.test.tsx index 1c5cef6bb6059..3a0bd639a1705 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.test.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutSidebar/index.test.tsx @@ -12,6 +12,70 @@ vi.mock('components/ui/AIAssistantPanel/AIAssistant', () => ({ AIAssistant: () =>
AI Assistant
, })) +vi.mock('components/ui/EditorPanel/EditorPanel', () => ({ + EditorPanel: () =>
Editor Panel
, +})) + +vi.mock('components/ui/AdvisorPanel/AdvisorPanel', () => ({ + AdvisorPanel: () =>
Advisor Panel
, +})) + +const mockProject = { + id: 1, + ref: 'default', + name: 'Project 1', + status: 'ACTIVE_HEALTHY' as const, + organization_id: 1, + cloud_provider: 'AWS', + region: 'us-east-1', + inserted_at: new Date().toISOString(), + subscription_id: 'subscription-1', + db_host: 'db.supabase.co', + is_branch_enabled: false, + is_physical_backups_enabled: false, + restUrl: 'https://project-1.supabase.co', +} + +let mockProjectData: typeof mockProject | undefined = mockProject + +vi.mock('hooks/misc/useSelectedProject', () => ({ + useSelectedProjectQuery: () => { + // Access the variable at runtime when the function is called + return { + data: mockProjectData, + } + }, +})) + +vi.mock('hooks/misc/useSelectedOrganization', () => ({ + useSelectedOrganizationQuery: () => ({ + data: { + id: 1, + name: 'Organization 1', + slug: 'test-org', + plan: { id: 'free', name: 'Free' }, + managed_by: 'supabase', + is_owner: true, + billing_email: 'billing@example.com', + billing_partner: null, + usage_billing_enabled: false, + stripe_customer_id: 'stripe-1', + subscription_id: 'subscription-1', + organization_requires_mfa: false, + opt_in_tags: [], + restriction_status: null, + restriction_data: null, + organization_missing_address: false, + }, + }), +})) + +vi.mock('data/telemetry/send-event-mutation', () => ({ + useSendEventMutation: () => ({ + mutate: vi.fn(), + }), +})) + const resetSidebarManagerState = () => { Object.keys(sidebarManagerState.sidebars).forEach((id) => { sidebarManagerState.unregisterSidebar(id) @@ -59,7 +123,8 @@ describe('LayoutSidebar', () => { sidebarManagerState.toggleSidebar(SIDEBAR_KEYS.AI_ASSISTANT) }) - expect(await screen.findByTestId('ai-assistant-sidebar')).toBeInTheDocument() + const sidebar = await screen.findByTestId('ai-assistant-sidebar') + expect(sidebar).toBeTruthy() }) it('auto-opens when sidebar query param matches a registered sidebar', async () => { @@ -69,4 +134,54 @@ describe('LayoutSidebar', () => { await screen.findByTestId('ai-assistant-sidebar') }) + + describe('at organization level', () => { + beforeEach(() => { + routerMock.setCurrentUrl('/org/default') + // Set project to undefined to simulate org-level (no project) + mockProjectData = undefined + }) + + afterEach(() => { + // Reset to project data for other tests + mockProjectData = mockProject + }) + + it('does not register project-related sidebars when no project is available', async () => { + renderSidebar() + + // Wait a bit to ensure sidebars have been registered + await waitFor(() => { + // Project-related sidebars should not be registered + expect(sidebarManagerState.sidebars[SIDEBAR_KEYS.AI_ASSISTANT]).toBeUndefined() + expect(sidebarManagerState.sidebars[SIDEBAR_KEYS.EDITOR_PANEL]).toBeUndefined() + // Advisor panel should still be available (doesn't require project) + expect(sidebarManagerState.sidebars[SIDEBAR_KEYS.ADVISOR_PANEL]).toBeDefined() + }) + }) + + it('does not render project-related sidebars even when toggled', async () => { + renderSidebar() + + await waitFor(() => { + expect(sidebarManagerState.sidebars[SIDEBAR_KEYS.ADVISOR_PANEL]).toBeDefined() + }) + + // Try to toggle AI_ASSISTANT - should not work since it's not registered + act(() => { + sidebarManagerState.toggleSidebar(SIDEBAR_KEYS.AI_ASSISTANT) + }) + + // Should not render since it's not registered + expect(screen.queryByTestId('ai-assistant-sidebar')).toBeNull() + expect(screen.queryByTestId('editor-panel-sidebar')).toBeNull() + + // Advisor panel should work + act(() => { + sidebarManagerState.toggleSidebar(SIDEBAR_KEYS.ADVISOR_PANEL) + }) + + expect(await screen.findByTestId('advisor-panel-sidebar')).toBeTruthy() + }) + }) }) diff --git a/apps/studio/components/layouts/ProjectLayout/index.tsx b/apps/studio/components/layouts/ProjectLayout/index.tsx index 451cb975512a8..45514fe65d86b 100644 --- a/apps/studio/components/layouts/ProjectLayout/index.tsx +++ b/apps/studio/components/layouts/ProjectLayout/index.tsx @@ -19,7 +19,6 @@ import MobileSheetNav from 'ui-patterns/MobileSheetNav/MobileSheetNav' import { useEditorType } from '../editors/EditorsLayout.hooks' import BuildingState from './BuildingState' import ConnectingState from './ConnectingState' -import { LayoutSidebar } from './LayoutSidebar' import { LoadingState } from './LoadingState' import { ProjectPausedState } from './PausedState/ProjectPausedState' import { PauseFailedState } from './PauseFailedState' @@ -182,39 +181,26 @@ export const ProjectLayout = forwardRef - - -
- {showPausedState ? ( -
-
- -
-
- ) : ( - - - {children} - - )} -
-
- -
+ {showPausedState ? ( +
+
+ +
+
+ ) : ( + + + {children} + + )} +
diff --git a/apps/studio/components/ui/AdvisorPanel/AdvisorDetail.tsx b/apps/studio/components/ui/AdvisorPanel/AdvisorDetail.tsx new file mode 100644 index 0000000000000..cc124a5c69c14 --- /dev/null +++ b/apps/studio/components/ui/AdvisorPanel/AdvisorDetail.tsx @@ -0,0 +1,41 @@ +import LintDetail from 'components/interfaces/Linter/LintDetail' +import { Lint } from 'data/lint/lint-query' +import { Notification } from 'data/notifications/notifications-v2-query' +import { noop } from 'lodash' +import { AdvisorItem } from './AdvisorPanelHeader' +import { NotificationDetail } from './NotificationDetail' + +interface AdvisorDetailProps { + item: AdvisorItem + projectRef: string + onUpdateNotificationStatus?: (id: string, status: 'archived' | 'seen') => void +} + +export const AdvisorDetail = ({ + item, + projectRef, + onUpdateNotificationStatus = noop, +}: AdvisorDetailProps) => { + if (item.source === 'lint') { + const lint = item.original as Lint + return ( +
+ +
+ ) + } + + if (item.source === 'notification') { + const notification = item.original as Notification + return ( +
+ +
+ ) + } + + return null +} diff --git a/apps/studio/components/ui/AdvisorPanel/AdvisorFilters.tsx b/apps/studio/components/ui/AdvisorPanel/AdvisorFilters.tsx new file mode 100644 index 0000000000000..6fc70c1930cbd --- /dev/null +++ b/apps/studio/components/ui/AdvisorPanel/AdvisorFilters.tsx @@ -0,0 +1,100 @@ +import { X } from 'lucide-react' + +import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { FilterPopover } from 'components/ui/FilterPopover' +import { AdvisorSeverity, AdvisorTab } from 'state/advisor-state' +import { TabsList_Shadcn_, TabsTrigger_Shadcn_, Tabs_Shadcn_ } from 'ui' + +const severityOptions = [ + { label: 'Critical', value: 'critical' }, + { label: 'Warning', value: 'warning' }, + { label: 'Info', value: 'info' }, +] + +const statusOptions = [ + { label: 'Unread', value: 'unread' }, + { label: 'Archived', value: 'archived' }, +] + +interface AdvisorFiltersProps { + activeTab: AdvisorTab + onTabChange: (tab: string) => void + severityFilters: AdvisorSeverity[] + onSeverityFiltersChange: (filters: AdvisorSeverity[]) => void + statusFilters: string[] + onStatusFiltersChange: (filters: string[]) => void + hasProjectRef?: boolean + onClose: () => void + isPlatform?: boolean +} + +export const AdvisorFilters = ({ + activeTab, + onTabChange, + severityFilters, + onSeverityFiltersChange, + statusFilters, + onStatusFiltersChange, + hasProjectRef = true, + onClose, + isPlatform = false, +}: AdvisorFiltersProps) => { + return ( +
+
+ + + + All + + + Security + + + Performance + + {isPlatform && ( + + Messages + + )} + + +
+ {isPlatform && ( + + )} + { + onSeverityFiltersChange(values as AdvisorSeverity[]) + }} + /> + } + onClick={onClose} + tooltip={{ content: { side: 'bottom', text: 'Close Advisor Center' } }} + /> +
+
+
+ ) +} diff --git a/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.tsx b/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.tsx index 1a5cdfe2724fc..23d798e7deaa1 100644 --- a/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.tsx +++ b/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.tsx @@ -1,33 +1,23 @@ -import { AlertTriangle, ChevronLeft, ChevronRight, Gauge, Inbox, Shield, X } from 'lucide-react' -import { useMemo } from 'react' +import dayjs from 'dayjs' +import { useMemo, useRef } from 'react' -import LintDetail from 'components/interfaces/Linter/LintDetail' import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' -import { ButtonTooltip } from 'components/ui/ButtonTooltip' -import { FilterPopover } from 'components/ui/FilterPopover' import { Lint, useProjectLintsQuery } from 'data/lint/lint-query' +import { + Notification, + NotificationData, + useNotificationsV2Query, +} from 'data/notifications/notifications-v2-query' +import { useNotificationsV2UpdateMutation } from 'data/notifications/notifications-v2-update-mutation' +import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { IS_PLATFORM } from 'lib/constants' import { AdvisorSeverity, AdvisorTab, useAdvisorStateSnapshot } from 'state/advisor-state' import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state' -import { Badge, Button, TabsList_Shadcn_, TabsTrigger_Shadcn_, Tabs_Shadcn_, cn } from 'ui' -import { GenericSkeletonLoader } from 'ui-patterns' -import { EmptyAdvisor } from './EmptyAdvisor' - -type AdvisorItem = { - id: string - title: string - severity: AdvisorSeverity - createdAt?: number - tab: Exclude - source: 'lint' - original: Lint -} - -const severityOptions = [ - { label: 'Critical', value: 'critical' }, - { label: 'Warning', value: 'warning' }, - { label: 'Info', value: 'info' }, -] +import { AdvisorDetail } from './AdvisorDetail' +import { AdvisorFilters } from './AdvisorFilters' +import { AdvisorPanelBody } from './AdvisorPanelBody' +import { AdvisorItem, AdvisorPanelHeader } from './AdvisorPanelHeader' const severityOrder: Record = { critical: 0, @@ -35,30 +25,6 @@ const severityOrder: Record = { info: 2, } -const severityLabels: Record = { - critical: 'Critical', - warning: 'Warning', - info: 'Info', -} - -const severityBadgeVariants: Record = { - critical: 'destructive', - warning: 'warning', - info: 'default', -} - -const severityColorClasses: Record = { - critical: 'text-destructive', - warning: 'text-warning', - info: 'text-foreground-light', -} - -const tabIconMap: Record, React.ElementType> = { - security: Shield, - performance: Gauge, - messages: Inbox, -} - const lintLevelToSeverity = (level: Lint['level']): AdvisorSeverity => { switch (level) { case 'ERROR': @@ -70,20 +36,39 @@ const lintLevelToSeverity = (level: Lint['level']): AdvisorSeverity => { } } +const notificationPriorityToSeverity = (priority: string | null | undefined): AdvisorSeverity => { + switch (priority) { + case 'Critical': + return 'critical' + case 'Warning': + return 'warning' + default: + return 'info' + } +} + export const AdvisorPanel = () => { const { activeTab, severityFilters, selectedItemId, + selectedItemSource, setActiveTab, setSeverityFilters, clearSeverityFilters, - setSelectedItemId, + setSelectedItem, + notificationFilterStatuses, + notificationFilterPriorities, + setNotificationFilters, + resetNotificationFilters, } = useAdvisorStateSnapshot() const { data: project } = useSelectedProjectQuery() + const { data: selectedOrganization } = useSelectedOrganizationQuery() const { activeSidebar, closeSidebar } = useSidebarManagerSnapshot() const isSidebarOpen = activeSidebar?.id === SIDEBAR_KEYS.ADVISOR_PANEL + const markedRead = useRef([]) + const hasProjectRef = !!project?.ref const { data: lintData, @@ -91,9 +76,58 @@ export const AdvisorPanel = () => { isError: isLintsError, } = useProjectLintsQuery( { projectRef: project?.ref }, - { enabled: isSidebarOpen && !!project?.ref } + { enabled: isSidebarOpen && hasProjectRef && activeTab !== 'messages' } + ) + + // Notifications should always load when sidebar is open (shown in both 'all' and 'messages' tabs) + const shouldLoadNotifications = isSidebarOpen && IS_PLATFORM + + const notificationStatus = useMemo(() => { + if (notificationFilterStatuses.includes('archived')) { + return 'archived' + } + if (notificationFilterStatuses.includes('unread')) { + return 'new' + } + return undefined + }, [notificationFilterStatuses]) + + // Memoize filters to prevent query key changes on every render + // Use selected organization and project if they exist + const notificationFilters = useMemo( + () => ({ + priority: notificationFilterPriorities, + organizations: selectedOrganization?.slug ? [selectedOrganization.slug] : [], + projects: project?.ref ? [project.ref] : [], + }), + [notificationFilterPriorities, selectedOrganization?.slug, project?.ref] ) + const { + data: notificationsData, + isLoading: isNotificationsLoading, + isError: isNotificationsError, + } = useNotificationsV2Query( + { + status: notificationStatus, + filters: notificationFilters, + limit: 20, + }, + { enabled: shouldLoadNotifications } + ) + + const { mutate: updateNotifications } = useNotificationsV2UpdateMutation() + + const notifications = useMemo(() => { + return notificationsData?.pages.flatMap((page) => page) ?? [] + }, [notificationsData?.pages]) + + const markNotificationsRead = () => { + if (markedRead.current.length > 0) { + updateNotifications({ ids: markedRead.current, status: 'seen' }) + } + } + const lintItems = useMemo(() => { if (!lintData) return [] @@ -121,8 +155,24 @@ export const AdvisorPanel = () => { .filter((item): item is AdvisorItem => item !== null) }, [lintData]) + const notificationItems = useMemo(() => { + if (!IS_PLATFORM) return [] + return notifications?.map((notification): AdvisorItem => { + const data = notification.data as NotificationData + return { + id: notification.id, + title: data.title, + severity: notificationPriorityToSeverity(notification.priority), + createdAt: dayjs(notification.inserted_at).valueOf(), + tab: 'messages' as const, + source: 'notification' as const, + original: notification, + } + }) + }, [notifications]) + const combinedItems = useMemo(() => { - const all = [...lintItems] + const all = [...lintItems, ...notificationItems] return all.sort((a, b) => { const severityDiff = severityOrder[a.severity] - severityOrder[b.severity] @@ -133,80 +183,107 @@ export const AdvisorPanel = () => { return a.title.localeCompare(b.title) }) - }, [lintItems]) + }, [lintItems, notificationItems]) const filteredItems = useMemo(() => { return combinedItems.filter((item) => { + // Filter by severity if (severityFilters.length > 0 && !severityFilters.includes(item.severity)) { return false } - if (activeTab === 'all') return true + // Filter by tab + if (activeTab === 'all') { + // When no projectRef, only show notifications in 'all' tab + if (!hasProjectRef && item.source !== 'notification') { + return false + } + return true + } return item.tab === activeTab }) - }, [combinedItems, severityFilters, activeTab]) + }, [combinedItems, severityFilters, activeTab, hasProjectRef]) const itemsFilteredByTabOnly = useMemo(() => { return combinedItems.filter((item) => { - if (activeTab === 'all') return true + if (activeTab === 'all') { + // When no projectRef, only show notifications in 'all' tab + if (!hasProjectRef && item.source !== 'notification') { + return false + } + return true + } return item.tab === activeTab }) - }, [combinedItems, activeTab]) + }, [combinedItems, activeTab, hasProjectRef]) const hiddenItemsCount = itemsFilteredByTabOnly.length - filteredItems.length - const selectedItem = combinedItems.find((item) => item.id === selectedItemId) + const selectedItem = combinedItems.find( + (item) => item.id === selectedItemId && item.source === selectedItemSource + ) const isDetailView = !!selectedItem - const isLoading = isLintsLoading - const isError = isLintsError + // Only show loading state if the query is actually enabled + const isLintsActuallyLoading = + isSidebarOpen && hasProjectRef && activeTab !== 'messages' && isLintsLoading + const isNotificationsActuallyLoading = shouldLoadNotifications && isNotificationsLoading + const isLoading = isLintsActuallyLoading || isNotificationsActuallyLoading + const isError = isLintsError || isNotificationsError const handleTabChange = (tab: string) => { setActiveTab(tab as AdvisorTab) + setSelectedItem(undefined) } const handleBackToList = () => { - setSelectedItemId(undefined) + setSelectedItem(undefined) + markNotificationsRead() } const handleClose = () => { + markNotificationsRead() closeSidebar(SIDEBAR_KEYS.ADVISOR_PANEL) } + const handleItemClick = (item: AdvisorItem) => { + setSelectedItem(item.id, item.source) + if (item.source === 'notification') { + const notification = item.original as Notification + if (notification.status === 'new' && !markedRead.current.includes(notification.id)) { + markedRead.current.push(notification.id) + } + } + } + + const handleUpdateNotificationStatus = (id: string, status: 'archived' | 'seen') => { + updateNotifications({ ids: [id], status }) + } + + const handleClearAllFilters = () => { + clearSeverityFilters() + resetNotificationFilters() + } + + const hasAnyFilters = severityFilters.length > 0 || notificationFilterStatuses.length > 0 + return (
{isDetailView ? ( <> -
- } - 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 ( + + ) + } else if (action.action_type !== undefined) { + return ( + + ) + } else { + return null + } + })} + {notification.status === 'archived' ? ( + + ) : ( + + )} +
+
+ ) +} 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<