mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 01:15:03 +03:00
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:
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>
|
||||
|
||||
+1
-9
@@ -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)}
|
||||
/>
|
||||
)} */}
|
||||
</>
|
||||
)
|
||||
}
|
||||
+4
-46
@@ -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
|
||||
+224
@@ -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),
|
||||
}))
|
||||
+219
@@ -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>
|
||||
)
|
||||
}
|
||||
+138
@@ -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>
|
||||
|
||||
+1
-2
@@ -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 } })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
+1
-1
@@ -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),
|
||||
|
||||
File renamed without changes.
File renamed without changes.
+1
-1
@@ -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'
|
||||
File renamed without changes.
File renamed without changes.
@@ -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
-2
@@ -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
-1
@@ -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'
|
||||
|
||||
+4
-2
@@ -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>{' '}
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
-1
@@ -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)) {
|
||||
|
||||
+1
-1
@@ -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,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>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
Reference in new issue
Block a user