diff --git a/apps/docs/content/_partials/migration_warnings.mdx b/apps/docs/content/_partials/migration_warnings.mdx index a2a23f1148ad6..c875713b09b5b 100644 --- a/apps/docs/content/_partials/migration_warnings.mdx +++ b/apps/docs/content/_partials/migration_warnings.mdx @@ -2,8 +2,6 @@ - If you're planning to migrate a database larger than 6 GB, we recommend [upgrading to at least a Large compute add-on](/docs/guides/platform/compute-add-ons). This will ensure you have the necessary resources to handle the migration efficiently. -- For databases smaller than 150 GB, you can increase the size of the disk on paid projects by navigating to the [Compute and Disk Settings](dashboard/project/_/settings/compute-and-disk) page. - -- If you're dealing with a database larger than 150 GB, we strongly advise you to [contact our support team](/dashboard/support/new) for assistance in provisioning the required resources and ensuring a smooth migration process. +- We strongly advise you to pre-provision the disk space you will need for your migration. On paid projects, you can do this by navigating to the [Compute and Disk Settings](https://supabase.com/dashboard/project/_/settings/compute-and-disk) page. For more information on disk scaling and disk limits, check out our [disk settings](https://supabase.com/docs/guides/platform/compute-and-disk#disk) documentation. diff --git a/apps/studio/components/interfaces/BranchManagement/BranchPanels.tsx b/apps/studio/components/interfaces/BranchManagement/BranchPanels.tsx index 57f3f3d0c0c6f..6b8c96bee7d86 100644 --- a/apps/studio/components/interfaces/BranchManagement/BranchPanels.tsx +++ b/apps/studio/components/interfaces/BranchManagement/BranchPanels.tsx @@ -6,6 +6,7 @@ import { PropsWithChildren, ReactNode } from 'react' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import ShimmeringLoader from 'components/ui/ShimmeringLoader' import type { Branch } from 'data/branches/branches-query' +import { BASE_PATH } from 'lib/constants' import { Tooltip, TooltipContent, TooltipTrigger } from 'ui' import { WorkflowLogs } from './WorkflowLogs' @@ -87,7 +88,7 @@ export const BranchRow = ({ const handleRowClick = () => { if (external) { - window.open(navigateUrl, '_blank', 'noopener noreferrer') + window.open(`${BASE_PATH}/${navigateUrl}`, '_blank', 'noopener noreferrer') } else { router.push(navigateUrl) } diff --git a/apps/studio/components/interfaces/Home/ProjectList/ProjectTableRow.tsx b/apps/studio/components/interfaces/Home/ProjectList/ProjectTableRow.tsx index 323ca7923fa96..812fc067d81e0 100644 --- a/apps/studio/components/interfaces/Home/ProjectList/ProjectTableRow.tsx +++ b/apps/studio/components/interfaces/Home/ProjectList/ProjectTableRow.tsx @@ -42,7 +42,7 @@ export const ProjectTableRow = ({ className="cursor-pointer hover:bg-surface-200" onClick={(event) => { if (event.metaKey) { - window.open(url, '_blank') + window.open(`${BASE_PATH}/${url}`, '_blank') } else { router.push(url) } diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet.tsx index fb97c5c828581..1d3072b5a9096 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CreateCronJobSheet.tsx @@ -8,6 +8,7 @@ import { toast } from 'sonner' import z from 'zod' import { useWatch } from '@ui/components/shadcn/ui/form' +import { useParams } from 'common' import { urlRegex } from 'components/interfaces/Auth/Auth.constants' import EnableExtensionModal from 'components/interfaces/Database/Extensions/EnableExtensionModal' import { ButtonTooltip } from 'components/ui/ButtonTooltip' @@ -201,11 +202,13 @@ export const CreateCronJobSheet = ({ setIsClosing, onClose, }: CreateCronJobSheetProps) => { + const { childId } = useParams() const { data: project } = useSelectedProjectQuery() const { data: org } = useSelectedOrganizationQuery() const [searchQuery] = useQueryState('search', parseAsString.withDefault('')) const [isLoadingGetCronJob, setIsLoadingGetCronJob] = useState(false) + const jobId = Number(childId) const isEditing = !!selectedCronJob?.jobname const [showEnableExtensionModal, setShowEnableExtensionModal] = useState(false) @@ -340,6 +343,8 @@ export const CreateCronJobSheet = ({ connectionString: project?.connectionString, query, searchTerm: searchQuery, + // [Joshen] Only need to invalidate a specific cron job if in the job's previous run tab + identifier: !!jobId ? jobId : undefined, }, { onSuccess: () => { diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobPage.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobPage.tsx new file mode 100644 index 0000000000000..02de19a27d94a --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobPage.tsx @@ -0,0 +1,192 @@ +import { toString as CronToString } from 'cronstrue' +import { Edit3, List } from 'lucide-react' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { useState } from 'react' + +import { useParams } from 'common' +import { NavigationItem, PageLayout } from 'components/layouts/PageLayout/PageLayout' +import { useCronJobQuery } from 'data/database-cron-jobs/database-cron-job-query' +import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { + Button, + cn, + CodeBlock, + Sheet, + SheetContent, + Tooltip, + TooltipContent, + TooltipTrigger, +} from 'ui' +import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' +import { CreateCronJobSheet } from './CreateCronJobSheet' +import { isSecondsFormat, parseCronJobCommand } from './CronJobs.utils' +import { PreviousRunsTab } from './PreviousRunsTab' + +export const CronJobPage = () => { + const router = useRouter() + const { ref, id, pageId, childId } = useParams() + const childLabel = router?.query?.['child-label'] as string + const { data: project } = useSelectedProjectQuery() + + const [isEditSheetOpen, setIsEditSheetOpen] = useState(false) + const [isClosing, setIsClosing] = useState(false) + + const jobId = Number(childId) + + const { data: job, isLoading } = useCronJobQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + id: jobId, + }) + + const { data: edgeFunctions = [] } = useEdgeFunctionsQuery({ projectRef: project?.ref }) + + // Parse the cron job command to check if it's an edge function + const cronJobValues = parseCronJobCommand(job?.command || '', project?.ref!) + const edgeFunction = + cronJobValues.type === 'edge_function' ? cronJobValues.edgeFunctionName : undefined + const edgeFunctionSlug = edgeFunction?.split('/functions/v1/').pop() + const isValidEdgeFunction = edgeFunctions.some((x) => x.slug === edgeFunctionSlug) + + const breadcrumbItems = [ + { + label: 'Integrations', + href: `/project/${ref}/integrations`, + }, + { + label: 'Cron', + href: pageId + ? `/project/${ref}/integrations/${id}/${pageId}` + : `/project/${ref}/integrations/${id}`, + }, + { + label: childLabel ?? job?.jobname ?? '', + }, + ] + + const navigationItems: NavigationItem[] = [] + + const pageTitle = childLabel || childId || 'Cron Job' + + const pageSubtitle = job ? ( +
+ Running{' '} + + + + {isSecondsFormat(job.schedule) + ? job.schedule.toLowerCase() + : CronToString(job.schedule.toLowerCase())} + + + +
+

{job.schedule.toLowerCase()}

+ {!isSecondsFormat(job.schedule) && ( +

{CronToString(job.schedule.toLowerCase())}

+ )} +
+
+
{' '} + with command{' '} + + + + {job.command} + + + +

Command

+ code]:m-0 [&>code>span]:flex [&>code>span]:flex-wrap min-h-11', + '[&>code]:text-xs' + )} + /> +
+
+
+ ) : null + + // Secondary actions + const secondaryActions = [ + , + , + ...(isValidEdgeFunction + ? [ + , + ] + : []), + ] + + return ( + <> + : pageSubtitle} + className="border-b-0" + > + + + + + + {job && ( + { + setIsEditSheetOpen(false) + setIsClosing(false) + }} + /> + )} + + + + ) +} diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx index 61dc269bffdea..0f1e2e2e85eb4 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.tsx @@ -17,6 +17,7 @@ import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-ex import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { BASE_PATH } from 'lib/constants' import { isAtBottom } from 'lib/helpers' import { Button, cn, LoadingLine, Sheet, SheetContent } from 'ui' import { Input } from 'ui-patterns/DataInputs/Input' @@ -211,7 +212,7 @@ export const CronjobsTab = () => { }) if (e.metaKey) { - window.open(url, '_blank') + window.open(`${BASE_PATH}/${url}`, '_blank') } else { router.push(url) } diff --git a/apps/studio/components/interfaces/Integrations/CronJobs/PreviousRunsTab.tsx b/apps/studio/components/interfaces/Integrations/CronJobs/PreviousRunsTab.tsx index a4e94a4612ab8..2490a348133c8 100644 --- a/apps/studio/components/interfaces/Integrations/CronJobs/PreviousRunsTab.tsx +++ b/apps/studio/components/interfaces/Integrations/CronJobs/PreviousRunsTab.tsx @@ -1,42 +1,26 @@ -import { toString as CronToString } from 'cronstrue' -import { CircleCheck, CircleX, List, Loader } from 'lucide-react' -import Link from 'next/link' +import dayjs from 'dayjs' +import { CircleCheck, CircleX, Loader } from 'lucide-react' import { UIEvent, useCallback, useMemo } from 'react' import DataGrid, { Column, Row } from 'react-data-grid' import { useParams } from 'common' -import { useCronJobQuery } from 'data/database-cron-jobs/database-cron-job-query' import { CronJobRun, useCronJobRunsInfiniteQuery, } from 'data/database-cron-jobs/database-cron-jobs-runs-infinite-query' -import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query' -import dayjs from 'dayjs' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { - Button, - cn, - LoadingLine, - SimpleCodeBlock, - Tooltip, - TooltipContent, - TooltipTrigger, -} from 'ui' +import { cn, CodeBlock, LoadingLine, Tooltip, TooltipContent, TooltipTrigger } from 'ui' import { TimestampInfo } from 'ui-patterns' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' -import { - calculateDuration, - formatDate, - isSecondsFormat, - parseCronJobCommand, -} from './CronJobs.utils' +import { calculateDuration, formatDate } from './CronJobs.utils' import CronJobsEmptyState from './CronJobsEmptyState' const cronJobColumns = [ { id: 'runid', name: 'RunID', - minWidth: 60, + minWidth: 30, + width: 30, value: (row: CronJobRun) => (

