feat: Redesign for the integrations pages (#30476)

* Initial commit.

* Minor type fixes.

* Add a integration for Queues. Refactor some of the integration layout.

* Migrate the Cron integration to the new style.

* Add useInstalledIntegrations hook.

* Add an integration entry for vault.

* Add an integration entry for GraphiQL.

* Add supabase webhooks.

* Feat/integrations get layout (#30538)

* scroll based icon

* Update header.tsx

* remove dep from overview

* moar

* more table stuff

* moar

* alt nav put in

* fix MotionNumber issues

* more

* trying both layouts

* Fix bunch of type errors.

---------

Co-authored-by: Ivan Vasilov <vasilov.ivan@gmail.com>

* Migrate Vercel and Github files to their own folders.

* Switch all integrations with the new designs.

* More fixes for links, pages structure and other random stuff.

* Remove unneeded file.

* Another set of fixes. Migrated most of the extension integrations.

* Migrated Vault and webhooks to the new style.

* Various fixes to make the animation work.

* Remove extra code.

* Tiny fixes 😬 i swear its tiny

* Refactor IntegrationOverviewTab

* chore/ update integrations routes (#30585)

* init

* add child support in tabs

* add webhooks

* Update IntegrationPageHandler.tsx

* fix id issues

* use messageId instead

* animation tweaks

* Move the description to the wrappers array.

* The useInstalledIntegrations now provides integrations which could be installed.

* Add static content for the various integrations.

* Move the page handler logic into the integrations definitions.

* Clean up some extra code.

* Add logic to make the overview tab the default tab.

* Don't show the header until the integration id has been checked.

* Add logic to the integration pages to avoid weird loading bugs, deselecting tabs if the integration hasn't been installed etc.

* Fix the webhooks overview tab.

* Fix the buttons for enabling extensions.

* Add padding to all custom tab contents.

* Small fixes

* Prettier lint

* Fix icon color + add empty state for when available integrations are all installed

* Fix ts errors

* Fiox

* Add enable webhooks cta

* Fix key

* Fix all lints

* Fix the queues create sheet.

* Fix the deletion of wrappers.

* Fix the minimum version alert for the wrappers extension.

* Make the queues table fit the whole container.

* Fix an issue which reset the tab when installing an extension.

* Address comments

* Add loading state for installed integrations in side nav

* Fix edit secret not rendering value in input field after subsequent openings

* Fix vault keys auto filling search input with vault

* Fix search input placeholder for cron

* Minor fix in install database extension copy

* Fix a bad redirect when reloading.

* Fix bad url redirects.

* Fix scrolling in create new/edit wrapper sheet.

* Add y padding to the wrappers rows.

* Fix merge errors.

* More merge fixes.

* Fix bad imports during the merge.

---------

Co-authored-by: Jonathan Summers-Muir <MildTomato@users.noreply.github.com>
Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
authored and GitHub committed 2024-11-25 13:26:22 +01:00
1 parent 12d92aed99
commit 590ed362ab
104 files changed
+4867 -811

No files matched your search

@@ -189,7 +189,10 @@ const EditWrapper = () => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [loadingSecrets, setLoadingSecrets] = useState(false)
const initialTables = formatWrapperTables(wrapper?.tables ?? [])
const initialTables = formatWrapperTables({
handler: wrapper.handler,
tables: wrapper?.tables ?? [],
})
const hasFormChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
const hasTableChanges = JSON.stringify(initialTables) !== JSON.stringify(wrapperTables)
const hasChanges = hasFormChanges || hasTableChanges
@@ -23,6 +23,7 @@ export const WRAPPERS: WrapperMeta[] = [
handlerName: WRAPPER_HANDLERS.STRIPE,
validatorName: 'stripe_fdw_validator',
icon: `${BASE_PATH}/img/icons/stripe-icon.svg`,
description: 'Payment processing and subscription management',
extensionName: 'StripeFdw',
label: 'Stripe',
docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/stripe',
@@ -1139,6 +1140,7 @@ export const WRAPPERS: WrapperMeta[] = [
handlerName: WRAPPER_HANDLERS.FIREBASE,
validatorName: 'firebase_fdw_validator',
icon: `${BASE_PATH}/img/icons/firebase-icon.svg`,
description: 'Backend-as-a-Service with real-time database',
extensionName: 'FirebaseFdw',
label: 'Firebase',
docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/firebase',
@@ -1265,6 +1267,7 @@ export const WRAPPERS: WrapperMeta[] = [
handlerName: WRAPPER_HANDLERS.S3,
validatorName: 's3_fdw_validator',
icon: `${BASE_PATH}/img/icons/s3-icon.svg`,
description: 'Cloud object storage service',
extensionName: 'S3Fdw',
label: 'S3',
docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/s3',
@@ -1348,6 +1351,7 @@ export const WRAPPERS: WrapperMeta[] = [
handlerName: WRAPPER_HANDLERS.CLICK_HOUSE,
validatorName: 'click_house_fdw_validator',
icon: `${BASE_PATH}/img/icons/clickhouse-icon.svg`,
description: 'Column-oriented analytics database',
extensionName: 'ClickHouseFdw',
label: 'ClickHouse',
docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/clickhouse',
@@ -1392,6 +1396,7 @@ export const WRAPPERS: WrapperMeta[] = [
handlerName: WRAPPER_HANDLERS.BIG_QUERY,
validatorName: 'big_query_fdw_validator',
icon: `${BASE_PATH}/img/icons/bigquery-icon.svg`,
description: 'Serverless data warehouse and analytics',
extensionName: 'BigQueryFdw',
label: 'BigQuery',
docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/bigquery',
@@ -1466,6 +1471,7 @@ export const WRAPPERS: WrapperMeta[] = [
handlerName: WRAPPER_HANDLERS.AIRTABLE,
validatorName: 'airtable_fdw_validator',
icon: `${BASE_PATH}/img/icons/airtable-icon.svg`,
description: 'No-code database and spreadsheet platform',
extensionName: 'airtableFdw',
label: 'Airtable',
docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/airtable',
@@ -1508,6 +1514,7 @@ export const WRAPPERS: WrapperMeta[] = [
handlerName: WRAPPER_HANDLERS.LOGFLARE,
validatorName: 'logflare_fdw_validator',
icon: `${BASE_PATH}/img/icons/logflare-icon.svg`,
description: 'Log management and analytics service',
extensionName: 'logflareFdw',
label: 'Logflare',
docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/logflare',
@@ -1543,6 +1550,7 @@ export const WRAPPERS: WrapperMeta[] = [
handlerName: WRAPPER_HANDLERS.AUTH0,
validatorName: 'auth0_fdw_validator',
icon: `${BASE_PATH}/img/icons/auth0-icon.svg`,
description: 'Identity and access management platform',
extensionName: 'Auth0Fdw',
label: 'Auth0',
docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/auth0',
@@ -1673,6 +1681,7 @@ export const WRAPPERS: WrapperMeta[] = [
handlerName: WRAPPER_HANDLERS.COGNITO,
validatorName: 'cognito_fdw_validator',
icon: `${BASE_PATH}/img/icons/cognito-icon.svg`,
description: 'AWS user authentication and authorization',
extensionName: 'CognitoFdw',
label: 'Cognito',
docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/cognito',
@@ -1756,6 +1765,7 @@ export const WRAPPERS: WrapperMeta[] = [
handlerName: WRAPPER_HANDLERS.MSSQL,
validatorName: 'mssql_fdw_validator',
icon: `${BASE_PATH}/img/icons/mssql-icon.svg`,
description: 'Microsoft SQL Server database',
extensionName: 'mssqlFdw',
label: 'Microsoft SQL Server',
docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/mssql',
@@ -1792,6 +1802,7 @@ export const WRAPPERS: WrapperMeta[] = [
handlerName: WRAPPER_HANDLERS.REDIS,
validatorName: 'redis_fdw_validator',
icon: `${BASE_PATH}/img/icons/redis-icon.svg`,
description: 'In-memory data structure store',
extensionName: 'redisFdw',
label: 'Redis',
docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/redis',
@@ -1874,6 +1885,7 @@ export const WRAPPERS: WrapperMeta[] = [
handlerName: WRAPPER_HANDLERS.PADDLE,
validatorName: 'wasm_fdw_validator',
icon: `${BASE_PATH}/img/icons/paddle-icon.svg`,
description: 'Subscription billing and payments platform',
extensionName: 'paddleFdw',
label: 'Paddle',
docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/paddle',
@@ -1974,6 +1986,7 @@ export const WRAPPERS: WrapperMeta[] = [
handlerName: WRAPPER_HANDLERS.SNOWFLAKE,
validatorName: 'wasm_fdw_validator',
icon: `${BASE_PATH}/img/icons/snowflake-icon.svg`,
description: 'Cloud data warehouse platform',
extensionName: 'snowflakeFdw',
label: 'Snowflake',
docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/snowflake',
@@ -3,6 +3,8 @@ export type WrapperMeta = {
handlerName: string
validatorName: string
icon: string
/** Short description (one-sentence) used in lists of integrations */
description: string
extensionName: string
label: string
server: Server
@@ -1,4 +1,4 @@
import { FDW } from 'data/fdw/fdws-query'
import { FDW, FDWTable } from 'data/fdw/fdws-query'
import { WRAPPERS, WRAPPER_HANDLERS } from './Wrappers.constants'
import type { WrapperMeta } from './Wrappers.types'
@@ -50,10 +50,24 @@ export const makeValidateRequired = (options: { name: string; required: boolean
}
}
export const formatWrapperTables = (wrapper: any, wrapperMeta?: WrapperMeta) => {
interface FormattedWrapperTable {
index: number
columns: { name: string }[]
is_new_schema: boolean
schema: string
schema_name: string
table_name: string
object?: string // From options object for Firebase/Stripe
[key: string]: any // For other dynamic options from table.options
}
export const formatWrapperTables = (
wrapper: { handler: string; tables?: FDWTable[] },
wrapperMeta?: WrapperMeta
): FormattedWrapperTable[] => {
const tables = wrapper?.tables ?? []
return tables.map((table: any) => {
return tables.map((table) => {
let index: number = 0
const options = Object.fromEntries(table.options.map((option: string) => option.split('=')))
@@ -85,6 +99,7 @@ export const formatWrapperTables = (wrapper: any, wrapperMeta?: WrapperMeta) =>
return {
...options,
index,
id: table.id,
columns: table.columns,
is_new_schema: false,
schema: table.schema,
@@ -98,7 +113,10 @@ export const convertKVStringArrayToJson = (values: string[]) => {
return Object.fromEntries(values.map((value) => value.split('=')))
}
export function wrapperMetaComparator(wrapperMeta: WrapperMeta, wrapper: FDW | undefined) {
export function wrapperMetaComparator(
wrapperMeta: Pick<WrapperMeta, 'handlerName' | 'server'>,
wrapper: FDW | undefined
) {
if (wrapperMeta.handlerName === 'wasm_fdw_handler') {
const serverOptions = convertKVStringArrayToJson(wrapper?.server_options ?? [])
return (
@@ -1,6 +1,6 @@
import { AnimatePresence, motion } from 'framer-motion'
import { Info } from 'lucide-react'
import MotionNumber from 'motion-number'
import MotionNumber from '@number-flow/react'
import { useTheme } from 'next-themes'
import { UseFormReturn } from 'react-hook-form'
@@ -64,14 +64,7 @@ export default function DiskSpaceBar({ form }: DiskSpaceBarProps) {
{usedSize.toFixed(2)}
<span>GB used of </span>
<span className="text-foreground font-semibold -mt-[2px]">
<MotionNumber
value={newTotalSize}
style={{ lineHeight: 0.8 }}
transition={{
y: { type: 'spring', duration: 0.35, bounce: 0 },
}}
className="font-mono"
/>
<MotionNumber value={newTotalSize} style={{ lineHeight: 0.8 }} className="font-mono" />
</span>{' '}
GB
</span>
@@ -16,10 +16,8 @@ import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-ex
import { API_URL, IS_PLATFORM } from 'lib/constants'
import { getRoleImpersonationJWT } from 'lib/role-impersonation'
import { useGetImpersonatedRole } from 'state/role-impersonation-state'
import type { NextPageWithLayout } from 'types'
import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout'
const GraphiQLPage: NextPageWithLayout = () => {
export const GraphiQLTab = () => {
const { resolvedTheme } = useTheme()
const { ref: projectRef } = useParams()
const { project } = useProjectContext()
@@ -107,9 +105,3 @@ const GraphiQLPage: NextPageWithLayout = () => {
return <GraphiQL fetcher={fetcher} theme={currentTheme} />
}
GraphiQLPage.getLayout = (page) => (
<ProjectIntegrationsLayout title="GraphiQL">{page}</ProjectIntegrationsLayout>
)
export default GraphiQLPage
@@ -0,0 +1,63 @@
import { Book } from 'lucide-react'
import Link from 'next/link'
import { ComponentPropsWithoutRef, ElementRef, forwardRef } from 'react'
import { cn } from 'ui'
import { IntegrationDefinition } from '../Landing/Integrations.constants'
interface BuiltBySectionProps extends ComponentPropsWithoutRef<'div'> {
integration: IntegrationDefinition
}
export const BuiltBySection = forwardRef<ElementRef<'div'>, BuiltBySectionProps>(
({ integration, className, ...props }, ref) => {
const { docsUrl } = integration
const { name, websiteUrl } = integration?.author ?? {}
if (!name && !docsUrl && !websiteUrl) return null
return (
<div ref={ref} className={cn('flex items-center gap-10 px-10', className)} {...props}>
{name && (
<div>
<div className="text-foreground-lighter font-mono text-xs mb-1">BUILT BY</div>
<div className="text-foreground-light text-sm">{name}</div>
</div>
)}
{docsUrl && (
<div>
<div className="text-foreground-lighter font-mono text-xs mb-1">DOCS</div>
<Link
href={docsUrl}
target="_blank"
rel="noreferrer"
className="text-foreground-light hover:text-foreground text-sm flex items-center gap-2"
>
<Book size={16} />
{docsUrl.includes('supabase.com/docs')
? 'Supabase Docs'
: docsUrl.includes('github.com')
? 'GitHub Docs'
: 'Documentation'}
</Link>
</div>
)}
{websiteUrl && (
<div>
<div className="text-foreground-lighter font-mono text-xs mb-1">WEBSITE</div>
<Link
href={websiteUrl}
target="_blank"
rel="noreferrer"
className="text-foreground-light hover:text-foreground text-sm"
>
{websiteUrl.replace('https://', '')}
</Link>
</div>
)}
</div>
)
}
)
BuiltBySection.displayName = 'BuiltBySection'
@@ -0,0 +1,86 @@
import { useRouter } from 'next/router'
import { PropsWithChildren, ReactNode } from 'react'
import { useParams } from 'common'
import { Markdown } from 'components/interfaces/Markdown'
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
import { Badge, Separator } from 'ui'
import { Admonition } from 'ui-patterns'
import { INTEGRATIONS } from '../Landing/Integrations.constants'
import { BuiltBySection } from './BuildBySection'
import { MarkdownContent } from './MarkdownContent'
import { MissingExtensionAlert } from './MissingExtensionAlert'
interface IntegrationOverviewTabProps {
actions?: ReactNode
}
export const IntegrationOverviewTab = ({
actions,
children,
}: PropsWithChildren<IntegrationOverviewTabProps>) => {
const { id } = useParams()
const router = useRouter()
const project = useSelectedProject()
const integration = INTEGRATIONS.find((i) => i.id === id)
const dependsOnExtension = (integration?.requiredExtensions ?? []).length > 0
const { data: extensions } = useDatabaseExtensionsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const neededExtensions = (extensions ?? []).filter((ext) =>
(integration?.requiredExtensions ?? []).includes(ext.name)
)
const hasMissingExtensions = neededExtensions.some((x) => !x.installed_version)
if (!integration) {
return <div>Unsupported integration type</div>
}
return (
<div className="flex flex-col gap-8 py-10">
<BuiltBySection integration={integration} />
{dependsOnExtension && (
<div className="px-10 max-w-4xl">
<Admonition
showIcon={false}
type="default"
className="[&>div]:flex [&>div]:flex-col [&>div]:gap-y-2"
>
<Badge className="bg-surface-300 bg-opacity-100 flex items-center gap-x-2 w-max">
<img
alt="Supabase"
src={`${router.basePath}/img/supabase-logo.svg`}
className=" h-2.5 cursor-pointer rounded"
/>
<span>Database Extension</span>
</Badge>
<Markdown
className="max-w-full"
content={`This integration uses the ${integration.requiredExtensions.map((x) => `\`${x}\``).join(', ')}
extension${integration.requiredExtensions.length > 1 ? 's' : ''} directly in your Postgres database.
${hasMissingExtensions ? `Install ${integration.requiredExtensions.length > 1 ? 'these' : 'this'} database extension${integration.requiredExtensions.length > 1 ? 's' : ''} to use ${integration.name} in your project.` : ''}
`}
/>
<div className="flex flex-row gap-x-2">
{neededExtensions.map((extension) => (
<MissingExtensionAlert key={extension.name} extension={extension} />
))}
</div>
</Admonition>
</div>
)}
{!!actions && !hasMissingExtensions && <div className="px-10 max-w-4xl">{actions}</div>}
<MarkdownContent key={integration.id} integrationId={integration.id} />
<Separator />
{children}
</div>
)
}
@@ -0,0 +1,57 @@
import { motion } from 'framer-motion'
import { useEffect, useState } from 'react'
import { Markdown } from 'components/interfaces/Markdown'
import { cn } from 'ui'
const CHAR_LIMIT = 500 // Adjust this number as needed
export const MarkdownContent = ({ integrationId }: { integrationId: string }) => {
const [content, setContent] = useState<string>('')
const [isExpanded, setIsExpanded] = useState(false)
useEffect(() => {
import(`static-data/integrations/${integrationId}/overview.md`)
.then((module) => setContent(String(module.default)))
.catch((error) => console.error('Error loading markdown:', error))
}, [integrationId])
const displayContent = isExpanded ? content : content.slice(0, CHAR_LIMIT)
const supportExpanding = content.length > CHAR_LIMIT || (content.match(/\n/g) || []).length > 1
if (displayContent.length === 0) return null
return (
<div className="px-10">
<div className="relative">
<motion.div
initial={false}
animate={{ height: isExpanded ? 'auto' : 80 }}
className="overflow-hidden"
transition={{ duration: 0.4 }}
>
<Markdown content={displayContent} className="!max-w-3xl" />
</motion.div>
{!isExpanded && (
<div
className={cn(
'bottom-0 left-0 right-0 h-24',
supportExpanding && 'bg-gradient-to-t from-background-200 to-transparent',
!isExpanded ? 'absolute' : 'relative'
)}
/>
)}
{supportExpanding && (
<div className={cn('bottom-0 z-10', !isExpanded ? 'absolute' : 'relative mt-3')}>
<button
className="text-foreground-light hover:text-foreground underline text-sm"
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? 'Show less' : 'Read more'}
</button>
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,27 @@
import { useState } from 'react'
import EnableExtensionModal from 'components/interfaces/Database/Extensions/EnableExtensionModal'
import { DatabaseExtension } from 'data/database-extensions/database-extensions-query'
import { Button } from 'ui'
export const MissingExtensionAlert = ({ extension }: { extension: DatabaseExtension }) => {
const [showEnableExtensionModal, setShowEnableExtensionModal] = useState(false)
const extensionInstalled = !!extension?.installed_version
if (!extensionInstalled) {
return (
<>
<Button type="primary" className="w-min" onClick={() => setShowEnableExtensionModal(true)}>
Enable {extension.name}
</Button>
<EnableExtensionModal
visible={showEnableExtensionModal}
extension={extension}
onCancel={() => setShowEnableExtensionModal(false)}
/>
</>
)
}
return null
}
@@ -0,0 +1,107 @@
import { Search } from 'lucide-react'
import { useState } from 'react'
import AlertError from 'components/ui/AlertError'
import NoSearchResults from 'components/ui/NoSearchResults'
import { Tabs_Shadcn_, TabsList_Shadcn_, TabsTrigger_Shadcn_ } from 'ui'
import { Input } from 'ui-patterns/DataInputs/Input'
import { IntegrationCard, IntegrationLoadingCard } from './IntegrationCard'
import { useInstalledIntegrations } from './useInstalledIntegrations'
import { Admonition } from 'ui-patterns'
type IntegrationCategory = 'all' | 'wrapper' | 'postgres_extensions' | 'custom'
export const AvailableIntegrations = () => {
const [search, setSearch] = useState('')
const [selectedCategory, setSelectedCategory] = useState<IntegrationCategory>('all')
const { availableIntegrations, installedIntegrations, error, isError, isLoading, isSuccess } =
useInstalledIntegrations()
const installedIds = installedIntegrations.map((i) => i.id)
// available integrations for install
const integrationsByCategory =
selectedCategory === 'all'
? availableIntegrations.filter((i) => !installedIds.includes(i.id))
: availableIntegrations.filter(
(i) => !installedIds.includes(i.id) && i.type === selectedCategory
)
const filteredIntegrations = (
search.length > 0
? integrationsByCategory.filter((i) => i.name.toLowerCase().includes(search.toLowerCase()))
: integrationsByCategory
).sort((a, b) => a.name.localeCompare(b.name))
return (
<>
<Tabs_Shadcn_
className="mt-4"
value={selectedCategory}
onValueChange={(value) => setSelectedCategory(value as IntegrationCategory)}
>
<TabsList_Shadcn_ className="px-10">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
containerClassName="group w-40"
icon={
<Search
size={14}
className="transition text-foreground-lighter group-hover:text-foreground"
/>
}
iconContainerClassName="p-0"
className="pl-7 rounded-none !border-0 border-transparent bg-transparent !shadow-none !ring-0 !ring-offset-0"
placeholder="Search..."
/>
<TabsTrigger_Shadcn_ value="all" className="px-4">
All Integrations
</TabsTrigger_Shadcn_>
<TabsTrigger_Shadcn_ value="wrapper" className="px-4">
Foreign Data Wrappers
</TabsTrigger_Shadcn_>
<TabsTrigger_Shadcn_ value="postgres_extension" className="px-4">
Postgres Extensions
</TabsTrigger_Shadcn_>
</TabsList_Shadcn_>
</Tabs_Shadcn_>
<div className="p-10 py-8 flex flex-col gap-y-5">
<h2>Available integrations</h2>
<div className="grid xl:grid-cols-3 2xl:grid-cols-4 gap-x-4 gap-y-3">
{isLoading &&
new Array(3)
.fill(0)
.map((_, idx) => <IntegrationLoadingCard key={`integration-loading-${idx}`} />)}
{isError && (
<AlertError
className="xl:col-span-3 2xl:col-span-4"
subject="Failed to retrieve available integrations"
error={error}
/>
)}
{isSuccess && filteredIntegrations.map((i) => <IntegrationCard key={i.id} {...i} />)}
{isSuccess && search.length > 0 && filteredIntegrations.length === 0 && (
<NoSearchResults
className="xl:col-span-3 2xl:col-span-4"
searchString={search}
onResetFilter={() => setSearch('')}
/>
)}
{isSuccess &&
selectedCategory !== 'all' &&
search.length === 0 &&
filteredIntegrations.length === 0 && (
<Admonition
showIcon={false}
className="xl:col-span-3 2xl:col-span-4"
type="default"
title="All integrations in this category are currently in use"
description="Manage your installed integrations in the section above"
/>
)}
</div>
</div>
</>
)
}
@@ -0,0 +1,39 @@
import AlertError from 'components/ui/AlertError'
import { IntegrationCard, IntegrationLoadingCard } from './IntegrationCard'
import { useInstalledIntegrations } from './useInstalledIntegrations'
export const InstalledIntegrations = () => {
const { installedIntegrations, error, isLoading, isSuccess, isError } = useInstalledIntegrations()
return (
<div className="px-10 py-6 flex flex-col gap-y-5">
<h2>Installed integrations</h2>
<div className="grid xl:grid-cols-3 2xl:grid-cols-4 gap-x-4 gap-y-3">
{isLoading &&
new Array(3)
.fill(0)
.map((_, idx) => <IntegrationLoadingCard key={`integration-loading-${idx}`} />)}
{isError && (
<AlertError
className="xl:col-span-3 2xl:col-span-4"
subject="Failed to retrieve installed integrations"
error={error}
/>
)}
{isSuccess && (
<>
{installedIntegrations.length === 0 ? (
<div className="xl:col-span-3 2xl:col-span-4 w-full h-[110px] border rounded flex items-center justify-center">
{/* [Joshen] Not high priority imo - very low chance this state will be seen cause Vault is always installed */}
{/* Some placeholder image when no integrations are installed */}
<p className="text-sm text-foreground-light">No integrations installed yet</p>
</div>
) : (
installedIntegrations.map((i) => <IntegrationCard key={i.id} {...i} />)
)}
</>
)}
</div>
</div>
)
}
@@ -0,0 +1,59 @@
import Link from 'next/link'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { Badge, cn } from 'ui'
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
import { IntegrationDefinition } from './Integrations.constants'
type IntegrationCardProps = IntegrationDefinition & {}
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'
)
export const IntegrationLoadingCard = () => {
return (
<div className={cn(INTEGRATION_CARD_STYLE, 'h-[110px]')}>
<div className="w-10 h-10 relative">
<ShimmeringLoader className="w-full h-full bg-white border rounded-md" />
</div>
<div className="grow basis-0 w-full flex flex-col justify-between items-start gap-y-2">
<div className="flex-col justify-start items-start gap-y-1 flex">
<ShimmeringLoader className="w-20 py-2.5" />
<ShimmeringLoader className="w-56 py-2.5" />
</div>
</div>
</div>
)
}
export const IntegrationCard = ({ id, beta, name, icon, description }: IntegrationCardProps) => {
const { project } = useProjectContext()
return (
<Link href={`/project/${project?.ref}/integrations/${id}/overview`}>
<div className={INTEGRATION_CARD_STYLE}>
<div className="w-10 h-10 relative bg-white border rounded-md flex items-center justify-center">
{icon()}
</div>
<div className="grow basis-0 w-full flex flex-col justify-between items-start gap-y-2">
<div className="flex-col justify-start items-start gap-y-0.5 flex">
<div className="flex items-center gap-x-2">
<p className="text-foreground text-sm">{name}</p>
{beta && (
<Badge variant="warning" className="py-0 px-1.5">
Beta
</Badge>
)}
</div>
<p className="text-foreground-light text-xs">{description}</p>
</div>
<Badge className="bg-opacity-100 bg-surface-300 flex items-center gap-x-1.5">
<span>Official</span>
</Badge>
</div>
</div>
</Link>
)
}
@@ -0,0 +1,384 @@
import { Clock5, Layers, Vault, Webhook } from 'lucide-react'
import Image from 'next/image'
import { ComponentType, ReactNode } from 'react'
import { WRAPPERS } from 'components/interfaces/Database/Wrappers/Wrappers.constants'
import { WrapperMeta } from 'components/interfaces/Database/Wrappers/Wrappers.types'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { BASE_PATH } from 'lib/constants'
import dynamic from 'next/dynamic'
import { cn } from 'ui'
export type Navigation = {
route: string
label: string
hasChild?: boolean
childIcon?: React.ReactNode
children?: Navigation[]
}
const Loading = () => (
<div className="p-10">
<GenericSkeletonLoader />
</div>
)
export type IntegrationDefinition = {
id: string
name: string
beta?: boolean
icon: (props?: { className?: string; style?: Record<string, any> }) => ReactNode
description: string
docsUrl: string
author: {
name: string
websiteUrl: string
}
requiredExtensions: string[]
navigation?: Navigation[]
navigate: (
id: string,
pageId: string | undefined,
childId: string | undefined
) => ComponentType<{}> | null
} & ({ type: 'wrapper'; meta: WrapperMeta } | { type: 'postgres_extension' } | { type: 'custom' })
const authorSupabase = {
name: 'Supabase',
websiteUrl: 'https://supabase.com',
}
const supabaseIntegrations: IntegrationDefinition[] = [
{
id: 'queues',
type: 'postgres_extension' as const,
requiredExtensions: ['pgmq'],
name: `Queues`,
icon: ({ className, ...props } = {}) => (
<Layers className={cn('inset-0 p-2 text-black w-full h-full', className)} {...props} />
),
description: 'Lightweight message queue in Postgres',
docsUrl: 'https://github.com/tembo-io/pgmq',
author: {
name: 'pgmq',
websiteUrl: 'https://github.com/tembo-io/pgmq',
},
navigation: [
{
route: 'overview',
label: 'Overview',
},
{
route: 'queues',
label: 'Queues',
hasChild: true,
childIcon: (
<Layers size={12} strokeWidth={1.5} className={cn('text-foreground w-full h-full')} />
),
},
],
navigate: (id: string, pageId: string = 'overview', childId: string | undefined) => {
if (childId) {
return dynamic(
() =>
import('components/interfaces/Integrations/NewQueues/QueueTab').then(
(mod) => mod.QueueTab
),
{
loading: Loading,
}
)
}
switch (pageId) {
case 'overview':
return dynamic(
() =>
import('components/interfaces/Integrations/Integration/IntegrationOverviewTab').then(
(mod) => mod.IntegrationOverviewTab
),
{
loading: Loading,
}
)
case 'queues':
return dynamic(
() =>
import('components/interfaces/Integrations/NewQueues/QueuesTab').then(
(mod) => mod.QueuesTab
),
{
loading: Loading,
}
)
}
return null
},
},
{
id: 'cron-jobs',
type: 'postgres_extension' as const,
requiredExtensions: ['pg_cron'],
name: `Cron Jobs`,
icon: ({ className, ...props } = {}) => (
<Clock5 className={cn('inset-0 p-2 text-black w-full h-full', className)} {...props} />
),
description: 'Schedule and automate tasks to run maintenance routines at specified intervals.',
docsUrl: 'https://github.com/citusdata/pg_cron',
author: {
name: 'pg_cron',
websiteUrl: 'https://github.com/citusdata/pg_cron',
},
navigation: [
{
route: 'overview',
label: 'Overview',
},
{
route: 'cron-jobs',
label: 'Cron Jobs',
},
],
navigate: (id: string, pageId: string = 'overview', childId: string | undefined) => {
switch (pageId) {
case 'overview':
return dynamic(
() =>
import('components/interfaces/Integrations/Integration/IntegrationOverviewTab').then(
(mod) => mod.IntegrationOverviewTab
),
{
loading: Loading,
}
)
case 'cron-jobs':
return dynamic(
() =>
import('components/interfaces/Integrations/NewCronJobs/CronjobsTab').then(
(mod) => mod.CronjobsTab
),
{
loading: Loading,
}
)
}
return null
},
},
{
id: 'vault',
type: 'postgres_extension' as const,
requiredExtensions: ['supabase_vault'],
name: `Vault`,
beta: true,
icon: ({ className, ...props } = {}) => (
<Vault className={cn('inset-0 p-2 text-black w-full h-full', className)} {...props} />
),
description: 'Application level encryption for your project',
docsUrl: 'https://supabase.com/docs',
author: authorSupabase,
navigation: [
{
route: 'overview',
label: 'Overview',
},
{
route: 'keys',
label: 'Keys',
},
{
route: 'secrets',
label: 'Secrets',
},
],
navigate: (id: string, pageId: string = 'overview', childId: string | undefined) => {
switch (pageId) {
case 'overview':
return dynamic(
() =>
import('components/interfaces/Integrations/Integration/IntegrationOverviewTab').then(
(mod) => mod.IntegrationOverviewTab
),
{
loading: Loading,
}
)
case 'keys':
return dynamic(
() =>
import('components/interfaces/Settings/Vault').then(
(mod) => mod.EncryptionKeysManagement
),
{
loading: Loading,
}
)
case 'secrets':
return dynamic(
() =>
import('components/interfaces/Settings/Vault').then((mod) => mod.SecretsManagement),
{
loading: Loading,
}
)
}
return null
},
},
{
id: 'webhooks',
type: 'custom' as const,
name: `Webhooks`,
icon: ({ className, ...props } = {}) => (
<Webhook className={cn('inset-0 p-2 text-black w-full h-full', className)} {...props} />
),
description:
'Send real-time data from your database to another system when a table event occurs',
docsUrl: 'https://supabase.com/docs',
author: authorSupabase,
requiredExtensions: [],
navigation: [
{
route: 'overview',
label: 'Overview',
},
{
route: 'webhooks',
label: 'Webhooks',
},
],
navigate: (id: string, pageId: string = 'overview', childId: string | undefined) => {
switch (pageId) {
case 'overview':
return dynamic(
() =>
import('components/interfaces/Integrations/Webhooks/OverviewTab').then(
(mod) => mod.WebhooksOverviewTab
),
{
loading: Loading,
}
)
case 'webhooks':
return dynamic(
() =>
import('components/interfaces/Integrations/Webhooks/ListTab').then(
(mod) => mod.WebhooksListTab
),
{
loading: Loading,
}
)
}
return null
},
},
{
id: 'graphiql',
type: 'postgres_extension' as const,
requiredExtensions: ['pg_graphql'],
name: `GraphiQL`,
icon: ({ className, ...props } = {}) => (
<Image
fill
src={`${BASE_PATH}/img/graphql.svg`}
alt="GraphiQL"
className={cn('p-2', className)}
{...props}
/>
),
description: 'Run GraphQL queries through our interactive in-browser IDE',
docsUrl: 'https://supabase.com/docs',
author: authorSupabase,
navigation: [
{
route: 'overview',
label: 'Overview',
},
{
route: 'graphiql',
label: 'GraphiQL',
},
],
navigate: (id: string, pageId: string = 'overview', childId: string | undefined) => {
switch (pageId) {
case 'overview':
return dynamic(
() =>
import('components/interfaces/Integrations/Integration/IntegrationOverviewTab').then(
(mod) => mod.IntegrationOverviewTab
),
{
loading: Loading,
}
)
case 'graphiql':
return dynamic(
() =>
import('components/interfaces/Integrations/GraphQL/GraphiQLTab').then(
(mod) => mod.GraphiQLTab
),
{
loading: Loading,
}
)
}
return null
},
},
] as const
const wrapperIntegrations: IntegrationDefinition[] = WRAPPERS.map((w) => {
return {
id: w.name,
type: 'wrapper' as const,
name: `${w.label} Wrapper`,
icon: ({ className, ...props } = {}) => (
<Image fill src={w.icon} alt={w.name} className={cn('p-2', className)} {...props} />
),
requiredExtensions: ['wrappers', 'supabase_vault'],
description: w.description,
docsUrl: w.docsUrl,
meta: w,
author: authorSupabase,
navigation: [
{
route: 'overview',
label: 'Overview',
},
{
route: 'wrappers',
label: 'Wrappers',
},
],
navigate: (id: string, pageId: string = 'overview', childId: string | undefined) => {
switch (pageId) {
case 'overview':
return dynamic(
() =>
import('components/interfaces/Integrations/Wrappers/OverviewTab').then(
(mod) => mod.WrapperOverviewTab
),
{
loading: Loading,
}
)
case 'wrappers':
return dynamic(
() =>
import('components/interfaces/Integrations/Wrappers/WrappersTab').then(
(mod) => mod.WrappersTab
),
{
loading: Loading,
}
)
}
return null
},
}
})
export const INTEGRATIONS: IntegrationDefinition[] = [
...wrapperIntegrations,
...supabaseIntegrations,
]
@@ -0,0 +1,103 @@
import { useMemo } from 'react'
import { wrapperMetaComparator } from 'components/interfaces/Database/Wrappers/Wrappers.utils'
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
import { useSchemasQuery } from 'data/database/schemas-query'
import { useFDWsQuery } from 'data/fdw/fdws-query'
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
import { EMPTY_ARR } from 'lib/void'
import { INTEGRATIONS } from './Integrations.constants'
export const useInstalledIntegrations = () => {
const project = useSelectedProject()
const {
data,
error: fdwError,
isError: isErrorFDWs,
isLoading: isFDWLoading,
isSuccess: isSuccessFDWs,
} = useFDWsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const {
data: extensions,
error: extensionsError,
isError: isErrorExtensions,
isLoading: isExtensionsLoading,
isSuccess: isSuccessExtensions,
} = useDatabaseExtensionsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const {
data: schemas,
error: schemasError,
isError: isErrorSchemas,
isLoading: isSchemasLoading,
isSuccess: isSuccessSchemas,
} = useSchemasQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const isHooksEnabled = schemas?.some((schema) => schema.name === 'supabase_functions')
const wrappers = useMemo(() => data ?? EMPTY_ARR, [data])
const installedIntegrations = useMemo(() => {
return INTEGRATIONS.filter((i) => {
// special handling for supabase webhooks
if (i.id === 'webhooks') {
return isHooksEnabled
}
if (i.type === 'wrapper') {
return wrappers.find((w) => wrapperMetaComparator(i.meta, w))
}
if (i.type === 'postgres_extension') {
return i.requiredExtensions.every((extName) => {
const foundExtension = (extensions ?? []).find((ext) => ext.name === extName)
return !!foundExtension?.installed_version
})
}
return false
}).sort((a, b) => a.name.localeCompare(b.name))
}, [wrappers, extensions, isHooksEnabled])
// available integrations are all integrations that can be installed. If an extension is on available to be installed
// on the project, that integration is hidden.
const availableIntegrations = useMemo(() => {
return INTEGRATIONS.filter((i) => {
// special handling for supabase webhooks
if (i.id === 'webhooks') {
return true
}
if (i.type === 'wrapper') {
return true
}
if (i.type === 'postgres_extension') {
return i.requiredExtensions.every((extName) => {
const foundExtension = (extensions ?? []).find((ext) => ext.name === extName)
return !!foundExtension
})
}
return false
}).sort((a, b) => a.name.localeCompare(b.name))
}, [extensions])
const error = fdwError || extensionsError || schemasError
const isLoading = isSchemasLoading || isFDWLoading || isExtensionsLoading
const isError = isErrorFDWs || isErrorExtensions || isErrorSchemas
const isSuccess = isSuccessFDWs && isSuccessExtensions && isSuccessSchemas
return {
// show all integrations at once instead of showing partial results
installedIntegrations: isLoading ? EMPTY_ARR : installedIntegrations,
availableIntegrations: isLoading ? EMPTY_ARR : availableIntegrations,
error,
isError,
isLoading,
isSuccess,
}
}
@@ -0,0 +1,135 @@
import { useState } from 'react'
import { CreateCronJobSheet } from 'components/interfaces/Integrations/CronJobs/CreateCronJobSheet'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { CronJob, useCronJobsQuery } from 'data/database-cron-jobs/database-cron-jobs-query'
import { Search } from 'lucide-react'
import { parseAsString, useQueryState } from 'nuqs'
import { Button, Input, Sheet, SheetContent } from 'ui'
import { CronJobCard } from '../CronJobs/CronJobCard'
import DeleteCronJob from '../CronJobs/DeleteCronJob'
export const CronjobsTab = () => {
const { project } = useProjectContext()
const [searchQuery, setSearchQuery] = useQueryState('search', parseAsString.withDefault(''))
// used for confirmation prompt in the Create Cron Job Sheet
const [isClosingCreateCronJobSheet, setIsClosingCreateCronJobSheet] = useState(false)
const [createCronJobSheetShown, setCreateCronJobSheetShown] = useState<
Pick<CronJob, 'jobname' | 'schedule' | 'active' | 'command'> | undefined
>()
const [cronJobForDeletion, setCronJobForDeletion] = useState<CronJob | undefined>()
const { data: cronJobs, isLoading } = useCronJobsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
if (isLoading)
return (
<div className="p-10">
<GenericSkeletonLoader />
</div>
)
const filteredCronJobs = (cronJobs ?? []).filter((cj) => cj.jobname.includes(searchQuery))
return (
<>
<div className="w-full space-y-4 p-10">
{(cronJobs ?? []).length == 0 ? (
<div
className={
'border rounded border-default px-20 py-16 flex flex-col items-center justify-center space-y-4 border-dashed'
}
>
<p className="text-sm text-foreground">No cron jobs created yet</p>
<Button
onClick={() =>
setCreateCronJobSheetShown({
jobname: '',
schedule: '',
command: '',
active: true,
})
}
>
Add a new cron job
</Button>
</div>
) : (
<div className="w-full space-y-4">
<div className="flex items-center justify-between flex-wrap">
<Input
placeholder="Search for a job"
size="small"
icon={<Search size={14} />}
value={searchQuery || ''}
className="w-64"
onChange={(e) => setSearchQuery(e.target.value)}
/>
<Button
onClick={() =>
setCreateCronJobSheetShown({
jobname: '',
schedule: '',
command: '',
active: true,
})
}
>
Create a queue
</Button>
</div>
{filteredCronJobs.length === 0 ? (
<div
className={
'border rounded border-default px-20 py-16 flex flex-col items-center justify-center space-y-4 border-dashed'
}
>
<p className="text-sm text-foreground">No results found</p>
<p className="text-sm text-foreground-light">
Your search for "{searchQuery}" did not return any results
</p>
</div>
) : (
filteredCronJobs.map((job) => (
<CronJobCard
job={job}
onEditCronJob={(job) => setCreateCronJobSheetShown(job)}
onDeleteCronJob={(job) => setCronJobForDeletion(job)}
/>
))
)}
</div>
)}
</div>
<DeleteCronJob
visible={!!cronJobForDeletion}
onClose={() => setCronJobForDeletion(undefined)}
cronJob={cronJobForDeletion!}
/>
<Sheet
open={!!createCronJobSheetShown}
onOpenChange={() => setIsClosingCreateCronJobSheet(true)}
>
<SheetContent size="default" tabIndex={undefined}>
<CreateCronJobSheet
selectedCronJob={createCronJobSheetShown}
onClose={() => {
setIsClosingCreateCronJobSheet(false)
setCreateCronJobSheetShown(undefined)
}}
isClosing={isClosingCreateCronJobSheet}
setIsClosing={setIsClosingCreateCronJobSheet}
/>
</SheetContent>
</Sheet>
</>
)
}
@@ -0,0 +1,337 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { SubmitHandler, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import z from 'zod'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
import { useDatabaseQueueCreateMutation } from 'data/database-queues/database-queues-create-mutation'
import {
Badge,
Button,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
Input,
Input_Shadcn_,
RadioGroupStacked,
RadioGroupStackedItem,
Separator,
SheetFooter,
SheetHeader,
SheetSection,
SheetTitle,
} from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { QUEUE_TYPES } from './Queues.constants'
export interface CreateQueueSheetProps {
isClosing: boolean
setIsClosing: (v: boolean) => void
onClose: () => void
}
const normalQueueSchema = z.object({
type: z.literal('basic'),
})
const partitionedQueueSchema = z.object({
type: z.literal('partitioned'),
partitionInterval: z.number(),
retentionInterval: z.number(),
})
const unloggedQueueSchema = z.object({
type: z.literal('unlogged'),
})
const FormSchema = z.object({
name: z
.string()
.trim()
.min(1, 'Please provide a name for your queue')
.max(47, "The name can't be longer than 47 characters"),
values: z.discriminatedUnion('type', [
normalQueueSchema,
partitionedQueueSchema,
unloggedQueueSchema,
]),
})
export type CreateQueueForm = z.infer<typeof FormSchema>
export type QueueType = CreateQueueForm['values']
const FORM_ID = 'create-queue-sidepanel'
export const CreateQueueSheet = ({ isClosing, setIsClosing, onClose }: CreateQueueSheetProps) => {
// const [showEnableExtensionModal, setShowEnableExtensionModal] = useState(false)
const { mutate: createQueue, isLoading } = useDatabaseQueueCreateMutation()
// const canToggleExtensions = useCheckPermissions(
// PermissionAction.TENANT_SQL_ADMIN_WRITE,
// 'extensions'
// )
const form = useForm<CreateQueueForm>({
resolver: zodResolver(FormSchema),
defaultValues: {
name: '',
values: {
type: 'basic',
},
},
})
const { project } = useProjectContext()
const isEdited = form.formState.isDirty
// if the form hasn't been touched and the user clicked esc or the backdrop, close the sheet
if (!isEdited && isClosing) {
onClose()
}
const onClosePanel = () => {
if (isEdited) {
setIsClosing(true)
} else {
onClose()
}
}
const onSubmit: SubmitHandler<CreateQueueForm> = async ({ name, values }) => {
let query = `SELECT pgmq.create('${name}');`
if (values.type === 'partitioned') {
query = `select from pgmq.create_partitioned('${name}', '${values.partitionInterval}', '${values.retentionInterval}');`
}
if (values.type === 'unlogged') {
query = `SELECT pgmq.create_unlogged('${name}');`
}
createQueue(
{
projectRef: project!.ref,
connectionString: project?.connectionString,
query,
},
{
onSuccess: () => {
toast.success(`Successfully created queue ${name}`)
onClose()
},
}
)
}
const { data } = useDatabaseExtensionsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const pgPartmanExtension = (data ?? []).find((ext) => ext.name === 'pg_partman')
const pgPartmanExtensionInstalled = pgPartmanExtension?.installed_version != undefined
const queueType = form.watch('values.type')
return (
<>
<div className="flex flex-col h-full" tabIndex={-1}>
<SheetHeader>
<SheetTitle>Create a new queue</SheetTitle>
</SheetHeader>
<div className="overflow-auto flex-grow">
<Form_Shadcn_ {...form}>
<form
id={FORM_ID}
className="flex-grow overflow-auto"
onSubmit={form.handleSubmit(onSubmit)}
>
<SheetSection>
<FormField_Shadcn_
control={form.control}
name="name"
render={({ field }) => (
<FormItemLayout label="Name" layout="vertical" className="gap-1">
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</SheetSection>
<Separator />
<SheetSection>
<FormField_Shadcn_
control={form.control}
name="values.type"
render={({ field }) => (
<FormItemLayout label="Type" layout="vertical" className="gap-1">
<FormControl_Shadcn_>
<RadioGroupStacked
id="queue_type"
name="queue_type"
value={field.value}
disabled={field.disabled}
onValueChange={field.onChange}
>
{QUEUE_TYPES.map((definition) => (
<RadioGroupStackedItem
key={definition.value}
id={definition.value}
value={definition.value}
label=""
disabled={
!pgPartmanExtensionInstalled && definition.value === 'partitioned'
}
showIndicator={false}
>
<div className="flex items-center gap-x-5">
<div className="text-foreground">{definition.icon}</div>
<div className="flex flex-col">
<div className="flex gap-x-2">
<p className="text-foreground">{definition.label}</p>
</div>
<p className="text-foreground-light text-left">
{definition.description}
</p>
</div>
</div>
{definition.value === 'partitioned' ? (
<div className="pt-2 pl-10">
<Badge variant="warning">COMING SOON</Badge>
</div>
) : null}
{/* {!pgPartmanExtensionInstalled &&
definition.value === 'partitioned' ? (
<div className="w-full flex gap-x-2 pl-11 py-2 items-center">
<WarningIcon />
<span className="text-xs">
<code>pg_partman</code> needs to be installed to use this type
</span>
</div>
) : null} */}
</RadioGroupStackedItem>
))}
</RadioGroupStacked>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
{/* {!pgPartmanExtensionInstalled && (
<Admonition
type="note"
// @ts-ignore
title={
<span>
Enable <code className="text-xs w-min">pg_partman</code> for partitioned
queues
</span>
}
description={
<div className="flex flex-col gap-y-2">
<span>
This will allow you to create partitioned queues which can handle a large
amount of messages
</span>
<ButtonTooltip
type="default"
className="w-min"
disabled={!canToggleExtensions}
onClick={() => setShowEnableExtensionModal(true)}
tooltip={{
content: {
side: 'bottom',
text: 'You need additional permissions to enable database extensions',
},
}}
>
Install pg_partman extension
</ButtonTooltip>
</div>
}
/>
)} */}
</SheetSection>
<Separator />
{queueType === 'partitioned' && (
<>
<SheetSection className="flex flex-col gap-3">
<FormField_Shadcn_
control={form.control}
name="values.partitionInterval"
render={({ field: { ref, ...rest } }) => (
<FormItemLayout label="Partition interval" className="gap-1">
<Input
{...rest}
type="number"
placeholder="1000"
actions={<p className="text-foreground-light pr-2">ms</p>}
/>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="values.retentionInterval"
render={({ field: { ref, ...rest } }) => (
<FormItemLayout label="Retention interval" className="gap-1">
<Input
{...rest}
type="number"
placeholder="1000"
actions={<p className="text-foreground-light pr-2">ms</p>}
/>
</FormItemLayout>
)}
/>
</SheetSection>
</>
)}
</form>
</Form_Shadcn_>
</div>
<SheetFooter>
<Button
size="tiny"
type="default"
htmlType="button"
onClick={onClosePanel}
disabled={isLoading}
>
Cancel
</Button>
<Button
size="tiny"
type="primary"
form={FORM_ID}
htmlType="submit"
disabled={isLoading}
loading={isLoading}
>
Create queue
</Button>
</SheetFooter>
</div>
<ConfirmationModal
visible={isClosing}
title="Discard changes"
confirmLabel="Discard"
onCancel={() => setIsClosing(false)}
onConfirm={() => onClose()}
>
<p className="text-sm text-foreground-light">
There are unsaved changes. Are you sure you want to close the panel? Your changes will be
lost.
</p>
</ConfirmationModal>
{/* {pgPartmanExtension && (
<EnableExtensionModal
visible={showEnableExtensionModal}
extension={pgPartmanExtension}
onCancel={() => setShowEnableExtensionModal(false)}
/>
)} */}
</>
)
}
@@ -1,5 +1,4 @@
import { Paintbrush, Trash2 } from 'lucide-react'
import Link from 'next/link'
import { useMemo, useState } from 'react'
import { useParams } from 'common'
@@ -9,32 +8,12 @@ import { QUEUE_MESSAGE_TYPE } from 'components/interfaces/Integrations/Queues/Si
import { QueueMessagesDataGrid } from 'components/interfaces/Integrations/Queues/SingleQueue/QueueDataGrid'
import { QueueFilters } from 'components/interfaces/Integrations/Queues/SingleQueue/QueueFilters'
import { SendMessageModal } from 'components/interfaces/Integrations/Queues/SingleQueue/SendMessageModal'
import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useQueueMessagesInfiniteQuery } from 'data/database-queues/database-queue-messages-infinite-query'
import type { NextPageWithLayout } from 'types'
import {
Breadcrumb_Shadcn_,
BreadcrumbItem_Shadcn_,
BreadcrumbLink_Shadcn_,
BreadcrumbList_Shadcn_,
BreadcrumbPage_Shadcn_,
BreadcrumbSeparator_Shadcn_,
Button,
LoadingLine,
Separator,
} from 'ui'
import { Button, LoadingLine, Separator } from 'ui'
const QueueMessagesPage: NextPageWithLayout = () => {
// TODO: Change this to the correct permissions
// const canReadFunctions = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'functions')
// const isPermissionsLoaded = usePermissionsLoaded()
// if (isPermissionsLoaded && !canReadFunctions) {
// return <NoPermission isFullPage resourceText="manage database queues" />
// }
const { name: queueName } = useParams()
export const QueueTab = () => {
const { childId: queueName } = useParams()
const { project } = useProjectContext()
const [sendMessageModalShown, setSendMessageModalShown] = useState(false)
const [purgeQueueModalShown, setPurgeQueueModalShown] = useState(false)
@@ -59,22 +38,7 @@ const QueueMessagesPage: NextPageWithLayout = () => {
return (
<div className="h-full flex flex-col">
<div className="flex items-center justify-between gap-x-4 py-4 px-6 mb-0">
<div className="space-y-1 flex-shrink">
<Breadcrumb_Shadcn_>
<BreadcrumbList_Shadcn_>
<BreadcrumbItem_Shadcn_>
<BreadcrumbLink_Shadcn_ asChild className="text-xl">
<Link href={`/project/${project?.ref}/integrations/queues`}>Queues</Link>
</BreadcrumbLink_Shadcn_>
</BreadcrumbItem_Shadcn_>
<BreadcrumbSeparator_Shadcn_ />
<BreadcrumbItem_Shadcn_>
<BreadcrumbPage_Shadcn_ className="text-xl">{queueName}</BreadcrumbPage_Shadcn_>
</BreadcrumbItem_Shadcn_>
</BreadcrumbList_Shadcn_>
</Breadcrumb_Shadcn_>
</div>
<div className="flex items-center justify-end gap-x-4 py-4 px-6 mb-0">
<div className="flex gap-x-2">
<Button
type="text"
@@ -122,9 +86,3 @@ const QueueMessagesPage: NextPageWithLayout = () => {
</div>
)
}
QueueMessagesPage.getLayout = (page) => (
<ProjectIntegrationsLayout title="Queues">{page}</ProjectIntegrationsLayout>
)
export default QueueMessagesPage
@@ -0,0 +1,23 @@
import { Rows2, Rows3, Rows4 } from 'lucide-react'
export const QUEUE_TYPES = [
{
value: 'basic',
icon: <Rows4 strokeWidth={1} />,
label: 'Basic queue',
description: 'Create a basic queue.',
},
{
value: 'unlogged',
icon: <Rows2 strokeWidth={1} />,
label: 'Unlogged queue',
description:
'Creates an unlogged queue which loses all data on database restart. Can be useful when write throughput is more important than durability.',
},
{
value: 'partitioned',
icon: <Rows3 strokeWidth={1} />,
label: 'Partitioned queue',
description: 'Create a partitioned queue which is optimized for large amount of messages',
},
] as const
@@ -0,0 +1,79 @@
import * as Tooltip from '@radix-ui/react-tooltip'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useState } from 'react'
import EnableExtensionModal from 'components/interfaces/Database/Extensions/EnableExtensionModal'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState'
import { DocsButton } from 'components/ui/DocsButton'
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { Button } from 'ui'
export const QueuesDisabledState = () => {
const [showEnableExtensionModal, setShowEnableExtensionModal] = useState(false)
const { project } = useProjectContext()
const canToggleExtensions = useCheckPermissions(
PermissionAction.TENANT_SQL_ADMIN_WRITE,
'extensions'
)
const { data } = useDatabaseExtensionsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const pgmqExtension = (data ?? []).find((ext) => ext.name === 'pgmq')
if (!pgmqExtension) return null
return (
<>
<div className="w-full h-full flex items-center justify-center">
<ProductEmptyState title="Queues">
<div className="text-sm text-foreground-light mb-4 grid gap-2">
<p>
Message queues in PostgreSQL allow you to handle asynchronous tasks, manage workloads,
and enable reliable communication between different parts of your application.
</p>
<p>Enable queues in your project.</p>
</div>
<div className="flex items-center gap-3 mt-4">
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger asChild>
<Button type="primary" onClick={() => setShowEnableExtensionModal(true)}>
Enable Queues
</Button>
</Tooltip.Trigger>
{!canToggleExtensions && (
<Tooltip.Portal>
<Tooltip.Content side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'rounded bg-alternative py-1 px-2 leading-none shadow',
'border border-background',
].join(' ')}
>
<span className="text-xs text-foreground">
You need additional permissions to enable pgmq for this project
</span>
</div>
</Tooltip.Content>
</Tooltip.Portal>
)}
</Tooltip.Root>
<DocsButton href="https://supabase.com/docs/guides/database/extensions/pgmq" />
</div>
</ProductEmptyState>
</div>
<EnableExtensionModal
visible={showEnableExtensionModal}
extension={pgmqExtension}
onCancel={() => setShowEnableExtensionModal(false)}
/>
</>
)
}
@@ -0,0 +1,100 @@
import dayjs from 'dayjs'
import { includes, sortBy } from 'lodash'
import { ChevronRight, Loader2 } from 'lucide-react'
import { useRouter } from 'next/router'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import Table from 'components/to-be-cleaned/Table'
import { useQueuesMetricsQuery } from 'data/database-queues/database-queues-metrics-query'
import { PostgresQueue } from 'data/database-queues/database-queues-query'
import { DATETIME_FORMAT } from 'lib/constants'
interface QueuesRowsProps {
queues: PostgresQueue[]
filterString: string
}
const QueueRow = ({ queue }: { queue: PostgresQueue }) => {
const router = useRouter()
const { project: selectedProject } = useProjectContext()
const { data: metrics, isLoading } = useQueuesMetricsQuery(
{
queueName: queue.queue_name,
projectRef: selectedProject?.ref,
connectionString: selectedProject?.connectionString,
},
{
staleTime: 30 * 1000, // 30 seconds, talk with Oli whether this is ok to call every minute
}
)
const type = queue.is_partitioned ? 'Partitioned' : queue.is_unlogged ? 'Unlogged' : 'Basic'
return (
<Table.tr
key={queue.queue_name}
onClick={() => {
router.push(
`/project/${selectedProject?.ref}/integrations/queues/queues/${queue.queue_name}`
)
}}
>
<Table.td className="truncate">
<p title={queue.queue_name}>{queue.queue_name}</p>
</Table.td>
<Table.td className="table-cell overflow-auto">
<p title={type.toLocaleLowerCase()} className="truncate">
{type}
</p>
</Table.td>
<Table.td className="table-cell">
<p title={queue.created_at}>{dayjs(queue.created_at).format(DATETIME_FORMAT)}</p>
</Table.td>
<Table.td className="table-cell">
<div className="flex justify-center">
{isLoading ? (
<Loader2 className="animate-spin" size={16} />
) : (
<p>
{metrics?.queue_length} {metrics?.method === 'estimated' ? '(Approximate)' : null}
</p>
)}
</div>
</Table.td>
<Table.td>
<div className="flex items-center justify-end">
<ChevronRight size="18" />
</div>
</Table.td>
</Table.tr>
)
}
export const QueuesRows = ({ queues: fetchedQueues, filterString }: QueuesRowsProps) => {
const filteredQueues = fetchedQueues.filter((x) =>
includes(x.queue_name.toLowerCase(), filterString.toLowerCase())
)
const queues = sortBy(filteredQueues, (func) => func.queue_name.toLocaleLowerCase())
if (queues.length === 0 && filterString.length > 0) {
return (
<Table.tr>
<Table.td colSpan={5}>
<p className="text-sm text-foreground">No results found</p>
<p className="text-sm text-foreground-light">
Your search for "{filterString}" did not return any results
</p>
</Table.td>
</Table.tr>
)
}
return (
<>
{queues.map((q) => (
<QueueRow key={q.queue_name} queue={q} />
))}
</>
)
}
@@ -0,0 +1,110 @@
import { Search } from 'lucide-react'
import { useQueryState } from 'nuqs'
import { useState } from 'react'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import Table from 'components/to-be-cleaned/Table'
import AlertError from 'components/ui/AlertError'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { useQueuesQuery } from 'data/database-queues/database-queues-query'
import { Button, Input, Sheet, SheetContent } from 'ui'
import { CreateQueueSheet } from './CreateQueueSheet'
import { QueuesRows } from './QueuesRows'
export const QueuesTab = () => {
const { project } = useProjectContext()
// used for confirmation prompt in the Create Queue Sheet
const [isClosingCreateQueueSheet, setIsClosingCreateQueueSheet] = useState(false)
const [createQueueSheetShown, setCreateQueueSheetShown] = useState(false)
const {
data: queues,
error,
isLoading,
isError,
} = useQueuesQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const [searchQuery, setSearchQuery] = useQueryState('search')
if (isLoading)
return (
<div className="p-10">
<GenericSkeletonLoader />
</div>
)
if (isError)
return (
<div className="p-10">
<AlertError error={error} subject="Failed to retrieve database queues" />
</div>
)
return (
<>
<div className="w-full space-y-4 p-10">
{queues.length === 0 ? (
<div
className={
'border rounded border-default px-20 py-16 flex flex-col items-center justify-center space-y-4 border-dashed'
}
>
<p className="text-sm text-foreground">No queues created yet</p>
<Button onClick={() => setCreateQueueSheetShown(true)}>Add a new queue</Button>
</div>
) : (
<div className="w-full space-y-4">
<div className="flex items-center justify-between flex-wrap">
<Input
placeholder="Search for a queue"
size="small"
icon={<Search size={14} />}
value={searchQuery || ''}
className="w-64"
onChange={(e) => setSearchQuery(e.target.value)}
/>
<Button onClick={() => setCreateQueueSheetShown(true)}>Create a queue</Button>
</div>
<Table
className="table-fixed overflow-x-auto"
head={
<>
<Table.th key="name">Name</Table.th>
<Table.th key="arguments" className="table-cell">
Type
</Table.th>
<Table.th key="created_at" className="table-cell w-60">
Created at
</Table.th>
<Table.th key="queue_size" className="table-cell">
<div className="flex justify-center">Size</div>
</Table.th>
<Table.th key="buttons" className="table-cell"></Table.th>
</>
}
body={<QueuesRows queues={queues} filterString={searchQuery || ''} />}
/>
</div>
)}
</div>
<Sheet open={createQueueSheetShown} onOpenChange={() => setIsClosingCreateQueueSheet(true)}>
<SheetContent size="default" tabIndex={undefined}>
<CreateQueueSheet
onClose={() => {
setIsClosingCreateQueueSheet(false)
setCreateQueueSheetShown(false)
}}
isClosing={isClosingCreateQueueSheet}
setIsClosing={setIsClosingCreateQueueSheet}
/>
</SheetContent>
</Sheet>
</>
)
}
@@ -0,0 +1,62 @@
import { useRouter } from 'next/router'
import { toast } from 'sonner'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useDatabaseQueueDeleteMutation } from 'data/database-queues/database-queues-delete-mutation'
import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal'
interface DeleteQueueProps {
queueName: string
visible: boolean
onClose: () => void
}
const DeleteQueue = ({ queueName, visible, onClose }: DeleteQueueProps) => {
const { project } = useProjectContext()
const router = useRouter()
const { mutate: deleteDatabaseQueue, isLoading } = useDatabaseQueueDeleteMutation({
onSuccess: () => {
toast.success(`Successfully removed queue ${queueName}`)
router.push(`/project/${project?.ref}/integrations/queues`)
onClose()
},
})
async function handleDelete() {
if (!project) return console.error('Project is required')
deleteDatabaseQueue({
queueName: queueName,
projectRef: project.ref,
connectionString: project.connectionString,
})
}
if (!queueName) {
return null
}
return (
<TextConfirmModal
variant="destructive"
visible={visible}
onCancel={() => onClose()}
onConfirm={handleDelete}
title="Delete this queue"
loading={isLoading}
confirmLabel={`Delete queue ${queueName}`}
confirmPlaceholder="Type in name of queue"
confirmString={queueName ?? 'Unknown'}
text={
<>
<span>This will delete the queue</span>{' '}
<span className="text-bold text-foreground">{queueName}</span>
</>
}
alert={{ title: 'You cannot recover this queue and its messages once deleted.' }}
/>
)
}
export default DeleteQueue
@@ -0,0 +1,224 @@
import { useEscapeKeydown } from '@radix-ui/react-use-escape-keydown'
import { isNil, noop } from 'lodash'
import { Archive, Clock12, Trash2, X } from 'lucide-react'
import { useState } from 'react'
import { useParams } from 'common'
import { MonacoEditor } from 'components/grid/components/common/MonacoEditor'
import { RowAction, RowData } from 'components/interfaces/Auth/Users/UserOverview'
import { useDatabaseQueueMessageArchiveMutation } from 'data/database-queues/database-queue-messages-archive-mutation'
import { useDatabaseQueueMessageDeleteMutation } from 'data/database-queues/database-queue-messages-delete-mutation'
import { PostgresQueueMessage } from 'data/database-queues/database-queue-messages-infinite-query'
import { useDatabaseQueueMessageReadMutation } from 'data/database-queues/database-queue-messages-read-mutation'
import dayjs from 'dayjs'
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
import { prettifyJSON } from 'lib/helpers'
import {
Button,
ResizablePanel,
Separator,
Tabs_Shadcn_,
TabsContent_Shadcn_,
TabsList_Shadcn_,
TabsTrigger_Shadcn_,
} from 'ui'
export const DATE_FORMAT = 'DD MMM, YYYY HH:mm'
interface MessageDetailsPanelProps {
selectedMessage: PostgresQueueMessage
setSelectedMessage: (value: number | null) => void
}
const tryFormatInitialValue = (value: string) => {
try {
const jsonValue = JSON.parse(value)
return JSON.stringify(jsonValue)
} catch (err) {
if (typeof value === 'string') {
return value.replaceAll(`\"`, `"`)
} else {
return JSON.stringify(value)
}
}
}
export const MessageDetailsPanel = ({
selectedMessage,
setSelectedMessage,
}: MessageDetailsPanelProps) => {
const { id: _id } = useParams()
const { childId: queueName } = useParams()
const project = useSelectedProject()
useEscapeKeydown(() => setSelectedMessage(null))
const {
mutate: archiveMessage,
isLoading: isLoadingArchive,
isSuccess: isSuccessArchive,
} = useDatabaseQueueMessageArchiveMutation()
const {
mutate: readMessage,
isLoading: isLoadingRead,
isSuccess: isSuccessRead,
} = useDatabaseQueueMessageReadMutation()
const {
mutate: deleteMessage,
isLoading: isLoadingDelete,
isSuccess: isSuccessDelete,
} = useDatabaseQueueMessageDeleteMutation()
const initialValue = JSON.stringify(selectedMessage?.message)
const jsonString = prettifyJSON(!isNil(initialValue) ? tryFormatInitialValue(initialValue) : '')
const [view, setView] = useState<'details' | 'suggestion'>('details')
return (
<ResizablePanel
defaultSize={30}
maxSize={45}
minSize={30}
collapsible
onCollapse={() => setSelectedMessage(null)}
className="bg-studio border-t pointer-events-auto"
>
<Button
type="text"
className="absolute top-3 right-3 px-1"
icon={<X />}
onClick={() => setSelectedMessage(null)}
/>
<Tabs_Shadcn_
value={view}
className="flex flex-col h-full"
onValueChange={(value: any) => {
setView(value)
}}
>
<TabsList_Shadcn_ className="px-5 flex gap-x-4 min-h-[46px]">
<TabsTrigger_Shadcn_
value="details"
className="px-0 pb-0 h-full text-xs data-[state=active]:bg-transparent !shadow-none"
>
Overview
</TabsTrigger_Shadcn_>
</TabsList_Shadcn_>
<TabsContent_Shadcn_ value="details" className="w-full mt-0 overflow-y-auto grow">
<div className="flex flex-col px-4 py-4 text-sm">
<RowData property="Message ID" value={`${selectedMessage.msg_id}`} />
<RowData
property="Added at"
value={dayjs(selectedMessage.enqueued_at).format(DATE_FORMAT)}
/>
<RowData
property="Available at"
value={dayjs(selectedMessage.vt).format(DATE_FORMAT)}
/>
<RowData property="Retries" value={`${selectedMessage.read_ct}`} />
<div>
<h3 className="text-foreground-light py-1">Payload</h3>
<MonacoEditor
key={selectedMessage.msg_id}
onChange={noop}
width="100%"
value={jsonString || 'NULL'}
language="json"
readOnly
/>
</div>
</div>
<Separator />
<div className="flex flex-col px-4 py-4 -space-y-1">
{!selectedMessage.archived_at ? (
<>
<RowAction
title="Postpone message"
description="The message will be postponed and won't show up in reads for 60 seconds."
button={{
icon: <Clock12 />,
text: 'Postpone',
isLoading: isLoadingRead,
onClick: () => {
readMessage({
projectRef: project!.ref,
connectionString: project?.connectionString,
queryName: queueName!,
messageId: selectedMessage.msg_id,
duration: 60,
})
},
}}
success={
isSuccessRead
? {
title: 'Postponed',
description: 'The message was postponed for 60 seconds.',
}
: undefined
}
/>
<RowAction
title="Archive message"
description="The message will be marked as archived and hidden from future reads by consumers. You can still access the message later."
button={{
icon: <Archive />,
text: 'Archive',
isLoading: isLoadingArchive,
type: 'warning',
onClick: () => {
archiveMessage({
projectRef: project!.ref,
connectionString: project?.connectionString,
queryName: queueName!,
messageId: selectedMessage.msg_id,
})
},
}}
success={
isSuccessArchive
? {
title: 'Archived',
description: 'The message was archived successfully.',
}
: undefined
}
/>
<RowAction
title="Delete message"
description="The message cannot be recovered afterwards."
button={{
icon: <Trash2 />,
text: 'Delete',
type: 'danger',
isLoading: isLoadingDelete,
onClick: () => {
deleteMessage({
projectRef: project!.ref,
connectionString: project?.connectionString,
queueName: queueName!,
messageId: selectedMessage.msg_id,
})
},
}}
success={
isSuccessDelete
? {
title: 'Deleted',
description: 'The message was deleted successfully.',
}
: undefined
}
/>
</>
) : null}
</div>
</TabsContent_Shadcn_>
</Tabs_Shadcn_>
</ResizablePanel>
)
}
@@ -0,0 +1,65 @@
import { useRouter } from 'next/router'
import { toast } from 'sonner'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useDatabaseQueuePurgeMutation } from 'data/database-queues/database-queues-purge-mutation'
import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal'
interface PurgeQueueProps {
queueName: string
visible: boolean
onClose: () => void
}
const PurgeQueue = ({ queueName, visible, onClose }: PurgeQueueProps) => {
const { project } = useProjectContext()
const router = useRouter()
const { mutate: purgeDatabaseQueue, isLoading } = useDatabaseQueuePurgeMutation({
onSuccess: () => {
toast.success(`Successfully purged queue ${queueName}`)
router.push(`/project/${project?.ref}/integrations/queues`)
onClose()
},
})
async function handlePurge() {
if (!project) return console.error('Project is required')
purgeDatabaseQueue({
queueName: queueName,
projectRef: project.ref,
connectionString: project.connectionString,
})
}
if (!queueName) {
return null
}
return (
<TextConfirmModal
variant="warning"
visible={visible}
onCancel={() => onClose()}
onConfirm={handlePurge}
title="Purge this queue"
loading={isLoading}
confirmLabel={`Purge queue ${queueName}`}
confirmPlaceholder="Type in name of queue"
confirmString={queueName ?? 'Unknown'}
text={
<>
<span>This will purge the queue</span>{' '}
<span className="text-bold text-foreground">{queueName}</span>
</>
}
alert={{
title:
"This action will delete all messages from the queue. They can't be recovered afterwards.",
}}
/>
)
}
export default PurgeQueue
@@ -0,0 +1,9 @@
import { capitalize } from 'lodash'
export const QUEUE_MESSAGE_TYPES = ['archived', 'available', 'scheduled'] as const
export type QUEUE_MESSAGE_TYPE = (typeof QUEUE_MESSAGE_TYPES)[number]
export const QUEUE_MESSAGE_OPTIONS = QUEUE_MESSAGE_TYPES.map((type) => ({
id: type,
name: capitalize(type),
}))
@@ -0,0 +1,219 @@
import dayjs from 'dayjs'
import { TextSearch } from 'lucide-react'
import { useRouter } from 'next/router'
import { parseAsInteger, useQueryState } from 'nuqs'
import { UIEvent, useMemo, useRef } from 'react'
import DataGrid, { Column, DataGridHandle, Row } from 'react-data-grid'
import { PostgresQueueMessage } from 'data/database-queues/database-queue-messages-infinite-query'
import { Badge, Button, ResizableHandle, ResizablePanel, ResizablePanelGroup, cn } from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { DATE_FORMAT, MessageDetailsPanel } from './MessageDetailsPanel'
interface QueueDataGridProps {
isLoading: boolean
messages: PostgresQueueMessage[]
showMessageModal: () => void
fetchNextPage: () => void
}
function isAtBottom({ currentTarget }: UIEvent<HTMLDivElement>): boolean {
return currentTarget.scrollTop + 10 >= currentTarget.scrollHeight - currentTarget.clientHeight
}
const messagesCols = [
{
id: 'id',
name: 'ID',
description: undefined,
minWidth: 50,
width: 70,
value: (row: PostgresQueueMessage) => (
<div className="flex items-center gap-1.5">
<h3 className="text-xs">{row.msg_id}</h3>
</div>
),
},
{
id: 'enqueued_at',
name: 'Added at',
description: undefined,
minWidth: 160,
width: 180,
value: (row: PostgresQueueMessage) => (
<div className="flex items-center gap-1 text-xs">
{dayjs(row.enqueued_at).format(DATE_FORMAT)}
</div>
),
},
{
id: 'status',
name: 'Status',
description: undefined,
minWidth: 280,
width: 300,
value: (row: PostgresQueueMessage) => {
const isAvailable = new Date() > new Date(row.vt)
if (row.archived_at) {
return (
<Badge variant="default">Archived at {dayjs(row.archived_at).format(DATE_FORMAT)}</Badge>
)
}
return (
<Badge variant={isAvailable ? 'brand' : 'warning'}>
{isAvailable ? 'Available ' : `Available at ${dayjs(row.vt).format(DATE_FORMAT)}`}
</Badge>
)
},
},
{
id: 'retries',
name: 'Retries',
description: undefined,
minWidth: 50,
width: 70,
value: (row: PostgresQueueMessage) => <span>{row.read_ct}</span>,
},
{
id: 'payload',
name: 'Payload',
description: undefined,
minWidth: 600,
value: (row: PostgresQueueMessage) => <span>{JSON.stringify(row.message)}</span>,
},
]
const columns = messagesCols.map((col) => {
const result: Column<PostgresQueueMessage> = {
key: col.id,
name: col.name,
resizable: true,
minWidth: col.minWidth ?? 120,
width: col.width,
headerCellClass: 'first:pl-6 cursor-pointer',
renderHeaderCell: () => {
return (
<div className="flex items-center justify-between font-normal text-xs w-full">
<div className="flex items-center gap-x-2">
<p className="!text-foreground">{col.name}</p>
{col.description && <p className="text-foreground-lighter">{col.description}</p>}
</div>
</div>
)
},
renderCell: (props) => {
const value = col.value(props.row)
return (
<div
className={cn(
'w-full flex flex-col justify-center font-mono text-xs',
typeof value === 'number' ? 'text-right' : ''
)}
>
<span>{value}</span>
</div>
)
},
}
return result
})
export const QueueMessagesDataGrid = ({
isLoading,
messages,
showMessageModal,
fetchNextPage,
}: QueueDataGridProps) => {
const gridRef = useRef<DataGridHandle>(null)
const router = useRouter()
const [selectedMessageId, setSelectedMessageId] = useQueryState('id', parseAsInteger)
const handleScroll = (event: UIEvent<HTMLDivElement>) => {
if (isLoading || !isAtBottom(event)) return
fetchNextPage()
}
const selectedMessage = useMemo(
() => messages.find((m) => m.msg_id === selectedMessageId),
[messages, selectedMessageId]
)
return (
<div className="relative h-full">
<DataGrid
ref={gridRef}
className="h-full"
rowHeight={44}
headerRowHeight={36}
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' : ''}`,
'[&>.rdg-cell]:border-box [&>.rdg-cell]:outline-none [&>.rdg-cell]:shadow-none',
'[&>.rdg-cell:first-child>div]:ml-4',
].join(' ')
}}
renderers={{
renderRow(idx, props) {
return (
<Row
key={props.row.msg_id}
{...props}
onClick={() => {
if (typeof idx === 'number' && idx >= 0) {
setSelectedMessageId(props.row.msg_id)
gridRef.current?.scrollToCell({ idx: 0, rowIdx: idx })
const { id, ...rest } = router.query
router.push({ ...router, query: { ...rest, id: props.row.msg_id } })
}
}}
/>
)
},
noRowsFallback: isLoading ? (
<div className="absolute top-14 px-6 w-full">
<GenericSkeletonLoader />
</div>
) : (
<div className="absolute top-28 px-6 flex flex-col items-center justify-center w-full gap-y-2">
<TextSearch className="text-foreground-muted" strokeWidth={1} />
<div className="text-center">
<p className="text-foreground">No messages found</p>
<p className="text-foreground-light">
The selected queue doesn't have any messages.
</p>
<Button className="mt-2" onClick={() => showMessageModal()}>
Add message
</Button>
</div>
</div>
),
}}
/>
<ResizablePanelGroup
direction="horizontal"
className="absolute inset-0 z-10 pointer-events-none"
autoSaveId="queue-messages-layout-v1"
>
<ResizablePanel defaultSize={1} />
{selectedMessage && (
<>
<ResizableHandle withHandle className="pointer-events-auto" />
<MessageDetailsPanel
selectedMessage={selectedMessage}
setSelectedMessage={setSelectedMessageId}
/>
</>
)}
</ResizablePanelGroup>
</div>
)
}
@@ -0,0 +1,29 @@
import { FilterPopover } from 'components/ui/FilterPopover'
import { QUEUE_MESSAGE_OPTIONS, QUEUE_MESSAGE_TYPE } from './Queue.utils'
interface QueueFiltersProps {
selectedTypes: QUEUE_MESSAGE_TYPE[]
setSelectedTypes: (value: QUEUE_MESSAGE_TYPE[]) => void
}
export const QueueFilters = ({ selectedTypes, setSelectedTypes }: QueueFiltersProps) => {
return (
<div className="bg-surface-200 py-3 px-6 flex items-center justify-between border-t">
<div className="flex items-center gap-x-2">
<FilterPopover
name={selectedTypes.length === 0 ? 'All types' : 'Types'}
title="Select types to show"
buttonType={selectedTypes.length === 0 ? 'dashed' : 'default'}
options={QUEUE_MESSAGE_OPTIONS} // Ignore user image column
labelKey="name"
valueKey="id"
labelClass="text-xs"
maxHeightClass="h-[190px]"
clearButtonText="Reset"
activeOptions={selectedTypes}
onSaveFilters={(value) => setSelectedTypes(value as any)}
/>
</div>
</div>
)
}
@@ -0,0 +1,138 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { SubmitHandler, useForm } from 'react-hook-form'
import z from 'zod'
import { useParams } from 'common'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import CodeEditor from 'components/ui/CodeEditor/CodeEditor'
import { useDatabaseQueueMessageSendMutation } from 'data/database-queues/database-queue-messages-send-mutation'
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'
interface SendMessageModalProps {
visible: boolean
onClose: () => void
}
const FormSchema = z.object({
delay: z.coerce.number().int().gte(0).default(5),
payload: z.string().refine(
(val) => {
try {
JSON.parse(val)
} catch {
return false
}
},
{
message: 'The payload should be a JSON object',
}
),
})
export type SendMessageForm = z.infer<typeof FormSchema>
const FORM_ID = 'QUEUES_SEND_MESSAGE_FORM'
export const SendMessageModal = ({ visible, onClose }: SendMessageModalProps) => {
const { childId: queueName } = useParams()
const { project } = useProjectContext()
const form = useForm<SendMessageForm>({
resolver: zodResolver(FormSchema),
defaultValues: {
delay: 1,
payload: '{}',
},
})
const { isLoading, mutate } = useDatabaseQueueMessageSendMutation({
onSuccess: () => {
toast.success(`Successfully added a message to the queue.`)
onClose()
},
})
const onSubmit: SubmitHandler<SendMessageForm> = (values) => {
mutate({
projectRef: project?.ref!,
connectionString: project?.connectionString,
queueName: queueName!,
payload: values.payload,
delay: values.delay,
})
}
useEffect(() => {
if (visible) {
form.reset({ delay: 1, payload: '{}' })
}
}, [visible])
return (
<Modal
size="medium"
alignFooter="right"
header="Add a message to the queue"
visible={visible}
loading={isLoading}
onCancel={onClose}
confirmText="Add"
onConfirm={() => {
const values = form.getValues()
onSubmit(values)
}}
>
<Modal.Content className="flex flex-col gap-y-4">
<Form_Shadcn_ {...form}>
<form
id={FORM_ID}
className="flex-grow overflow-auto gap-2 flex flex-col"
onSubmit={form.handleSubmit(onSubmit)}
>
<FormField_Shadcn_
control={form.control}
name="delay"
render={({ field: { ref, ...rest } }) => (
<FormItemLayout
label="Delay"
layout="vertical"
className="gap-1"
description="Time in seconds before the message becomes available for reading."
>
<FormControl_Shadcn_>
<Input
{...rest}
type="number"
placeholder="1"
actions={<p className="text-foreground-light pr-2">sec</p>}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="payload"
render={({ field }) => (
<FormItemLayout label="Message payload" layout="vertical" className="gap-1">
<FormControl_Shadcn_>
<CodeEditor
id="message-payload"
language="json"
className="!mb-0 h-32 overflow-hidden rounded border"
onInputChange={(e: string | undefined) => field.onChange(e)}
options={{ wordWrap: 'off', contextmenu: false }}
value={field.value}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</form>
</Form_Shadcn_>
</Modal.Content>
</Modal>
)
}
@@ -25,7 +25,7 @@ const QueueRow = ({ queue }: { queue: PostgresQueue }) => {
connectionString: selectedProject?.connectionString,
},
{
staleTime: 30 * 1000, // 60 seconds, talk with Oli whether this is ok to call every minute
staleTime: 30 * 1000, // 30 seconds, talk with Oli whether this is ok to call every minute
}
)
@@ -37,7 +37,6 @@ const QueueRow = ({ queue }: { queue: PostgresQueue }) => {
onClick={() => {
router.push(`/project/${selectedProject?.ref}/integrations/queues/${queue.queue_name}`)
}}
className="hover:"
>
<Table.td className="truncate">
<p title={queue.queue_name}>{queue.queue_name}</p>
@@ -47,8 +47,7 @@ export const MessageDetailsPanel = ({
selectedMessage,
setSelectedMessage,
}: MessageDetailsPanelProps) => {
const { id: _id } = useParams()
const { name: queueName } = useParams()
const { id: _id, childId: queueName } = useParams()
const project = useSelectedProject()
useEscapeKeydown(() => setSelectedMessage(null))
@@ -130,7 +130,7 @@ export const QueueMessagesDataGrid = ({
const gridRef = useRef<DataGridHandle>(null)
const router = useRouter()
const [selectedMessageId, setSelectedMessageId] = useQueryState('id', parseAsInteger)
const [selectedMessageId, setSelectedMessageId] = useQueryState('messageId', parseAsInteger)
const handleScroll = (event: UIEvent<HTMLDivElement>) => {
if (isLoading || !isAtBottom(event)) return
@@ -171,8 +171,8 @@ export const QueueMessagesDataGrid = ({
if (typeof idx === 'number' && idx >= 0) {
setSelectedMessageId(props.row.msg_id)
gridRef.current?.scrollToCell({ idx: 0, rowIdx: idx })
const { id, ...rest } = router.query
router.push({ ...router, query: { ...rest, id: props.row.msg_id } })
const { messageId, ...rest } = router.query
router.push({ ...router, query: { ...rest, messageId: props.row.msg_id } })
}
}}
/>
@@ -37,7 +37,7 @@ export type SendMessageForm = z.infer<typeof FormSchema>
const FORM_ID = 'QUEUES_SEND_MESSAGE_FORM'
export const SendMessageModal = ({ visible, onClose }: SendMessageModalProps) => {
const { name: queueName } = useParams()
const { childId: queueName } = useParams()
const { project } = useProjectContext()
const form = useForm<SendMessageForm>({
resolver: zodResolver(FormSchema),
@@ -7,7 +7,7 @@ import { toast } from 'sonner'
import {
IntegrationConnection,
IntegrationConnectionProps,
} from 'components/interfaces/Integrations/IntegrationPanels'
} from 'components/interfaces/Integrations/VercelGithub/IntegrationPanels'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { useIntegrationsVercelConnectionSyncEnvsMutation } from 'data/integrations/integrations-vercel-connection-sync-envs-mutation'
import type { IntegrationProjectConnection } from 'data/integrations/integrations.types'
@@ -0,0 +1,53 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useState } from 'react'
import DeleteHookModal from 'components/interfaces/Database/Hooks/DeleteHookModal'
import EditHookPanel from 'components/interfaces/Database/Hooks/EditHookPanel'
import HooksList from 'components/interfaces/Database/Hooks/HooksList/HooksList'
import NoPermission from 'components/ui/NoPermission'
import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions'
export const WebhooksListTab = () => {
const [selectedHook, setSelectedHook] = useState<any>()
const [showCreateHookForm, setShowCreateHookForm] = useState<boolean>(false)
const [showDeleteHookForm, setShowDeleteHookForm] = useState<boolean>(false)
const canReadWebhooks = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'triggers')
const canCreateWebhooks = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'triggers')
const isPermissionsLoaded = usePermissionsLoaded()
const createHook = () => {
setSelectedHook(undefined)
setShowCreateHookForm(true)
}
const editHook = (hook: any) => {
setSelectedHook(hook)
setShowCreateHookForm(true)
}
const deleteHook = (hook: any) => {
setSelectedHook(hook)
setShowDeleteHookForm(true)
}
if (isPermissionsLoaded && !canReadWebhooks) {
return <NoPermission isFullPage resourceText="view database webhooks" />
}
return (
<div className="p-10">
<HooksList createHook={createHook} editHook={editHook} deleteHook={deleteHook} />
<EditHookPanel
visible={showCreateHookForm}
selectedHook={selectedHook}
onClose={() => setShowCreateHookForm(false)}
/>
<DeleteHookModal
visible={showDeleteHookForm}
selectedHook={selectedHook}
onClose={() => setShowDeleteHookForm(false)}
/>
</div>
)
}
@@ -0,0 +1,94 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { toast } from 'sonner'
import { useParams } from 'common'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import NoPermission from 'components/ui/NoPermission'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { useHooksEnableMutation } from 'data/database/hooks-enable-mutation'
import { useSchemasQuery } from 'data/database/schemas-query'
import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions'
import { Admonition } from 'ui-patterns'
import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab'
export const WebhooksOverviewTab = () => {
const { project } = useProjectContext()
const { ref: projectRef } = useParams()
const {
data: schemas,
isSuccess: isSchemasLoaded,
refetch,
} = useSchemasQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const isHooksEnabled = schemas?.some((schema) => schema.name === 'supabase_functions')
const canReadWebhooks = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'triggers')
const isPermissionsLoaded = usePermissionsLoaded()
const { mutate: enableHooks, isLoading: isEnablingHooks } = useHooksEnableMutation({
onSuccess: async () => {
await refetch()
toast.success('Successfully enabled webhooks')
},
})
const enableHooksForProject = async () => {
if (!projectRef) return console.error('Project ref is required')
enableHooks({ ref: projectRef })
}
if (isPermissionsLoaded && !canReadWebhooks) {
return (
<div className="p-10">
<NoPermission isFullPage resourceText="view database webhooks" />
</div>
)
}
if (!isSchemasLoaded) {
return (
<div className="p-10">
<GenericSkeletonLoader />
</div>
)
}
return (
<IntegrationOverviewTab
actions={
isSchemasLoaded && isHooksEnabled ? null : (
<Admonition
showIcon={false}
type="default"
title="Enable database webhooks on your project"
>
<p>
Database Webhooks can be used to trigger serverless functions or send requests to an
HTTP endpoint
</p>
<ButtonTooltip
className="w-fit"
onClick={() => enableHooksForProject()}
disabled={!isPermissionsLoaded || isEnablingHooks}
tooltip={{
content: {
side: 'bottom',
text:
isPermissionsLoaded && !canReadWebhooks
? 'You need additional permissions to enable webhooks'
: undefined,
},
}}
>
Enable webhooks
</ButtonTooltip>
</Admonition>
)
}
/>
)
}
@@ -0,0 +1,283 @@
import { useQueryClient } from '@tanstack/react-query'
import { isEmpty } from 'lodash'
import { Edit, Trash } from 'lucide-react'
import { useState } from 'react'
import { toast } from 'sonner'
import InputField from 'components/interfaces/Database/Wrappers/InputField'
import WrapperTableEditor from 'components/interfaces/Database/Wrappers/WrapperTableEditor'
import { WrapperMeta } from 'components/interfaces/Database/Wrappers/Wrappers.types'
import { makeValidateRequired } from 'components/interfaces/Database/Wrappers/Wrappers.utils'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection'
import { invalidateSchemasQuery } from 'data/database/schemas-query'
import { useFDWCreateMutation } from 'data/fdw/fdw-create-mutation'
import { Button, Form, Input, SheetFooter, SheetHeader, SheetTitle } from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
export interface CreateWrapperSheetProps {
isClosing: boolean
wrapperMeta: WrapperMeta
setIsClosing: (v: boolean) => void
onClose: () => void
}
const FORM_ID = 'create-wrapper-form'
export const CreateWrapperSheet = ({
wrapperMeta,
isClosing,
setIsClosing,
onClose,
}: CreateWrapperSheetProps) => {
const queryClient = useQueryClient()
const { project } = useProjectContext()
const [newTables, setNewTables] = useState<any[]>([])
const [isEditingTable, setIsEditingTable] = useState(false)
const [selectedTableToEdit, setSelectedTableToEdit] = useState()
const [formErrors, setFormErrors] = useState<{ [k: string]: string }>({})
const { mutate: createFDW, isLoading: isCreating } = useFDWCreateMutation({
onSuccess: () => {
toast.success(`Successfully created ${wrapperMeta?.label} foreign data wrapper`)
setNewTables([])
const hasNewSchema = newTables.some((table) => table.is_new_schema)
if (hasNewSchema) invalidateSchemasQuery(queryClient, project?.ref)
onClose()
},
})
const initialValues = {
wrapper_name: '',
server_name: '',
...Object.fromEntries(
wrapperMeta.server.options.map((option) => [option.name, option.defaultValue ?? ''])
),
}
const onUpdateTable = (values: any) => {
setNewTables((prev) => {
// if the new values have tableIndex, we are editing an existing table
if (values.tableIndex !== undefined) {
const tableIndex = values.tableIndex
const newTables = [...prev]
delete values.tableIndex
newTables[tableIndex] = values
return newTables
}
return [...prev, values]
})
setIsEditingTable(false)
setSelectedTableToEdit(undefined)
}
const onSubmit = async (values: any) => {
const validate = makeValidateRequired(wrapperMeta.server.options)
const errors: any = validate(values)
const { wrapper_name } = values
if (wrapper_name.length === 0) errors.name = 'Please provide a name for your wrapper'
if (newTables.length === 0) errors.tables = 'Please add at least one table'
if (!isEmpty(errors)) return setFormErrors(errors)
createFDW({
projectRef: project?.ref,
connectionString: project?.connectionString,
wrapperMeta,
formState: { ...values, server_name: `${wrapper_name}_server` },
tables: newTables,
})
}
return (
<>
<div className="h-full" tabIndex={-1}>
<Form
id={FORM_ID}
initialValues={initialValues}
onSubmit={onSubmit}
className="flex-grow flex flex-col h-full"
>
{({ handleReset, values, initialValues }: any) => {
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
const onClosePanel = () => {
if (hasChanges) {
setIsClosing(true)
} else {
onClose()
}
}
// if the form hasn't been touched and the user clicked esc or the backdrop, close the sheet
if (!hasChanges && isClosing) {
onClose()
}
return (
<>
<SheetHeader>
<SheetTitle>Create a {wrapperMeta.label} wrapper</SheetTitle>
</SheetHeader>
<div className="flex-grow overflow-y-auto">
<FormSection header={<FormSectionLabel>Wrapper Configuration</FormSectionLabel>}>
<FormSectionContent loading={false}>
<Input
id="wrapper_name"
label="Wrapper Name"
error={formErrors.wrapper_name}
descriptionText={
(values?.wrapper_name ?? '').length > 0 ? (
<>
Your wrapper's server name will be{' '}
<code className="text-xs">{values.wrapper_name}_server</code>
</>
) : (
''
)
}
/>
</FormSectionContent>
</FormSection>
<FormSection
header={<FormSectionLabel>{wrapperMeta.label} Configuration</FormSectionLabel>}
>
<FormSectionContent loading={false}>
{wrapperMeta.server.options
.filter((option) => !option.hidden)
.map((option) => (
<InputField
key={option.name}
option={option}
loading={false}
error={formErrors[option.name]}
/>
))}
</FormSectionContent>
</FormSection>
<FormSection
header={
<FormSectionLabel>
<p>Foreign Tables</p>
<p className="text-foreground-light mt-2 w-[90%]">
You can query your data from these foreign tables after the wrapper is
created
</p>
</FormSectionLabel>
}
>
<FormSectionContent loading={false}>
{newTables.length === 0 ? (
<div className="flex justify-end translate-y-4">
<Button type="default" onClick={() => setIsEditingTable(true)}>
Add foreign table
</Button>
</div>
) : (
<div className="space-y-2">
{newTables.map((table, i) => (
<div
key={`${table.schema_name}.${table.table_name}`}
className="flex items-center justify-between px-4 py-2 border rounded-md border-control"
>
<div>
<p className="text-sm">
{table.schema_name}.{table.table_name}
</p>
<p className="text-sm text-foreground-light">
Columns:{' '}
{table.columns.map((column: any) => column.name).join(', ')}
</p>
</div>
<div className="flex items-center space-x-2">
<Button
type="default"
className="px-1"
icon={<Edit />}
onClick={() => {
setIsEditingTable(true)
setSelectedTableToEdit({ ...table, tableIndex: i })
}}
/>
<Button
type="default"
className="px-1"
icon={<Trash />}
onClick={() => {
setNewTables((prev) => prev.filter((_, j) => j !== i))
}}
/>
</div>
</div>
))}
</div>
)}
{newTables.length > 0 && (
<div className="flex justify-end">
<Button type="default" onClick={() => setIsEditingTable(true)}>
Add foreign table
</Button>
</div>
)}
{newTables.length === 0 && formErrors.tables && (
<p className="text-sm text-right text-red-900">{formErrors.tables}</p>
)}
</FormSectionContent>
</FormSection>
</div>
<SheetFooter>
<Button
size="tiny"
type="default"
htmlType="button"
onClick={onClosePanel}
disabled={isCreating}
>
Cancel
</Button>
<Button
size="tiny"
type="primary"
form={FORM_ID}
htmlType="submit"
disabled={isCreating}
loading={isCreating}
>
Create wrapper
</Button>
</SheetFooter>
</>
)
}}
</Form>
</div>
<ConfirmationModal
visible={isClosing}
title="Discard changes"
confirmLabel="Discard"
onCancel={() => setIsClosing(false)}
onConfirm={() => onClose()}
>
<p className="text-sm text-foreground-light">
There are unsaved changes. Are you sure you want to close the panel? Your changes will be
lost.
</p>
</ConfirmationModal>
<WrapperTableEditor
visible={isEditingTable}
tables={wrapperMeta.tables}
onCancel={() => {
setSelectedTableToEdit(undefined)
setIsEditingTable(false)
}}
onSave={onUpdateTable}
initialData={selectedTableToEdit}
/>
</>
)
}
@@ -0,0 +1,364 @@
import { useQueryClient } from '@tanstack/react-query'
import { isEmpty } from 'lodash'
import { Edit, Trash } from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import InputField from 'components/interfaces/Database/Wrappers/InputField'
import WrapperTableEditor from 'components/interfaces/Database/Wrappers/WrapperTableEditor'
import { WrapperMeta } from 'components/interfaces/Database/Wrappers/Wrappers.types'
import {
convertKVStringArrayToJson,
formatWrapperTables,
makeValidateRequired,
} from 'components/interfaces/Database/Wrappers/Wrappers.utils'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection'
import { invalidateSchemasQuery } from 'data/database/schemas-query'
import { useFDWUpdateMutation } from 'data/fdw/fdw-update-mutation'
import { FDW } from 'data/fdw/fdws-query'
import { getDecryptedValue } from 'data/vault/vault-secret-decrypted-value-query'
import { useVaultSecretsQuery } from 'data/vault/vault-secrets-query'
import { Button, Form, Input, SheetFooter, SheetHeader, SheetTitle } from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
export interface EditWrapperSheetProps {
wrapper: FDW
isClosing: boolean
wrapperMeta: WrapperMeta
setIsClosing: (v: boolean) => void
onClose: () => void
}
const FORM_ID = 'edit-wrapper-form'
export const EditWrapperSheet = ({
wrapper,
wrapperMeta,
isClosing,
setIsClosing,
onClose,
}: EditWrapperSheetProps) => {
const queryClient = useQueryClient()
const { project } = useProjectContext()
const { data: secrets, isLoading: isSecretsLoading } = useVaultSecretsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const { mutate: updateFDW, isLoading: isSaving } = useFDWUpdateMutation({
onSuccess: () => {
toast.success(`Successfully updated ${wrapperMeta?.label} foreign data wrapper`)
setWrapperTables([])
const hasNewSchema = wrapperTables.some((table) => table.is_new_schema)
if (hasNewSchema) invalidateSchemasQuery(queryClient, project?.ref)
},
})
const [wrapperTables, setWrapperTables] = useState<any[]>(
formatWrapperTables(wrapper, wrapperMeta)
)
const [isEditingTable, setIsEditingTable] = useState(false)
const [selectedTableToEdit, setSelectedTableToEdit] = useState()
const [formErrors, setFormErrors] = useState<{ [k: string]: string }>({})
const initialValues = {
wrapper_name: wrapper?.name,
server_name: wrapper?.server_name,
...convertKVStringArrayToJson(wrapper?.server_options ?? []),
}
const onUpdateTable = (values: any) => {
setWrapperTables((prev) => {
// if the new values have tableIndex, we are editing an existing table
if (values.tableIndex !== undefined) {
const tableIndex = values.tableIndex
const wrapperTables = [...prev]
delete values.tableIndex
wrapperTables[tableIndex] = values
return wrapperTables
}
return [...prev, values]
})
setIsEditingTable(false)
setSelectedTableToEdit(undefined)
}
const onSubmit = async (values: any) => {
const validate = makeValidateRequired(wrapperMeta.server.options)
const errors: any = validate(values)
const { wrapper_name } = values
if (wrapper_name.length === 0) errors.name = 'Please provide a name for your wrapper'
if (wrapperTables.length === 0) errors.tables = 'Please add at least one table'
if (!isEmpty(errors)) return setFormErrors(errors)
updateFDW({
projectRef: project?.ref,
connectionString: project?.connectionString,
wrapper,
wrapperMeta,
formState: { ...values, server_name: `${wrapper_name}_server` },
tables: wrapperTables,
})
}
return (
<>
<div className="flex flex-col h-full" tabIndex={-1}>
<Form
id={FORM_ID}
initialValues={initialValues}
onSubmit={onSubmit}
className="h-full flex flex-col"
>
{({ values, initialValues, resetForm }: any) => {
// [Alaister] although this "technically" is breaking the rules of React hooks
// it won't error because the hooks are always rendered in the same order
// eslint-disable-next-line react-hooks/rules-of-hooks
const [loadingSecrets, setLoadingSecrets] = useState(false)
const initialTables = formatWrapperTables({
handler: wrapper.handler,
tables: wrapper?.tables ?? [],
})
const hasFormChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
const hasTableChanges = JSON.stringify(initialTables) !== JSON.stringify(wrapperTables)
const hasChanges = hasFormChanges || hasTableChanges
const encryptedOptions = wrapperMeta.server.options.filter((option) => option.encrypted)
const onClosePanel = () => {
if (hasChanges) {
setIsClosing(true)
} else {
onClose()
}
}
// if the form hasn't been touched and the user clicked esc or the backdrop, close the sheet
if (!hasChanges && isClosing) {
onClose()
}
// [Alaister] although this "technically" is breaking the rules of React hooks
// it won't error because the hooks are always rendered in the same order
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
const fetchEncryptedValues = async () => {
setLoadingSecrets(true)
// If the secrets haven't loaded, escape and run the effect again when they're loaded
if (isSecretsLoading) {
return
}
const res = await Promise.all(
encryptedOptions.map(async (option) => {
const secret = secrets?.find(
(secret) => secret.name === `${wrapper.name}_${option.name}`
)
if (secret !== undefined) {
const value = await getDecryptedValue({
projectRef: project?.ref,
connectionString: project?.connectionString,
id: secret.id,
})
return { [option.name]: value[0]?.decrypted_secret ?? '' }
} else {
return { [option.name]: '' }
}
})
)
const secretValues = res.reduce((a: any, b: any) => {
const [key] = Object.keys(b)
return { ...a, [key]: b[key] }
}, {})
resetForm({
values: { ...values, ...secretValues },
initialValues: { ...initialValues, ...secretValues },
})
setLoadingSecrets(false)
}
if (encryptedOptions.length > 0) fetchEncryptedValues()
}, [isSecretsLoading])
return (
<>
<SheetHeader>
<SheetTitle>
Edit {wrapperMeta.label} wrapper: {wrapper.name}
</SheetTitle>
</SheetHeader>
<div className="flex-grow overflow-y-auto">
<FormSection header={<FormSectionLabel>Wrapper Configuration</FormSectionLabel>}>
<FormSectionContent loading={false}>
<Input
id="wrapper_name"
label="Wrapper Name"
error={formErrors.wrapper_name}
descriptionText={
values.wrapper_name !== initialValues.wrapper_name ? (
<>
Your wrapper's server name will be updated to{' '}
<code className="text-xs">{values.wrapper_name}_server</code>
</>
) : (
<>
Your wrapper's server name is{' '}
<code className="text-xs">{values.wrapper_name}_server</code>
</>
)
}
/>
</FormSectionContent>
</FormSection>
<FormSection
header={<FormSectionLabel>{wrapperMeta.label} Configuration</FormSectionLabel>}
>
<FormSectionContent loading={false}>
{wrapperMeta.server.options
.filter((option) => !option.hidden)
.map((option) => (
<InputField
key={option.name}
option={option}
loading={option.encrypted ? loadingSecrets : false}
error={formErrors[option.name]}
/>
))}
</FormSectionContent>
</FormSection>
<FormSection
header={
<FormSectionLabel>
<p>Foreign Tables</p>
<p className="text-foreground-light mt-2 w-[90%]">
You can query your data from these foreign tables after the wrapper is
created
</p>
</FormSectionLabel>
}
>
<FormSectionContent loading={false}>
{wrapperTables.length === 0 ? (
<div className="flex justify-end translate-y-4">
<Button type="default" onClick={() => setIsEditingTable(true)}>
Add foreign table
</Button>
</div>
) : (
<div className="space-y-2">
{wrapperTables.map((table, i) => {
const target = table?.table ?? table.object
return (
<div
key={`${table.schema_name}.${table.table_name}`}
className="flex items-center justify-between px-4 py-2 border rounded-md border-control"
>
<div>
<p className="text-sm">
{table.schema_name}.{table.table_name}{' '}
</p>
<p className="text-sm text-foreground-light mt-1">
Target: {target}
</p>
<p className="text-sm text-foreground-light">
Columns:{' '}
{table.columns.map((column: any) => column.name).join(', ')}
</p>
</div>
<div className="flex items-center space-x-2">
<Button
type="default"
className="px-1"
icon={<Edit />}
onClick={() => {
setIsEditingTable(true)
setSelectedTableToEdit({ ...table, tableIndex: i })
}}
/>
<Button
type="default"
className="px-1"
icon={<Trash />}
onClick={() => {
setWrapperTables((prev) => prev.filter((_, j) => j !== i))
}}
/>
</div>
</div>
)
})}
</div>
)}
{wrapperTables.length > 0 && (
<div className="flex justify-end">
<Button type="default" onClick={() => setIsEditingTable(true)}>
Add foreign table
</Button>
</div>
)}
{wrapperTables.length === 0 && formErrors.tables && (
<p className="text-sm text-right text-red-900">{formErrors.tables}</p>
)}
</FormSectionContent>
</FormSection>
</div>
<SheetFooter>
<Button
size="tiny"
type="default"
htmlType="button"
onClick={onClosePanel}
disabled={isSaving}
>
Cancel
</Button>
<Button
size="tiny"
type="primary"
form={FORM_ID}
htmlType="submit"
disabled={isSaving}
loading={isSaving}
>
Save wrapper
</Button>
</SheetFooter>
</>
)
}}
</Form>
</div>
<ConfirmationModal
visible={isClosing}
title="Discard changes"
confirmLabel="Discard"
onCancel={() => setIsClosing(false)}
onConfirm={() => onClose()}
>
<p className="text-sm text-foreground-light">
There are unsaved changes. Are you sure you want to close the panel? Your changes will be
lost.
</p>
</ConfirmationModal>
<WrapperTableEditor
visible={isEditingTable}
tables={wrapperMeta.tables}
onCancel={() => {
setSelectedTableToEdit(undefined)
setIsEditingTable(false)
}}
onSave={onUpdateTable}
initialData={selectedTableToEdit}
/>
</>
)
}
@@ -0,0 +1,54 @@
import { useParams } from 'common'
import { Markdown } from 'components/interfaces/Markdown'
import { motion } from 'framer-motion'
import { useEffect, useState } from 'react'
import { cn } from 'ui'
const CHAR_LIMIT = 500 // Adjust this number as needed
export const MarkdownContent = () => {
const { id } = useParams()
const [content, setContent] = useState<string>('')
const [isExpanded, setIsExpanded] = useState(false)
useEffect(() => {
import(`static-data/integrations/${id}/overview.md`)
.then((module) => setContent(String(module.default)))
.catch((error) => console.error('Error loading markdown:', error))
}, [id])
const displayContent = isExpanded ? content : content.slice(0, CHAR_LIMIT)
return (
<div className="px-10">
<div className="relative">
<motion.div
initial={false}
animate={{ height: isExpanded ? 'auto' : 80 }}
className="overflow-hidden"
transition={{ duration: 0.4 }}
>
<Markdown content={displayContent} className="!max-w-3xl" />
</motion.div>
{!isExpanded && (
<div
className={cn(
'bottom-0 left-0 right-0 h-24 bg-gradient-to-t from-background-200 to-transparent',
!isExpanded ? 'absolute' : 'relative'
)}
/>
)}
{content.length > CHAR_LIMIT && (
<div className={cn('bottom-0 z-10', !isExpanded ? 'absolute' : 'relative mt-3')}>
<button
className="text-foreground-light hover:text-foreground underline text-sm"
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? 'Show less' : 'Read more'}
</button>
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,130 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import Link from 'next/link'
import { useState } from 'react'
import { useParams } from 'common'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import {
Alert_Shadcn_,
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
Button,
Separator,
Sheet,
SheetContent,
WarningIcon,
} from 'ui'
import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab'
import { INTEGRATIONS } from '../Landing/Integrations.constants'
import { CreateWrapperSheet } from './CreateWrapperSheet'
import { WrapperTable } from './WrapperTable'
export const WrapperOverviewTab = () => {
const { id } = useParams()
const { project } = useProjectContext()
const [createWrapperShown, setCreateWrapperShown] = useState(false)
const [isClosingCreateWrapper, setisClosingCreateWrapper] = useState(false)
const canCreateWrapper = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'wrappers')
const { data } = useDatabaseExtensionsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const integration = INTEGRATIONS.find((i) => i.id === id)
if (integration?.type !== 'wrapper') {
return <p className="text-sm text-foreground-light">Unsupported integration type</p>
}
const wrapperMeta = integration.meta
const wrappersExtension = data?.find((ext) => ext.name === 'wrappers')
const isWrappersExtensionInstalled = !!wrappersExtension?.installed_version
const hasRequiredVersion =
(wrappersExtension?.installed_version ?? '') >= (wrapperMeta?.minimumExtensionVersion ?? '')
const databaseNeedsUpgrading =
wrappersExtension?.installed_version !== wrappersExtension?.default_version
return (
<IntegrationOverviewTab
actions={
isWrappersExtensionInstalled && !hasRequiredVersion ? (
<div className="">
<Alert_Shadcn_ variant="warning">
<WarningIcon />
<AlertTitle_Shadcn_>
Your extension version is outdated for this wrapper.
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_ className="flex flex-col gap-y-2">
<p>
The {wrapperMeta.label} wrapper requires a minimum extension version of{' '}
{wrapperMeta.minimumExtensionVersion}. You have version{' '}
{wrappersExtension?.installed_version} installed. Please{' '}
{databaseNeedsUpgrading && 'upgrade your database then '}update the extension by
disabling and enabling the <code className="text-xs">wrappers</code> extension to
create this wrapper.
</p>
<p className="text-warning">
Warning: Before reinstalling the wrapper extension, you must first remove all
existing wrappers. Afterward, you can recreate the wrappers.
</p>
</AlertDescription_Shadcn_>
<AlertDescription_Shadcn_ className="mt-3">
<Button asChild type="default">
<Link
href={
databaseNeedsUpgrading
? `/project/${project?.ref}/settings/infrastructure`
: `/project/${project?.ref}/database/extensions?filter=wrappers`
}
>
{databaseNeedsUpgrading ? 'Upgrade database' : 'View wrappers extension'}
</Link>
</Button>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
</div>
) : (
<div className="py-3 px-5 border rounded-md">
<ButtonTooltip
type="default"
onClick={() => setCreateWrapperShown(true)}
disabled={!canCreateWrapper}
tooltip={{
content: {
text: !canCreateWrapper
? 'You need additional permissions to create a foreign data wrapper'
: undefined,
},
}}
>
Add new wrapper
</ButtonTooltip>
</div>
)
}
>
<div className="mx-10 flex flex-col gap-5">
Recent wrappers
<WrapperTable />
</div>
<Separator />
<Sheet open={!!createWrapperShown} onOpenChange={() => setisClosingCreateWrapper(true)}>
<SheetContent size="lg" tabIndex={undefined}>
<CreateWrapperSheet
wrapperMeta={integration.meta}
onClose={() => {
setCreateWrapperShown(false)
setisClosingCreateWrapper(false)
}}
isClosing={isClosingCreateWrapper}
setIsClosing={setisClosingCreateWrapper}
/>
</SheetContent>
</Sheet>
</IntegrationOverviewTab>
)
}
@@ -0,0 +1,159 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { partition } from 'lodash'
import { ChevronRight, Edit, ExternalLink, Table2, Trash } from 'lucide-react'
import Link from 'next/link'
import { useState } from 'react'
import { useParams } from 'common'
import DeleteWrapperModal from 'components/interfaces/Database/Wrappers/DeleteWrapperModal'
import { formatWrapperTables } from 'components/interfaces/Database/Wrappers/Wrappers.utils'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import type { FDW } from 'data/fdw/fdws-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { Badge, Sheet, SheetContent, TableCell, TableRow } from 'ui'
import { INTEGRATIONS } from '../Landing/Integrations.constants'
import { EditWrapperSheet } from './EditWrapperSheet'
interface WrapperRowProps {
wrapper: FDW
}
const WrapperRow = ({ wrapper }: WrapperRowProps) => {
const { ref, id } = useParams()
const canManageWrappers = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'wrappers')
const [editWrapperShown, setEditWrapperShown] = useState(false)
const [isClosingEditWrapper, setisClosingEditWrapper] = useState(false)
const [deleteWrapperShown, setDeleteWrapperShown] = useState(false)
const integration = INTEGRATIONS.find((i) => i.id === id)
if (!integration || integration.type !== 'wrapper') {
return <p className="text-foreground-lighter text-sm">A wrapper with this ID does not exist</p>
}
const serverOptions = Object.fromEntries(
wrapper.server_options.map((option) => option.split('='))
)
const [encryptedMetadata, visibleMetadata] = partition(
integration?.meta?.server.options.filter((option) => !option.hidden),
'secureEntry'
)
const _tables = formatWrapperTables(wrapper, integration?.meta)
return (
<>
<TableRow>
<TableCell className="space-y-3 align-top !py-3 min-w-80">
{wrapper.name}
{visibleMetadata.map((metadata) => (
<div
key={metadata.name}
className="flex items-center space-x-2 text-sm text-foreground-light"
>
<span className="text-foreground-lighter">{metadata.label}:</span>
<span>{serverOptions[metadata.name]}</span>
</div>
))}
</TableCell>
<TableCell className="space-y-2 !p-4">
{_tables?.map((table) => {
const target = table.table ?? table.object
return (
<div key={table.id} className="flex items-center -space-x-3">
<Badge className="bg-surface-300 bg-opacity-100 pr-1 gap-2 z-[1] font-mono text-[0.75rem] h-6 text-foreground">
<div className="relative w-3 h-3 flex items-center justify-center">
{integration.icon({ className: 'p-0' })}
</div>
{target}{' '}
<ChevronRight
size={12}
strokeWidth={1.5}
className="text-foreground-lighter/50"
/>
</Badge>
<Badge className="pl-5 rounded-l-none gap-2 h-6 font-mono text-[0.75rem]">
<Table2 size={12} strokeWidth={1.5} className="text-foreground-lighter/50" />
{table.schema}.{table.table_name}
</Badge>
</div>
)
})}
</TableCell>
<TableCell>
{encryptedMetadata.map((metadata) => (
<div key={metadata.name} className="flex items-center space-x-2 text-sm">
{/* <p className="text-foreground-light">{metadata.label}:</p> */}
<Link
href={`/project/${ref}/settings/vault/secrets?search=${wrapper.name}_${metadata.name}`}
className="transition text-foreground-light hover:text-foreground flex items-center space-x-2"
>
<span>Vault</span>
<ExternalLink size={12} strokeWidth={1.5} className="text-foreground-lighter" />
</Link>
</div>
))}
</TableCell>
<TableCell className="space-x-2">
<ButtonTooltip
disabled={!canManageWrappers}
type="default"
icon={<Edit strokeWidth={1.5} />}
className="px-1.5"
onClick={() => setEditWrapperShown(true)}
tooltip={{
content: {
side: 'bottom',
text: !canManageWrappers
? 'You need additional permissions to edit wrappers'
: 'Edit wrapper',
},
}}
/>
<ButtonTooltip
type="default"
disabled={!canManageWrappers}
icon={<Trash strokeWidth={1.5} />}
className="px-1.5"
onClick={() => setDeleteWrapperShown(true)}
tooltip={{
content: {
side: 'bottom',
text: !canManageWrappers
? 'You need additional permissions to delete wrappers'
: 'Delete wrapper',
},
}}
/>
</TableCell>
</TableRow>
<Sheet open={editWrapperShown} onOpenChange={() => setisClosingEditWrapper(true)}>
<SheetContent size="default" tabIndex={undefined}>
<EditWrapperSheet
wrapper={wrapper}
wrapperMeta={integration.meta}
onClose={() => {
setEditWrapperShown(false)
setisClosingEditWrapper(false)
}}
isClosing={isClosingEditWrapper}
setIsClosing={setisClosingEditWrapper}
/>
</SheetContent>
</Sheet>
{deleteWrapperShown && (
<DeleteWrapperModal
selectedWrapper={wrapper}
onClose={() => setDeleteWrapperShown(false)}
/>
)}
</>
)
}
export default WrapperRow
@@ -0,0 +1,66 @@
import { useParams } from 'common'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useFDWsQuery } from 'data/fdw/fdws-query'
import {
Card,
CardContent,
Table,
TableBody,
TableCaption,
TableHead,
TableHeader,
TableRow,
} from 'ui'
import { INTEGRATIONS } from '../Landing/Integrations.constants'
import WrapperRow from './WrapperRow'
interface WrapperTableProps {
isLatest?: boolean
}
export const WrapperTable = ({ isLatest = false }: WrapperTableProps) => {
const { id, ref } = useParams()
const { project } = useProjectContext()
const integration = INTEGRATIONS.find((i) => i.id === id)
const { data } = useFDWsQuery({
projectRef: ref,
connectionString: project?.connectionString,
})
if (!integration || integration.type !== 'wrapper') {
return (
<p className="text-foreground-light text-sm">
The referenced ID doesn't correspond to a wrapper integration
</p>
)
}
const wrappers = (data ?? []).filter((x) => x.handler === integration.meta.handlerName) || []
return (
<Card className="max-w-5xl">
<CardContent className="p-0 pb-3">
<Table className="">
<TableCaption className="text-xs">
{wrappers.length} {integration?.name}
{wrappers.length > 1 ? 's' : ''} created
</TableCaption>
<TableHeader className="font-mono uppercase text-xs [&_th]:h-auto [&_th]:py-2">
<TableRow className="rounded">
<TableHead className="w-[220px]">Name</TableHead>
<TableHead>Tables</TableHead>
<TableHead>Encrypted key</TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody className="[&_td]:py-0 [&_tr]:h-[50px] [&_tr]:border-dotted bg-surface-100">
{(isLatest ? wrappers.slice(0, 3) : wrappers).map((x) => {
return <WrapperRow key={x.id} wrapper={x} />
})}
</TableBody>
</Table>
</CardContent>
</Card>
)
}
@@ -0,0 +1,105 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { HTMLProps, ReactNode, useCallback, useState } from 'react'
import { useParams } from 'common'
import DeleteWrapperModal from 'components/interfaces/Database/Wrappers/DeleteWrapperModal'
import { wrapperMetaComparator } from 'components/interfaces/Database/Wrappers/Wrappers.utils'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { FDW, useFDWsQuery } from 'data/fdw/fdws-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { Sheet, SheetContent } from 'ui'
import { INTEGRATIONS } from '../Landing/Integrations.constants'
import { CreateWrapperSheet } from './CreateWrapperSheet'
import { WrapperTable } from './WrapperTable'
export const WrappersTab = () => {
const { id } = useParams()
const { project } = useProjectContext()
const [selectedWrapperForDelete, setSelectedWrapperForDelete] = useState<FDW | null>(null)
const [createWrapperShown, setCreateWrapperShown] = useState(false)
const [isClosingCreateWrapper, setisClosingCreateWrapper] = useState(false)
const canCreateWrapper = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'wrappers')
const { data } = useFDWsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const wrappers = data ?? []
const integration = INTEGRATIONS.find((i) => i.id === id)
// this contains a collection of all wrapper instances for the wrapper type
const createdWrappers =
integration?.type === 'wrapper'
? wrappers.filter((w) => wrapperMetaComparator(integration.meta, w))
: []
const Container = useCallback(
({ ...props }: { children: ReactNode } & HTMLProps<HTMLDivElement>) => (
<div className="w-full mx-10 py-10 ">
{props.children}
<Sheet open={!!createWrapperShown} onOpenChange={() => setisClosingCreateWrapper(true)}>
<SheetContent size="lg" tabIndex={undefined}>
{integration?.type === 'wrapper' && (
<CreateWrapperSheet
wrapperMeta={integration.meta}
onClose={() => {
setCreateWrapperShown(false)
setisClosingCreateWrapper(false)
}}
isClosing={isClosingCreateWrapper}
setIsClosing={setisClosingCreateWrapper}
/>
)}
</SheetContent>
</Sheet>
</div>
),
[createWrapperShown, integration, isClosingCreateWrapper]
)
if (!integration || integration.type !== 'wrapper') {
return <div>Missing integration.</div>
}
if (createdWrappers.length === 0) {
return (
<Container>
<div className=" w-full h-48 max-w-4xl">
<div className="border rounded-lg h-full flex flex-col gap-y-2 items-center justify-center">
<p className="text-sm text-foreground-light">
No {integration.meta.label} wrappers have been installed
</p>
<ButtonTooltip
type="default"
onClick={() => setCreateWrapperShown(true)}
disabled={!canCreateWrapper}
tooltip={{
content: {
text: !canCreateWrapper
? 'You need additional permissions to create a foreign data wrapper'
: undefined,
},
}}
>
Add new wrapper
</ButtonTooltip>
</div>
</div>
</Container>
)
}
return (
<Container>
<WrapperTable />
{selectedWrapperForDelete && (
<DeleteWrapperModal
selectedWrapper={selectedWrapperForDelete}
onClose={() => setSelectedWrapperForDelete(null)}
/>
)}
</Container>
)
}
@@ -2,7 +2,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useCallback } from 'react'
import { toast } from 'sonner'
import { EmptyIntegrationConnection } from 'components/interfaces/Integrations/IntegrationPanels'
import { EmptyIntegrationConnection } from 'components/interfaces/Integrations/VercelGithub/IntegrationPanels'
import { Markdown } from 'components/interfaces/Markdown'
import VercelSection from 'components/interfaces/Settings/Integrations/VercelIntegration/VercelSection'
import {
@@ -25,7 +25,7 @@ import {
GITHUB_INTEGRATION_REVOKE_AUTHORIZATION_URL,
} from 'lib/github'
import { useSidePanelsStateSnapshot } from 'state/side-panels'
import { IntegrationConnectionItem } from '../../Integrations/IntegrationConnection'
import { IntegrationConnectionItem } from '../../Integrations/VercelGithub/IntegrationConnection'
import SidePanelGitHubRepoLinker from './SidePanelGitHubRepoLinker'
import SidePanelVercelProjectLinker from './SidePanelVercelProjectLinker'
@@ -1,7 +1,7 @@
import { useMemo } from 'react'
import { toast } from 'sonner'
import ProjectLinker from 'components/interfaces/Integrations/ProjectLinker'
import ProjectLinker from 'components/interfaces/Integrations/VercelGithub/ProjectLinker'
import { Markdown } from 'components/interfaces/Markdown'
import { useGitHubAuthorizationQuery } from 'data/integrations/github-authorization-query'
import { useGitHubConnectionCreateMutation } from 'data/integrations/github-connection-create-mutation'
@@ -3,8 +3,10 @@ import { useCallback, useMemo } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
import { ENV_VAR_RAW_KEYS } from 'components/interfaces/Integrations/Integrations-Vercel.constants'
import ProjectLinker, { ForeignProject } from 'components/interfaces/Integrations/ProjectLinker'
import { ENV_VAR_RAW_KEYS } from 'components/interfaces/Integrations/Vercel/Integrations-Vercel.constants'
import ProjectLinker, {
ForeignProject,
} from 'components/interfaces/Integrations/VercelGithub/ProjectLinker'
import { Markdown } from 'components/interfaces/Markdown'
import { vercelIcon } from 'components/to-be-cleaned/ListIcons'
import { useOrgIntegrationsQuery } from 'data/integrations/integrations-query-org-only'
@@ -12,7 +12,7 @@ import {
AlertTitle_Shadcn_,
AlertDescription_Shadcn_,
} from 'ui'
import MotionNumber from 'motion-number'
import MotionNumber from '@number-flow/react'
import Link from 'next/link'
import { useMemo } from 'react'
import { InfoTooltip } from 'ui-patterns/info-tooltip'
@@ -190,9 +190,6 @@ const DiskUsage = ({
<MotionNumber
value={totalDiskUsage}
style={{ lineHeight: 0.8 }}
transition={{
y: { type: 'spring', duration: 0.35, bounce: 0 },
}}
className="font-mono"
/>
</span>{' '}
@@ -3,8 +3,8 @@ import { useCallback } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
import { IntegrationConnectionItem } from 'components/interfaces/Integrations/IntegrationConnection'
import { EmptyIntegrationConnection } from 'components/interfaces/Integrations/IntegrationPanels'
import { IntegrationConnectionItem } from 'components/interfaces/Integrations/VercelGithub/IntegrationConnection'
import { EmptyIntegrationConnection } from 'components/interfaces/Integrations/VercelGithub/IntegrationPanels'
import { Markdown } from 'components/interfaces/Markdown'
import {
ScaffoldContainer,
@@ -4,12 +4,12 @@ import { useCallback, useMemo } from 'react'
import { toast } from 'sonner'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { IntegrationConnectionItem } from 'components/interfaces/Integrations/IntegrationConnection'
import { IntegrationConnectionItem } from 'components/interfaces/Integrations/VercelGithub/IntegrationConnection'
import {
EmptyIntegrationConnection,
IntegrationConnectionHeader,
IntegrationInstallation,
} from 'components/interfaces/Integrations/IntegrationPanels'
} from 'components/interfaces/Integrations/VercelGithub/IntegrationPanels'
import { Markdown } from 'components/interfaces/Markdown'
import {
ScaffoldContainer,
@@ -19,7 +19,7 @@ import { Alert, Button, Form, Input, Listbox, Modal, Separator } from 'ui'
const DEFAULT_KEY_NAME = 'No description provided'
const EncryptionKeysManagement = () => {
const { id } = useParams()
const { search } = useParams()
const { project } = useProjectContext()
const [searchValue, setSearchValue] = useState<string>('')
@@ -29,8 +29,8 @@ const EncryptionKeysManagement = () => {
const canManageKeys = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables')
useEffect(() => {
if (id !== undefined) setSearchValue(id)
}, [id])
if (search !== undefined) setSearchValue(search)
}, [search])
const { data, isLoading } = usePgSodiumKeysQuery({
projectRef: project?.ref,
@@ -90,7 +90,7 @@ const EncryptionKeysManagement = () => {
return (
<>
<div className="space-y-4">
<div className="space-y-4 p-10">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Input
@@ -109,26 +109,34 @@ const EditSecretModal = ({ selectedSecret, onClose }: EditSecretModalProps) => {
onSubmit={onUpdateSecret}
>
{({ isSubmitting, resetForm }: any) => {
// [Joshen] JFYI this is breaking rules of hooks, will be fixed once we move to
// using react hook form instead
// eslint-disable-next-line react-hooks/rules-of-hooks
const { isLoading: isLoadingSecretValue } = useVaultSecretDecryptedValueQuery(
const {
data,
isLoading: isLoadingSecretValue,
isSuccess: isSuccessSecretValue,
// [Joshen] JFYI this is breaking rules of hooks, will be fixed once we move to
// using react hook form instead
// eslint-disable-next-line react-hooks/rules-of-hooks
} = useVaultSecretDecryptedValueQuery(
{
projectRef: project?.ref!,
id: selectedSecret?.id!,
connectionString: project?.connectionString,
},
{
enabled: !!(project?.ref && selectedSecret?.id),
onSuccess: (res) => {
resetForm({
values: { ...INITIAL_VALUES, secret: res },
initialValues: { ...INITIAL_VALUES, secret: res },
})
},
}
{ enabled: !!(project?.ref && selectedSecret?.id) }
)
// [Joshen] JFYI this is breaking rules of hooks, will be fixed once we move to
// using react hook form instead
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (selectedSecret !== undefined && isSuccessSecretValue) {
resetForm({
values: { ...INITIAL_VALUES, secret: data },
initialValues: { ...INITIAL_VALUES, secret: data },
})
}
}, [selectedSecret, isSuccessSecretValue])
return isLoadingSecretValue ? (
<Modal.Content>
<GenericSkeletonLoader />
@@ -16,8 +16,8 @@ import {
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useVaultSecretDecryptedValueQuery } from 'data/vault/vault-secret-decrypted-value-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { Edit3, Eye, EyeOff, Key, Loader, MoreVertical, Trash } from 'lucide-react'
import type { VaultSecret } from 'types'
import { Key, Loader, Eye, EyeOff, MoreVertical, Edit3, Trash } from 'lucide-react'
interface SecretRowProps {
secret: VaultSecret
@@ -65,7 +65,7 @@ const SecretRow = ({ secret, onSelectEdit, onSelectRemove }: SecretRowProps) =>
className="text-foreground-light transition group-hover:text-brand"
/>
<Link
href={`/project/${ref}/settings/vault/keys?id=${secret.key_id}`}
href={`/project/${ref}/integrations/vault/keys?search=${secret.key_id}`}
className="text-foreground-light font-mono text-xs cursor-pointer transition group-hover:text-brand"
title={secret.key_id}
>
@@ -56,7 +56,7 @@ const SecretsManagement = () => {
return (
<>
<div className="space-y-4">
<div className="space-y-4 p-10">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Input
@@ -210,7 +210,6 @@ export const generateRowObjectFromFields = (
rowObject[field.name] = tryParseJson(value)
}
} else if (field.format === 'bool' && value) {
if (field.name === 'bool_default_true') console.log(field)
if (value === 'null') rowObject[field.name] = null
else rowObject[field.name] = value === 'true'
} else if (DATETIME_TYPES.includes(field.format)) {
@@ -2,7 +2,7 @@ import { useParams } from 'common'
import {
EmptyIntegrationConnection,
IntegrationConnection,
} from 'components/interfaces/Integrations/IntegrationPanels'
} from 'components/interfaces/Integrations/VercelGithub/IntegrationPanels'
import type { GitHubConnection } from 'data/integrations/github-connections-query'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { Check, Loader2 } from 'lucide-react'
@@ -0,0 +1,180 @@
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 { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { Badge, cn } from 'ui'
interface HeaderProps {
scroll: ReturnType<typeof useScroll>
}
export const Header = forwardRef<HTMLDivElement, HeaderProps>(({ scroll }, ref) => {
const router = useRouter()
const { id } = useParams()
// Get project context
const { project } = useProjectContext()
// 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<HTMLDivElement>(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 (
<>
<AnimatePresence>
<motion.div
ref={ref}
layout
transition={layoutTransition}
className={cn(isIntegrationsHome && 'border-b', ' relative')}
>
{/* Main header content */}
<div className="py-6">
<div className="relative">
{/* Container with animated padding */}
<motion.div
layout
transition={layoutTransition}
className="px-10 flex flex-col gap-5"
>
{/* Navigation link back to integrations landing */}
<div className="flex items-center gap-0.5">
{/* Back arrow */}
{!isIntegrationsHome && (
<motion.div
initial={{ opacity: 0, x: -10, width: 0 }}
animate={{ opacity: 1, x: 0, width: 'auto' }}
exit={{ opacity: 0, x: -10, width: 0 }}
transition={{ duration: 0.15 }}
>
<Link
href={`/project/${project?.ref}/integrations`}
className="text-foreground-light hover:text-foreground transition flex items-center"
>
<ChevronLeft size={14} />
</Link>
</motion.div>
)}
{/* Two separate spans with the same key */}
{isIntegrationsHome ? (
<motion.span
layout
key="integrations-text"
transition={layoutTransition}
className="text-foreground text-xl !p-0"
style={{
padding: iconPadding,
}}
>
Integrations
</motion.span>
) : (
<motion.span
layout
key="integrations-text"
transition={layoutTransition}
className="text-xs text-foreground-light hover:text-foreground !p-0"
>
<Link href={`/project/${project?.ref}/integrations`}>Integrations</Link>
</motion.span>
)}
</div>
{/* Integration details section - only shown when viewing a specific integration */}
{!isIntegrationsHome && integration && (
<motion.div
layout
transition={{
duration: 0.2,
delay: 0.2,
}}
className="flex items-center gap-4"
>
{/* Integration icon */}
<motion.div
initial={{ opacity: 1, y: 0 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
layoutId="integration-icon"
className={
'relative bg-white border border-muted rounded z-[3] flex flex-shrink-0 items-center justify-center'
}
style={{
y: iconY, // Controlled by scrollY (0 to 128px)
width: iconSize,
height: iconSize,
minWidth: iconSize,
minHeight: iconSize,
}}
>
{integration.icon({
style: {
padding: iconPadding.get(),
},
})}
</motion.div>
{/* Integration name and description */}
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto', minHeight: 32 }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
>
<div className="flex-col justify-start items-start flex">
<div className="text-foreground text-sm flex items-center gap-x-2">
<span>{integration.name}</span>
{integration.beta && (
<Badge variant="warning" className="py-0 px-1.5">
Beta
</Badge>
)}
</div>
<p className="text-foreground-light text-xs">{integration.description}</p>
</div>
</motion.div>
</motion.div>
)}
</motion.div>
</div>
</div>
</motion.div>
</AnimatePresence>
</>
)
})
Header.displayName = 'Header'
@@ -0,0 +1,245 @@
import { useRouter } from 'next/router'
import { PropsWithChildren, useEffect, useRef, useState } from 'react'
import { IntegrationDefinition } from 'components/interfaces/Integrations/Landing/Integrations.constants'
import { useInstalledIntegrations } from 'components/interfaces/Integrations/Landing/useInstalledIntegrations'
import { Header } from 'components/layouts/Integrations/header'
import ProjectLayout from 'components/layouts/ProjectLayout/ProjectLayout'
import { ProductMenu } from 'components/ui/ProductMenu'
import { ProductMenuGroup } from 'components/ui/ProductMenu/ProductMenu.types'
import { useScroll } from 'framer-motion'
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
import { withAuth } from 'hooks/misc/withAuth'
import { useFlag } from 'hooks/ui/useFlag'
import { IntegrationTabs } from './tabs'
import { Menu, Separator } from 'ui'
import ProductMenuItem from 'components/ui/ProductMenu/ProductMenuItem'
import { GenericSkeletonLoader } from 'ui-patterns'
import AlertError from 'components/ui/AlertError'
/**
* Layout component for the Integrations section
* Handles scroll-based sticky header behavior and authentication
*/
const IntegrationsLayout = ({ ...props }: PropsWithChildren) => {
const layoutSidebar = useFlag('integrationLayoutSidebar')
if (layoutSidebar) {
return <IntegrationsLayoutSide {...props} />
}
return <IntegrationTopHeaderLayout {...props} />
}
/**
* Top level layout
*/
const IntegrationTopHeaderLayout = ({ ...props }: PropsWithChildren) => {
const project = useSelectedProject()
const router = useRouter()
// Refs for the main scrollable area and header
const mainElementRef = useRef<HTMLDivElement>(null)
const headerRef = useRef<HTMLDivElement>(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<HTMLElement | null>(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 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]}` : ''}`
const {
installedIntegrations: integrations,
error,
isLoading,
isSuccess,
isError,
} = useInstalledIntegrations()
const installedIntegrationItems = integrations.map((integration) => ({
name: integration.name,
label: integration.beta ? 'Beta' : undefined,
key: `integrations/${integration.id}`,
url: `/project/${project?.ref}/integrations/${integration.id}/overview`,
icon: (
<div className="relative w-6 h-6 bg-white border rounded flex items-center justify-center">
{integration.icon({ className: 'p-1' })}
</div>
),
items: [],
}))
return (
<ProjectLayout
ref={mainElementRef}
title={'Integrations'}
product="Integrations"
isBlocking={false}
productMenu={
<>
<ProductMenu page={page} menu={generateIntegrationsMenu({ projectRef: project?.ref })} />
<Separator />
<div className="p-6">
<Menu.Group
title={
<div className="flex flex-col space-y-2 uppercase font-mono">
<span>Installed integrations</span>
</div>
}
/>
{isLoading && <GenericSkeletonLoader />}
{isError && (
<AlertError
showIcon={false}
error={error}
subject="Failed to retrieve installed integrations"
/>
)}
{isSuccess && (
<div>
{installedIntegrationItems.map((item) => (
<ProductMenuItem
key={item.key}
url={item.url}
name={item.name}
icon={item.icon}
isActive={page === item.key}
label={item.label}
/>
))}
</div>
)}
</div>
</>
}
>
<Header scroll={scroll} ref={headerRef} />
<IntegrationTabs scroll={scroll} isSticky={isSticky} />
{props.children}
</ProjectLayout>
)
}
const IntegrationsLayoutSide = ({ ...props }: PropsWithChildren) => {
const router = useRouter()
const page = router.pathname.split('/')[4]
const project = useSelectedProject()
const {
installedIntegrations: integrations,
error,
isLoading,
isError,
isSuccess,
} = useInstalledIntegrations()
const installedIntegrationItems = integrations.map((integration) => ({
name: integration.name,
label: integration.beta ? 'Beta' : undefined,
key: `integrations/${integration.id}`,
url: `/project/${project?.ref}/integrations/${integration.id}/overview`,
icon: (
<div className="relative w-6 h-6 bg-white border rounded flex items-center justify-center">
{integration.icon({ className: 'p-1' })}
</div>
),
items: [],
}))
return (
<ProjectLayout
isLoading={false}
product="Integrations"
productMenu={
<>
<ProductMenu page={page} menu={generateIntegrationsMenu({ projectRef: project?.ref })} />
<Separator />
<div className="p-6">
<Menu.Group
title={
<div className="flex flex-col space-y-2 uppercase font-mono">
<span>Installed integrations</span>
</div>
}
/>
{isLoading && <GenericSkeletonLoader />}
{isError && (
<AlertError
showIcon={false}
error={error}
subject="Failed to retrieve installed integrations"
/>
)}
{isSuccess && (
<div>
{installedIntegrationItems.map((item) => (
<ProductMenuItem
key={item.key}
url={item.url}
name={item.name}
icon={item.icon}
isActive={page === item.key}
label={item.label}
/>
))}
</div>
)}
</div>
</>
}
>
{props.children}
</ProjectLayout>
)
}
// Wrap component with authentication HOC before exporting
export default withAuth(IntegrationsLayout)
const generateIntegrationsMenu = ({ projectRef }: { projectRef?: string }): ProductMenuGroup[] => {
return [
{
title: 'All Integrations',
items: [
{
name: 'All Integrations',
key: 'integrations',
url: `/project/${projectRef}/integrations`,
items: [],
},
],
},
].filter(Boolean)
}
@@ -0,0 +1,116 @@
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 { useParams } from 'common'
import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integrations.constants'
import { useInstalledIntegrations } from 'components/interfaces/Integrations/Landing/useInstalledIntegrations'
import { NavMenu, NavMenuItem } from 'ui'
import { useProjectContext } from '../ProjectLayout/ProjectContext'
const MotionNavMenu = motion(NavMenu) as ComponentType<ComponentProps<typeof NavMenu> & 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<typeof useScroll>
isSticky?: boolean
}
export const IntegrationTabs = ({ scroll, isSticky }: IntegrationTabsProps) => {
const navRef = useRef(null)
const { project } = useProjectContext()
const { id, pageId, childId } = useParams()
const { installedIntegrations } = useInstalledIntegrations()
// Find the integration details based on ID
const integration = INTEGRATIONS.find((i) => i.id === id)
const headerRef = useRef<HTMLDivElement>(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 (
<AnimatePresence>
<div className="sticky top-[0px] z-[1] bg-dash-sidebar" ref={navRef}>
<MotionNavMenu
transition={{ duration: 0.2 }}
className="px-10 [&_ul]:items-center bg-200"
aria-label="Integration menu"
style={{ paddingLeft: !isSticky ? (navInnerLeftPaddingX as unknown as number) : 40 }}
>
{isSticky && (
<motion.div
layoutId="integration-icon"
className="w-[20px] h-[20px] relative bg-white rounded"
transition={{ duration: 0 }}
>
{integration?.icon({
style: { padding: iconPadding.get() },
})}
</motion.div>
)}
{tabs.map((tab) => {
const tabUrl = `/project/${project?.ref}/integrations/${integration?.id}/${tab.route}`
return (
<div className="flex items-center gap-2" key={tab.route}>
<NavMenuItem active={pageId === tab.route}>
<Link href={tabUrl}>{tab.label}</Link>
</NavMenuItem>
<AnimatePresence>
{tab.hasChild && childId && (
<>
<motion.div
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -10 }}
transition={{ duration: 0.12, delay: 0.05 }}
className="flex items-center"
>
<ChevronRight size={14} className="text-foreground-muted" />
</motion.div>
<motion.div
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -10 }}
transition={{ duration: 0.12, delay: 0.1 }}
className="flex items-center"
>
<NavMenuItem active={true} className="flex items-center gap-2">
{tab.childIcon}
<Link href={`/project/${project?.ref}/integrations/${tab.route}`}>
{childId}
</Link>
</NavMenuItem>
</motion.div>
</>
)}
</AnimatePresence>
</div>
)
})}
</MotionNavMenu>
</div>
</AnimatePresence>
)
}
IntegrationTabs.displayName = 'IntegrationTabs'
@@ -145,7 +145,7 @@ export const generateOtherRoutes = (ref?: string, project?: Project): Route[] =>
key: 'integrations',
label: 'Integrations',
icon: <Blocks size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/integrations/wrappers`),
link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/integrations`),
},
]
}
@@ -1,9 +1,11 @@
import { useParams } from 'common'
import Head from 'next/head'
import { useRouter } from 'next/router'
import { Fragment, PropsWithChildren, ReactNode, useEffect } from 'react'
import { forwardRef, Fragment, PropsWithChildren, ReactNode, useEffect } from 'react'
import { useIsAssistantV2Enabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import ProjectAPIDocs from 'components/interfaces/ProjectAPIDocs/ProjectAPIDocs'
import { AiAssistantPanel } from 'components/ui/AIAssistantPanel/AIAssistantPanel'
import AISettingsModal from 'components/ui/AISettingsModal'
import { Loading } from 'components/ui/Loading'
import ResourceExhaustionWarningBanner from 'components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner'
@@ -12,8 +14,9 @@ import { useSelectedProject } from 'hooks/misc/useSelectedProject'
import { withAuth } from 'hooks/misc/withAuth'
import { useFlag } from 'hooks/ui/useFlag'
import { IS_PLATFORM, PROJECT_STATUS } from 'lib/constants'
import { useAppStateSnapshot } from 'state/app-state'
import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
import { ResizableHandle, ResizablePanel, ResizablePanelGroup, cn } from 'ui'
import { cn, ResizableHandle, ResizablePanel, ResizablePanelGroup } from 'ui'
import AppLayout from '../AppLayout/AppLayout'
import EnableBranchingModal from '../AppLayout/EnableBranchingButton/EnableBranchingModal'
import BuildingState from './BuildingState'
@@ -26,14 +29,11 @@ import PauseFailedState from './PauseFailedState'
import PausingState from './PausingState'
import ProductMenuBar from './ProductMenuBar'
import { ProjectContextProvider } from './ProjectContext'
import { ResizingState } from './ResizingState'
import RestartingState from './RestartingState'
import RestoreFailedState from './RestoreFailedState'
import RestoringState from './RestoringState'
import { UpgradingState } from './UpgradingState'
import { ResizingState } from './ResizingState'
import { AiAssistantPanel } from 'components/ui/AIAssistantPanel/AIAssistantPanel'
import { useAppStateSnapshot } from 'state/app-state'
import { useIsAssistantV2Enabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
// [Joshen] This is temporary while we unblock users from managing their project
// if their project is not responding well for any reason. Eventually needs a bit of an overhaul
@@ -72,143 +72,153 @@ export interface ProjectLayoutProps {
resizableSidebar?: boolean
}
const ProjectLayout = ({
title,
isLoading = false,
isBlocking = true,
product = '',
productMenu,
children,
hideHeader = false,
hideIconBar = false,
selectedTable,
resizableSidebar = false,
}: PropsWithChildren<ProjectLayoutProps>) => {
const router = useRouter()
const { ref: projectRef } = useParams()
const selectedOrganization = useSelectedOrganization()
const selectedProject = useSelectedProject()
const { aiAssistantPanel, setAiAssistantPanel } = useAppStateSnapshot()
const { open } = aiAssistantPanel
const ProjectLayout = forwardRef<HTMLDivElement, PropsWithChildren<ProjectLayoutProps>>(
(
{
title,
isLoading = false,
isBlocking = true,
product = '',
productMenu,
children,
hideHeader = false,
hideIconBar = false,
selectedTable,
resizableSidebar = false,
},
ref
) => {
const router = useRouter()
const { ref: projectRef } = useParams()
const selectedOrganization = useSelectedOrganization()
const selectedProject = useSelectedProject()
const { aiAssistantPanel, setAiAssistantPanel } = useAppStateSnapshot()
const { open } = aiAssistantPanel
const navLayoutV2 = useFlag('navigationLayoutV2')
const isAssistantV2Enabled = useIsAssistantV2Enabled()
const navLayoutV2 = useFlag('navigationLayoutV2')
const isAssistantV2Enabled = useIsAssistantV2Enabled()
const projectName = selectedProject?.name
const organizationName = selectedOrganization?.name
const projectName = selectedProject?.name
const organizationName = selectedOrganization?.name
const isPaused = selectedProject?.status === PROJECT_STATUS.INACTIVE
const showProductMenu = selectedProject
? selectedProject.status === PROJECT_STATUS.ACTIVE_HEALTHY ||
(selectedProject.status === PROJECT_STATUS.COMING_UP &&
router.pathname.includes('/project/[ref]/settings'))
: true
const isPaused = selectedProject?.status === PROJECT_STATUS.INACTIVE
const showProductMenu = selectedProject
? selectedProject.status === PROJECT_STATUS.ACTIVE_HEALTHY ||
(selectedProject.status === PROJECT_STATUS.COMING_UP &&
router.pathname.includes('/project/[ref]/settings'))
: true
const ignorePausedState =
router.pathname === '/project/[ref]' || router.pathname.includes('/project/[ref]/settings')
const showPausedState = isPaused && !ignorePausedState
const ignorePausedState =
router.pathname === '/project/[ref]' || router.pathname.includes('/project/[ref]/settings')
const showPausedState = isPaused && !ignorePausedState
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.metaKey && e.code === 'KeyI') setAiAssistantPanel({ open: !open })
}
if (isAssistantV2Enabled) window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAssistantV2Enabled, open])
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.metaKey && e.code === 'KeyI') setAiAssistantPanel({ open: !open })
}
if (isAssistantV2Enabled) window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAssistantV2Enabled, open])
return (
<AppLayout>
<ProjectContextProvider projectRef={projectRef}>
<Head>
<title>
{title
? `${title} | Supabase`
: selectedTable
? `${selectedTable} | ${projectName} | ${organizationName} | Supabase`
: projectName
? `${projectName} | ${organizationName} | Supabase`
: organizationName
? `${organizationName} | Supabase`
: 'Supabase'}
</title>
<meta name="description" content="Supabase Studio" />
</Head>
<div className="flex h-full">
{/* Left-most navigation side bar to access products */}
{!hideIconBar && <NavigationBar />}
{/* Product menu bar */}
<ResizablePanelGroup
className="flex h-full"
direction="horizontal"
autoSaveId="project-layout"
>
<ResizablePanel
id="panel-left"
className={cn(resizableSidebar ? 'min-w-64 max-w-[32rem]' : 'min-w-64 max-w-64', {
hidden: !showProductMenu || !productMenu,
})}
defaultSize={0} // forces panel to smallest width possible, at w-64
return (
<AppLayout>
<ProjectContextProvider projectRef={projectRef}>
<Head>
<title>
{title
? `${title} | Supabase`
: selectedTable
? `${selectedTable} | ${projectName} | ${organizationName} | Supabase`
: projectName
? `${projectName} | ${organizationName} | Supabase`
: organizationName
? `${organizationName} | Supabase`
: 'Supabase'}
</title>
<meta name="description" content="Supabase Studio" />
</Head>
<div className="flex h-full">
{/* Left-most navigation side bar to access products */}
{!hideIconBar && <NavigationBar />}
{/* Product menu bar */}
<ResizablePanelGroup
className="flex h-full"
direction="horizontal"
autoSaveId="project-layout"
>
<MenuBarWrapper
isLoading={isLoading}
isBlocking={isBlocking}
productMenu={productMenu}
<ResizablePanel
id="panel-left"
className={cn(resizableSidebar ? 'min-w-64 max-w-[32rem]' : 'min-w-64 max-w-64', {
hidden: !showProductMenu || !productMenu,
})}
defaultSize={0} // forces panel to smallest width possible, at w-64
>
<ProductMenuBar title={product}>{productMenu}</ProductMenuBar>
</MenuBarWrapper>
</ResizablePanel>
<ResizableHandle
className={cn({ hidden: !showProductMenu || !productMenu })}
withHandle
disabled={resizableSidebar ? false : true}
/>
<ResizablePanel id="panel-right" className="h-full flex flex-col">
{!navLayoutV2 && !hideHeader && IS_PLATFORM && <LayoutHeader />}
<ResizablePanelGroup
className="h-full w-full overflow-x-hidden flex-1"
direction="horizontal"
autoSaveId="project-layout-content"
>
<ResizablePanel id="panel-content" className=" w-full min-w-[600px]">
<main className="h-full flex flex-col flex-1 w-full overflow-x-hidden">
{showPausedState ? (
<div className="mx-auto my-16 w-full h-full max-w-7xl flex items-center">
<div className="w-full">
<ProjectPausedState product={product} />
</div>
</div>
) : (
<ContentWrapper isLoading={isLoading} isBlocking={isBlocking}>
<ResourceExhaustionWarningBanner />
{children}
</ContentWrapper>
)}
</main>
</ResizablePanel>
{aiAssistantPanel.open && (
<>
<ResizableHandle />
<ResizablePanel
id="panel-assistant"
className="min-w-[400px] max-w-[500px] bg 2xl:max-w-[600px] xl:relative xl:top-0 absolute right-0 top-[48px] bottom-0"
<MenuBarWrapper
isLoading={isLoading}
isBlocking={isBlocking}
productMenu={productMenu}
>
<ProductMenuBar title={product}>{productMenu}</ProductMenuBar>
</MenuBarWrapper>
</ResizablePanel>
<ResizableHandle
className={cn({ hidden: !showProductMenu || !productMenu })}
withHandle
disabled={resizableSidebar ? false : true}
/>
<ResizablePanel id="panel-right" className="h-full flex flex-col">
{!navLayoutV2 && !hideHeader && IS_PLATFORM && <LayoutHeader />}
<ResizablePanelGroup
className="h-full w-full overflow-x-hidden flex-1"
direction="horizontal"
autoSaveId="project-layout-content"
>
<ResizablePanel id="panel-content" className=" w-full min-w-[600px]">
<main
className="h-full flex flex-col flex-1 w-full overflow-y-auto overflow-x-hidden"
ref={ref}
>
<AiAssistantPanel />
</ResizablePanel>
</>
)}
</ResizablePanelGroup>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{showPausedState ? (
<div className="mx-auto my-16 w-full h-full max-w-7xl flex items-center">
<div className="w-full">
<ProjectPausedState product={product} />
</div>
</div>
) : (
<ContentWrapper isLoading={isLoading} isBlocking={isBlocking}>
<ResourceExhaustionWarningBanner />
{children}
</ContentWrapper>
)}
</main>
</ResizablePanel>
{aiAssistantPanel.open && (
<>
<ResizableHandle />
<ResizablePanel
id="panel-assistant"
className="min-w-[400px] max-w-[500px] bg 2xl:max-w-[600px] xl:relative xl:top-0 absolute right-0 top-[48px] bottom-0"
>
<AiAssistantPanel />
</ResizablePanel>
</>
)}
</ResizablePanelGroup>
</ResizablePanel>
</ResizablePanelGroup>
</div>
<EnableBranchingModal />
<AISettingsModal />
<ProjectAPIDocs />
</ProjectContextProvider>
</AppLayout>
)
}
<EnableBranchingModal />
<AISettingsModal />
<ProjectAPIDocs />
</ProjectContextProvider>
</AppLayout>
)
}
)
ProjectLayout.displayName = 'ProjectLayout'
export const ProjectLayoutWithAuth = withAuth(ProjectLayout)
+9 -2
View File
@@ -9,11 +9,18 @@ export interface AlertErrorProps {
subject?: string
error?: ResponseError | null
className?: string
showIcon?: boolean
}
// [Joshen] To standardize the language for all error UIs
const AlertError = ({ projectRef, subject, error, className }: AlertErrorProps) => {
const AlertError = ({
projectRef,
subject,
error,
className,
showIcon = true,
}: AlertErrorProps) => {
const subjectString = subject?.replace(/ /g, '%20')
let href = `/support/new?category=dashboard_bug`
@@ -27,7 +34,7 @@ const AlertError = ({ projectRef, subject, error, className }: AlertErrorProps)
return (
<Alert_Shadcn_ className={className} variant="warning" title={subject}>
<WarningIcon className="h-4 w-4" strokeWidth={2} />
{showIcon && <WarningIcon className="h-4 w-4" strokeWidth={2} />}
<AlertTitle_Shadcn_>{subject}</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_ className="flex flex-col gap-3 break-words">
<div>
@@ -1,14 +1,10 @@
import MotionNumber from 'motion-number'
import MotionNumber from '@number-flow/react'
interface CountdownTimerSpanProps {
seconds: number
}
const CountdownTimerSpan = ({ seconds }: CountdownTimerSpanProps) => {
const transitionConfig = {
y: { type: 'spring', duration: 0.35, bounce: 0 },
}
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const remainingSeconds = seconds % 60
@@ -20,23 +16,11 @@ const CountdownTimerSpan = ({ seconds }: CountdownTimerSpanProps) => {
<span className="inline-flex gap-2">
<span className="text-foreground-lighter text-sm p-0">Time remaining: </span>
<span className="text-foreground text-sm font-mono flex items-center gap-0">
<MotionNumber
format={formatConfig}
value={formatValue(hours)}
transition={transitionConfig}
/>
<MotionNumber format={formatConfig} value={Number(formatValue(hours))} />
hr
<MotionNumber
format={formatConfig}
value={formatValue(minutes)}
transition={transitionConfig}
/>
<MotionNumber format={formatConfig} value={Number(formatValue(minutes))} />
m
<MotionNumber
format={formatConfig}
value={formatValue(remainingSeconds)}
transition={transitionConfig}
/>
<MotionNumber format={formatConfig} value={Number(formatValue(remainingSeconds))} />
</span>
</span>
)
@@ -43,7 +43,7 @@ const ProductMenu = ({ page, menu }: ProductMenuProps) => {
</div>
</div>
</div>
{idx !== menu.length - 1 && <div className="h-px w-full bg-border-overlay"></div>}
{idx !== menu.length - 1 && <div className="h-px w-full bg-border-overlay" />}
</div>
))}
</Menu>
@@ -37,7 +37,11 @@ const ProductMenuItem = ({
className={'flex items-center gap-2 truncate w-full ' + textClassName}
>
<span className="truncate">{name} </span>
{label !== undefined && <Badge variant="warning">{label}</Badge>}
{label !== undefined && (
<Badge variant="warning" className="py-0 px-1.5">
{label}
</Badge>
)}
</div>
{rightIcon && <div>{rightIcon}</div>}
</div>
+6
View File
@@ -533,6 +533,12 @@ const nextConfig = {
}
})
// .md files to be loaded as raw text
config.module.rules.push({
test: /\.md$/,
type: 'asset/source',
})
return config
},
onDemandEntries: {
+2 -2
View File
@@ -61,7 +61,7 @@
"dayjs": "^1.11.10",
"dnd-core": "^16.0.1",
"file-saver": "^2.0.5",
"framer-motion": "^11.0.3",
"framer-motion": "^11.11.17",
"generate-password-browser": "^1.1.0",
"html-to-image": "^1.10.8",
"icons": "*",
@@ -77,7 +77,7 @@
"mobx": "^6.10.2",
"mobx-react-lite": "^4.0.5",
"monaco-editor": "0.33.0",
"motion-number": "^0.1.7",
"@number-flow/react": "^0.3.2",
"next": "^14.2.10",
"next-themes": "^0.3.0",
"nuqs": "^1.17.8",
@@ -3,8 +3,10 @@ import { useCallback, useMemo } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
import { ENV_VAR_RAW_KEYS } from 'components/interfaces/Integrations/Integrations-Vercel.constants'
import ProjectLinker, { ForeignProject } from 'components/interfaces/Integrations/ProjectLinker'
import { ENV_VAR_RAW_KEYS } from 'components/interfaces/Integrations/Vercel/Integrations-Vercel.constants'
import ProjectLinker, {
ForeignProject,
} from 'components/interfaces/Integrations/VercelGithub/ProjectLinker'
import { Markdown } from 'components/interfaces/Markdown'
import VercelIntegrationWindowLayout from 'components/layouts/IntegrationsLayout/VercelIntegrationWindowLayout'
import { ScaffoldColumn, ScaffoldContainer } from 'components/layouts/Scaffold'
@@ -5,7 +5,7 @@ import { useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button } from 'ui'
import OrganizationPicker from 'components/interfaces/Integrations/OrganizationPicker'
import OrganizationPicker from 'components/interfaces/Integrations/Vercel/OrganizationPicker'
import { Markdown } from 'components/interfaces/Markdown'
import { getHasInstalledObject } from 'components/layouts/IntegrationsLayout/Integrations.utils'
import VercelIntegrationWindowLayout from 'components/layouts/IntegrationsLayout/VercelIntegrationWindowLayout'
@@ -0,0 +1,63 @@
import { useParams } from 'common'
import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integrations.constants'
import { useInstalledIntegrations } from 'components/interfaces/Integrations/Landing/useInstalledIntegrations'
import IntegrationsLayout from 'components/layouts/Integrations/layout'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { useRouter } from 'next/compat/router'
import { useEffect, useMemo } 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]
)
useEffect(() => {
// if the integration is not installed, redirect to the overview page
if (
router &&
router?.isReady &&
!isIntegrationsLoading &&
!installation &&
pageId !== 'overview'
) {
router.replace(`/project/${ref}/integrations/${id}/overview`)
}
}, [installation, isIntegrationsLoading, pageId, router])
if (!router?.isReady || isIntegrationsLoading) {
return (
<div className="px-10 py-6">
<GenericSkeletonLoader />
</div>
)
}
if (!id || !integration) {
return <div>Integration not found</div>
}
if (!Component) return <div>Component not found</div>
return <Component />
}
IntegrationPage.getLayout = (page) => <IntegrationsLayout>{page}</IntegrationsLayout>
export default IntegrationPage
@@ -0,0 +1,63 @@
import { useParams } from 'common'
import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integrations.constants'
import { useInstalledIntegrations } from 'components/interfaces/Integrations/Landing/useInstalledIntegrations'
import IntegrationsLayout from 'components/layouts/Integrations/layout'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { useRouter } from 'next/compat/router'
import { useEffect, useMemo } 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]
)
useEffect(() => {
// if the integration is not installed, redirect to the overview page
if (
router &&
router?.isReady &&
!isIntegrationsLoading &&
!installation &&
pageId !== 'overview'
) {
router.replace(`/project/${ref}/integrations/${id}/overview`)
}
}, [installation, isIntegrationsLoading, pageId, router])
if (!router?.isReady || isIntegrationsLoading) {
return (
<div className="px-10 py-6">
<GenericSkeletonLoader />
</div>
)
}
if (!id || !integration) {
return <div>Integration not found</div>
}
if (!Component) return <div>Component not found</div>
return <Component />
}
IntegrationPage.getLayout = (page) => <IntegrationsLayout>{page}</IntegrationsLayout>
export default IntegrationPage
@@ -0,0 +1,63 @@
import { useParams } from 'common'
import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integrations.constants'
import { useInstalledIntegrations } from 'components/interfaces/Integrations/Landing/useInstalledIntegrations'
import IntegrationsLayout from 'components/layouts/Integrations/layout'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { useRouter } from 'next/compat/router'
import { useEffect, useMemo } 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]
)
useEffect(() => {
// if the integration is not installed, redirect to the overview page
if (
router &&
router?.isReady &&
!isIntegrationsLoading &&
!installation &&
pageId !== 'overview'
) {
router.replace(`/project/${ref}/integrations/${id}/overview`)
}
}, [installation, isIntegrationsLoading, pageId, router])
if (!router?.isReady || isIntegrationsLoading) {
return (
<div className="px-10 py-6">
<GenericSkeletonLoader />
</div>
)
}
if (!id || !integration) {
return <div>Integration not found</div>
}
if (!Component) return <div>Component not found</div>
return <Component />
}
IntegrationPage.getLayout = (page) => <IntegrationsLayout>{page}</IntegrationsLayout>
export default IntegrationPage
@@ -1,107 +0,0 @@
import { CronJobsListing } from 'components/interfaces/Integrations/CronJobs/CronJobsListing'
import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
import { DocsButton } from 'components/ui/DocsButton'
import { FormHeader } from 'components/ui/Forms/FormHeader'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { CronJob, useCronJobsQuery } from 'data/database-cron-jobs/database-cron-jobs-query'
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
import { useEffect, useState } from 'react'
import type { NextPageWithLayout } from 'types'
import { Button } from 'ui'
import { CronJobsDisabledState } from '../../../../components/interfaces/Integrations/CronJobs/CronJobsDisabledState'
import { CreateCronJobSheet } from 'components/interfaces/Integrations/CronJobs/CreateCronJobSheet'
import { Sheet, SheetContent } from 'ui'
const CronJobsPage: NextPageWithLayout = () => {
const { project } = useProjectContext()
const [isClosingCreateCronJobSheet, setIsClosingCreateCronJobSheet] = useState(false)
const [createCronJobSheetShown, setCreateCronJobSheetShown] = useState<
Pick<CronJob, 'jobname' | 'schedule' | 'active' | 'command'> | undefined
>()
const { isError, refetch } = useCronJobsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const { data: extensions, isLoading: isLoadingExtensions } = useDatabaseExtensionsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const pgCronExtension = (extensions ?? []).find((ext) => ext.name === 'pg_cron')
const pgCronExtensionInstalled = pgCronExtension?.installed_version
useEffect(() => {
// refetch the cron jobs after the pgcron extension has been installed
if (pgCronExtensionInstalled && isError) {
refetch()
}
}, [isError, pgCronExtensionInstalled, refetch])
return (
<ScaffoldContainer className="h-full">
<ScaffoldSection className="h-full">
<div className="col-span-12 h-full pb-8">
{pgCronExtensionInstalled && (
<FormHeader
title="Cron jobs"
description="Schedule and automate tasks like running queries or maintenance routines at specified intervals"
actions={
<>
<DocsButton href="https://supabase.com/docs/guides/database/extensions/pg_cron" />
<Button
type="primary"
onClick={() =>
setCreateCronJobSheetShown({
jobname: '',
schedule: '',
command: '',
active: true,
})
}
>
Create a new cron job
</Button>
</>
}
/>
)}
{isLoadingExtensions ? (
<GenericSkeletonLoader />
) : !pgCronExtensionInstalled ? (
<CronJobsDisabledState />
) : (
<CronJobsListing />
)}
</div>
</ScaffoldSection>
<Sheet
open={!!createCronJobSheetShown}
onOpenChange={() => setIsClosingCreateCronJobSheet(true)}
>
<SheetContent size="default" tabIndex={undefined}>
<CreateCronJobSheet
selectedCronJob={createCronJobSheetShown}
onClose={() => {
setIsClosingCreateCronJobSheet(false)
setCreateCronJobSheetShown(undefined)
}}
isClosing={isClosingCreateCronJobSheet}
setIsClosing={setIsClosingCreateCronJobSheet}
/>
</SheetContent>
</Sheet>
</ScaffoldContainer>
)
}
CronJobsPage.getLayout = (page) => (
<ProjectIntegrationsLayout title="Cron Jobs">{page}</ProjectIntegrationsLayout>
)
export default CronJobsPage
@@ -1,131 +0,0 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useState } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
import DeleteHookModal from 'components/interfaces/Database/Hooks/DeleteHookModal'
import EditHookPanel from 'components/interfaces/Database/Hooks/EditHookPanel'
import HooksList from 'components/interfaces/Database/Hooks/HooksList/HooksList'
import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState'
import { FormHeader } from 'components/ui/Forms/FormHeader'
import NoPermission from 'components/ui/NoPermission'
import { useHooksEnableMutation } from 'data/database/hooks-enable-mutation'
import { useSchemasQuery } from 'data/database/schemas-query'
import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions'
import type { NextPageWithLayout } from 'types'
const HooksPage: NextPageWithLayout = () => {
const { project } = useProjectContext()
const { ref: projectRef } = useParams()
const {
data: schemas,
isSuccess: isSchemasLoaded,
refetch,
} = useSchemasQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const [selectedHook, setSelectedHook] = useState<any>()
const [showCreateHookForm, setShowCreateHookForm] = useState<boolean>(false)
const [showDeleteHookForm, setShowDeleteHookForm] = useState<boolean>(false)
const isHooksEnabled = schemas?.some((schema) => schema.name === 'supabase_functions')
const canReadWebhooks = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'triggers')
const canCreateWebhooks = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'triggers')
const isPermissionsLoaded = usePermissionsLoaded()
const { mutate: enableHooks, isLoading: isEnablingHooks } = useHooksEnableMutation({
onSuccess: () => {
refetch()
toast.success('Successfully enabled webhooks')
},
})
const enableHooksForProject = async () => {
if (!projectRef) return console.error('Project ref is required')
enableHooks({ ref: projectRef })
}
const createHook = () => {
setSelectedHook(undefined)
setShowCreateHookForm(true)
}
const editHook = (hook: any) => {
setSelectedHook(hook)
setShowCreateHookForm(true)
}
const deleteHook = (hook: any) => {
setSelectedHook(hook)
setShowDeleteHookForm(true)
}
if (isPermissionsLoaded && !canReadWebhooks) {
return <NoPermission isFullPage resourceText="view database webhooks" />
}
if (isSchemasLoaded && !isHooksEnabled) {
return (
<div className="w-full h-full flex items-center justify-center">
<ProductEmptyState
size="large"
title="Database Webhooks"
ctaButtonLabel="Enable webhooks"
onClickCta={() => enableHooksForProject()}
loading={isEnablingHooks}
disabled={isEnablingHooks || (isPermissionsLoaded && !canReadWebhooks)}
disabledMessage={
isPermissionsLoaded && !canCreateWebhooks
? 'You need additional permissions to enable webhooks'
: undefined
}
>
<p className="text-sm text-foreground-light">
Database Webhooks can be used to trigger serverless functions or send requests to an
HTTP endpoint.
</p>
<p className="text-sm text-foreground-light">Enable database webhooks on your project.</p>
</ProductEmptyState>
</div>
)
}
return (
<>
<ScaffoldContainer className="h-full">
<ScaffoldSection className="h-full">
<div className="col-span-12">
<FormHeader
title="Database Webhooks"
description="Send real-time data from your database to another system whenever a table event occurs"
/>
<HooksList createHook={createHook} editHook={editHook} deleteHook={deleteHook} />
</div>
</ScaffoldSection>
</ScaffoldContainer>
<EditHookPanel
visible={showCreateHookForm}
selectedHook={selectedHook}
onClose={() => setShowCreateHookForm(false)}
/>
<DeleteHookModal
visible={showDeleteHookForm}
selectedHook={selectedHook}
onClose={() => setShowDeleteHookForm(false)}
/>
</>
)
}
HooksPage.getLayout = (page) => (
<ProjectIntegrationsLayout title="Webhooks">{page}</ProjectIntegrationsLayout>
)
export default HooksPage
@@ -0,0 +1,17 @@
import { AvailableIntegrations } from 'components/interfaces/Integrations/Landing/AvailableIntegrations'
import { InstalledIntegrations } from 'components/interfaces/Integrations/Landing/InstalledIntegrations'
import IntegrationsLayout from 'components/layouts/Integrations/layout'
import type { NextPageWithLayout } from 'types'
const IntegrationsPage: NextPageWithLayout = () => {
return (
<div>
<InstalledIntegrations />
<AvailableIntegrations />
</div>
)
}
IntegrationsPage.getLayout = (page) => <IntegrationsLayout>{page}</IntegrationsLayout>
export default IntegrationsPage
@@ -1,30 +0,0 @@
import { QueuesListing } from 'components/interfaces/Integrations/Queues/QueuesListing'
import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout'
import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
import type { NextPageWithLayout } from 'types'
const QueuesPage: NextPageWithLayout = () => {
// TODO: Change this to the correct permissions
// const canReadFunctions = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'functions')
// const isPermissionsLoaded = usePermissionsLoaded()
// if (isPermissionsLoaded && !canReadFunctions) {
// return <NoPermission isFullPage resourceText="manage database queues" />
// }
return (
<ScaffoldContainer className="h-full">
<ScaffoldSection className="h-full">
<div className="col-span-12 h-full pb-8">
<QueuesListing />
</div>
</ScaffoldSection>
</ScaffoldContainer>
)
}
QueuesPage.getLayout = (page) => (
<ProjectIntegrationsLayout title="Queues">{page}</ProjectIntegrationsLayout>
)
export default QueuesPage
@@ -1,61 +0,0 @@
import { useParams } from 'common'
import { EncryptionKeysManagement, VaultToggle } from 'components/interfaces/Settings/Vault'
import VaultNavTabs from 'components/interfaces/Settings/Vault/VaultNavTabs'
import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout'
import {
ScaffoldContainer,
ScaffoldDescription,
ScaffoldHeader,
ScaffoldTitle,
} from 'components/layouts/Scaffold'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
import type { NextPageWithLayout } from 'types'
const VaultSettingsSecrets: NextPageWithLayout = () => {
const { ref } = useParams()
const { project } = useProjectContext()
const { data, isLoading } = useDatabaseExtensionsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const vaultExtension = (data ?? []).find((ext) => ext.name === 'supabase_vault')
const isEnabled = vaultExtension !== undefined && vaultExtension.installed_version !== null
return (
<>
<ScaffoldContainer>
<ScaffoldHeader>
<ScaffoldTitle>Vault</ScaffoldTitle>
<ScaffoldDescription>Application level encryption for your project</ScaffoldDescription>
</ScaffoldHeader>
</ScaffoldContainer>
<ScaffoldContainer bottomPadding>
{isLoading ? (
<div className="border rounded border-default p-12 space-y-2">
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
<ShimmeringLoader className="w-1/2" />
</div>
) : !isEnabled ? (
<VaultToggle />
) : (
<>
<VaultNavTabs projRef={ref || ''} activeTab={'keys'} />
<EncryptionKeysManagement />
</>
)}
</ScaffoldContainer>
</>
)
}
VaultSettingsSecrets.getLayout = (page) => (
<ProjectIntegrationsLayout title="Vault">{page}</ProjectIntegrationsLayout>
)
export default VaultSettingsSecrets
@@ -1,62 +0,0 @@
import { useParams } from 'common/hooks'
import { SecretsManagement, VaultToggle } from 'components/interfaces/Settings/Vault'
import VaultNavTabs from 'components/interfaces/Settings/Vault/VaultNavTabs'
import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import {
ScaffoldContainer,
ScaffoldDescription,
ScaffoldHeader,
ScaffoldTitle,
} from 'components/layouts/Scaffold'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
import { useRouter } from 'next/router'
import type { NextPageWithLayout } from 'types'
const VaultSettingsSecrets: NextPageWithLayout = () => {
const router = useRouter()
const { ref } = useParams()
const { project } = useProjectContext()
const { data, isLoading } = useDatabaseExtensionsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const vaultExtension = (data ?? []).find((ext) => ext.name === 'supabase_vault')
const isEnabled = vaultExtension !== undefined && vaultExtension.installed_version !== null
return (
<>
<ScaffoldContainer>
<ScaffoldHeader>
<ScaffoldTitle>Vault</ScaffoldTitle>
<ScaffoldDescription>Application level encryption for your project</ScaffoldDescription>
</ScaffoldHeader>
</ScaffoldContainer>
<ScaffoldContainer bottomPadding>
{isLoading ? (
<div className="border rounded border-default p-12 space-y-2">
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
<ShimmeringLoader className="w-1/2" />
</div>
) : !isEnabled ? (
<VaultToggle />
) : (
<>
<VaultNavTabs projRef={ref || ''} activeTab="secrets" />
<SecretsManagement />
</>
)}
</ScaffoldContainer>
</>
)
}
VaultSettingsSecrets.getLayout = (page) => (
<ProjectIntegrationsLayout title="Vault">{page}</ProjectIntegrationsLayout>
)
export default VaultSettingsSecrets
@@ -1,24 +0,0 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { EditWrapper } from 'components/interfaces/Database'
import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout'
import NoPermission from 'components/ui/NoPermission'
import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions'
import type { NextPageWithLayout } from 'types'
const DatabaseWrappersNew: NextPageWithLayout = () => {
const canReadWrappers = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'wrappers')
const isPermissionsLoaded = usePermissionsLoaded()
if (isPermissionsLoaded && !canReadWrappers) {
return <NoPermission isFullPage resourceText="view foreign data wrappers" />
}
return <EditWrapper />
}
DatabaseWrappersNew.getLayout = (page) => (
<ProjectIntegrationsLayout title="Wrappers">{page}</ProjectIntegrationsLayout>
)
export default DatabaseWrappersNew
@@ -1,80 +0,0 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { Wrappers } from 'components/interfaces/Database'
import WrappersDropdown from 'components/interfaces/Database/Wrappers/WrappersDropdown'
import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import {
ScaffoldContainer,
ScaffoldSection,
ScaffoldSectionContent,
ScaffoldSectionDetail,
} from 'components/layouts/Scaffold'
import { DocsButton } from 'components/ui/DocsButton'
import { FormHeader } from 'components/ui/Forms/FormHeader'
import NoPermission from 'components/ui/NoPermission'
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
import { useFDWsQuery } from 'data/fdw/fdws-query'
import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions'
import type { NextPageWithLayout } from 'types'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
const DatabaseWrappers: NextPageWithLayout = () => {
const { project } = useProjectContext()
const isPermissionsLoaded = usePermissionsLoaded()
const canReadWrappers = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'tables')
const { isLoading: isLoadingFDWs } = useFDWsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const { data: exts, isLoading: isLoadingExtensions } = useDatabaseExtensionsQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const wrappersExtension = (exts ?? []).find((ext) => ext.name === 'wrappers')
const vaultExtension = (exts ?? []).find((ext) => ext.name === 'supabase_vault')
const isWrappersEnabled =
wrappersExtension !== undefined &&
wrappersExtension.installed_version !== null &&
vaultExtension !== undefined &&
vaultExtension.installed_version !== null
return (
<ScaffoldContainer>
<ScaffoldSection>
<ScaffoldSectionContent className="xl:!col-span-8">
<FormHeader
className="!mb-0"
title="Foreign Data Wrappers"
description="Query your data warehouse directly from your database, or third-party APIs using SQL"
/>
</ScaffoldSectionContent>
<ScaffoldSectionDetail className="xl:!col-span-4 flex items-center justify-end gap-x-2">
<DocsButton
className="no-underline"
href="https://supabase.com/docs/guides/database/extensions/wrappers/overview"
/>
{isWrappersEnabled && <WrappersDropdown />}
</ScaffoldSectionDetail>
<div className="col-span-12 mt-3">
{isLoadingExtensions || isLoadingFDWs ? (
<GenericSkeletonLoader />
) : isPermissionsLoaded && !canReadWrappers ? (
<NoPermission isFullPage resourceText="view foreign data wrappers" />
) : (
<Wrappers isEnabled={isWrappersEnabled} />
)}
</div>
</ScaffoldSection>
</ScaffoldContainer>
)
}
DatabaseWrappers.getLayout = (page) => (
<ProjectIntegrationsLayout title="Wrappers">{page}</ProjectIntegrationsLayout>
)
export default DatabaseWrappers
@@ -1,24 +0,0 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { CreateWrapper } from 'components/interfaces/Database'
import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout'
import NoPermission from 'components/ui/NoPermission'
import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions'
import type { NextPageWithLayout } from 'types'
const DatabaseWrappersNew: NextPageWithLayout = () => {
const canCreateWrappers = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables')
const isPermissionsLoaded = usePermissionsLoaded()
if (isPermissionsLoaded && !canCreateWrappers) {
return <NoPermission isFullPage resourceText="create foreign data wrappers" />
}
return <CreateWrapper />
}
DatabaseWrappersNew.getLayout = (page) => (
<ProjectIntegrationsLayout title="Wrappers">{page}</ProjectIntegrationsLayout>
)
export default DatabaseWrappersNew
@@ -0,0 +1,3 @@
Airtable is an easy-to-use online platform for creating and sharing relational databases.
The Airtable Wrapper allows you to read data from your Airtable bases/tables within your Postgres database.
@@ -0,0 +1,3 @@
[Auth0](https://auth0.com/) is a flexible, drop-in solution to add authentication and authorization services to your applications
The Auth0 Wrapper allows you to read data from your Auth0 tenant for use within your Postgres database.
@@ -0,0 +1,3 @@
BigQuery is a completely serverless and cost-effective enterprise data warehouse that works across clouds and scales with your data, with BI, machine learning and AI built in.
The BigQuery Wrapper allows you to read and write data from BigQuery within your Postgres database.
@@ -0,0 +1,3 @@
ClickHouse is a fast open-source column-oriented database management system that allows generating analytical data reports in real-time using SQL queries.
The ClickHouse Wrapper allows you to read and write data from ClickHouse within your Postgres database.
@@ -0,0 +1,3 @@
[AWS Cognito](https://docs.aws.amazon.com/cognito/latest/developerguide/what-is-amazon-cognito.html) is an identity platform for web and mobile apps.
The Cognito wrapper allows you to read data from your Cognito Userpool within your Postgres database.
@@ -0,0 +1 @@
Cron jobs uses the `pg_cron` Postgres extension, which is a simple cron-based job scheduler for Postgres that runs inside the database. It uses the same syntax as a regular cron, but it allows you to schedule Postgres SQL commands directly from the database.
@@ -0,0 +1,3 @@
Firebase is an app development platform built around non-relational technologies. The Firebase Wrapper supports connecting to below objects.
The Firebase Wrapper is aforeign data wrapper which allows you to read data from Firebase within your Postgres database.
@@ -0,0 +1,3 @@
GraphiQL is the GraphQL integrated development environment (IDE). It’s a powerful (and all-around awesome) tool you’ll use often while building Gatsby websites. It offers syntax highlighting, intellisense autocompletion, automatic documentation, and much more.
Use GraphiQL to interactively build your page and static queries.
@@ -0,0 +1,3 @@
[Logflare](https://logflare.app/) is a centralized web-based log management solution to easily access Cloudflare, Vercel & Elixir logs.
The Logflare Wrapper allows you to read data from Logflare endpoints within your Postgres database.
@@ -0,0 +1,3 @@
[Microsoft SQL Server](https://www.microsoft.com/en-au/sql-server/) is a proprietary relational database management system developed by Microsoft.
The SQL Server Wrapper allows you to read data from Microsoft SQL Server within your Postgres database.
@@ -0,0 +1,3 @@
[Paddle](https://www.paddle.com/) is a merchant of record that acts to provide a payment infrastructure to thousands of software companies around the world.
The Paddle Wrapper is a WebAssembly(Wasm) foreign data wrapper which allows you to read and write data from Paddle within your Postgres database.
@@ -0,0 +1 @@
Queues uses the `pgmq` Postgres extension, which is a lightweight message queue like [AWS SQS](https://aws.amazon.com/sqs/) and [RSMQ](https://github.com/smrchy/rsmq) but on Postgres.
@@ -0,0 +1,3 @@
[Redis](https://redis.io/) is an open-source in-memory storage, used as a distributed, in-memory key–value database, cache and message broker, with optional durability.
The Redis Wrapper allows you to read data from Redis within your Postgres database.
@@ -0,0 +1,15 @@
AWS S3 is an object storage service offering industry-leading scalability, data availability, security, and performance. It is read-only and supports below file formats:
- CSV (with or without header line)
- [JSON Lines](https://jsonlines.org/)
- [Parquet](https://parquet.apache.org/)
The S3 Wrapper also supports below compression algorithms:
- gzip
- bzip2
- xz
- zlib
Note for CSV and JSONL files: currently all columns in S3 files must be defined in the foreign table and their types must be `text` type.
Note for Parquet files: the whole Parquet file will be loaded into local memory if it is compressed, so keep the file size as small as possible.
@@ -0,0 +1,3 @@
[Snowflake](https://www.snowflake.com/) is a cloud-based data platform provided as a DaaS (Data-as-a-Service) solution with data storage and analytics service.
The Snowflake Wrapper is a WebAssembly(Wasm) foreign data wrapper which allows you to read and write data from Snowflake within your Postgres database.
@@ -0,0 +1,3 @@
[Stripe](https://stripe.com/) is an API driven payment processing and subscription management platform.
The Stripe Wrapper is a foreign data wrapper which allows you to read and write data from Stripe within your Postgres database.
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff. Show more