{row.runid}

@@ -55,14 +39,22 @@ const cronJobColumns = [ {row.return_message} - - - {row.return_message} - + +

Message

+ code]:m-0 [&>code>span]:flex [&>code>span]:flex-wrap min-h-11', + '[&>code]:text-xs' + )} + />
@@ -95,9 +87,11 @@ const cronJobColumns = [ name: 'Duration', minWidth: 100, value: (row: CronJobRun) => ( - - {row.status === 'succeeded' ? calculateDuration(row.start_time, row.end_time) : ''} - +
+ + {row.status === 'succeeded' ? calculateDuration(row.start_time, row.end_time) : ''} + +
), }, ] @@ -108,13 +102,16 @@ const columns = cronJobColumns.map((col) => { name: col.name, resizable: true, minWidth: col.minWidth ?? 120, - headerCellClass: 'first:pl-6 cursor-pointer', + headerCellClass: undefined, renderHeaderCell: () => { return ( -
-
-

{col.name}

-
+
+

{col.name}

) }, @@ -128,22 +125,13 @@ const columns = cronJobColumns.map((col) => {
) } - return ( -
- {value} -
- ) + return value }, } return result @@ -159,18 +147,11 @@ export const PreviousRunsTab = () => { const jobId = Number(childId) - const { data: job, isLoading: isLoadingCronJobs } = useCronJobQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - id: jobId, - }) - const { data, isLoading: isLoadingCronJobRuns, - fetchNextPage, - refetch, isFetching, + fetchNextPage, } = useCronJobRunsInfiniteQuery( { projectRef: project?.ref, @@ -180,14 +161,7 @@ export const PreviousRunsTab = () => { { enabled: !!jobId, staleTime: 30000 } ) - const { data: edgeFunctions = [] } = useEdgeFunctionsQuery({ projectRef: project?.ref }) - const cronJobRuns = useMemo(() => data?.pages.flatMap((p) => p) || [], [data?.pages]) - const cronJobValues = parseCronJobCommand(job?.command || '', project?.ref!) - const edgeFunction = - cronJobValues.type === 'edge_function' ? cronJobValues.edgeFunctionName : undefined - const edgeFunctionSlug = edgeFunction?.split('/functions/v1/').pop() - const isValidEdgeFunction = edgeFunctions.some((x) => x.slug === edgeFunctionSlug) const handleScroll = useCallback( (event: UIEvent) => { @@ -203,20 +177,18 @@ export const PreviousRunsTab = () => {
{ - const isSelected = false - return cn([ - `${isSelected ? 'bg-surface-300 dark:bg-surface-300' : 'bg-200'} `, - `${isSelected ? '[&>div:first-child]:border-l-4 border-l-secondary [&>div]:border-l-foreground' : ''}`, + return cn( + 'cursor-pointer', '[&>.rdg-cell]:border-box [&>.rdg-cell]:outline-none [&>.rdg-cell]:shadow-none', - '[&>.rdg-cell:first-child>div]:ml-4', - ]) + '[&>.rdg-cell:first-child>div]:ml-8' + ) }} renderers={{ renderRow(_idx, props) { @@ -233,89 +205,6 @@ export const PreviousRunsTab = () => { ), }} /> - -
- {isLoadingCronJobs ? ( - - ) : ( - <> -
-

Schedule

-

- {job?.schedule ? ( - <> - {job.schedule.toLocaleLowerCase()} -

- {isSecondsFormat(job.schedule) - ? '' - : CronToString(job.schedule.toLowerCase())} -

- - ) : ( - Loading schedule... - )} -

-
- -
-

Command

- - - - {job?.command} - -
- - -

Command

- - {job?.command} - -
- -
- -
-

Explore

-
- - {isValidEdgeFunction && ( - - )} -
-
- - )} -
) } @@ -327,7 +216,7 @@ interface StatusBadgeProps { function StatusBadge({ status }: StatusBadgeProps) { if (status === 'succeeded') { return ( - + Succeeded ) @@ -335,7 +224,7 @@ function StatusBadge({ status }: StatusBadgeProps) { if (status === 'failed') { return ( - + Failed ) @@ -343,7 +232,7 @@ function StatusBadge({ status }: StatusBadgeProps) { if (['running', 'starting', 'sending', 'connecting'].includes(status)) { return ( - + Running ) diff --git a/apps/studio/components/interfaces/Integrations/Landing/IntegrationCard.tsx b/apps/studio/components/interfaces/Integrations/Landing/IntegrationCard.tsx index 0f1cf568bc3d5..00d770d3c72f0 100644 --- a/apps/studio/components/interfaces/Integrations/Landing/IntegrationCard.tsx +++ b/apps/studio/components/interfaces/Integrations/Landing/IntegrationCard.tsx @@ -1,23 +1,27 @@ import { BadgeCheck } from 'lucide-react' +import Image from 'next/image' import Link from 'next/link' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { Badge, cn } from 'ui' +import { BASE_PATH } from 'lib/constants' +import { Badge, Card, CardContent, cn } from 'ui' import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' import { IntegrationDefinition } from './Integrations.constants' type IntegrationCardProps = IntegrationDefinition & { isInstalled?: boolean + featured?: boolean + image?: string } const INTEGRATION_CARD_STYLE = cn( - 'w-full h-full pl-5 pr-6 py-3 bg-surface-100 hover:bg-surface-200 hover:border-strong', - 'border border-border gap-3 rounded-md inline-flex ease-out duration-200 transition-all' + 'w-full h-full bg-surface-100 hover:bg-surface-200 hover:border-strong', + 'border border-border rounded-md ease-out duration-200 transition-all' ) export const IntegrationLoadingCard = () => { return ( -
+
@@ -38,40 +42,84 @@ export const IntegrationCard = ({ icon, description, isInstalled, + featured = false, + image, }: IntegrationCardProps) => { const { data: project } = useSelectedProjectQuery() - return ( - -
-
- {icon()} -
-
-
-
-

{name}

- {status && ( - - {status} + if (featured) { + return ( + + + {/* Full-width image/icon at the top */} +
+ {image ? ( + {`${name} + ) : ( +
+ {icon({ className: 'w-full h-full text-foreground' })} +
+ )} +
+ +
+

{name}

+

{description}

+
+ {status && ( + + {status} + + )} + + Official - )} +
+
+
+
+ + ) + } + + return ( + + + +
+
+ {icon()}
-

{description}

-
-
- - Official - {isInstalled && (
- - Installed + + Installed
)}
-
-
+
+

{name}

+ +

{description}

+
+ {status && ( + + {status} + + )} + + Official + +
+
+ + ) } diff --git a/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx b/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx index 528a6438755e4..2e10655b7f4d8 100644 --- a/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx +++ b/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx @@ -87,7 +87,7 @@ const supabaseIntegrations: IntegrationDefinition[] = [ ], navigate: (id: string, pageId: string = 'overview', childId: string | undefined) => { if (childId) { - return dynamic(() => import('../Queues/QueueTab').then((mod) => mod.QueueTab), { + return dynamic(() => import('../Queues/QueuePage').then((mod) => mod.QueuePage), { loading: Loading, }) } @@ -121,7 +121,7 @@ const supabaseIntegrations: IntegrationDefinition[] = [ icon: ({ className, ...props } = {}) => ( ), - description: 'Schedule recurring Jobs in Postgres.', + description: 'Schedule recurring Jobs in Postgres', docsUrl: 'https://github.com/citusdata/pg_cron', author: { name: 'Citus Data', @@ -143,12 +143,9 @@ const supabaseIntegrations: IntegrationDefinition[] = [ ], navigate: (id: string, pageId: string = 'overview', childId: string | undefined) => { if (childId) { - return dynamic( - () => import('../CronJobs/PreviousRunsTab').then((mod) => mod.PreviousRunsTab), - { - loading: Loading, - } - ) + return dynamic(() => import('../CronJobs/CronJobPage').then((mod) => mod.CronJobPage), { + loading: Loading, + }) } switch (pageId) { case 'overview': diff --git a/apps/studio/components/interfaces/Integrations/Queues/QueueCells.tsx b/apps/studio/components/interfaces/Integrations/Queues/QueueCells.tsx new file mode 100644 index 0000000000000..a8d5179608433 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Queues/QueueCells.tsx @@ -0,0 +1,87 @@ +import dayjs from 'dayjs' +import { Check, Loader2, X } from 'lucide-react' + +import { useQueuesMetricsQuery } from 'data/database-queues/database-queues-metrics-query' +import { PostgresQueue } from 'data/database-queues/database-queues-query' +import { useTablesQuery } from 'data/tables/tables-query' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { DATETIME_FORMAT } from 'lib/constants' + +export interface QueueWithMetrics extends PostgresQueue { + id: string // Add unique id for DataGrid +} + +interface QueueCellProps { + queue: QueueWithMetrics +} + +export const QueueNameCell = ({ queue }: QueueCellProps) => ( +
+ + {queue.queue_name} + +
+) + +export const QueueTypeCell = ({ queue }: QueueCellProps) => { + const type = queue.is_partitioned ? 'Partitioned' : queue.is_unlogged ? 'Unlogged' : 'Basic' + return ( +
+ + {type} + +
+ ) +} + +export const QueueRLSCell = ({ queue }: QueueCellProps) => { + const { data: selectedProject } = useSelectedProjectQuery() + + const { data: queueTables } = useTablesQuery({ + projectRef: selectedProject?.ref, + connectionString: selectedProject?.connectionString, + schema: 'pgmq', + }) + + const queueTable = queueTables?.find((x) => x.name === `q_${queue.queue_name}`) + const isRlsEnabled = !!queueTable?.rls_enabled + + return ( +
+ {isRlsEnabled ? : } +
+ ) +} + +export const QueueCreatedAtCell = ({ queue }: QueueCellProps) => ( +
+ {dayjs(queue.created_at).format(DATETIME_FORMAT)} +
+) + +export const QueueSizeCell = ({ queue }: QueueCellProps) => { + const { data: selectedProject } = useSelectedProjectQuery() + + const { data: metrics, isLoading } = useQueuesMetricsQuery( + { + queueName: queue.queue_name, + projectRef: selectedProject?.ref, + connectionString: selectedProject?.connectionString, + }, + { + staleTime: 30 * 1000, // 30 seconds + } + ) + + return ( +
+ {isLoading ? ( + + ) : ( + + {metrics?.queue_length} {metrics?.method === 'estimated' ? '(Approximate)' : null} + + )} +
+ ) +} diff --git a/apps/studio/components/interfaces/Integrations/Queues/QueuePage.tsx b/apps/studio/components/interfaces/Integrations/Queues/QueuePage.tsx new file mode 100644 index 0000000000000..c015278f8bb5d --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Queues/QueuePage.tsx @@ -0,0 +1,64 @@ +import dayjs from 'dayjs' +import { useRouter } from 'next/router' + +import { useParams } from 'common' +import { NavigationItem, PageLayout } from 'components/layouts/PageLayout/PageLayout' +import { useQueuesQuery } from 'data/database-queues/database-queues-query' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { DATETIME_FORMAT } from 'lib/constants' +import { QueueTab } from './QueueTab' + +export const QueuePage = () => { + const router = useRouter() + const { ref, id, pageId, childId } = useParams() + const childLabel = router?.query?.['child-label'] as string + const { data: project } = useSelectedProjectQuery() + + const { data: queues } = useQueuesQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + + const currentQueue = queues?.find((queue) => queue.queue_name === childId) + + const breadcrumbItems = [ + { + label: 'Integrations', + href: `/project/${ref}/integrations`, + }, + { + label: 'Queues', + href: pageId + ? `/project/${ref}/integrations/${id}/${pageId}` + : `/project/${ref}/integrations/${id}`, + }, + { + label: childId, + }, + ] + + const navigationItems: NavigationItem[] = [] + + const pageTitle = childLabel || childId || 'Queue' + + const getQueueType = (queue: typeof currentQueue) => { + if (!queue) return 'Unknown' + return queue.is_partitioned ? 'Partitioned' : queue.is_unlogged ? 'Unlogged' : 'Basic' + } + + const pageSubtitle = currentQueue + ? `${getQueueType(currentQueue)} queue created on ${dayjs(currentQueue.created_at).format(DATETIME_FORMAT)}` + : undefined + + return ( + + + + ) +} diff --git a/apps/studio/components/interfaces/Integrations/Queues/QueueTab.tsx b/apps/studio/components/interfaces/Integrations/Queues/QueueTab.tsx index f595ec0faa0b4..fe207c0c946a4 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/QueueTab.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/QueueTab.tsx @@ -4,8 +4,8 @@ import { useMemo, useState } from 'react' import { toast } from 'sonner' import { useParams } from 'common' -import DeleteQueue from 'components/interfaces/Integrations/Queues/SingleQueue/DeleteQueue' -import PurgeQueue from 'components/interfaces/Integrations/Queues/SingleQueue/PurgeQueue' +import { DeleteQueue } from 'components/interfaces/Integrations/Queues/SingleQueue/DeleteQueue' +import { PurgeQueue } from 'components/interfaces/Integrations/Queues/SingleQueue/PurgeQueue' import { QUEUE_MESSAGE_TYPE } from 'components/interfaces/Integrations/Queues/SingleQueue/Queue.utils' import { QueueMessagesDataGrid } from 'components/interfaces/Integrations/Queues/SingleQueue/QueueDataGrid' import { QueueFilters } from 'components/interfaces/Integrations/Queues/SingleQueue/QueueFilters' @@ -100,7 +100,8 @@ export const QueueTab = () => { return (
-
+
+
@@ -238,8 +239,8 @@ You may opt to manage your queues via any Supabase client libraries or PostgREST
- + [] => { + return [ + { + key: 'queue_name', + name: 'Name', + resizable: true, + minWidth: 200, + headerCellClass: undefined, + renderHeaderCell: () => { + return ( +
+

Name

+
+ ) + }, + renderCell: (props) => { + return + }, + }, + { + key: 'type', + name: 'Type', + resizable: true, + minWidth: 120, + headerCellClass: undefined, + renderHeaderCell: () => { + return ( +
+

Type

+
+ ) + }, + renderCell: (props) => { + return + }, + }, + { + key: 'rls_enabled', + name: 'RLS enabled', + resizable: true, + minWidth: 120, + headerCellClass: undefined, + renderHeaderCell: () => { + return ( +
+

RLS enabled

+
+ ) + }, + renderCell: (props) => { + return + }, + }, + { + key: 'created_at', + name: 'Created at', + resizable: true, + minWidth: 180, + headerCellClass: undefined, + renderHeaderCell: () => { + return ( +
+

Created at

+
+ ) + }, + renderCell: (props) => { + return + }, + }, + { + key: 'queue_size', + name: 'Size', + resizable: true, + minWidth: 120, + headerCellClass: undefined, + renderHeaderCell: () => { + return ( +
+

Size

+
+ ) + }, + renderCell: (props) => { + return + }, + }, + ] +} + +export const prepareQueuesForDataGrid = (queues: PostgresQueue[]): QueueWithMetrics[] => { + return queues.map((queue) => ({ + ...queue, + id: queue.queue_name, // Use queue_name as unique id + })) +} diff --git a/apps/studio/components/interfaces/Integrations/Queues/QueuesTab.tsx b/apps/studio/components/interfaces/Integrations/Queues/QueuesTab.tsx index f3be8b1f7823c..60ffde22d84c3 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/QueuesTab.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/QueuesTab.tsx @@ -1,19 +1,27 @@ -import { Search } from 'lucide-react' -import { useQueryState } from 'nuqs' -import { useState } from 'react' +import { RefreshCw, Search, X } from 'lucide-react' +import { useRouter } from 'next/router' +import { parseAsString, useQueryState } from 'nuqs' +import { useMemo, useState } from 'react' +import DataGrid, { Row } from 'react-data-grid' -import Table from 'components/to-be-cleaned/Table' +import { useParams } from 'common' import AlertError from 'components/ui/AlertError' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useQueuesQuery } from 'data/database-queues/database-queues-query' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { Button, Input, Sheet, SheetContent } from 'ui' +import { Button, cn, LoadingLine, Sheet, SheetContent } from 'ui' +import { Input } from 'ui-patterns/DataInputs/Input' import { CreateQueueSheet } from './CreateQueueSheet' -import { QueuesRows } from './QueuesRows' +import { formatQueueColumns, prepareQueuesForDataGrid } from './Queues.utils' export const QueuesTab = () => { + const router = useRouter() + const { ref } = useParams() const { data: project } = useSelectedProjectQuery() + const [searchQuery, setSearchQuery] = useQueryState('search', parseAsString.withDefault('')) + const [search, setSearch] = useState(searchQuery) + // used for confirmation prompt in the Create Queue Sheet const [isClosingCreateQueueSheet, setIsClosingCreateQueueSheet] = useState(false) const [createQueueSheetShown, setCreateQueueSheetShown] = useState(false) @@ -23,77 +31,135 @@ export const QueuesTab = () => { error, isLoading, isError, + isRefetching, + refetch, } = useQueuesQuery({ projectRef: project?.ref, connectionString: project?.connectionString, }) - const [searchQuery, setSearchQuery] = useQueryState('search') - - if (isLoading) - return ( -
- -
- ) - if (isError) - return ( -
- -
+ // Filter queues based on search query + const filteredQueues = useMemo(() => { + if (!queues) return [] + if (!searchQuery) return queues + return queues.filter((queue) => + queue.queue_name.toLowerCase().includes(searchQuery.toLowerCase()) ) + }, [queues, searchQuery]) + + // Prepare queues for DataGrid + const queueData = useMemo(() => prepareQueuesForDataGrid(filteredQueues), [filteredQueues]) + + // Get columns configuration + const columns = useMemo(() => formatQueueColumns(), []) return ( <> -
- {queues.length === 0 ? ( -
-

No queues created yet

- -
- ) : ( -
-
- } - value={searchQuery || ''} - className="w-64" - onChange={(e) => setSearchQuery(e.target.value)} - /> +
+
+
+ } + value={search ?? ''} + onChange={(e) => setSearch(e.target.value)} + onKeyDown={(e) => { + if (e.code === 'Enter') setSearchQuery(search.trim()) + }} + actions={[ + search && ( + +
+ +
+
- - Name - - Type - - -
RLS enabled
-
- - Created at - - -
Size
-
- - - } - body={} - /> + + + row.id} + rowClass={() => { + return cn( + 'cursor-pointer', + '[&>.rdg-cell]:border-box [&>.rdg-cell]:outline-none [&>.rdg-cell]:shadow-none', + '[&>.rdg-cell:first-child>div]:ml-8' + ) + }} + renderers={{ + renderRow(_, props) { + return ( + { + const { queue_name } = props.row + const url = `/project/${ref}/integrations/queues/queues/${queue_name}` + router.push(url) + }} + /> + ) + }, + }} + /> + + {/* Render 0 rows state outside of the grid */} + {queueData.length === 0 ? ( + isLoading ? ( +
+ +
+ ) : isError ? ( +
+ +
+ ) : ( +
+
+

+ {!!searchQuery ? 'No queues found' : 'No queues created yet'} +

+

+ {!!searchQuery + ? 'There are currently no queues based on the search applied' + : 'There are currently no queues created yet in your project'} +

+
+
+ ) + ) : null} + +
+ {`Total: ${queueData.length} queues`}
- )} + setIsClosingCreateQueueSheet(true)}> diff --git a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/DeleteQueue.tsx b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/DeleteQueue.tsx index f572987e5ee33..e953d0b49ed05 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/DeleteQueue.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/DeleteQueue.tsx @@ -11,7 +11,7 @@ interface DeleteQueueProps { onClose: () => void } -const DeleteQueue = ({ queueName, visible, onClose }: DeleteQueueProps) => { +export const DeleteQueue = ({ queueName, visible, onClose }: DeleteQueueProps) => { const router = useRouter() const { data: project } = useSelectedProjectQuery() @@ -58,5 +58,3 @@ const DeleteQueue = ({ queueName, visible, onClose }: DeleteQueueProps) => { /> ) } - -export default DeleteQueue diff --git a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/PurgeQueue.tsx b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/PurgeQueue.tsx index b7c9ad924a9af..d6f757d3e2ee7 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/PurgeQueue.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/PurgeQueue.tsx @@ -1,4 +1,3 @@ -import { useRouter } from 'next/router' import { toast } from 'sonner' import { useDatabaseQueuePurgeMutation } from 'data/database-queues/database-queues-purge-mutation' @@ -11,14 +10,12 @@ interface PurgeQueueProps { onClose: () => void } -const PurgeQueue = ({ queueName, visible, onClose }: PurgeQueueProps) => { - const router = useRouter() +export const PurgeQueue = ({ queueName, visible, onClose }: PurgeQueueProps) => { const { data: project } = useSelectedProjectQuery() const { mutate: purgeDatabaseQueue, isLoading } = useDatabaseQueuePurgeMutation({ onSuccess: () => { toast.success(`Successfully purged queue ${queueName}`) - router.push(`/project/${project?.ref}/integrations/queues`) onClose() }, }) @@ -61,5 +58,3 @@ const PurgeQueue = ({ queueName, visible, onClose }: PurgeQueueProps) => { /> ) } - -export default PurgeQueue diff --git a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueDataGrid.tsx b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueDataGrid.tsx index cb57532557c00..c57020fd0c732 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueDataGrid.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueDataGrid.tsx @@ -5,12 +5,12 @@ import { parseAsInteger, useQueryState } from 'nuqs' import { UIEvent, useMemo, useRef } from 'react' import DataGrid, { Column, DataGridHandle, Row } from 'react-data-grid' +import AlertError from 'components/ui/AlertError' import { PostgresQueueMessage } from 'data/database-queues/database-queue-messages-infinite-query' +import { ResponseError } from 'types' import { Badge, Button, ResizableHandle, ResizablePanel, ResizablePanelGroup, cn } from 'ui' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { DATE_FORMAT, MessageDetailsPanel } from './MessageDetailsPanel' -import { ResponseError } from 'types' -import AlertError from 'components/ui/AlertError' interface QueueDataGridProps { error?: ResponseError | null @@ -60,14 +60,20 @@ const messagesCols = [ if (row.archived_at) { return ( - Archived at {dayjs(row.archived_at).format(DATE_FORMAT)} +
+ + Archived at {dayjs(row.archived_at).format(DATE_FORMAT)} + +
) } return ( - - {isAvailable ? 'Available ' : `Available at ${dayjs(row.vt).format(DATE_FORMAT)}`} - +
+ + {isAvailable ? 'Available ' : `Available at ${dayjs(row.vt).format(DATE_FORMAT)}`} + +
) }, }, @@ -77,14 +83,22 @@ const messagesCols = [ description: undefined, minWidth: 50, width: 70, - value: (row: PostgresQueueMessage) => {row.read_ct}, + value: (row: PostgresQueueMessage) => ( +
+ {row.read_ct} +
+ ), }, { id: 'payload', name: 'Payload', description: undefined, minWidth: 600, - value: (row: PostgresQueueMessage) => {JSON.stringify(row.message)}, + value: (row: PostgresQueueMessage) => ( +
+ {JSON.stringify(row.message)} +
+ ), }, ] @@ -95,31 +109,23 @@ const columns = messagesCols.map((col) => { resizable: true, minWidth: col.minWidth ?? 120, width: col.width, - headerCellClass: 'first:pl-6 cursor-pointer', + headerCellClass: undefined, renderHeaderCell: () => { - return ( -
-
-

{col.name}

- {col.description &&

{col.description}

} -
-
- ) - }, - renderCell: (props) => { - const value = col.value(props.row) - return (
- {value} +

{col.name}

) }, + renderCell: (props) => { + const value = col.value(props.row) + return value + }, } return result }) @@ -156,14 +162,12 @@ export const QueueMessagesDataGrid = ({ columns={columns} onScroll={handleScroll} rows={messages} - rowClass={(message) => { - const isSelected = message.msg_id === selectedMessageId - return [ - `${isSelected ? 'bg-surface-300 dark:bg-surface-300' : 'bg-200'} cursor-pointer`, - `${isSelected ? '[&>div:first-child]:border-l-4 border-l-secondary [&>div]:border-l-foreground' : ''}`, + rowClass={() => { + return cn( + 'cursor-pointer', '[&>.rdg-cell]:border-box [&>.rdg-cell]:outline-none [&>.rdg-cell]:shadow-none', - '[&>.rdg-cell:first-child>div]:ml-4', - ].join(' ') + '[&>.rdg-cell:first-child>div]:ml-8' + ) }} renderers={{ renderRow(idx, props) { diff --git a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueFilters.tsx b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueFilters.tsx index 2fd77da9157b5..6b4b1dc686330 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueFilters.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueFilters.tsx @@ -8,22 +8,18 @@ interface QueueFiltersProps { export const QueueFilters = ({ selectedTypes, setSelectedTypes }: QueueFiltersProps) => { return ( -
-
- setSelectedTypes(value as any)} - /> -
-
+ setSelectedTypes(value as any)} + /> ) } diff --git a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/SendMessageModal.tsx b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/SendMessageModal.tsx index 713b36031dca3..12b22e64bba0d 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/SendMessageModal.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/SendMessageModal.tsx @@ -1,13 +1,13 @@ import { zodResolver } from '@hookform/resolvers/zod' +import { useEffect } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' +import { toast } from 'sonner' import z from 'zod' import { useParams } from 'common' import CodeEditor from 'components/ui/CodeEditor/CodeEditor' import { useDatabaseQueueMessageSendMutation } from 'data/database-queues/database-queue-messages-send-mutation' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { useEffect } from 'react' -import { toast } from 'sonner' import { Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, Input, Modal } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' @@ -121,6 +121,7 @@ export const SendMessageModal = ({ visible, onClose }: SendMessageModalProps) => field.onChange(e)} options={{ wordWrap: 'off', contextmenu: false }} diff --git a/apps/studio/components/layouts/Integrations/header.tsx b/apps/studio/components/layouts/Integrations/header.tsx deleted file mode 100644 index 320406c222bb0..0000000000000 --- a/apps/studio/components/layouts/Integrations/header.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { AnimatePresence, motion, useScroll, useTransform } from 'framer-motion' -import { ChevronLeft } from 'lucide-react' -import { useRouter } from 'next/compat/router' -import Link from 'next/link' -import { forwardRef, useRef } from 'react' - -import { useParams } from 'common' -import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integrations.constants' -import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { Badge, cn } from 'ui' - -interface HeaderProps { - scroll: ReturnType -} - -export const Header = forwardRef(({ scroll }, ref) => { - const router = useRouter() - const { id } = useParams() - // Get project context - const { data: project } = useSelectedProjectQuery() - // Find the integration details based on ID - const integration = INTEGRATIONS.find((i) => i.id === id) - // Check if we're on the main integrations page - const isIntegrationsHome = !id - - const layoutTransition = { duration: 0.15 } - - const headerRef = useRef(null) - - // Input range: The scrollY range for triggering the animation (e.g., 0 to 200px of scroll) - const scrollRange = [40, headerRef.current?.offsetHeight ?? 128] - - // Output range: The Y position range for the icon (e.g., 0 to 150px movement) - const iconYRange = [0, headerRef.current?.offsetHeight ? headerRef.current.offsetHeight / 2 : 64] // Change 150 to set the end Y position of the icon - // Output range: The size range for the icon container - const sizeRange = [32, 20] // From 24px (scrolled) to 32px (top) - // Output range: The padding range for the image - const iconPaddingRange = [3, 1.5] // From 1.5px (scrolled) to 4px (top) - - // Map scrollY to the icon's Y position - const iconY = useTransform(scroll?.scrollY!, scrollRange, iconYRange) - - const iconSize = useTransform(scroll?.scrollY!, scrollRange, sizeRange) - - const iconPadding = useTransform(scroll?.scrollY!, scrollRange, iconPaddingRange) - - if (!router?.isReady) { - return null - } - - return ( - <> - - - {/* Main header content */} -
-
- {/* Container with animated padding */} - - {/* Navigation link back to integrations landing */} -
- {/* Back arrow */} - - {!isIntegrationsHome && ( - - - - - - )} - - {/* Two separate spans with the same key */} - {isIntegrationsHome ? ( - - Integrations - - ) : ( - - Integrations - - )} -
- - {/* Integration details section - only shown when viewing a specific integration */} - - {!isIntegrationsHome && integration && ( - - {/* Integration icon */} - - {integration.icon({ - style: { - padding: iconPadding.get(), - }, - })} - - - {/* Integration name and description */} - -
-
- {integration.name} - {integration.status && ( - - {integration.status} - - )} -
-

{integration.description}

-
-
-
- )} -
-
-
-
-
- - ) -}) - -Header.displayName = 'Header' diff --git a/apps/studio/components/layouts/Integrations/layout.tsx b/apps/studio/components/layouts/Integrations/layout.tsx index 8a0bd18923cc4..bef2d1ce074e6 100644 --- a/apps/studio/components/layouts/Integrations/layout.tsx +++ b/apps/studio/components/layouts/Integrations/layout.tsx @@ -1,86 +1,34 @@ import { useRouter } from 'next/router' -import { PropsWithChildren, useEffect, useRef, useState } from 'react' +import { PropsWithChildren } from 'react' import { useFlag } from 'common' import { useInstalledIntegrations } from 'components/interfaces/Integrations/Landing/useInstalledIntegrations' -import { Header } from 'components/layouts/Integrations/header' import ProjectLayout from 'components/layouts/ProjectLayout/ProjectLayout' import AlertError from 'components/ui/AlertError' import { ProductMenu } from 'components/ui/ProductMenu' import { ProductMenuGroup } from 'components/ui/ProductMenu/ProductMenu.types' import ProductMenuItem from 'components/ui/ProductMenu/ProductMenuItem' -import { useScroll } from 'framer-motion' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { withAuth } from 'hooks/misc/withAuth' import { Menu, Separator } from 'ui' import { GenericSkeletonLoader } from 'ui-patterns' -import { IntegrationTabs } from './tabs' /** * Layout component for the Integrations section - * Handles scroll-based sticky header behavior and authentication + * Provides sidebar navigation for integrations */ -const IntegrationsLayout = ({ ...props }: PropsWithChildren) => { - const layoutSidebar = useFlag('integrationLayoutSidebar') - if (layoutSidebar) { - return - } - return -} - -/** - * Top level layout - */ -const IntegrationTopHeaderLayout = ({ ...props }: PropsWithChildren) => { - const { data: project } = useSelectedProjectQuery() +const IntegrationsLayout = ({ children }: PropsWithChildren) => { const router = useRouter() - // Refs for the main scrollable area and header - const mainElementRef = useRef(null) - const headerRef = useRef(null) - // Track if header should be in sticky state - const [isSticky, setIsSticky] = useState(false) - - // State to hold the scrollable container element - const [container, setContainer] = useState(null) - - // Initialize framer-motion scroll tracking - // Only tracks scroll when container is available - const scroll = useScroll({ - container: container ? { current: container } : undefined, - }) - - // Set up container reference once mainElementRef is mounted - useEffect(() => { - if (mainElementRef.current) { - setContainer(mainElementRef.current) - } - }, [mainElementRef.current]) - - // Set up scroll event listener to handle sticky header behavior - useEffect(() => { - // Exit if scroll tracking isn't available yet - if (!scroll.scrollY) return - - // Update sticky state based on scroll position relative to header height - const handleScroll = (latest: number) => { - if (headerRef.current) { - setIsSticky(latest > headerRef.current.offsetHeight) - } - } - - // Subscribe to scroll position changes - const unsubscribe = scroll.scrollY.on('change', handleScroll) - - // Clean up scroll listener on unmount - return () => { - unsubscribe() - } - }, [scroll.scrollY]) + const { data: project } = useSelectedProjectQuery() const segments = router.asPath.split('/') // construct the page url to be used to determine the active state for the sidebar const page = `${segments[3]}${segments[4] ? `/${segments[4]}` : ''}` + // Check for category query parameter to determine active menu item + const urlParams = new URLSearchParams(router.asPath.split('?')[1] || '') + const categoryParam = urlParams.get('category') + const { installedIntegrations: integrations, error, @@ -88,6 +36,7 @@ const IntegrationTopHeaderLayout = ({ ...props }: PropsWithChildren) => { isSuccess, isError, } = useInstalledIntegrations() + const installedIntegrationItems = integrations.map((integration) => ({ name: integration.name, label: integration.status, @@ -103,19 +52,27 @@ const IntegrationTopHeaderLayout = ({ ...props }: PropsWithChildren) => { return ( - +
- Installed integrations + Installed
} /> @@ -145,81 +102,7 @@ const IntegrationTopHeaderLayout = ({ ...props }: PropsWithChildren) => { } > -
- - {props.children} - - ) -} - -const IntegrationsLayoutSide = ({ ...props }: PropsWithChildren) => { - const router = useRouter() - const page = router.pathname.split('/')[4] - const { data: project } = useSelectedProjectQuery() - - const { - installedIntegrations: integrations, - error, - isLoading, - isError, - isSuccess, - } = useInstalledIntegrations() - const installedIntegrationItems = integrations.map((integration) => ({ - name: integration.name, - label: integration.status, - key: `integrations/${integration.id}`, - url: `/project/${project?.ref}/integrations/${integration.id}/overview`, - icon: ( -
- {integration.icon({ className: 'p-1' })} -
- ), - items: [], - })) - - return ( - - - -
- - Installed integrations -
- } - /> - {isLoading && } - {isError && ( - - )} - {isSuccess && ( -
- {installedIntegrationItems.map((item) => ( - - ))} -
- )} - - - } - > - {props.children} + {children}
) } @@ -230,12 +113,27 @@ export default withAuth(IntegrationsLayout) const generateIntegrationsMenu = ({ projectRef }: { projectRef?: string }): ProductMenuGroup[] => { return [ { - title: 'All Integrations', + title: 'Explore', items: [ { - name: 'All Integrations', + name: 'All', key: 'integrations', url: `/project/${projectRef}/integrations`, + pages: ['integrations'], + items: [], + }, + { + name: 'Wrappers', + key: 'integrations-wrapper', + url: `/project/${projectRef}/integrations?category=wrapper`, + pages: ['integrations?category=wrapper'], + items: [], + }, + { + name: 'Postgres Modules', + key: 'integrations-postgres_extension', + url: `/project/${projectRef}/integrations?category=postgres_extension`, + pages: ['integrations?category=postgres_extension'], items: [], }, ], diff --git a/apps/studio/components/layouts/Integrations/tabs.tsx b/apps/studio/components/layouts/Integrations/tabs.tsx deleted file mode 100644 index 2bb36708ff4cc..0000000000000 --- a/apps/studio/components/layouts/Integrations/tabs.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { AnimatePresence, motion, MotionProps, useScroll, useTransform } from 'framer-motion' -import { ChevronRight } from 'lucide-react' -import Link from 'next/link' -import { ComponentProps, ComponentType, useRef } from 'react' - -import { useBreakpoint, useParams } from 'common' -import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integrations.constants' -import { useInstalledIntegrations } from 'components/interfaces/Integrations/Landing/useInstalledIntegrations' -import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { cn, NavMenu, NavMenuItem } from 'ui' - -const MotionNavMenu = motion(NavMenu) as ComponentType & MotionProps> - -// Output range: The padding range for the nav (from compact to expanded) -const paddingRange = [40, 86] - -// Output range: The padding range for the image -const iconPaddingRange = [3, 1.5] // From 1.5px (scrolled) to 4px (top) - -interface IntegrationTabsProps { - scroll: ReturnType - isSticky?: boolean -} - -export const IntegrationTabs = ({ scroll, isSticky }: IntegrationTabsProps) => { - const navRef = useRef(null) - const { data: project } = useSelectedProjectQuery() - const { id, pageId, childId, childLabel } = useParams() - const isMobile = useBreakpoint('md') - - const { installedIntegrations } = useInstalledIntegrations() - // Find the integration details based on ID - const integration = INTEGRATIONS.find((i) => i.id === id) - - const headerRef = useRef(null) - - // Input range: The scrollY range for triggering the animation (e.g., 0 to 200px of scroll) - const scrollRange = [40, headerRef.current?.offsetHeight ?? 128] - const navInnerLeftPaddingX = useTransform(scroll?.scrollY!, scrollRange, paddingRange) - const iconPadding = useTransform(scroll?.scrollY!, scrollRange, iconPaddingRange) - - const installedIntegration = installedIntegrations?.find((i) => i.id === id) - - const tabs = installedIntegration - ? integration?.navigation ?? [] - : (integration?.navigation ?? []).filter((tab) => tab.route === 'overview') - - if (!integration) return null - - return ( - -
- - {isSticky && ( - - {integration?.icon({ - style: { padding: iconPadding.get() }, - })} - - )} - - {tabs.map((tab) => { - const tabUrl = `/project/${project?.ref}/integrations/${integration?.id}/${tab.route}` - return ( -
- - {tab.label} - - - - {tab.hasChild && childId && ( - <> - - - - - - {tab.childIcon} - - {childLabel ? childLabel : childId} - - - - - )} - -
- ) - })} -
-
-
- ) -} - -IntegrationTabs.displayName = 'IntegrationTabs' diff --git a/apps/studio/components/layouts/PageLayout/PageLayout.tsx b/apps/studio/components/layouts/PageLayout/PageLayout.tsx index f725e915cb333..e9144545a7ec4 100644 --- a/apps/studio/components/layouts/PageLayout/PageLayout.tsx +++ b/apps/studio/components/layouts/PageLayout/PageLayout.tsx @@ -20,7 +20,7 @@ export interface NavigationItem { interface PageLayoutProps { children?: ReactNode title?: string | ReactNode - subtitle?: string + subtitle?: string | ReactNode icon?: ReactNode breadcrumbs?: Array<{ label?: string @@ -81,7 +81,7 @@ export const PageLayout = ({ className={cn( 'w-full mx-auto', size === 'full' && - (isCompact ? 'max-w-none !px-6 border-b pt-4' : 'max-w-none pt-6 border-b'), + (isCompact ? 'max-w-none !px-6 border-b pt-4' : 'max-w-none pt-6 !px-10 border-b'), size !== 'full' && (isCompact ? 'pt-4' : 'pt-12'), navigationItems.length === 0 && size === 'full' && (isCompact ? 'pb-4' : 'pb-8'), className diff --git a/apps/studio/components/layouts/Scaffold.tsx b/apps/studio/components/layouts/Scaffold.tsx index f98d986389a1b..0d9851b26182c 100644 --- a/apps/studio/components/layouts/Scaffold.tsx +++ b/apps/studio/components/layouts/Scaffold.tsx @@ -2,7 +2,7 @@ import { forwardRef, HTMLAttributes } from 'react' import { cn } from 'ui' export const MAX_WIDTH_CLASSES = 'mx-auto w-full max-w-[1200px]' -export const PADDING_CLASSES = 'px-4 @lg:px-6 @xl:px-12 @2xl:px-20 @3xl:px-24' +export const PADDING_CLASSES = 'px-4 @lg:px-6 @xl:px-10' export const MAX_WIDTH_CLASSES_COLUMN = 'min-w-[420px]' /** diff --git a/apps/studio/data/database-cron-jobs/database-cron-jobs-create-mutation.ts b/apps/studio/data/database-cron-jobs/database-cron-jobs-create-mutation.ts index 2e7b2396856c4..ab918eb91d275 100644 --- a/apps/studio/data/database-cron-jobs/database-cron-jobs-create-mutation.ts +++ b/apps/studio/data/database-cron-jobs/database-cron-jobs-create-mutation.ts @@ -10,6 +10,7 @@ export type DatabaseCronJobCreateVariables = { connectionString?: string | null query: string searchTerm?: string + identifier?: string | number } export async function createDatabaseCronJob({ @@ -43,10 +44,15 @@ export const useDatabaseCronJobCreateMutation = ({ (vars) => createDatabaseCronJob(vars), { async onSuccess(data, variables, context) { - const { projectRef, searchTerm } = variables - await queryClient.invalidateQueries( - databaseCronJobsKeys.listInfinite(projectRef, searchTerm) - ) + const { projectRef, searchTerm, identifier } = variables + + await Promise.all([ + queryClient.invalidateQueries(databaseCronJobsKeys.listInfinite(projectRef, searchTerm)), + ...(!!identifier + ? [queryClient.invalidateQueries(databaseCronJobsKeys.job(projectRef, identifier))] + : []), + ]) + await onSuccess?.(data, variables, context) }, async onError(data, variables, context) { diff --git a/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/[childId]/index.tsx b/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/[childId]/index.tsx index 91321e9afde90..e2527a9219d4e 100644 --- a/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/[childId]/index.tsx +++ b/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/[childId]/index.tsx @@ -3,10 +3,13 @@ import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integra import { useInstalledIntegrations } from 'components/interfaces/Integrations/Landing/useInstalledIntegrations' import DefaultLayout from 'components/layouts/DefaultLayout' import IntegrationsLayout from 'components/layouts/Integrations/layout' +import { PageLayout } from 'components/layouts/PageLayout/PageLayout' +import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useRouter } from 'next/compat/router' import { useEffect, useMemo } from 'react' import { NextPageWithLayout } from 'types' +import { Admonition } from 'ui-patterns' const IntegrationPage: NextPageWithLayout = () => { const router = useRouter() @@ -40,23 +43,40 @@ const IntegrationPage: NextPageWithLayout = () => { ) { router.replace(`/project/${ref}/integrations/${id}/overview`) } - }, [installation, isIntegrationsLoading, pageId, router]) + }, [installation, isIntegrationsLoading, pageId, router, ref, id]) - if (!router?.isReady || isIntegrationsLoading) { - return ( -
- -
- ) - } - - if (!id || !integration) { - return
Integration not found
- } - - if (!Component) return
Component not found
+ // Determine content based on state + const content = useMemo(() => { + if (!router?.isReady || isIntegrationsLoading) { + return ( + + + + + + ) + } else if (!Component || !id || !integration) { + return ( + + + + + Please try again later or contact support if the problem persists. + + + + + ) + } else { + return + } + }, [router?.isReady, isIntegrationsLoading, id, integration, Component]) - return + return content } IntegrationPage.getLayout = (page) => ( diff --git a/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/index.tsx b/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/index.tsx index 91321e9afde90..b0eb70e5efe46 100644 --- a/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/index.tsx +++ b/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/index.tsx @@ -3,10 +3,13 @@ import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integra import { useInstalledIntegrations } from 'components/interfaces/Integrations/Landing/useInstalledIntegrations' import DefaultLayout from 'components/layouts/DefaultLayout' import IntegrationsLayout from 'components/layouts/Integrations/layout' +import { PageLayout, NavigationItem } from 'components/layouts/PageLayout/PageLayout' +import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useRouter } from 'next/compat/router' import { useEffect, useMemo } from 'react' import { NextPageWithLayout } from 'types' +import { Admonition } from 'ui-patterns' const IntegrationPage: NextPageWithLayout = () => { const router = useRouter() @@ -29,6 +32,36 @@ const IntegrationPage: NextPageWithLayout = () => { [integration, id, pageId, childId] ) + // Create breadcrumb items + const breadcrumbItems = [ + { + label: 'Integrations', + href: `/project/${ref}/integrations`, + }, + { + label: integration?.name || 'Integration not found', + }, + ] + + // Create navigation items from integration navigation + const navigationItems: NavigationItem[] = useMemo(() => { + if (!integration?.navigation) return [] + + // Only show navigation if the integration is installed, or if we're on the overview page + const showNavigation = installation || pageId === 'overview' + if (!showNavigation) return [] + + const availableTabs = installation + ? integration.navigation + : integration.navigation.filter((tab) => tab.route === 'overview') + + return availableTabs.map((nav) => ({ + label: nav.label, + href: `/project/${ref}/integrations/${id}/${nav.route}`, + active: pageId === nav.route, + })) + }, [integration, ref, id, pageId, installation]) + useEffect(() => { // if the integration is not installed, redirect to the overview page if ( @@ -42,21 +75,60 @@ const IntegrationPage: NextPageWithLayout = () => { } }, [installation, isIntegrationsLoading, pageId, router]) - if (!router?.isReady || isIntegrationsLoading) { - return ( -
- -
- ) - } + // Determine page title, icon, and subtitle based on state + const pageTitle = integration?.name || 'Integration not found' - if (!id || !integration) { - return
Integration not found
- } + const pageSubTitle = + integration?.description || 'If you think this is an error, please contact support' + + // Get integration icon and subtitle + const pageIcon = integration ? ( +
+ {integration.icon()} +
+ ) : null + + // Determine content based on state + const content = useMemo(() => { + if (!router?.isReady || isIntegrationsLoading) { + return ( + + + + + + ) + } else if (!Component || !id || !integration) { + return ( + + + + Please try again later or contact support if the problem persists. + + + + ) + } else { + return + } + }, [router?.isReady, isIntegrationsLoading, id, integration, Component]) - if (!Component) return
Component not found
+ if (!router?.isReady) { + return null + } - return + return ( + + {content} + + ) } IntegrationPage.getLayout = (page) => ( diff --git a/apps/studio/pages/project/[ref]/integrations/[id]/index.tsx b/apps/studio/pages/project/[ref]/integrations/[id]/index.tsx index 91321e9afde90..37e5f844e0da5 100644 --- a/apps/studio/pages/project/[ref]/integrations/[id]/index.tsx +++ b/apps/studio/pages/project/[ref]/integrations/[id]/index.tsx @@ -1,62 +1,31 @@ import { useParams } from 'common' -import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integrations.constants' -import { useInstalledIntegrations } from 'components/interfaces/Integrations/Landing/useInstalledIntegrations' import DefaultLayout from 'components/layouts/DefaultLayout' import IntegrationsLayout from 'components/layouts/Integrations/layout' +import { PageLayout } from 'components/layouts/PageLayout/PageLayout' +import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useRouter } from 'next/compat/router' -import { useEffect, useMemo } from 'react' +import { useEffect } from 'react' import { NextPageWithLayout } from 'types' const IntegrationPage: NextPageWithLayout = () => { const router = useRouter() - const { ref, id, pageId, childId } = useParams() - - const { installedIntegrations: installedIntegrations, isLoading: isIntegrationsLoading } = - useInstalledIntegrations() - - // everything is wrapped in useMemo to avoid UI resets when installing additional extensions like pg_net - const integration = useMemo(() => INTEGRATIONS.find((i) => i.id === id), [id]) - - const installation = useMemo( - () => installedIntegrations.find((inst) => inst.id === id), - [installedIntegrations, id] - ) - - // Get the corresponding component dynamically - const Component = useMemo( - () => integration?.navigate(id!, pageId, childId), - [integration, id, pageId, childId] - ) + const { ref, id } = useParams() useEffect(() => { - // if the integration is not installed, redirect to the overview page - if ( - router && - router?.isReady && - !isIntegrationsLoading && - !installation && - pageId !== 'overview' - ) { + // Always redirect to the overview page since this route should not render content + if (router?.isReady) { router.replace(`/project/${ref}/integrations/${id}/overview`) } - }, [installation, isIntegrationsLoading, pageId, router]) + }, [router, ref, id]) - if (!router?.isReady || isIntegrationsLoading) { - return ( -
+ return ( + + -
- ) - } - - if (!id || !integration) { - return
Integration not found
- } - - if (!Component) return
Component not found
- - return + + + ) } IntegrationPage.getLayout = (page) => ( diff --git a/apps/studio/pages/project/[ref]/integrations/index.tsx b/apps/studio/pages/project/[ref]/integrations/index.tsx index 45364188a61e4..dcb1a512faaa2 100644 --- a/apps/studio/pages/project/[ref]/integrations/index.tsx +++ b/apps/studio/pages/project/[ref]/integrations/index.tsx @@ -1,16 +1,216 @@ -import { AvailableIntegrations } from 'components/interfaces/Integrations/Landing/AvailableIntegrations' -import { InstalledIntegrations } from 'components/interfaces/Integrations/Landing/InstalledIntegrations' +import { Search } from 'lucide-react' +import { parseAsString, useQueryState } from 'nuqs' +import { useMemo } from 'react' + +import { + IntegrationCard, + IntegrationLoadingCard, +} from 'components/interfaces/Integrations/Landing/IntegrationCard' +import { useInstalledIntegrations } from 'components/interfaces/Integrations/Landing/useInstalledIntegrations' import DefaultLayout from 'components/layouts/DefaultLayout' import IntegrationsLayout from 'components/layouts/Integrations/layout' +import { PageLayout } from 'components/layouts/PageLayout/PageLayout' +import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' +import AlertError from 'components/ui/AlertError' +import { DocsButton } from 'components/ui/DocsButton' +import NoSearchResults from 'components/ui/NoSearchResults' import type { NextPageWithLayout } from 'types' +import { Input } from 'ui-patterns/DataInputs/Input' + +const FEATURED_INTEGRATIONS = ['cron', 'queues', 'stripe_wrapper'] + +// Featured integration images +const FEATURED_INTEGRATION_IMAGES: Record = { + cron: 'img/integrations/covers/cron-cover.webp', + queues: 'img/integrations/covers/queues-cover.png', + stripe_wrapper: 'img/integrations/covers/stripe-cover.png', +} const IntegrationsPage: NextPageWithLayout = () => { - return ( -
- - + const [selectedCategory] = useQueryState( + 'category', + parseAsString.withDefault('all').withOptions({ clearOnDefault: true }) + ) + const [search, setSearch] = useQueryState( + 'search', + parseAsString.withDefault('').withOptions({ clearOnDefault: true }) + ) + + const { availableIntegrations, installedIntegrations, error, isError, isLoading, isSuccess } = + useInstalledIntegrations() + + const installedIds = installedIntegrations.map((i) => i.id) + + // Dynamic page content based on selected category + const pageContent = useMemo(() => { + switch (selectedCategory) { + case 'wrapper': + return { + title: 'Wrappers', + subtitle: + 'Connect to external data sources and services by querying APIs, databases, and files as if they were Postgres tables.', + secondaryActions: ( + + ), + } + case 'postgres_extension': + return { + title: 'Postgres Modules', + subtitle: 'Extend your database with powerful Postgres extensions.', + } + default: + return { + title: 'Extend your database', + subtitle: + 'Extensions and wrappers that add functionality to your database and connect to external services.', + } + } + }, [selectedCategory]) + + const filteredAndSortedIntegrations = useMemo(() => { + let filtered = availableIntegrations + + if (selectedCategory !== 'all') { + filtered = filtered.filter((i) => i.type === selectedCategory) + } + + if (search.length > 0) { + filtered = filtered.filter((i) => i.name.toLowerCase().includes(search.toLowerCase())) + } + + // Sort by installation status, then alphabetically + return filtered.sort((a, b) => { + const aIsInstalled = installedIds.includes(a.id) + const bIsInstalled = installedIds.includes(b.id) + + if (aIsInstalled && !bIsInstalled) return -1 + if (!aIsInstalled && bIsInstalled) return 1 + + return a.name.localeCompare(b.name) + }) + }, [availableIntegrations, selectedCategory, search, installedIds]) + + const groupedIntegrations = useMemo(() => { + if (selectedCategory !== 'all' || search.length > 0) { + return null + } + + const featured = filteredAndSortedIntegrations.filter((i) => + FEATURED_INTEGRATIONS.includes(i.id) + ) + const allIntegrations = filteredAndSortedIntegrations // Include all integrations, including featured + + return { + featured, + allIntegrations, + } + }, [filteredAndSortedIntegrations, selectedCategory, search]) + + // Helper component to render featured integrations grid + const FeaturedIntegrationsGrid = ({ + integrations, + }: { + integrations: typeof filteredAndSortedIntegrations + }) => ( +
+ {integrations.map((integration) => ( + + ))}
) + + // Helper component to render all integrations grid + const AllIntegrationsGrid = ({ + integrations, + }: { + integrations: typeof filteredAndSortedIntegrations + }) => ( +
+ {integrations.map((integration) => ( + + ))} +
+ ) + + return ( + + + +
+ setSearch(e.target.value)} + placeholder="Search integrations..." + icon={} + className="w-52" + /> +
+ + {isLoading && ( +
+ {new Array(8).fill(0).map((_, idx) => ( + + ))} +
+ )} + + {/* Error State */} + {isError && ( + + )} + + {/* Success State */} + {isSuccess && ( + <> + {/* No Search Results */} + {search.length > 0 && filteredAndSortedIntegrations.length === 0 && ( + setSearch('')} /> + )} + + {/* Grouped View (All integrations, no search) */} + {groupedIntegrations && ( + <> + {/* Featured Integrations */} + {groupedIntegrations.featured.length > 0 && ( + + )} + + {/* All Integrations */} + {groupedIntegrations.allIntegrations.length > 0 && ( + + )} + + )} + + {/* Single List View (Category filtered or searching) */} + {!groupedIntegrations && filteredAndSortedIntegrations.length > 0 && ( + + )} + + )} +
+
+
+ ) } IntegrationsPage.getLayout = (page) => ( diff --git a/apps/studio/public/img/integrations/covers/cron-cover.webp b/apps/studio/public/img/integrations/covers/cron-cover.webp new file mode 100644 index 0000000000000..54b4f0793a2fd Binary files /dev/null and b/apps/studio/public/img/integrations/covers/cron-cover.webp differ diff --git a/apps/studio/public/img/integrations/covers/queues-cover.png b/apps/studio/public/img/integrations/covers/queues-cover.png new file mode 100644 index 0000000000000..cc3f2254abc2a Binary files /dev/null and b/apps/studio/public/img/integrations/covers/queues-cover.png differ diff --git a/apps/studio/public/img/integrations/covers/stripe-cover.png b/apps/studio/public/img/integrations/covers/stripe-cover.png new file mode 100644 index 0000000000000..2f15fa367afa9 Binary files /dev/null and b/apps/studio/public/img/integrations/covers/stripe-cover.png differ