diff --git a/.github/workflows/dashboard-pr-reminder.yml b/.github/workflows/dashboard-pr-reminder.yml new file mode 100644 index 0000000000000..876b0d6c4abde --- /dev/null +++ b/.github/workflows/dashboard-pr-reminder.yml @@ -0,0 +1,46 @@ +name: Dashboard PR Reminder + +on: + schedule: + # Run at 10am Singapore Time (2am UTC) + - cron: '0 2 * * *' + # Run at 10am US Eastern Time (2pm UTC = 10am EDT / 9am EST) + - cron: '0 14 * * *' + workflow_dispatch: # Allow manual trigger for testing + +permissions: + pull-requests: read + contents: read + +jobs: + check-dashboard-prs: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + + - name: Find Dashboard PRs older than 24 hours + id: find-prs + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + with: + script: | + const findStalePRs = require('./scripts/actions/find-stale-dashboard-prs.js'); + return await findStalePRs({ github, context, core }); + + - name: Send Slack notification + if: fromJSON(steps.find-prs.outputs.count) > 0 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_DASHBOARD_WEBHOOK_URL }} + STALE_PRS_JSON: ${{ steps.find-prs.outputs.stale_prs }} + with: + script: | + const sendSlackNotification = require('./scripts/actions/send-slack-pr-notification.js'); + const stalePRs = JSON.parse(process.env.STALE_PRS_JSON); + const webhookUrl = process.env.SLACK_WEBHOOK_URL; + await sendSlackNotification(stalePRs, webhookUrl); + + - name: No stale PRs found + if: fromJSON(steps.find-prs.outputs.count) == 0 + run: | + echo "✓ No Dashboard PRs older than 24 hours found" diff --git a/apps/docs/app/guides/database/extensions/wrappers/[[...slug]]/page.tsx b/apps/docs/app/guides/database/extensions/wrappers/[[...slug]]/page.tsx index 239763225b54e..dae36f2de1e24 100644 --- a/apps/docs/app/guides/database/extensions/wrappers/[[...slug]]/page.tsx +++ b/apps/docs/app/guides/database/extensions/wrappers/[[...slug]]/page.tsx @@ -225,6 +225,14 @@ const pageMap = [ }, remoteFile: 's3.md', }, + { + slug: 's3_vectors', + meta: { + title: 'AWS S3 Vectors', + dashboardIntegrationPath: 's3_vectors_wrapper', + }, + remoteFile: 's3vectors.md', + }, { slug: 'snowflake', meta: { diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index 6c969cff86a15..b00b3698b5dd5 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -1293,6 +1293,10 @@ export const database: NavMenuConstant = { name: 'Connecting to AWS S3', url: '/guides/database/extensions/wrappers/s3' as `/${string}`, }, + { + name: 'Connecting to AWS S3 Vectors', + url: '/guides/database/extensions/wrappers/s3_vectors' as `/${string}`, + }, { name: 'Connecting to BigQuery', url: '/guides/database/extensions/wrappers/bigquery' as `/${string}`, diff --git a/apps/docs/content/guides/database/drizzle.mdx b/apps/docs/content/guides/database/drizzle.mdx index 9c1914df45b66..88a02df030ecf 100644 --- a/apps/docs/content/guides/database/drizzle.mdx +++ b/apps/docs/content/guides/database/drizzle.mdx @@ -68,6 +68,8 @@ If you plan on solely using Drizzle instead of the Supabase Data API (PostgREST) From the project [**Connect** panel](/dashboard/project/_?showConnect=true), copy the URI from the "Shared Pooler" option and save it as the `DATABASE_URL` environment variable. Remember to replace the password placeholder with your actual database password. + In local SUPABASE_DB_URL require to be adapted to work with Docker resolver + @@ -78,7 +80,12 @@ If you plan on solely using Drizzle instead of the Supabase Data API (PostgREST) import { drizzle } from 'drizzle-orm/postgres-js' import postgres from 'postgres' - const connectionString = process.env.DATABASE_URL + let connectionString = process.env.DATABASE_URL + if (host.includes('postgres:postgres@supabase_db_')) { + const url = URL.parse(host)! + url.hostname = url.hostname.split('_')[1] + connectionString = url.href + } // Disable prefetch as it is not supported for "Transaction" pool mode export const client = postgres(connectionString, { prepare: false }) diff --git a/apps/studio/.github/eslint-rule-baselines.json b/apps/studio/.github/eslint-rule-baselines.json index b5b413c54cf17..6fd45ff88d1b3 100644 --- a/apps/studio/.github/eslint-rule-baselines.json +++ b/apps/studio/.github/eslint-rule-baselines.json @@ -1,8 +1,270 @@ { "rules": { - "react-hooks/exhaustive-deps": 238, + "react-hooks/exhaustive-deps": 237, "import/no-anonymous-default-export": 62, "@tanstack/query/exhaustive-deps": 19, "@tanstack/query/no-deprecated-options": 2 + }, + "ruleFiles": { + "react-hooks/exhaustive-deps": { + "components/grid/SupabaseGrid.tsx": 1, + "components/grid/SupabaseGrid.utils.ts": 1, + "components/grid/components/common/BlockKeys.tsx": 2, + "components/grid/components/editor/JsonEditor.tsx": 2, + "components/grid/components/editor/TextEditor.tsx": 2, + "components/grid/components/grid/ColumnHeader.tsx": 1, + "components/grid/hooks/useTableSort.ts": 1, + "components/interfaces/Account/TOTPFactors/AddNewFactorModal.tsx": 1, + "components/interfaces/Advisors/CreateRuleSheet.tsx": 1, + "components/interfaces/App/CommandMenu/ApiKeys.tsx": 1, + "components/interfaces/App/RouteValidationWrapper.tsx": 2, + "components/interfaces/Auth/AdvancedAuthSettingsForm.tsx": 1, + "components/interfaces/Auth/AuditLogsForm.tsx": 1, + "components/interfaces/Auth/AuthProvidersForm/FormField.tsx": 2, + "components/interfaces/Auth/BasicAuthSettingsForm.tsx": 1, + "components/interfaces/Auth/Hooks/CreateHookSheet.tsx": 1, + "components/interfaces/Auth/MfaAuthSettingsForm/MfaAuthSettingsForm.tsx": 1, + "components/interfaces/Auth/OAuthApps/CreateOAuthAppSheet.tsx": 1, + "components/interfaces/Auth/OAuthApps/OAuthServerSettingsForm.tsx": 1, + "components/interfaces/Auth/Policies/PolicyEditor/PolicyDefinition.tsx": 1, + "components/interfaces/Auth/Policies/PolicyEditorPanel/PolicyDetailsV2.tsx": 1, + "components/interfaces/Auth/Policies/PolicyEditorPanel/RLSCodeEditor.tsx": 2, + "components/interfaces/Auth/Policies/PolicyEditorPanel/index.tsx": 1, + "components/interfaces/Auth/ProtectionAuthSettingsForm/ProtectionAuthSettingsForm.tsx": 1, + "components/interfaces/Auth/RedirectUrls/AddNewURLModal.tsx": 1, + "components/interfaces/Auth/SessionsAuthSettingsForm/SessionsAuthSettingsForm.tsx": 1, + "components/interfaces/Auth/SiteUrl/SiteUrl.tsx": 1, + "components/interfaces/Auth/ThirdPartyAuthForm/CreateAuth0Dialog.tsx": 1, + "components/interfaces/Auth/ThirdPartyAuthForm/CreateAwsCognitoAuthDialog.tsx": 1, + "components/interfaces/Auth/ThirdPartyAuthForm/CreateClerkAuthDialog.tsx": 1, + "components/interfaces/Auth/ThirdPartyAuthForm/CreateFirebaseAuthDialog.tsx": 1, + "components/interfaces/Auth/ThirdPartyAuthForm/CreateWorkOSDialog.tsx": 1, + "components/interfaces/Auth/Users/UsersV2.tsx": 1, + "components/interfaces/Billing/Payment/AddNewPaymentMethodModal.tsx": 1, + "components/interfaces/Billing/Payment/PaymentConfirmation.tsx": 1, + "components/interfaces/Billing/Payment/PaymentMethods/NewPaymentMethodElement.tsx": 2, + "components/interfaces/BranchManagement/EditBranchModal.tsx": 2, + "components/interfaces/Connect/DatabaseConnectionString.tsx": 2, + "components/interfaces/Database/Backups/PITR/TimeInput.tsx": 4, + "components/interfaces/Database/EnumeratedTypes/CreateEnumeratedTypeSidePanel.tsx": 1, + "components/interfaces/Database/Extensions/EnableExtensionModal.tsx": 1, + "components/interfaces/Database/Functions/CreateFunction/index.tsx": 1, + "components/interfaces/Database/Indexes/Indexes.tsx": 1, + "components/interfaces/Database/Schemas/SchemaGraph.tsx": 1, + "components/interfaces/DiskManagement/DiskManagementForm.tsx": 2, + "components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx": 1, + "components/interfaces/GraphQL/GraphiQL.tsx": 1, + "components/interfaces/Integrations/CronJobs/CreateCronJobSheet/CreateCronJobSheet.tsx": 1, + "components/interfaces/Integrations/Landing/useInstalledIntegrations.tsx": 2, + "components/interfaces/Integrations/Queues/QueuesSettings.tsx": 1, + "components/interfaces/Integrations/Queues/SingleQueue/QueueSettings.tsx": 1, + "components/interfaces/Integrations/Queues/SingleQueue/SendMessageModal.tsx": 1, + "components/interfaces/Integrations/Wrappers/EditWrapperSheet.tsx": 1, + "components/interfaces/Integrations/Wrappers/WrapperDynamicColumns.tsx": 1, + "components/interfaces/Organization/BillingSettings/BillingCustomerData/useBillingCustomerDataForm.ts": 1, + "components/interfaces/Organization/BillingSettings/BillingEmail.tsx": 1, + "components/interfaces/Organization/BillingSettings/CreditTopUp.tsx": 1, + "components/interfaces/Organization/BillingSettings/Subscription/PaymentMethodSelection.tsx": 2, + "components/interfaces/Organization/BillingSettings/Subscription/PlanUpdateSidePanel.tsx": 1, + "components/interfaces/Organization/IntegrationSettings/IntegrationSettings.tsx": 1, + "components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesPanel.tsx": 1, + "components/interfaces/Organization/Usage/Compute.tsx": 1, + "components/interfaces/ProjectAPIDocs/Content/Entity.tsx": 1, + "components/interfaces/ProjectAPIDocs/Content/RPC.tsx": 1, + "components/interfaces/QueryPerformance/QueryPerformanceChart.tsx": 1, + "components/interfaces/Realtime/Inspector/MessagesTable.tsx": 1, + "components/interfaces/Realtime/Inspector/useRealtimeMessages.ts": 2, + "components/interfaces/Reports/ReportBlock/ChartBlock.tsx": 1, + "components/interfaces/Reports/ReportFilterBar.tsx": 1, + "components/interfaces/Reports/Reports.tsx": 1, + "components/interfaces/Reports/useReportFilters.ts": 4, + "components/interfaces/SQLEditor/AskAIWidget.tsx": 1, + "components/interfaces/SQLEditor/InlineWidget.tsx": 2, + "components/interfaces/SQLEditor/MonacoEditor.tsx": 1, + "components/interfaces/SQLEditor/MoveQueryModal.tsx": 1, + "components/interfaces/SQLEditor/OngoingQueriesPanel.tsx": 1, + "components/interfaces/SQLEditor/RenameQueryModal.tsx": 1, + "components/interfaces/SQLEditor/SQLEditor.tsx": 2, + "components/interfaces/SQLEditor/UtilityPanel/SavingIndicator.tsx": 1, + "components/interfaces/Settings/API/PostgrestConfig.tsx": 1, + "components/interfaces/Settings/Addons/CustomDomainSidePanel.tsx": 1, + "components/interfaces/Settings/Addons/IPv4SidePanel.tsx": 1, + "components/interfaces/Settings/Addons/PITRSidePanel.tsx": 1, + "components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx": 1, + "components/interfaces/Settings/Database/SSLConfiguration.tsx": 1, + "components/interfaces/Settings/General/ComplianceConfig/ProjectComplianceMode.tsx": 1, + "components/interfaces/Settings/General/TransferProjectPanel/TransferProjectButton.tsx": 1, + "components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DeployNewReplicaPanel.tsx": 1, + "components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx": 1, + "components/interfaces/Settings/Logs/LogTable.tsx": 2, + "components/interfaces/Settings/Logs/LogsPreviewer.tsx": 2, + "components/interfaces/Settings/Logs/PreviewFilterPanel.tsx": 1, + "components/interfaces/SignIn/SignInPartner.tsx": 1, + "components/interfaces/Storage/AnalyticsBuckets/AnalyticsBucketDetails/CopyEnvButton.tsx": 1, + "components/interfaces/Storage/StorageExplorer/FileExplorerHeader.tsx": 2, + "components/interfaces/Storage/StorageExplorer/FileExplorerRowEditing.tsx": 1, + "components/interfaces/Storage/StorageExplorer/StorageExplorer.tsx": 2, + "components/interfaces/Storage/StorageSettings/StorageSettings.tsx": 1, + "components/interfaces/Storage/VectorBuckets/CreateVectorBucketDialog.tsx": 1, + "components/interfaces/Storage/VectorBuckets/CreateVectorTableSheet.tsx": 1, + "components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx": 1, + "components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.tsx": 2, + "components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/index.tsx": 2, + "components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.tsx": 1, + "components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/TextEditor.tsx": 2, + "components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx": 2, + "components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableQuickstart/QuickstartAIWidget.tsx": 1, + "components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableQuickstart/useAITableGeneration.ts": 2, + "components/interfaces/TableGridEditor/TableDefinition.tsx": 1, + "components/interfaces/TableGridEditor/TableGridEditor.tsx": 2, + "components/interfaces/UnifiedLogs/ServiceFlow/components/ServiceFlowHeader.tsx": 2, + "components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx": 1, + "components/layouts/ProjectLayout/ConnectingState.tsx": 1, + "components/layouts/ProjectLayout/LayoutHeader/FeedbackDropdown/FeedbackWidget.tsx": 2, + "components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider.tsx": 1, + "components/layouts/ProjectLayout/index.tsx": 1, + "components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx": 4, + "components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorTreeViewItem.tsx": 2, + "components/layouts/SignInLayout/SignInLayout.tsx": 1, + "components/layouts/Tabs/Tabs.utils.ts": 2, + "components/ui/Charts/ChartHighlightActions.tsx": 2, + "components/ui/Charts/Charts.utils.tsx": 7, + "components/ui/Charts/ComposedChartHandler.tsx": 1, + "components/ui/CodeEditor/CodeEditor.tsx": 1, + "components/ui/DataTable/DataTableFilters/DataTableFilterInput.tsx": 2, + "components/ui/DataTable/DataTableFilters/DataTableFilterSlider.tsx": 2, + "components/ui/DataTable/DataTableHeaderLayout.tsx": 1, + "components/ui/DataTable/DataTableSideBarLayout.tsx": 4, + "components/ui/DataTable/DataTableToolbar.tsx": 1, + "components/ui/DataTable/DataTableViewOptions.tsx": 1, + "components/ui/DataTable/LiveButton.tsx": 1, + "components/ui/DataTable/TimelineChart.tsx": 1, + "components/ui/DatePicker/TimeSplitInput.tsx": 1, + "components/ui/DateRangePicker.tsx": 1, + "components/ui/Error.tsx": 1, + "components/ui/FilterPopover.tsx": 1, + "components/ui/ScrollGradient.tsx": 1, + "data/__templates/resource-query.ts": 1, + "data/__templates/resources-query.ts": 1, + "data/reports/api-report-query.ts": 2, + "data/reports/storage-report-query.ts": 2, + "hooks/analytics/useFillTimeseriesSorted.ts": 3, + "hooks/analytics/useLogsPreview.tsx": 4, + "hooks/analytics/useLogsQuery.tsx": 1, + "hooks/analytics/useProjectUsageStats.tsx": 1, + "hooks/analytics/useTimeseriesUnixToIso.ts": 2, + "hooks/branches/useEdgeFunctionsDiff.ts": 1, + "hooks/misc/useSchemaQueryState.ts": 1, + "hooks/misc/useUpgradePrompt.tsx": 1, + "hooks/ui/useCsvFileDrop.ts": 1, + "hooks/ui/useHotKey.ts": 2, + "pages/forgot-password-mfa.tsx": 1, + "pages/integrations/vercel/install.tsx": 1, + "pages/logout.tsx": 1, + "pages/new/[slug].tsx": 4, + "pages/new/index.tsx": 1, + "pages/organizations.tsx": 1, + "pages/project/[ref]/auth/policies.tsx": 2, + "pages/project/[ref]/building.tsx": 1, + "pages/project/[ref]/editor/[id].tsx": 1, + "pages/project/[ref]/editor/index.tsx": 1, + "pages/project/[ref]/integrations/[id]/[pageId]/index.tsx": 1, + "pages/project/[ref]/logs/explorer/index.tsx": 1, + "pages/project/[ref]/reports/postgrest.tsx": 1, + "pages/project/[ref]/reports/realtime.tsx": 1, + "pages/project/[ref]/settings/jwt/index.tsx": 1, + "pages/project/[ref]/sql/quickstarts.tsx": 1, + "pages/project/[ref]/sql/templates.tsx": 1, + "pages/sign-in-fly-tos.tsx": 1, + "pages/sign-in-mfa.tsx": 1, + "state/role-impersonation-state.tsx": 1, + "state/sidebar-manager-state.tsx": 1, + "state/storage-explorer.tsx": 1, + "state/table-editor-table.tsx": 1 + }, + "import/no-anonymous-default-export": { + "pages/api/platform/auth/[ref]/invite.ts": 1, + "pages/api/platform/auth/[ref]/magiclink.ts": 1, + "pages/api/platform/auth/[ref]/otp.ts": 1, + "pages/api/platform/auth/[ref]/recover.ts": 1, + "pages/api/platform/auth/[ref]/users/[id]/factors.ts": 1, + "pages/api/platform/auth/[ref]/users/[id]/index.ts": 1, + "pages/api/platform/auth/[ref]/users/index.ts": 1, + "pages/api/platform/database/[ref]/pooling.ts": 1, + "pages/api/platform/integrations/[slug].ts": 1, + "pages/api/platform/integrations/github/authorization.ts": 1, + "pages/api/platform/integrations/github/connections.ts": 1, + "pages/api/platform/integrations/github/repositories.ts": 1, + "pages/api/platform/organizations/[slug]/billing/subscription.ts": 1, + "pages/api/platform/organizations/index.ts": 1, + "pages/api/platform/pg-meta/[ref]/column-privileges.ts": 1, + "pages/api/platform/pg-meta/[ref]/extensions.ts": 1, + "pages/api/platform/pg-meta/[ref]/foreign-tables.ts": 1, + "pages/api/platform/pg-meta/[ref]/materialized-views.ts": 1, + "pages/api/platform/pg-meta/[ref]/policies.ts": 1, + "pages/api/platform/pg-meta/[ref]/publications.ts": 1, + "pages/api/platform/pg-meta/[ref]/query/index.ts": 1, + "pages/api/platform/pg-meta/[ref]/tables.ts": 1, + "pages/api/platform/pg-meta/[ref]/triggers.ts": 1, + "pages/api/platform/pg-meta/[ref]/types.ts": 1, + "pages/api/platform/pg-meta/[ref]/views.ts": 1, + "pages/api/platform/profile/index.ts": 1, + "pages/api/platform/projects/[ref]/analytics/endpoints/[name].ts": 1, + "pages/api/platform/projects/[ref]/analytics/log-drains.ts": 1, + "pages/api/platform/projects/[ref]/analytics/log-drains/[uuid].ts": 1, + "pages/api/platform/projects/[ref]/api-keys/temporary.ts": 1, + "pages/api/platform/projects/[ref]/api/graphql.ts": 1, + "pages/api/platform/projects/[ref]/api/rest.ts": 1, + "pages/api/platform/projects/[ref]/billing/addons.ts": 1, + "pages/api/platform/projects/[ref]/config/index.ts": 1, + "pages/api/platform/projects/[ref]/config/postgrest.ts": 1, + "pages/api/platform/projects/[ref]/content/count.ts": 1, + "pages/api/platform/projects/[ref]/content/folders/[id].ts": 1, + "pages/api/platform/projects/[ref]/content/folders/index.ts": 1, + "pages/api/platform/projects/[ref]/content/index.ts": 1, + "pages/api/platform/projects/[ref]/content/item/[id].ts": 1, + "pages/api/platform/projects/[ref]/databases.ts": 1, + "pages/api/platform/projects/[ref]/index.ts": 1, + "pages/api/platform/projects/[ref]/infra-monitoring.ts": 1, + "pages/api/platform/projects/[ref]/run-lints.ts": 1, + "pages/api/platform/projects/[ref]/settings.ts": 1, + "pages/api/platform/projects/index.ts": 1, + "pages/api/platform/props/org/[slug].tsx": 1, + "pages/api/platform/props/project/[ref]/api.ts": 1, + "pages/api/platform/props/project/[ref]/index.ts": 1, + "pages/api/platform/storage/[ref]/buckets/[id]/empty.ts": 1, + "pages/api/platform/storage/[ref]/buckets/[id]/index.ts": 1, + "pages/api/platform/storage/[ref]/buckets/[id]/objects/download.ts": 1, + "pages/api/platform/storage/[ref]/buckets/[id]/objects/index.ts": 1, + "pages/api/platform/storage/[ref]/buckets/[id]/objects/list.ts": 1, + "pages/api/platform/storage/[ref]/buckets/[id]/objects/move.ts": 1, + "pages/api/platform/storage/[ref]/buckets/[id]/objects/public-url.ts": 1, + "pages/api/platform/storage/[ref]/buckets/[id]/objects/sign.ts": 1, + "pages/api/platform/storage/[ref]/buckets/index.ts": 1, + "pages/api/platform/telemetry/event.ts": 1, + "pages/api/v1/projects/[ref]/api-keys.ts": 1, + "pages/api/v1/projects/[ref]/database/migrations.ts": 1, + "pages/api/v1/projects/[ref]/types/typescript.ts": 1 + }, + "@tanstack/query/exhaustive-deps": { + "components/interfaces/Reports/SharedAPIReport/SharedAPIReport.constants.ts": 1, + "components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx": 1, + "components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx": 3, + "data/branches/branch-diff-query.ts": 1, + "data/database/foreign-key-constraints-query.ts": 1, + "data/database/schemas-query.ts": 1, + "data/entity-types/entity-types-infinite-query.ts": 1, + "data/table-editor/table-editor-query.ts": 1, + "data/table-rows/table-rows-query.ts": 1, + "data/tables/tables-query.ts": 2, + "hooks/analytics/useDbQuery.tsx": 1, + "hooks/analytics/useLogsPreview.tsx": 3, + "hooks/analytics/useProjectUsageStats.tsx": 1, + "hooks/analytics/useSingleLog.tsx": 1 + }, + "@tanstack/query/no-deprecated-options": { + "data/config/jwt-secret-updating-status-query.ts": 1, + "data/config/project-upgrade-status-query.ts": 1 + } } } diff --git a/apps/studio/components/grid/components/header/Header.tsx b/apps/studio/components/grid/components/header/Header.tsx index 9e3a7f9bda058..320845e0003ad 100644 --- a/apps/studio/components/grid/components/header/Header.tsx +++ b/apps/studio/components/grid/components/header/Header.tsx @@ -46,7 +46,7 @@ import { SortPopover } from './sort/SortPopover' export const MAX_EXPORT_ROW_COUNT = 500000 export const MAX_EXPORT_ROW_COUNT_MESSAGE = ( <> - Sorry! We're unable to support exporting row counts larger than $ + Sorry! We're unable to support exporting row counts larger than{' '} {MAX_EXPORT_ROW_COUNT.toLocaleString()} at the moment. Alternatively, you may consider using pg_dump diff --git a/apps/studio/components/interfaces/Database/ETL/ComingSoon.tsx b/apps/studio/components/interfaces/Database/ETL/ComingSoon.tsx index 44a41e88ecb74..83c93655395f8 100644 --- a/apps/studio/components/interfaces/Database/ETL/ComingSoon.tsx +++ b/apps/studio/components/interfaces/Database/ETL/ComingSoon.tsx @@ -1,23 +1,12 @@ -import { motion } from 'framer-motion' -import { - ArrowRight, - ArrowUpRight, - Circle, - Database, - MoreVertical, - Plus, - Search, -} from 'lucide-react' +import { ArrowRight, ArrowUpRight, Circle, Database, Plus } from 'lucide-react' import { useTheme } from 'next-themes' import Link from 'next/link' import { useMemo } from 'react' import ReactFlow, { Background, Handle, Position, ReactFlowProvider } from 'reactflow' import 'reactflow/dist/style.css' -import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' -import Table from 'components/to-be-cleaned/Table' import { BASE_PATH } from 'lib/constants' -import { Badge, Button, Card, CardContent, Input_Shadcn_ } from 'ui' +import { Badge, Button, Card, CardContent } from 'ui' import { NODE_WIDTH } from '../../Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.constants' const STATIC_NODES = [ @@ -100,28 +89,25 @@ const ReplicationStaticMockup = ({ projectRef }: { projectRef: string }) => { ) return ( -
-
- - - -
- +
+ + +
) } @@ -236,79 +222,3 @@ const CTANode = ({ projectRef }: { projectRef: string }) => { ) } - -const StaticDestinations = () => { - const mockRows = [ - { name: 'BigQuery', tables: 4, lag: '55ms', status: 'Enabled' }, - { name: 'Iceberg', tables: 4, lag: '85ms', status: 'Enabled' }, - { name: 'US East', tables: 4, lag: '125ms', status: 'Enabled' }, - ] - - return ( - <> -
-
- - - -
-
-
- - -
- -
- Name, - Publication, - Lag, - Status, - , - ]} - className="mt-4" - body={mockRows.map((row, i) => ( - - {row.name} - - - All - {row.tables} tables - - - {row.lag} - - - - {row.status} - - - - - - - ))} - /> - - - - - - ) -} diff --git a/apps/studio/components/interfaces/Database/EnumeratedTypes/DeleteEnumeratedTypeModal.tsx b/apps/studio/components/interfaces/Database/EnumeratedTypes/DeleteEnumeratedTypeModal.tsx index 92e509c6956ad..362a2a0314ef9 100644 --- a/apps/studio/components/interfaces/Database/EnumeratedTypes/DeleteEnumeratedTypeModal.tsx +++ b/apps/studio/components/interfaces/Database/EnumeratedTypes/DeleteEnumeratedTypeModal.tsx @@ -8,12 +8,14 @@ interface DeleteEnumeratedTypeModalProps { visible: boolean selectedEnumeratedType?: any onClose: () => void + onDelete?: () => void } const DeleteEnumeratedTypeModal = ({ visible, selectedEnumeratedType, onClose, + onDelete, }: DeleteEnumeratedTypeModalProps) => { const { data: project } = useSelectedProjectQuery() const { mutate: deleteEnumeratedType, isLoading: isDeleting } = useEnumeratedTypeDeleteMutation({ @@ -29,6 +31,7 @@ const DeleteEnumeratedTypeModal = ({ if (project?.connectionString === undefined) return console.error('Project connectionString required') + onDelete?.() deleteEnumeratedType({ projectRef: project?.ref, connectionString: project?.connectionString, diff --git a/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx b/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx index dae8ca6d2baee..98a4bda35eebd 100644 --- a/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx +++ b/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx @@ -1,16 +1,16 @@ import { Edit, MoreVertical, Search, Trash } from 'lucide-react' -import { useState } from 'react' +import { parseAsBoolean, useQueryState } from 'nuqs' +import { useRef, useState } from 'react' +import { toast } from 'sonner' import AlertError from 'components/ui/AlertError' import { DocsButton } from 'components/ui/DocsButton' import SchemaSelector from 'components/ui/SchemaSelector' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' -import { - EnumeratedType, - useEnumeratedTypesQuery, -} from 'data/enumerated-types/enumerated-types-query' +import { useEnumeratedTypesQuery } from 'data/enumerated-types/enumerated-types-query' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { handleErrorOnDelete, useQueryStateWithSelect } from 'hooks/misc/useQueryStateWithSelect' import { useIsProtectedSchema } from 'hooks/useProtectedSchemas' import { Button, @@ -36,14 +36,33 @@ export const EnumeratedTypes = () => { const { data: project } = useSelectedProjectQuery() const [search, setSearch] = useState('') const { selectedSchema, setSelectedSchema } = useQuerySchemaState() - const [showCreateTypePanel, setShowCreateTypePanel] = useState(false) - const [selectedTypeToEdit, setSelectedTypeToEdit] = useState() - const [selectedTypeToDelete, setSelectedTypeToDelete] = useState() + const deletingTypeIdRef = useRef(null) const { data, error, isLoading, isError, isSuccess } = useEnumeratedTypesQuery({ projectRef: project?.ref, connectionString: project?.connectionString, }) + + const [showCreateTypePanel, setShowCreateTypePanel] = useQueryState( + 'new', + parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true }) + ) + + const { value: typeToEdit, setValue: setSelectedTypeIdToEdit } = useQueryStateWithSelect({ + urlKey: 'edit', + select: (id) => (id ? data?.find((type) => type.id.toString() === id) : undefined), + enabled: !!data, + onError: () => toast.error(`Enumerated Type not found`), + }) + + const { value: typeToDelete, setValue: setSelectedTypeIdToDelete } = useQueryStateWithSelect({ + urlKey: 'delete', + select: (id) => (id ? data?.find((type) => type.id.toString() === id) : undefined), + enabled: !!data, + onError: (_error, selectedId) => + handleErrorOnDelete(deletingTypeIdRef, selectedId, `Enumerated Type not found`), + }) + const enumeratedTypes = (data ?? []).filter((type) => type.enums.length > 0) const filteredEnumeratedTypes = search.length > 0 @@ -150,14 +169,14 @@ export const EnumeratedTypes = () => { setSelectedTypeToEdit(type)} + onClick={() => setSelectedTypeIdToEdit(type.id.toString())} >

Update type

setSelectedTypeToDelete(type)} + onClick={() => setSelectedTypeIdToDelete(type.id.toString())} >

Delete type

@@ -182,15 +201,20 @@ export const EnumeratedTypes = () => { /> setSelectedTypeToEdit(undefined)} + visible={!!typeToEdit} + selectedEnumeratedType={typeToEdit} + onClose={() => setSelectedTypeIdToEdit(null)} /> setSelectedTypeToDelete(undefined)} + visible={!!typeToDelete} + selectedEnumeratedType={typeToDelete} + onClose={() => setSelectedTypeIdToDelete(null)} + onDelete={() => { + if (typeToDelete) { + deletingTypeIdRef.current = typeToDelete.id.toString() + } + }} /> ) diff --git a/apps/studio/components/interfaces/Database/Functions/DeleteFunction.tsx b/apps/studio/components/interfaces/Database/Functions/DeleteFunction.tsx index 5bae47e0cb6a7..235e48b656ae0 100644 --- a/apps/studio/components/interfaces/Database/Functions/DeleteFunction.tsx +++ b/apps/studio/components/interfaces/Database/Functions/DeleteFunction.tsx @@ -1,32 +1,34 @@ -import { toast } from 'sonner' - -import { useDatabaseFunctionDeleteMutation } from 'data/database-functions/database-functions-delete-mutation' -import { DatabaseFunction } from 'data/database-functions/database-functions-query' +import type { DatabaseFunction } from 'data/database-functions/database-functions-query' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal' interface DeleteFunctionProps { func?: DatabaseFunction visible: boolean - setVisible: (value: boolean) => void + setVisible: (value: string | null) => void + onDelete: (params: { + func: DatabaseFunction + projectRef: string + connectionString?: string | null + }) => void + isLoading: boolean } -export const DeleteFunction = ({ func, visible, setVisible }: DeleteFunctionProps) => { +export const DeleteFunction = ({ + func, + visible, + setVisible, + onDelete, + isLoading, +}: DeleteFunctionProps) => { const { data: project } = useSelectedProjectQuery() const { name, schema } = func ?? {} - const { mutate: deleteDatabaseFunction, isLoading } = useDatabaseFunctionDeleteMutation({ - onSuccess: () => { - toast.success(`Successfully removed function ${name}`) - setVisible(false) - }, - }) - async function handleDelete() { if (!func) return console.error('Function is required') if (!project) return console.error('Project is required') - deleteDatabaseFunction({ + onDelete({ func, projectRef: project.ref, connectionString: project.connectionString, @@ -38,7 +40,7 @@ export const DeleteFunction = ({ func, visible, setVisible }: DeleteFunctionProp setVisible(!visible)} + onCancel={() => setVisible(null)} onConfirm={handleDelete} title="Delete this function" loading={isLoading} diff --git a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx index cc3c9f647907f..c4de9497432a2 100644 --- a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx +++ b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx @@ -21,6 +21,7 @@ import { TableCell, TableRow, } from 'ui' +import type { DatabaseFunction } from 'data/database-functions/database-functions-query' interface FunctionListProps { schema: string @@ -31,6 +32,7 @@ interface FunctionListProps { duplicateFunction: (fn: any) => void editFunction: (fn: any) => void deleteFunction: (fn: any) => void + functions: DatabaseFunction[] } const FunctionList = ({ @@ -42,17 +44,13 @@ const FunctionList = ({ duplicateFunction = noop, editFunction = noop, deleteFunction = noop, + functions, }: FunctionListProps) => { const router = useRouter() const { data: selectedProject } = useSelectedProjectQuery() const aiSnap = useAiAssistantStateSnapshot() const { openSidebar } = useSidebarManagerSnapshot() - const { data: functions } = useDatabaseFunctionsQuery({ - projectRef: selectedProject?.ref, - connectionString: selectedProject?.connectionString, - }) - const filteredFunctions = (functions ?? []).filter((x) => { const matchesName = includes(x.name.toLowerCase(), filterString.toLowerCase()) const matchesReturnType = diff --git a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx index d504ba1bc1a55..30b5aef655ea1 100644 --- a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx +++ b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx @@ -1,9 +1,9 @@ -import type { PostgresFunction } from '@supabase/postgres-meta' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { noop } from 'lodash' import { Search } from 'lucide-react' import { useRouter } from 'next/router' -import { parseAsJson, useQueryState } from 'nuqs' +import { parseAsBoolean, parseAsJson, useQueryState } from 'nuqs' +import { useRef } from 'react' +import { toast } from 'sonner' import { useParams } from 'common' import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState' @@ -13,10 +13,12 @@ import SchemaSelector from 'components/ui/SchemaSelector' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import { useDatabaseFunctionsQuery } from 'data/database-functions/database-functions-query' +import { useDatabaseFunctionDeleteMutation } from 'data/database-functions/database-functions-delete-mutation' import { useSchemasQuery } from 'data/database/schemas-query' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { handleErrorOnDelete, useQueryStateWithSelect } from 'hooks/misc/useQueryStateWithSelect' import { useIsProtectedSchema } from 'hooks/useProtectedSchemas' import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state' @@ -36,26 +38,80 @@ import { } from 'components/interfaces/Reports/v2/ReportsSelectFilter' import { ProtectedSchemaWarning } from '../../ProtectedSchemaWarning' import FunctionList from './FunctionList' +import type { DatabaseFunction } from 'data/database-functions/database-functions-query' -interface FunctionsListProps { - createFunction: () => void - duplicateFunction: (fn: PostgresFunction) => void - editFunction: (fn: PostgresFunction) => void - deleteFunction: (fn: PostgresFunction) => void -} +import { useIsInlineEditorEnabled } from 'components/interfaces/Account/Preferences/InlineEditorSettings' +import { CreateFunction } from 'components/interfaces/Database/Functions/CreateFunction' +import { DeleteFunction } from 'components/interfaces/Database/Functions/DeleteFunction' +import { useEditorPanelStateSnapshot } from 'state/editor-panel-state' + +const createFunctionSnippet = `create function function_name() +returns void +language plpgsql +as $$ +begin + -- Write your function logic here +end; +$$;` -const FunctionsList = ({ - createFunction = noop, - editFunction = noop, - deleteFunction = noop, - duplicateFunction = noop, -}: FunctionsListProps) => { +const FunctionsList = () => { const router = useRouter() const { search } = useParams() const { data: project } = useSelectedProjectQuery() const aiSnap = useAiAssistantStateSnapshot() const { openSidebar } = useSidebarManagerSnapshot() const { selectedSchema, setSelectedSchema } = useQuerySchemaState() + const isInlineEditorEnabled = useIsInlineEditorEnabled() + const { + setValue: setEditorPanelValue, + setTemplates: setEditorPanelTemplates, + setInitialPrompt: setEditorPanelInitialPrompt, + } = useEditorPanelStateSnapshot() + + // Track the ID being deleted to exclude it from error checking + const deletingFunctionIdRef = useRef(null) + + const createFunction = () => { + setSelectedFunctionIdToDuplicate(null) + if (isInlineEditorEnabled) { + setEditorPanelInitialPrompt('Create a new database function that...') + setEditorPanelValue(createFunctionSnippet) + setEditorPanelTemplates([]) + openSidebar(SIDEBAR_KEYS.EDITOR_PANEL) + } else { + setShowCreateFunctionForm(true) + } + } + + const duplicateFunction = (fn: DatabaseFunction) => { + if (isInlineEditorEnabled) { + const dupFn = { + ...fn, + name: `${fn.name}_duplicate`, + } + setEditorPanelInitialPrompt('Create new database function that...') + setEditorPanelValue(dupFn.complete_statement) + setEditorPanelTemplates([]) + openSidebar(SIDEBAR_KEYS.EDITOR_PANEL) + } else { + setSelectedFunctionIdToDuplicate(fn.id.toString()) + } + } + + const editFunction = (fn: DatabaseFunction) => { + setSelectedFunctionIdToDuplicate(null) + if (isInlineEditorEnabled) { + setEditorPanelValue(fn.complete_statement) + setEditorPanelTemplates([]) + openSidebar(SIDEBAR_KEYS.EDITOR_PANEL) + } else { + setSelectedFunctionToEdit(fn.id.toString()) + } + } + + const deleteFunction = (fn: DatabaseFunction) => { + setSelectedFunctionToDelete(fn.id.toString()) + } const filterString = search ?? '' @@ -114,12 +170,56 @@ const FunctionsList = ({ ...(hasInvoker ? [{ label: 'Invoker', value: 'invoker' }] : []), ] + const [showCreateFunctionForm, setShowCreateFunctionForm] = useQueryState( + 'new', + parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true }) + ) + + const { setValue: setSelectedFunctionToEdit, value: functionToEdit } = useQueryStateWithSelect({ + urlKey: 'edit', + select: (id: string) => (id ? functions?.find((fn) => fn.id.toString() === id) : undefined), + enabled: !!functions, + onError: () => toast.error(`Function not found`), + }) + + const { setValue: setSelectedFunctionIdToDuplicate, value: functionToDuplicate } = + useQueryStateWithSelect({ + urlKey: 'duplicate', + select: (id: string) => { + if (!id) return undefined + const original = functions?.find((fn) => fn.id.toString() === id) + return original ? { ...original, name: `${original.name}_duplicate` } : undefined + }, + enabled: !!functions, + onError: () => toast.error(`Function not found`), + }) + + const { setValue: setSelectedFunctionToDelete, value: functionToDelete } = + useQueryStateWithSelect({ + urlKey: 'delete', + select: (id: string) => (id ? functions?.find((fn) => fn.id.toString() === id) : undefined), + enabled: !!functions, + onError: (_error, selectedId) => + handleErrorOnDelete(deletingFunctionIdRef, selectedId, `Function not found`), + }) + + const { mutate: deleteDatabaseFunction, isLoading: isDeletingFunction } = + useDatabaseFunctionDeleteMutation({ + onSuccess: (_, variables) => { + toast.success(`Successfully removed function ${variables.func.name}`) + setSelectedFunctionToDelete(null) + }, + onError: () => { + deletingFunctionIdRef.current = null + }, + }) + if (isLoading) return if (isError) return return ( <> - {(functions ?? []).length == 0 ? ( + {(functions ?? []).length === 0 ? (
)} + + {/* Create Function */} + { + setShowCreateFunctionForm(false) + }} + /> + + {/* Edit or Duplicate Function */} + { + setSelectedFunctionToEdit(null) + setSelectedFunctionIdToDuplicate(null) + }} + isDuplicating={!!functionToDuplicate} + /> + + [0]) => { + deletingFunctionIdRef.current = params.func.id.toString() + deleteDatabaseFunction(params) + }} + isLoading={isDeletingFunction} + /> ) } diff --git a/apps/studio/components/interfaces/Database/Indexes/Indexes.tsx b/apps/studio/components/interfaces/Database/Indexes/Indexes.tsx index 8cabd076c5c82..c10c7e8da9131 100644 --- a/apps/studio/components/interfaces/Database/Indexes/Indexes.tsx +++ b/apps/studio/components/interfaces/Database/Indexes/Indexes.tsx @@ -1,6 +1,7 @@ import { sortBy } from 'lodash' import { AlertCircle, Search, Trash } from 'lucide-react' -import { useEffect, useState } from 'react' +import { parseAsBoolean, useQueryState } from 'nuqs' +import { useEffect, useRef, useState } from 'react' import { toast } from 'sonner' import { useParams } from 'common' @@ -9,10 +10,11 @@ import CodeEditor from 'components/ui/CodeEditor/CodeEditor' import SchemaSelector from 'components/ui/SchemaSelector' import ShimmeringLoader, { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useDatabaseIndexDeleteMutation } from 'data/database-indexes/index-delete-mutation' -import { DatabaseIndex, useIndexesQuery } from 'data/database-indexes/indexes-query' +import { useIndexesQuery, type DatabaseIndex } from 'data/database-indexes/indexes-query' import { useSchemasQuery } from 'data/database/schemas-query' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { handleErrorOnDelete, useQueryStateWithSelect } from 'hooks/misc/useQueryStateWithSelect' import { useIsProtectedSchema } from 'hooks/useProtectedSchemas' import { Button, @@ -36,9 +38,7 @@ const Indexes = () => { const [search, setSearch] = useState('') const { selectedSchema, setSelectedSchema } = useQuerySchemaState() - const [showCreateIndex, setShowCreateIndex] = useState(false) - const [selectedIndex, setSelectedIndex] = useState() - const [selectedIndexToDelete, setSelectedIndexToDelete] = useState() + const deletingIndexNameRef = useRef(null) const { data: allIndexes, @@ -51,6 +51,28 @@ const Indexes = () => { projectRef: project?.ref, connectionString: project?.connectionString, }) + + const [showCreateIndex, setShowCreateIndex] = useQueryState( + 'new', + parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true }) + ) + + const { setValue: setSelectedIndexName, value: selectedIndex } = useQueryStateWithSelect({ + urlKey: 'edit', + select: (id) => (id ? allIndexes?.find((idx) => idx.name === id) : undefined), + enabled: !!allIndexes, + onError: () => toast.error(`Index not found`), + }) + + const { setValue: setSelectedIndexNameToDelete, value: selectedIndexToDelete } = + useQueryStateWithSelect({ + urlKey: 'delete', + select: (id) => (id ? allIndexes?.find((idx) => idx.name === id) : undefined), + enabled: !!allIndexes, + onError: (_error, selectedId) => + handleErrorOnDelete(deletingIndexNameRef, selectedId, `Index not found`), + }) + const { data: schemas, isLoading: isLoadingSchemas, @@ -63,7 +85,7 @@ const Indexes = () => { const { mutate: deleteIndex, isLoading: isExecuting } = useDatabaseIndexDeleteMutation({ onSuccess: async () => { - setSelectedIndexToDelete(undefined) + setSelectedIndexNameToDelete(null) toast.success('Successfully deleted index') }, }) @@ -79,6 +101,7 @@ const Indexes = () => { const onConfirmDeleteIndex = (index: DatabaseIndex) => { if (!project) return console.error('Project is required') + deletingIndexNameRef.current = index.name deleteIndex({ projectRef: project.ref, connectionString: project.connectionString, @@ -195,7 +218,10 @@ const Indexes = () => {
- {!isSchemaLocked && ( @@ -204,7 +230,7 @@ const Indexes = () => { type="text" className="px-1" icon={} - onClick={() => setSelectedIndexToDelete(index)} + onClick={() => setSelectedIndexNameToDelete(index.name)} /> )}
@@ -221,14 +247,14 @@ const Indexes = () => { Index: {selectedIndex?.name} } - onCancel={() => setSelectedIndex(undefined)} + onCancel={() => setSelectedIndexName(null)} >
@@ -248,7 +274,7 @@ const Indexes = () => { variant="warning" size="medium" loading={isExecuting} - visible={selectedIndexToDelete !== undefined} + visible={!!selectedIndexToDelete} title={ <> Confirm to delete index {selectedIndexToDelete?.name} @@ -259,7 +285,7 @@ const Indexes = () => { onConfirm={() => selectedIndexToDelete !== undefined ? onConfirmDeleteIndex(selectedIndexToDelete) : {} } - onCancel={() => setSelectedIndexToDelete(undefined)} + onCancel={() => setSelectedIndexNameToDelete(null)} alert={{ title: 'This action cannot be undone', description: diff --git a/apps/studio/components/interfaces/Database/Roles/DeleteRoleModal.tsx b/apps/studio/components/interfaces/Database/Roles/DeleteRoleModal.tsx index 91f868bc7b898..2b42240b506b4 100644 --- a/apps/studio/components/interfaces/Database/Roles/DeleteRoleModal.tsx +++ b/apps/studio/components/interfaces/Database/Roles/DeleteRoleModal.tsx @@ -9,9 +9,10 @@ interface DeleteRoleModalProps { role: PostgresRole visible: boolean onClose: () => void + onDelete?: () => void } -export const DeleteRoleModal = ({ role, visible, onClose }: DeleteRoleModalProps) => { +export const DeleteRoleModal = ({ role, visible, onClose, onDelete }: DeleteRoleModalProps) => { const { data: project } = useSelectedProjectQuery() const { mutate: deleteDatabaseRole, isLoading: isDeleting } = useDatabaseRoleDeleteMutation({ @@ -24,6 +25,7 @@ export const DeleteRoleModal = ({ role, visible, onClose }: DeleteRoleModalProps const deleteRole = async () => { if (!project) return console.error('Project is required') if (!role) return console.error('Failed to delete role: role is missing') + onDelete?.() deleteDatabaseRole({ projectRef: project.ref, connectionString: project.connectionString, diff --git a/apps/studio/components/interfaces/Database/Roles/RoleRow.tsx b/apps/studio/components/interfaces/Database/Roles/RoleRow.tsx index 876ffea6e0169..c4c5b82dee420 100644 --- a/apps/studio/components/interfaces/Database/Roles/RoleRow.tsx +++ b/apps/studio/components/interfaces/Database/Roles/RoleRow.tsx @@ -24,7 +24,7 @@ import { ROLE_PERMISSIONS } from './Roles.constants' interface RoleRowProps { role: PgRole disabled?: boolean - onSelectDelete: (role: PgRole) => void + onSelectDelete: (role: string) => void } export const RoleRow = ({ role, disabled = false, onSelectDelete }: RoleRowProps) => { @@ -141,7 +141,7 @@ export const RoleRow = ({ role, disabled = false, onSelectDelete }: RoleRowProps className="space-x-2" onClick={(event) => { event.stopPropagation() - onSelectDelete(role) + onSelectDelete(role.id.toString()) }} > diff --git a/apps/studio/components/interfaces/Database/Roles/RolesList.tsx b/apps/studio/components/interfaces/Database/Roles/RolesList.tsx index dfededaea12b1..0c6fe60f15b80 100644 --- a/apps/studio/components/interfaces/Database/Roles/RolesList.tsx +++ b/apps/studio/components/interfaces/Database/Roles/RolesList.tsx @@ -1,7 +1,8 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { partition, sortBy } from 'lodash' import { Plus, Search, X } from 'lucide-react' -import { useState } from 'react' +import { parseAsBoolean, useQueryState } from 'nuqs' +import { useRef, useState } from 'react' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import NoSearchResults from 'components/ui/NoSearchResults' @@ -10,12 +11,14 @@ import { useDatabaseRolesQuery } from 'data/database-roles/database-roles-query' import { useMaxConnectionsQuery } from 'data/database/max-connections-query' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { handleErrorOnDelete, useQueryStateWithSelect } from 'hooks/misc/useQueryStateWithSelect' import { Badge, Button, Input, Tooltip, TooltipContent, TooltipTrigger } from 'ui' import { CreateRolePanel } from './CreateRolePanel' import { DeleteRoleModal } from './DeleteRoleModal' import { RoleRow } from './RoleRow' import { RoleRowSkeleton } from './RoleRowSkeleton' import { SUPABASE_ROLES } from './Roles.constants' +import type { PostgresRole } from '@supabase/postgres-meta' type SUPABASE_ROLE = (typeof SUPABASE_ROLES)[number] @@ -24,8 +27,7 @@ export const RolesList = () => { const [filterString, setFilterString] = useState('') const [filterType, setFilterType] = useState<'all' | 'active'>('all') - const [isCreatingRole, setIsCreatingRole] = useState(false) - const [selectedRoleToDelete, setSelectedRoleToDelete] = useState() + const deletingRoleIdRef = useRef(null) const { can: canUpdateRoles } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_WRITE, @@ -42,6 +44,20 @@ export const RolesList = () => { projectRef: project?.ref, connectionString: project?.connectionString, }) + + const [isCreatingRole, setIsCreatingRole] = useQueryState( + 'new', + parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true }) + ) + + const { setValue: setSelectedRoleIdToDelete, value: roleToDelete } = useQueryStateWithSelect({ + urlKey: 'delete', + select: (id: string) => (id ? data?.find((role) => role.id.toString() === id) : undefined), + enabled: !!data, + onError: (_error, selectedId) => + handleErrorOnDelete(deletingRoleIdRef, selectedId, `Database Role not found`), + }) + const roles = sortBy(data ?? [], (r) => r.name.toLocaleLowerCase()) const filteredRoles = ( @@ -182,7 +198,7 @@ export const RolesList = () => { disabled key={role.id} role={role} - onSelectDelete={setSelectedRoleToDelete} + onSelectDelete={setSelectedRoleIdToDelete} /> ))}
@@ -199,7 +215,7 @@ export const RolesList = () => { key={role.id} disabled={!canUpdateRoles} role={role} - onSelectDelete={setSelectedRoleToDelete} + onSelectDelete={setSelectedRoleIdToDelete} /> ))}
@@ -212,9 +228,14 @@ export const RolesList = () => { setIsCreatingRole(false)} /> setSelectedRoleToDelete(undefined)} + role={roleToDelete as unknown as PostgresRole} + visible={!!roleToDelete} + onClose={() => setSelectedRoleIdToDelete(null)} + onDelete={() => { + if (roleToDelete) { + deletingRoleIdRef.current = roleToDelete.id.toString() + } + }} /> ) diff --git a/apps/studio/components/interfaces/EdgeFunctions/EdgeFunction.types.ts b/apps/studio/components/interfaces/EdgeFunctions/EdgeFunction.types.ts new file mode 100644 index 0000000000000..67207cc4cce08 --- /dev/null +++ b/apps/studio/components/interfaces/EdgeFunctions/EdgeFunction.types.ts @@ -0,0 +1,6 @@ +export type EdgeFunctionFile = { + id: number + name: string + content: string + selected?: boolean +} diff --git a/apps/studio/components/interfaces/EdgeFunctions/EdgeFunctions.utils.ts b/apps/studio/components/interfaces/EdgeFunctions/EdgeFunctions.utils.ts new file mode 100644 index 0000000000000..ef19c06ae2967 --- /dev/null +++ b/apps/studio/components/interfaces/EdgeFunctions/EdgeFunctions.utils.ts @@ -0,0 +1,32 @@ +import { EdgeFunctionFile } from './EdgeFunction.types' + +export const getFallbackImportMapPath = (files: Omit[]) => { + // try to find a deno.json or import_map.json file + const regex = /^.*?(deno|import_map).json*$/i + return files.find(({ name }) => regex.test(name))?.name +} + +export const getFallbackEntrypointPath = (files: Omit[]) => { + // when there's no matching entrypoint path is set, + // we use few heuristics to find an entrypoint file + // 1. If the function has only a single TS / JS file, if so set it as entrypoint + const jsFiles = files.filter(({ name }) => name.endsWith('.js') || name.endsWith('.ts')) + if (jsFiles.length === 1) { + return jsFiles[0].name + } else if (jsFiles.length) { + // 2. If function has a `index` or `main` file use it as the entrypoint + const regex = /^.*?(index|main).*$/i + const matchingFile = jsFiles.find(({ name }) => regex.test(name)) + // 3. if no valid index / main file found, we set the entrypoint expliclty to first JS file + return matchingFile ? matchingFile.name : jsFiles[0].name + } else { + // no potential entrypoint files found, this will most likely result in an error on deploy + return 'index.ts' + } +} + +export const getStaticPatterns = (files: Omit[]) => { + return files + .filter(({ name }) => !name.match(/\.(js|ts|jsx|tsx|json|wasm)$/i)) + .map(({ name }) => name) +} diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionsListItem.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionsListItem.tsx index 358c8fc2ad6b3..bb5f3e76fa798 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionsListItem.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionsListItem.tsx @@ -7,7 +7,8 @@ import { useParams } from 'common/hooks' import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query' import type { EdgeFunctionsResponse } from 'data/edge-functions/edge-functions-query' -import { copyToClipboard, TableCell, TableRow, Tooltip, TooltipContent, TooltipTrigger } from 'ui' +import { copyToClipboard, TableCell, TableRow } from 'ui' +import { TimestampInfo } from 'ui-patterns' interface EdgeFunctionsListItemProps { function: EdgeFunctionsResponse @@ -77,16 +78,11 @@ export const EdgeFunctionsListItem = ({ function: item }: EdgeFunctionsListItemP

- - -
-

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

-
-
- - Last updated on {dayjs(item.updated_at).format('DD MMM, YYYY HH:mm')} - -
+

{item.version}

diff --git a/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTab.tsx b/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTab.tsx index cdedd52cfc114..88a3e09f16066 100644 --- a/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTab.tsx +++ b/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTab.tsx @@ -5,8 +5,7 @@ import { useParams } from 'common' import { Markdown } from 'components/interfaces/Markdown' import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' -import { Badge, Separator } from 'ui' -import { Admonition } from 'ui-patterns/admonition' +import { Alert_Shadcn_, AlertDescription_Shadcn_, Badge, Separator } from 'ui' import { INTEGRATIONS } from '../Landing/Integrations.constants' import { BuiltBySection } from './BuildBySection' import { MarkdownContent } from './MarkdownContent' @@ -51,37 +50,35 @@ export const IntegrationOverviewTab = ({ {dependsOnExtension && (
- - - Supabase - Postgres Module - - `\`${x}\``).join(', ')} + + + + Supabase + Postgres Module + + `\`${x}\``).join(', ')} extension${integration.requiredExtensions.length > 1 ? 's' : ''} directly in your Postgres database. ${hasToInstallExtensions && !hasMissingExtensions ? `Install ${integration.requiredExtensions.length > 1 ? 'these' : 'this'} database extension${integration.requiredExtensions.length > 1 ? 's' : ''} to use ${integration.name} in your project.` : ''} `} - /> + /> - {hasMissingExtensions ? ( - integration.missingExtensionsAlert - ) : ( -
- {installableExtensions.map((extension) => ( - - ))} -
- )} -
+ {hasMissingExtensions ? ( + integration.missingExtensionsAlert + ) : ( +
+ {installableExtensions.map((extension) => ( + + ))} +
+ )} + +
)} {!!actions && !hasToInstallExtensions &&
{actions}
} diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx index 1b613018ff75d..47c1851edea7e 100644 --- a/apps/studio/components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx +++ b/apps/studio/components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx @@ -129,7 +129,7 @@ export const CreateWrapperSheet = ({ } } if (selectedMode === 'schema') { - if (values.source_schema.length === 0) { + if (wrapperMeta.sourceSchemaOption && values.source_schema.length === 0) { errors.source_schema = 'Please provide a source schema' } if (values.target_schema.length === 0) { @@ -162,7 +162,12 @@ export const CreateWrapperSheet = ({ server_name: `${values.wrapper_name}_server`, supabase_target_schema: selectedMode === 'schema' ? values.target_schema : undefined, }, - mode: selectedMode, + mode: + selectedMode === 'schema' + ? wrapperMeta.sourceSchemaOption + ? 'schema' + : 'skip' + : 'tables', tables: newTables, sourceSchema: values.source_schema, targetSchema: values.target_schema, diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/Wrappers.constants.ts b/apps/studio/components/interfaces/Integrations/Wrappers/Wrappers.constants.ts index 76deeca5c7cb4..113d6df59f54f 100644 --- a/apps/studio/components/interfaces/Integrations/Wrappers/Wrappers.constants.ts +++ b/apps/studio/components/interfaces/Integrations/Wrappers/Wrappers.constants.ts @@ -5,6 +5,7 @@ export const WRAPPER_HANDLERS = { STRIPE: 'stripe_fdw_handler', FIREBASE: 'firebase_fdw_handler', S3: 's3_fdw_handler', + S3_VECTORS: 's3_vectors_fdw_handler', CLICK_HOUSE: 'click_house_fdw_handler', BIG_QUERY: 'big_query_fdw_handler', AIRTABLE: 'airtable_fdw_handler', @@ -1415,6 +1416,53 @@ export const WRAPPERS: WrapperMeta[] = [ }, ], }, + { + name: 's3_vectors_wrapper', + handlerName: WRAPPER_HANDLERS.S3_VECTORS, + validatorName: 's3_vectors_fdw_validator', + icon: `${BASE_PATH}/img/icons/s3-icon.svg`, + description: 'Cloud storage service for high-dimensional vectors', + extensionName: 'S3VectorsFdw', + label: 'S3 Vectors', + docsUrl: `${DOCS_URL}/guides/database/extensions/wrappers/s3-vectors`, + minimumExtensionVersion: '0.5.6', + server: { + options: [ + { + name: 'vault_access_key_id', + label: 'Access Key ID', + required: true, + encrypted: true, + secureEntry: true, + }, + { + name: 'vault_secret_access_key', + label: 'Access Key Secret', + required: true, + encrypted: true, + secureEntry: true, + }, + { + name: 'aws_region', + label: 'AWS Region', + required: true, + encrypted: false, + secureEntry: false, + defaultValue: 'us-east-1', + }, + { + name: 'endpoint_url', + label: 'Endpoint URL', + required: false, + encrypted: false, + secureEntry: false, + defaultValue: '', + }, + ], + }, + canTargetSchema: true, + tables: [], + }, { name: 'clickhouse_wrapper', handlerName: WRAPPER_HANDLERS.CLICK_HOUSE, diff --git a/apps/studio/components/interfaces/Reports/v2/ReportChartUpsell.tsx b/apps/studio/components/interfaces/Reports/v2/ReportChartUpsell.tsx index 15fd4fb017363..4077b6548c958 100644 --- a/apps/studio/components/interfaces/Reports/v2/ReportChartUpsell.tsx +++ b/apps/studio/components/interfaces/Reports/v2/ReportChartUpsell.tsx @@ -38,7 +38,7 @@ export const ReportChartUpsell = ({ report, orgSlug }: ReportsChartUpsellProps) const demoData = isHoveringUpgrade ? exponentialChartData.current : demoChartData.current return ( - +

{report.label}

diff --git a/apps/studio/components/interfaces/Reports/v2/ReportChartV2.tsx b/apps/studio/components/interfaces/Reports/v2/ReportChartV2.tsx index d1ab6e80ac174..ef21606cd2c0f 100644 --- a/apps/studio/components/interfaces/Reports/v2/ReportChartV2.tsx +++ b/apps/studio/components/interfaces/Reports/v2/ReportChartV2.tsx @@ -1,9 +1,10 @@ import { useQuery } from '@tanstack/react-query' import { Loader2 } from 'lucide-react' -import { useEffect, useState } from 'react' +import { useState } from 'react' -import { ComposedChart } from 'components/ui/Charts/ComposedChart' import type { ChartHighlightAction } from 'components/ui/Charts/ChartHighlightActions' +import { ComposedChart } from 'components/ui/Charts/ComposedChart' +import { useChartHighlight } from 'components/ui/Charts/useChartHighlight' import type { AnalyticsInterval } from 'data/analytics/constants' import type { ReportConfig } from 'data/reports/v2/reports.types' import { useFillTimeseriesSorted } from 'hooks/analytics/useFillTimeseriesSorted' @@ -11,7 +12,6 @@ import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { Card, CardContent, cn } from 'ui' import { ReportChartUpsell } from './ReportChartUpsell' -import { useChartHighlight } from 'components/ui/Charts/useChartHighlight' export interface ReportChartV2Props { report: ReportConfig @@ -20,6 +20,10 @@ export interface ReportChartV2Props { endDate: string interval: AnalyticsInterval updateDateRange: (from: string, to: string) => void + /** + * Group ID used to invalidate React Query caches + */ + queryGroup?: string className?: string syncId?: string filters?: any @@ -63,6 +67,7 @@ export const ReportChartV2 = ({ syncId, filters, highlightActions, + queryGroup, }: ReportChartV2Props) => { const { data: org } = useSelectedOrganizationQuery() const { plan: orgPlan } = useCurrentOrgPlan() @@ -82,7 +87,7 @@ export const ReportChartV2 = ({ 'projects', projectRef, 'report-v2', - { reportId: report.id, startDate, endDate, interval, filters }, + { reportId: report.id, queryGroup, startDate, endDate, interval, filters }, ], queryFn: async () => { return await report.dataProvider(projectRef, startDate, endDate, interval, filters) @@ -95,7 +100,10 @@ export const ReportChartV2 = ({ const chartData = queryResult?.data || [] const dynamicAttributes = queryResult?.attributes || [] - const headerTotal = computePeriodTotal(chartData, dynamicAttributes) + const showSumAsDefaultHighlight = report.showSumAsDefaultHighlight ?? true + const headerTotal = showSumAsDefaultHighlight + ? computePeriodTotal(chartData, dynamicAttributes) + : undefined /** * Depending on the source the timestamp key could be 'timestamp' or 'period_start' @@ -140,7 +148,7 @@ export const ReportChartV2 = ({ Error loading chart data

) : ( -
+
)} diff --git a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainActivate.tsx b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainActivate.tsx index fb5b75d4f5c2a..8c4dcb9bc3649 100644 --- a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainActivate.tsx +++ b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainActivate.tsx @@ -78,8 +78,7 @@ const CustomDomainActivate = ({ projectRef, customDomain }: CustomDomainActivate

Your custom domain CNAME record for{' '} - {customDomain.hostname} - should resolve to{' '} + {customDomain.hostname} should resolve to{' '} {endpoint ? ( {endpoint} ) : ( diff --git a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx index 06bd6f23fcdb2..5c1078f72e6d1 100644 --- a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx +++ b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx @@ -42,7 +42,7 @@ export const CustomDomainConfig = () => { refetchInterval(data) { // while setting up the ssl certificate, we want to poll every 5 seconds if (data?.customDomain?.ssl.status) { - return 5000 + return 10000 // 10 seconds } return false diff --git a/apps/studio/components/interfaces/Storage/StorageSettings/StorageSettings.utils.ts b/apps/studio/components/interfaces/Storage/StorageSettings/StorageSettings.utils.ts index 60c104e641ea2..e0ec2e1eed6e8 100644 --- a/apps/studio/components/interfaces/Storage/StorageSettings/StorageSettings.utils.ts +++ b/apps/studio/components/interfaces/Storage/StorageSettings/StorageSettings.utils.ts @@ -44,3 +44,9 @@ export function getCatalogURI(projectRef: string, protocol: string, endpoint?: s url.pathname = '/storage/v1/iceberg' return url.toString() } + +export function getVectorURI(projectRef: string, protocol: string, endpoint?: string) { + const url = getStorageURL(projectRef, protocol, endpoint) + url.pathname = '/storage/v1/vector' + return url.toString() +} diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorBucketDialog.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorBucketDialog.tsx index 354ae2596f3af..d4e801dec5e57 100644 --- a/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorBucketDialog.tsx +++ b/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorBucketDialog.tsx @@ -8,11 +8,16 @@ import z from 'zod' import { useParams } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { InlineLink } from 'components/ui/InlineLink' +import { useSchemaCreateMutation } from 'data/database/schema-create-mutation' +import { useS3VectorsWrapperCreateMutation } from 'data/storage/s3-vectors-wrapper-create-mutation' import { useVectorBucketCreateMutation } from 'data/storage/vector-bucket-create-mutation' import { useVectorBucketsQuery } from 'data/storage/vector-buckets-query' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { DOCS_URL } from 'lib/constants' import { Button, Dialog, @@ -28,8 +33,11 @@ import { FormField_Shadcn_, Input_Shadcn_, } from 'ui' +import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { inverseValidBucketNameRegex, validBucketNameRegex } from '../CreateBucketModal.utils' +import { useS3VectorsWrapperExtension } from './useS3VectorsWrapper' +import { getVectorBucketFDWSchemaName } from './VectorBuckets.utils' const FormSchema = z.object({ name: z @@ -58,6 +66,8 @@ export type CreateBucketForm = z.infer export const CreateVectorBucketDialog = () => { const { ref } = useParams() const { data: org } = useSelectedOrganizationQuery() + const { data: project } = useSelectedProjectQuery() + const [isLoading, setIsLoading] = useState(false) const [visible, setVisible] = useState(false) const { can: canCreateBuckets } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*') @@ -70,20 +80,16 @@ export const CreateVectorBucketDialog = () => { }) const { mutate: sendEvent } = useSendEventMutation() - const { mutate: createVectorBucket, isLoading: isCreating } = useVectorBucketCreateMutation({ - onSuccess: (values) => { - sendEvent({ - action: 'storage_bucket_created', - properties: { bucketType: 'vector' }, - groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' }, - }) - toast.success(`Successfully created vector bucket ${values.name}`) - form.reset() - setVisible(false) - }, - onError: (error) => { - toast.error(`Failed to create vector bucket: ${error.message}`) - }, + const { mutateAsync: createVectorBucket } = useVectorBucketCreateMutation({ + onError: () => {}, + }) + + const { state: wrappersExtensionState } = useS3VectorsWrapperExtension() + + const { mutateAsync: createS3VectorsWrapper } = useS3VectorsWrapperCreateMutation() + + const { mutateAsync: createSchema } = useSchemaCreateMutation({ + onError: () => {}, }) const onSubmit: SubmitHandler = async (values) => { @@ -94,7 +100,40 @@ export const CreateVectorBucketDialog = () => { ) if (hasExistingBucket) return toast.error('Bucket name already exists') - createVectorBucket({ projectRef: ref, bucketName: values.name }) + setIsLoading(true) + try { + await createVectorBucket({ projectRef: ref, bucketName: values.name }) + } catch (error: any) { + toast.error(`Failed to create vector bucket: ${error.message}`) + setIsLoading(false) + return + } + + try { + if (wrappersExtensionState === 'installed') { + await createS3VectorsWrapper({ bucketName: values.name }) + + await createSchema({ + projectRef: project?.ref, + connectionString: project?.connectionString, + name: getVectorBucketFDWSchemaName(values.name), + }) + } + } catch (error: any) { + toast.warning( + `Failed to create vector bucket integration: ${error.message}. The bucket will be created but you will need to manually install the integration.` + ) + } + setIsLoading(false) + + sendEvent({ + action: 'storage_bucket_created', + properties: { bucketType: 'vector' }, + groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' }, + }) + toast.success(`Successfully created vector bucket ${values.name}`) + form.reset() + setVisible(false) } useEffect(() => { @@ -160,15 +199,26 @@ export const CreateVectorBucketDialog = () => { )} /> + +

+ Supabase will install the{' '} + {wrappersExtensionState !== 'installed' ? 'Wrappers extension and ' : ''} + S3 Vectors Wrapper integration on your behalf.{' '} + + Learn more + + . +

+ - - diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorTableSheet.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorTableSheet.tsx index 6b512720d427b..fe9d82bffa2e3 100644 --- a/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorTableSheet.tsx +++ b/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorTableSheet.tsx @@ -6,9 +6,9 @@ import { SubmitHandler, useFieldArray, useForm } from 'react-hook-form' import { toast } from 'sonner' import z from 'zod' -import { useParams } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { DocsButton } from 'components/ui/DocsButton' +import { useFDWImportForeignSchemaMutation } from 'data/fdw/fdw-import-foreign-schema-mutation' import { useVectorBucketIndexCreateMutation } from 'data/storage/vector-bucket-index-create-mutation' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' @@ -34,6 +34,8 @@ import { import { Admonition } from 'ui-patterns' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { inverseValidBucketNameRegex } from '../CreateBucketModal.utils' +import { getVectorBucketFDWSchemaName } from './VectorBuckets.utils' +import { useS3VectorsWrapperInstance } from './useS3VectorsWrapperInstance' const isStagingLocal = process.env.NEXT_PUBLIC_ENVIRONMENT !== 'prod' @@ -75,7 +77,7 @@ const FormSchema = z.object({ }) } }), - targetSchema: z.string().default(''), + bucketName: z.string().default(''), dimension: z .number() .int('Dimension must be an integer') @@ -102,18 +104,19 @@ interface CreateVectorTableSheetProps { } export const CreateVectorTableSheet = ({ bucketName }: CreateVectorTableSheetProps) => { - const { ref } = useParams() const { data: project } = useSelectedProjectQuery() const [visible, setVisible] = useState(false) const { can: canCreateBuckets } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*') + const { data: wrapperInstance } = useS3VectorsWrapperInstance({ bucketId: bucketName }) + // [Joshen] Can remove this once this restriction is removed const showIndexCreationNotice = isStagingLocal && !!project && project?.region !== 'us-east-1' const defaultValues = { name: '', - targetSchema: bucketName, + bucketName, dimension: undefined, distanceMetric: 'cosine' as 'cosine' | 'euclidean', metadataKeys: [], @@ -129,32 +132,52 @@ export const CreateVectorTableSheet = ({ bucketName }: CreateVectorTableSheetPro name: 'metadataKeys', }) - const { mutate: createVectorBucketTable, isLoading: isCreating } = - useVectorBucketIndexCreateMutation({ - onSuccess: (values) => { - toast.success(`Successfully created vector table ${values.name}`) - form.reset() + const { mutateAsync: createVectorBucketTable, isLoading: isCreatingVectorBucketTable } = + useVectorBucketIndexCreateMutation() - setVisible(false) - }, - onError: (error) => { - // For other errors, show a toast as fallback - toast.error(`Failed to create vector table: ${error.message}`) - }, + const { mutateAsync: importForeignSchema, isLoading: isImportingForeignSchema } = + useFDWImportForeignSchemaMutation({ + onError: () => {}, }) + const isCreating = isCreatingVectorBucketTable || isImportingForeignSchema const onSubmit: SubmitHandler = async (values) => { - if (!ref) return console.error('Project ref is required') + if (!project?.ref) return console.error('Project ref is required') - createVectorBucketTable({ - projectRef: ref, - bucketName: values.targetSchema, - indexName: values.name, - dataType: 'float32', - dimension: values.dimension!, - distanceMetric: values.distanceMetric, - metadataKeys: values.metadataKeys.map((key) => key.value), - }) + try { + await createVectorBucketTable({ + projectRef: project.ref, + bucketName: values.bucketName, + indexName: values.name, + dataType: 'float32', + dimension: values.dimension!, + distanceMetric: values.distanceMetric, + metadataKeys: values.metadataKeys.map((key) => key.value), + }) + } catch (error: any) { + toast.error(`Failed to create vector table: ${error.message}`) + return + } + + try { + if (wrapperInstance) { + await importForeignSchema({ + projectRef: project.ref, + connectionString: project?.connectionString, + serverName: wrapperInstance.server_name, + sourceSchema: getVectorBucketFDWSchemaName(values.bucketName), + targetSchema: getVectorBucketFDWSchemaName(values.bucketName), + schemaOptions: [`bucket_name '${values.bucketName}'`], + }) + } + } catch (error: any) { + toast.warning(`Failed to connect vector table to the database: ${error.message}`) + } + + toast.success(`Successfully created vector table ${values.name}`) + form.reset() + + setVisible(false) } useEffect(() => { @@ -235,25 +258,14 @@ export const CreateVectorTableSheet = ({ bucketName }: CreateVectorTableSheetPro /> ( - +
- +
diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/DeleteVectorBucketModal.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/DeleteVectorBucketModal.tsx index a2946484160b9..8ddc6b4f9a892 100644 --- a/apps/studio/components/interfaces/Storage/VectorBuckets/DeleteVectorBucketModal.tsx +++ b/apps/studio/components/interfaces/Storage/VectorBuckets/DeleteVectorBucketModal.tsx @@ -1,10 +1,13 @@ +import { useState } from 'react' import { toast } from 'sonner' -import { useParams } from 'common' +import { useFDWDeleteMutation } from 'data/fdw/fdw-delete-mutation' import { useVectorBucketDeleteMutation } from 'data/storage/vector-bucket-delete-mutation' import { deleteVectorBucketIndex } from 'data/storage/vector-bucket-index-delete-mutation' import { useVectorBucketsIndexesQuery } from 'data/storage/vector-buckets-indexes-query' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal' +import { useS3VectorsWrapperInstance } from './useS3VectorsWrapperInstance' export interface DeleteVectorBucketModalProps { visible: boolean @@ -19,40 +22,61 @@ export const DeleteVectorBucketModal = ({ onCancel, onSuccess, }: DeleteVectorBucketModalProps) => { - const { ref: projectRef } = useParams() + const { data: project } = useSelectedProjectQuery() + // Has to be a state because we're using a promise.all to delete the indexes + const [isDeletingIndexes, setIsDeletingIndexes] = useState(false) - const { mutate: deleteBucket, isLoading: isDeletingBucket } = useVectorBucketDeleteMutation({ + const { data: vectorBucketWrapper, meta: wrapperMeta } = useS3VectorsWrapperInstance({ + bucketId: bucketName, + }) + + const { mutate: deleteFDW } = useFDWDeleteMutation() + + const { mutateAsync: deleteBucket, isLoading: isDeletingBucket } = useVectorBucketDeleteMutation({ onSuccess: async () => { toast.success(`Bucket "${bucketName}" deleted successfully`) + if (vectorBucketWrapper) { + deleteFDW({ + projectRef: project?.ref, + connectionString: project?.connectionString, + wrapper: vectorBucketWrapper, + wrapperMeta: wrapperMeta!, + }) + } onSuccess() }, }) - const { data: { indexes = [] } = {}, isLoading: isDeletingIndexes } = - useVectorBucketsIndexesQuery({ - projectRef, + const { data: { indexes = [] } = {}, isLoading: isLoadingIndexes } = useVectorBucketsIndexesQuery( + { + projectRef: project?.ref, vectorBucketName: bucketName, - }) + } + ) const onConfirmDelete = async () => { - if (!projectRef) return console.error('Project ref is required') + if (!project?.ref) return console.error('Project ref is required') if (!bucketName) return console.error('No bucket is selected') try { + setIsDeletingIndexes(true) // delete all indexes from the bucket first const promises = indexes.map((index) => deleteVectorBucketIndex({ - projectRef, + projectRef: project?.ref, bucketName: bucketName, indexName: index.indexName, }) ) await Promise.all(promises) - deleteBucket({ projectRef, bucketName }) + + await deleteBucket({ projectRef: project?.ref, bucketName }) } catch (error) { toast.error( `Failed to delete bucket: ${error instanceof Error ? error.message : 'Unknown error'}` ) + } finally { + setIsDeletingIndexes(false) } } @@ -62,7 +86,7 @@ export const DeleteVectorBucketModal = ({ size="medium" variant="destructive" title={`Delete bucket “${bucketName}”`} - loading={isDeletingBucket || isDeletingIndexes} + loading={isDeletingBucket || isDeletingIndexes || isLoadingIndexes} confirmPlaceholder="Type bucket name" confirmString={bucketName ?? ''} confirmLabel="Delete bucket" diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/DeleteVectorTableModal.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/DeleteVectorTableModal.tsx index f92519807186c..89ded2ebe2e3e 100644 --- a/apps/studio/components/interfaces/Storage/VectorBuckets/DeleteVectorTableModal.tsx +++ b/apps/studio/components/interfaces/Storage/VectorBuckets/DeleteVectorTableModal.tsx @@ -1,9 +1,11 @@ import { toast } from 'sonner' -import { useParams } from 'common' +import { useFDWDropForeignTableMutation } from 'data/fdw/fdw-drop-foreign-table-mutation' import { useVectorBucketIndexDeleteMutation } from 'data/storage/vector-bucket-index-delete-mutation' import { VectorBucketIndex } from 'data/storage/vector-buckets-indexes-query' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { getVectorBucketFDWSchemaName } from './VectorBuckets.utils' interface DeleteVectorTableModalProps { visible: boolean @@ -16,21 +18,31 @@ export const DeleteVectorTableModal = ({ table, onClose, }: DeleteVectorTableModalProps) => { - const { ref: projectRef } = useParams() + const { data: project } = useSelectedProjectQuery() + + const { mutate: deleteForeignTable } = useFDWDropForeignTableMutation({ + onError: () => {}, + }) const { mutate: deleteIndex, isLoading: isDeleting } = useVectorBucketIndexDeleteMutation({ onSuccess: (_, vars) => { + deleteForeignTable({ + projectRef: project?.ref, + connectionString: project?.connectionString, + schemaName: getVectorBucketFDWSchemaName(vars.bucketName), + tableName: vars.indexName, + }) toast.success(`Table "${vars.indexName}" deleted successfully`) onClose() }, }) const onConfirmDelete = () => { - if (!projectRef) return console.error('Project ref is required') + if (!project?.ref) return console.error('Project ref is required') if (!table) return console.error('Vector table is required') deleteIndex({ - projectRef, + projectRef: project?.ref, bucketName: table.vectorBucketName, indexName: table.indexName, }) diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails.tsx index 527819a055cc4..d2790905194c2 100644 --- a/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails.tsx +++ b/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketDetails.tsx @@ -2,8 +2,10 @@ import { Eye, MoreVertical, Search, Trash2 } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' import { useState } from 'react' +import { toast } from 'sonner' import { useParams } from 'common' +import { WrapperMeta } from 'components/interfaces/Integrations/Wrappers/Wrappers.types' import { ScaffoldContainer, ScaffoldHeader, @@ -12,11 +14,17 @@ import { ScaffoldSectionTitle, } from 'components/layouts/Scaffold' import AlertError from 'components/ui/AlertError' +import { InlineLink } from 'components/ui/InlineLink' +import { DatabaseExtension } from 'data/database-extensions/database-extensions-query' +import { useSchemaCreateMutation } from 'data/database/schema-create-mutation' +import { useS3VectorsWrapperCreateMutation } from 'data/storage/s3-vectors-wrapper-create-mutation' import { useVectorBucketQuery } from 'data/storage/vector-bucket-query' import { useVectorBucketsIndexesQuery, VectorBucketIndex, } from 'data/storage/vector-buckets-indexes-query' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { DOCS_URL } from 'lib/constants' import { Button, Card, @@ -34,9 +42,13 @@ import { } from 'ui' import { Input } from 'ui-patterns/DataInputs/Input' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' +import { Admonition } from 'ui-patterns/admonition' import { CreateVectorTableSheet } from './CreateVectorTableSheet' import { DeleteVectorBucketModal } from './DeleteVectorBucketModal' import { DeleteVectorTableModal } from './DeleteVectorTableModal' +import { getVectorBucketFDWSchemaName } from './VectorBuckets.utils' +import { useS3VectorsWrapperExtension } from './useS3VectorsWrapper' +import { useS3VectorsWrapperInstance } from './useS3VectorsWrapperInstance' export const VectorBucketDetails = () => { const router = useRouter() @@ -66,6 +78,25 @@ export const VectorBucketDetails = () => { index.indexName.toLowerCase().includes(filterString.toLowerCase()) ) + const { extension: wrappersExtension, state: extensionState } = useS3VectorsWrapperExtension() + const { + data: wrapperInstance, + meta: wrapperMeta, + isLoading: isLoadingWrapper, + } = useS3VectorsWrapperInstance({ + bucketId, + }) + + const isLoading = isLoadingIndexes || isLoadingWrapper + + const state = isLoading + ? 'loading' + : extensionState === 'installed' + ? wrapperInstance + ? 'added' + : 'missing' + : extensionState + return ( <> {isErrorBucket ? ( @@ -95,6 +126,24 @@ export const VectorBucketDetails = () => {
+ {state === 'not-installed' && ( + + )} + {state === 'needs-upgrade' && ( + + )} + + {state === 'missing' && } {isLoadingIndexes ? ( ) : ( @@ -157,18 +206,20 @@ export const VectorBucketDetails = () => {
- + {/* TODO: Proper URL for table editor */} + + Table Editor + + + ) : null} + + + ) +} + +const ExtensionNeedsUpgrade = ({ + bucketName, + projectRef, + wrapperMeta, + wrappersExtension, +}: { + bucketName?: string + projectRef: string + wrapperMeta: WrapperMeta + wrappersExtension: DatabaseExtension +}) => { + // [Joshen] Default version is what's on the DB, so if the installed version is already the default version + // but still doesnt meet the minimum extension version, then DB upgrade is required + const databaseNeedsUpgrading = + wrappersExtension?.installed_version === wrappersExtension?.default_version + + return ( + + +

+ The {wrapperMeta.label} wrapper requires a minimum extension version of{' '} + {wrapperMeta.minimumExtensionVersion}. You have version{' '} + {wrappersExtension?.installed_version} installed. Please{' '} + {databaseNeedsUpgrading && 'first upgrade your database, and then '}update the extension + by disabling and enabling the Wrappers extension. +

+

+ Before reinstalling the wrapper extension, you must first remove all existing wrappers. + Afterward, you can recreate the wrappers. +

+ +
+
+ ) +} + +const WrapperMissing = ({ bucketName }: { bucketName?: string }) => { + const { data: project } = useSelectedProjectQuery() + const { mutateAsync: createS3VectorsWrapper, isLoading: isCreatingS3VectorsWrapper } = + useS3VectorsWrapperCreateMutation() + const { mutateAsync: createSchema, isLoading: isCreatingSchema } = useSchemaCreateMutation() + + const onSetupWrapper = async () => { + if (!bucketName) return console.error('Bucket name is required') + try { + await createS3VectorsWrapper({ bucketName }) + await createSchema({ + projectRef: project?.ref, + connectionString: project?.connectionString, + name: getVectorBucketFDWSchemaName(bucketName), + }) + } catch (error) { + toast.error( + `Failed to install wrapper: ${error instanceof Error ? error.message : 'Unknown error'}` + ) + } + } + + const isLoading = isCreatingS3VectorsWrapper || isCreatingSchema + + return ( + + +

The S3 Vectors Wrapper integration is required in order to query vector tables.

+ +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBuckets.utils.ts b/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBuckets.utils.ts new file mode 100644 index 0000000000000..8f6b71520334b --- /dev/null +++ b/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBuckets.utils.ts @@ -0,0 +1,13 @@ +import { snakeCase } from 'lodash' + +export const getVectorBucketS3KeyName = (bucketId: string) => { + return `${snakeCase(bucketId)}_keys` +} + +export const getVectorBucketFDWSchemaName = (bucketId: string) => { + return `fdw_vector_${snakeCase(bucketId)}` +} + +export const getVectorBucketFDWName = (bucketId: string) => { + return `${snakeCase(bucketId)}_fdw` +} diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/useS3VectorsWrapper.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/useS3VectorsWrapper.tsx new file mode 100644 index 0000000000000..89bec0b65c3fc --- /dev/null +++ b/apps/studio/components/interfaces/Storage/VectorBuckets/useS3VectorsWrapper.tsx @@ -0,0 +1,42 @@ +import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integrations.constants' +import { + DatabaseExtension, + useDatabaseExtensionsQuery, +} from 'data/database-extensions/database-extensions-query' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' + +export const useS3VectorsWrapperExtension = (): { + extension: DatabaseExtension | undefined + state: 'not-found' | 'loading' | 'installed' | 'needs-upgrade' | 'not-installed' +} => { + const { data: project } = useSelectedProjectQuery() + const { data: extensionsData, isLoading: isExtensionsLoading } = useDatabaseExtensionsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + + const integration = INTEGRATIONS.find((i) => i.id === 's3_vectors_wrapper') + + if (!integration || integration.type !== 'wrapper') { + // This should never happen + return { extension: undefined, state: 'not-found' as const } + } + + const wrapperMeta = integration.meta + + const wrappersExtension = extensionsData?.find((ext) => ext.name === 'wrappers') + const isWrappersExtensionInstalled = !!wrappersExtension?.installed_version + const hasRequiredVersion = + (wrappersExtension?.installed_version ?? '') >= (wrapperMeta?.minimumExtensionVersion ?? '') + + const state: 'not-found' | 'loading' | 'installed' | 'needs-upgrade' | 'not-installed' = + isExtensionsLoading + ? 'loading' + : isWrappersExtensionInstalled + ? hasRequiredVersion + ? 'installed' + : 'needs-upgrade' + : 'not-installed' + + return { extension: wrappersExtension, state } +} diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/useS3VectorsWrapperInstance.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/useS3VectorsWrapperInstance.tsx new file mode 100644 index 0000000000000..ad110ddc018cf --- /dev/null +++ b/apps/studio/components/interfaces/Storage/VectorBuckets/useS3VectorsWrapperInstance.tsx @@ -0,0 +1,49 @@ +import { useMemo } from 'react' + +import { + WRAPPER_HANDLERS, + WRAPPERS, +} from 'components/interfaces/Integrations/Wrappers/Wrappers.constants' +import { wrapperMetaComparator } from 'components/interfaces/Integrations/Wrappers/Wrappers.utils' +import { type FDW, useFDWsQuery } from 'data/fdw/fdws-query' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { getVectorBucketFDWName } from './VectorBuckets.utils' + +export const useS3VectorsWrapperInstance = ( + { bucketId }: { bucketId?: string }, + options?: { enabled?: boolean; refetchInterval?: (data: FDW[] | undefined) => number | false } +) => { + const { data: project, isLoading: isLoadingProject } = useSelectedProjectQuery() + + const defaultEnabled = options?.enabled ?? true + const { data, isLoading: isLoadingFDWs } = useFDWsQuery( + { + projectRef: project?.ref, + connectionString: project?.connectionString, + }, + { + enabled: defaultEnabled && !!bucketId, + refetchInterval: (data) => + !!options?.refetchInterval ? options.refetchInterval(data) : false, + } + ) + + const s3VectorsWrapper = useMemo(() => { + return data + ?.filter((wrapper) => + wrapperMetaComparator( + { handlerName: WRAPPER_HANDLERS.S3_VECTORS, server: { options: [] } }, + wrapper + ) + ) + .find((w) => w.name === getVectorBucketFDWName(bucketId ?? '')) + }, [data, bucketId]) + + const s3VectorsWrapperMeta = WRAPPERS.find((w) => w.handlerName === WRAPPER_HANDLERS.S3_VECTORS) + + return { + data: s3VectorsWrapper, + meta: s3VectorsWrapperMeta!, + isLoading: isLoadingProject || isLoadingFDWs, + } +} diff --git a/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx b/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx index 8edd24d71617a..270a1d31fb582 100644 --- a/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx @@ -267,7 +267,7 @@ export const GridHeaderActions = ({ table, isRefetching }: GridHeaderActionsProp > Add RLS policy @@ -297,7 +297,7 @@ export const GridHeaderActions = ({ table, isRefetching }: GridHeaderActionsProp > RLS {policies.length > 1 ? 'policies' : 'policy'} diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx index dc1e1fca80efa..c815c350d64f9 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx @@ -3,11 +3,12 @@ import { ChevronLeft } from 'lucide-react' import Link from 'next/link' import { ReactNode, useMemo } from 'react' -import { useParams } from 'common' +import { LOCAL_STORAGE_KEYS, useParams } from 'common' import { useIsBranching2Enabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { Connect } from 'components/interfaces/Connect/Connect' import { LocalDropdown } from 'components/interfaces/LocalDropdown' import { UserDropdown } from 'components/interfaces/UserDropdown' +import { AdvisorButton } from 'components/layouts/AppLayout/AdvisorButton' import { AssistantButton } from 'components/layouts/AppLayout/AssistantButton' import { BranchDropdown } from 'components/layouts/AppLayout/BranchDropdown' import { InlineEditorButton } from 'components/layouts/AppLayout/InlineEditorButton' @@ -15,20 +16,20 @@ import { OrganizationDropdown } from 'components/layouts/AppLayout/OrganizationD import { ProjectDropdown } from 'components/layouts/AppLayout/ProjectDropdown' import { getResourcesExceededLimitsOrg } from 'components/ui/OveragesBanner/OveragesBanner.utils' import { useOrgUsageQuery } from 'data/usage/org-usage-query' +import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { IS_PLATFORM } from 'lib/constants' import { useRouter } from 'next/router' import { useAppStateSnapshot } from 'state/app-state' import { Badge, cn } from 'ui' +import { CommandMenuTriggerInput } from 'ui-patterns' import { BreadcrumbsView } from './BreadcrumbsView' import { FeedbackDropdown } from './FeedbackDropdown/FeedbackDropdown' import { HelpPopover } from './HelpPopover' import { HomeIcon } from './HomeIcon' import { LocalVersionPopover } from './LocalVersionPopover' import MergeRequestButton from './MergeRequestButton' -import { AdvisorButton } from 'components/layouts/AppLayout/AdvisorButton' -import { CommandMenuTriggerInput } from 'ui-patterns' const LayoutHeaderDivider = ({ className, ...props }: React.HTMLProps) => ( @@ -70,6 +71,8 @@ export const LayoutHeader = ({ const { setMobileMenuOpen } = useAppStateSnapshot() const gitlessBranching = useIsBranching2Enabled() + const [commandMenuEnabled] = useLocalStorageQuery(LOCAL_STORAGE_KEYS.HOTKEY_COMMAND_MENU, true) + const isAccountPage = router.pathname.startsWith('/account') // We only want to query the org usage and check for possible over-ages for plans without usage billing enabled (free or pro with spend cap) @@ -211,13 +214,15 @@ export const LayoutHeader = ({
div]:border-none', + '[&_.command-shortcut>div]:pr-2', + '[&_.command-shortcut>div]:bg-transparent', + '[&_.command-shortcut>div]:text-foreground-lighter' + )} /> diff --git a/apps/studio/components/ui/Charts/ChartHeader.tsx b/apps/studio/components/ui/Charts/ChartHeader.tsx index 94df3ecdab928..527f8bf4972a8 100644 --- a/apps/studio/components/ui/Charts/ChartHeader.tsx +++ b/apps/studio/components/ui/Charts/ChartHeader.tsx @@ -17,6 +17,7 @@ import { formatBytes } from 'lib/helpers' import { numberFormatter } from './Charts.utils' import { useChartHoverState } from './useChartHoverState' import { InfoTooltip } from 'ui-patterns/info-tooltip' +import { Badge } from 'ui' export interface ChartHeaderProps { title?: string @@ -46,6 +47,7 @@ export interface ChartHeaderProps { attributes?: any[] sql?: string titleTooltip?: string + showNewBadge?: boolean } export const ChartHeader = ({ @@ -76,6 +78,7 @@ export const ChartHeader = ({ attributes, sql, titleTooltip, + showNewBadge, }: ChartHeaderProps) => { const { hoveredIndex, isHovered, isCurrentChart, setHover, clearHover } = useChartHoverState( syncId || 'default' @@ -230,7 +233,10 @@ export const ChartHeader = ({ )} >
- {title && chartTitle} +
+ {title && chartTitle} + {showNewBadge && New} +
{hasHighlightedValue && highlighted} {!hideHighlightedLabel && label} diff --git a/apps/studio/components/ui/Charts/ComposedChart.tsx b/apps/studio/components/ui/Charts/ComposedChart.tsx index acbcfc62db54c..f2983d0fe21e7 100644 --- a/apps/studio/components/ui/Charts/ComposedChart.tsx +++ b/apps/studio/components/ui/Charts/ComposedChart.tsx @@ -19,7 +19,7 @@ import { import { CategoricalChartState } from 'recharts/types/chart/types' import { cn } from 'ui' import { ChartHeader } from './ChartHeader' -import { ChartHighlightActions, ChartHighlightAction } from './ChartHighlightActions' +import { ChartHighlightAction, ChartHighlightActions } from './ChartHighlightActions' import { CHART_COLORS, DateTimeFormats, @@ -68,6 +68,7 @@ export interface ComposedChartProps extends CommonChartProps { docsUrl?: string sql?: string highlightActions?: ChartHighlightAction[] + showNewBadge?: boolean } export function ComposedChart({ @@ -110,6 +111,7 @@ export function ComposedChart({ sql, highlightActions, titleTooltip, + showNewBadge, }: ComposedChartProps) { const { resolvedTheme } = useTheme() const { hoveredIndex, syncTooltip, setHover, clearHover } = useChartHoverState( @@ -303,6 +305,7 @@ export function ComposedChart({ if (data.length === 0) { return ( [0] isFullHeight?: boolean titleTooltip?: string + hideTotalPlaceholder?: boolean } const NoDataPlaceholder = ({ attribute, @@ -24,6 +25,7 @@ const NoDataPlaceholder = ({ size, isFullHeight = false, titleTooltip, + hideTotalPlaceholder = false, }: NoDataPlaceholderProps) => { const { minHeight } = useChartSize(size) @@ -33,7 +35,7 @@ const NoDataPlaceholder = ({ )} diff --git a/apps/studio/components/ui/Forms/FormSection.tsx b/apps/studio/components/ui/Forms/FormSection.tsx index 918bc306aaa0d..714ee501fb0c5 100644 --- a/apps/studio/components/ui/Forms/FormSection.tsx +++ b/apps/studio/components/ui/Forms/FormSection.tsx @@ -1,4 +1,5 @@ import { Children } from 'react' +import { cn } from 'ui' export const FormSection = ({ children, @@ -39,7 +40,7 @@ export const FormSectionLabel = ({ }) => { if (description !== undefined) { return ( -
+
{description}
diff --git a/apps/studio/data/analytics/infra-monitoring-query.ts b/apps/studio/data/analytics/infra-monitoring-query.ts index 57acc306bb8a7..b7604bbeefd4e 100644 --- a/apps/studio/data/analytics/infra-monitoring-query.ts +++ b/apps/studio/data/analytics/infra-monitoring-query.ts @@ -14,6 +14,15 @@ export type InfraMonitoringAttribute = | 'disk_io_consumption' | 'pg_stat_database_num_backends' | 'supavisor_connections_active' + | 'realtime_connections_connected' + | 'realtime_channel_events' + | 'realtime_channel_db_events' + | 'realtime_channel_presence_events' + | 'realtime_channel_joins' + | 'realtime_authorization_rls_execution_time' + | 'realtime_payload_size' + | 'realtime_sum_connections_connected' + | 'realtime_replication_connection_lag' export type InfraMonitoringVariables = { projectRef?: string diff --git a/apps/studio/data/edge-functions/edge-function-body-query.ts b/apps/studio/data/edge-functions/edge-function-body-query.ts index 295366c3dade7..c18cd93731576 100644 --- a/apps/studio/data/edge-functions/edge-function-body-query.ts +++ b/apps/studio/data/edge-functions/edge-function-body-query.ts @@ -1,44 +1,16 @@ import { getMultipartBoundary, parseMultipartStream } from '@mjackson/multipart-parser' import { useQuery } from '@tanstack/react-query' +import { EdgeFunctionFile } from 'components/interfaces/EdgeFunctions/EdgeFunction.types' import { get, handleError } from 'data/fetchers' import { IS_PLATFORM } from 'lib/constants' import type { ResponseError, UseCustomQueryOptions } from 'types' import { edgeFunctionsKeys } from './keys' -export type EdgeFunctionBodyVariables = { +type EdgeFunctionBodyVariables = { projectRef?: string slug?: string } -export type EdgeFunctionFile = { - name: string - content: string -} - -export type EdgeFunctionBodyResponse = { - files: EdgeFunctionFile[] -} - -async function streamToString(stream: ReadableStream) { - const reader = stream.getReader() - const decoder = new TextDecoder() - let result = '' - - try { - while (true) { - const { done, value } = await reader.read() - if (done) break - result += decoder.decode(value, { stream: true }) - } - // Final decode to handle any remaining bytes - result += decoder.decode() - return result - } catch (error) { - console.error('Error reading stream:', error) - throw error - } -} - export async function getEdgeFunctionBody( { projectRef, slug }: EdgeFunctionBodyVariables, signal?: AbortSignal @@ -70,7 +42,7 @@ export async function getEdgeFunctionBody( } } - return { files: files as EdgeFunctionFile[] } + return { files: files as Omit[] } } export type EdgeFunctionBodyData = Awaited> diff --git a/apps/studio/data/edge-functions/edge-functions-deploy-mutation.ts b/apps/studio/data/edge-functions/edge-functions-deploy-mutation.ts index 8dcff2fbc02c1..fb473dee9cabe 100644 --- a/apps/studio/data/edge-functions/edge-functions-deploy-mutation.ts +++ b/apps/studio/data/edge-functions/edge-functions-deploy-mutation.ts @@ -2,30 +2,43 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' import { components } from 'api-types' +import { + getFallbackEntrypointPath, + getFallbackImportMapPath, + getStaticPatterns, +} from 'components/interfaces/EdgeFunctions/EdgeFunctions.utils' import { handleError, post } from 'data/fetchers' import type { ResponseError, UseCustomMutationOptions } from 'types' import { edgeFunctionsKeys } from './keys' -export type EdgeFunctionsDeployVariables = { +type EdgeFunctionsDeployBodyMetadata = components['schemas']['FunctionDeployBody']['metadata'] +type EdgeFunctionsDeployVariables = { projectRef: string slug: string - metadata: components['schemas']['FunctionDeployBody']['metadata'] + metadata: Partial files: { name: string; content: string }[] } export async function deployEdgeFunction({ projectRef, slug, - metadata, + metadata: _metadata, files, }: EdgeFunctionsDeployVariables) { if (!projectRef) throw new Error('projectRef is required') + // [Joshen] Consolidating this logic in the RQ since these values need to be set if they're not + // provided from the callee, and their fallback values depends on the files provided + const metadata = { ..._metadata } + if (!_metadata.entrypoint_path) metadata.entrypoint_path = getFallbackEntrypointPath(files) + if (!_metadata.import_map_path) metadata.import_map_path = getFallbackImportMapPath(files) + if (!_metadata.static_patterns) metadata.static_patterns = getStaticPatterns(files) + const { data, error } = await post(`/v1/projects/{ref}/functions/deploy`, { params: { path: { ref: projectRef }, query: { slug: slug } }, body: { file: files as any, - metadata, + metadata: metadata as EdgeFunctionsDeployBodyMetadata, }, bodySerializer(body) { const formData = new FormData() diff --git a/apps/studio/data/fdw/fdw-create-mutation.ts b/apps/studio/data/fdw/fdw-create-mutation.ts index 581983c99b0f6..9cd65975610a9 100644 --- a/apps/studio/data/fdw/fdw-create-mutation.ts +++ b/apps/studio/data/fdw/fdw-create-mutation.ts @@ -25,6 +25,7 @@ export type FDWCreateVariables = { tables: any[] sourceSchema: string targetSchema: string + schemaOptions?: string[] } export function getCreateFDWSql({ @@ -34,10 +35,8 @@ export function getCreateFDWSql({ tables, sourceSchema, targetSchema, -}: Pick< - FDWCreateVariables, - 'wrapperMeta' | 'formState' | 'tables' | 'mode' | 'sourceSchema' | 'targetSchema' ->) { + schemaOptions = [], +}: Omit) { const newSchemasSql = tables .filter((table) => table.is_new_schema) .map((table) => /* SQL */ `create schema if not exists ${table.schema_name};`) @@ -208,8 +207,10 @@ export function getCreateFDWSql({ }) .join('\n\n') + const options = [...schemaOptions, "strict 'true'"].join(', ') + const importForeignSchemaSql = /* SQL */ ` - import foreign schema "${sourceSchema}" from server ${formState.server_name} into ${targetSchema} options (strict 'true'); + import foreign schema "${sourceSchema}" from server ${formState.server_name} into ${targetSchema} options (${options}); ` const sql = /* SQL */ ` diff --git a/apps/studio/data/fdw/fdw-delete-mutation.ts b/apps/studio/data/fdw/fdw-delete-mutation.ts index 14c729fdfc82b..179be61e64862 100644 --- a/apps/studio/data/fdw/fdw-delete-mutation.ts +++ b/apps/studio/data/fdw/fdw-delete-mutation.ts @@ -8,13 +8,12 @@ import { executeSql } from 'data/sql/execute-sql-query' import { wrapWithTransaction } from 'data/sql/utils/transaction' import { vaultSecretsKeys } from 'data/vault/keys' import type { ResponseError, UseCustomMutationOptions } from 'types' -import { FDW } from './fdws-query' import { fdwKeys } from './keys' export type FDWDeleteVariables = { projectRef?: string connectionString?: string | null - wrapper: FDW + wrapper: { name: string } wrapperMeta: WrapperMeta } diff --git a/apps/studio/data/fdw/fdw-drop-foreign-table-mutation.ts b/apps/studio/data/fdw/fdw-drop-foreign-table-mutation.ts new file mode 100644 index 0000000000000..fb8d243fec5d3 --- /dev/null +++ b/apps/studio/data/fdw/fdw-drop-foreign-table-mutation.ts @@ -0,0 +1,73 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { entityTypeKeys } from 'data/entity-types/keys' +import { foreignTableKeys } from 'data/foreign-tables/keys' +import { executeSql } from 'data/sql/execute-sql-query' +import { wrapWithTransaction } from 'data/sql/utils/transaction' +import type { ResponseError, UseCustomMutationOptions } from 'types' +import { fdwKeys } from './keys' + +export type FDWDropForeignTableVariables = { + projectRef?: string + connectionString?: string | null + schemaName: string + tableName: string +} + +export function getDropForeignTableSql({ + schemaName, + tableName, +}: Omit) { + const sql = /* SQL */ ` +drop foreign table if exists "${schemaName}"."${tableName}"; +` + + return sql +} + +export async function dropForeignTable({ + projectRef, + connectionString, + ...rest +}: FDWDropForeignTableVariables) { + const sql = wrapWithTransaction(getDropForeignTableSql(rest)) + const { result } = await executeSql({ projectRef, connectionString, sql }) + return result +} + +type DropForeignTableData = Awaited> + +export const useFDWDropForeignTableMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseCustomMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (vars) => dropForeignTable(vars), + async onSuccess(data, variables, context) { + const { projectRef } = variables + + await Promise.all([ + queryClient.invalidateQueries({ queryKey: fdwKeys.list(projectRef), refetchType: 'all' }), + queryClient.invalidateQueries({ queryKey: entityTypeKeys.list(projectRef) }), + queryClient.invalidateQueries({ queryKey: foreignTableKeys.list(projectRef) }), + ]) + + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to drop foreign table for foreign data wrapper: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/fdw/fdw-import-foreign-schema-mutation.ts b/apps/studio/data/fdw/fdw-import-foreign-schema-mutation.ts index 603426a7283d4..95801c4345224 100644 --- a/apps/studio/data/fdw/fdw-import-foreign-schema-mutation.ts +++ b/apps/studio/data/fdw/fdw-import-foreign-schema-mutation.ts @@ -15,16 +15,21 @@ export type FDWImportForeignSchemaVariables = { serverName: string sourceSchema: string targetSchema: string + schemaOptions?: string[] } export function getImportForeignSchemaSql({ serverName, sourceSchema, targetSchema, -}: Pick) { + schemaOptions = [], +}: Omit) { + const options = [...schemaOptions, "strict 'true'"].join(', ') + const sql = /* SQL */ ` - import foreign schema "${sourceSchema}" from server ${serverName} into ${targetSchema}; + import foreign schema "${sourceSchema}" from server ${serverName} into ${targetSchema} options (${options}); ` + return sql } diff --git a/apps/studio/data/reports/realtime-charts.ts b/apps/studio/data/reports/realtime-charts.ts deleted file mode 100644 index b03b6577007df..0000000000000 --- a/apps/studio/data/reports/realtime-charts.ts +++ /dev/null @@ -1,85 +0,0 @@ -export const getRealtimeReportAttributes = (isFreePlan: boolean) => [ - { - id: 'client-to-realtime-connections', - label: 'Realtime connections', - valuePrecision: 2, - hide: false, - showTooltip: false, - showLegend: false, - showMaxValue: false, - hideChartType: false, - defaultChartStyle: 'bar', - titleTooltip: '', - attributes: [ - { - attribute: 'realtime_connections_connected', - provider: 'infra-monitoring', - label: 'Connections', - }, - ], - }, - { - id: 'channel-events', - label: 'Channel Events', - valuePrecision: 2, - hide: false, - showTooltip: true, - showLegend: true, - showMaxValue: false, - hideChartType: false, - defaultChartStyle: 'bar', - attributes: [ - { - attribute: 'realtime_channel_events', - provider: 'infra-monitoring', - label: 'Broadcast', - }, - { - attribute: 'realtime_channel_db_events', - provider: 'infra-monitoring', - label: 'Postgres Changes', - }, - { - attribute: 'realtime_channel_presence_events', - provider: 'infra-monitoring', - label: 'Presence', - }, - ], - }, - // { - // id: 'channel-presence-events', - // label: 'Channel Presence Events', - // valuePrecision: 2, - // hide: false, - // showTooltip: false, - // showLegend: false, - // showMaxValue: false, - // hideChartType: false, - // defaultChartStyle: 'bar', - // attributes: [ - // { - // attribute: 'realtime_channel_presence_events', - // provider: 'infra-monitoring', - // label: 'Presence', - // }, - // ], - // }, - { - id: 'realtime_rate_of_channel_joins', - label: 'Rate of Channel Joins', - valuePrecision: 2, - hide: false, - showTooltip: false, - showLegend: false, - showMaxValue: false, - hideChartType: false, - defaultChartStyle: 'bar', - attributes: [ - { - attribute: 'realtime_channel_joins', - provider: 'infra-monitoring', - label: 'Presence', - }, - ], - }, -] diff --git a/apps/studio/data/reports/v2/realtime.config.ts b/apps/studio/data/reports/v2/realtime.config.ts new file mode 100644 index 0000000000000..3d7c442281727 --- /dev/null +++ b/apps/studio/data/reports/v2/realtime.config.ts @@ -0,0 +1,306 @@ +import type { AnalyticsData, AnalyticsInterval } from 'data/analytics/constants' +import { getInfraMonitoring, InfraMonitoringAttribute } from 'data/analytics/infra-monitoring-query' +import { ReportConfig } from './reports.types' + +async function runInfraMonitoringQuery( + projectRef: string, + attribute: InfraMonitoringAttribute, + startDate: string, + endDate: string, + interval: AnalyticsInterval, + databaseIdentifier?: string +): Promise { + const data = await getInfraMonitoring({ + projectRef, + attribute, + startDate, + endDate, + interval, + databaseIdentifier, + }) + + return data +} + +export const realtimeReports = ({ + projectRef, + startDate, + endDate, + interval, + databaseIdentifier, +}: { + projectRef: string + startDate: string + endDate: string + interval: AnalyticsInterval + databaseIdentifier?: string +}): ReportConfig[] => [ + { + id: 'client-to-realtime-connections', + label: 'Connections', + valuePrecision: 0, + hide: false, + showTooltip: false, + showLegend: false, + showMaxValue: false, + hideChartType: false, + defaultChartStyle: 'line', + titleTooltip: '', + availableIn: ['free', 'pro', 'team', 'enterprise'], + dataProvider: async () => { + const data = await runInfraMonitoringQuery( + projectRef, + 'realtime_connections_connected', + startDate, + endDate, + interval, + databaseIdentifier + ) + + const transformedData = (data?.data ?? []).map((p) => { + const valueAsNumber = Number(p.realtime_connections_connected) + return { + ...p, + realtime_connections_connected: Number.isNaN(valueAsNumber) ? 0 : valueAsNumber, + } + }) + + const attributes = [ + { + attribute: 'realtime_connections_connected', + label: 'Connections', + }, + ] + + return { data: transformedData, attributes } + }, + }, + { + id: 'channel-events', + label: 'Channel Events', + valuePrecision: 0, + hide: false, + showTooltip: true, + showLegend: false, + showMaxValue: false, + hideChartType: false, + defaultChartStyle: 'line', + titleTooltip: '', + availableIn: ['free', 'pro', 'team', 'enterprise'], + dataProvider: async () => { + const { data } = await runInfraMonitoringQuery( + projectRef, + 'realtime_channel_events', + startDate, + endDate, + interval, + databaseIdentifier + ) + + const transformedData = data?.map((p) => ({ + ...p, + realtime_channel_events: Number(p.realtime_channel_events) || 0, + })) + + const attributes = [ + { + attribute: 'realtime_channel_events', + label: 'Events', + }, + ] + + return { data: transformedData || [], attributes } + }, + }, + { + id: 'realtime_rate_of_channel_joins', + label: 'Rate of Channel Joins', + valuePrecision: 2, + hide: false, + showSumAsDefaultHighlight: false, + showTooltip: false, + showLegend: false, + showMaxValue: false, + hideChartType: false, + defaultChartStyle: 'line', + titleTooltip: '', + availableIn: ['free', 'pro', 'team', 'enterprise'], + dataProvider: async () => { + const data = await runInfraMonitoringQuery( + projectRef, + 'realtime_channel_joins', + startDate, + endDate, + interval, + databaseIdentifier + ) + + const attributes = [ + { + attribute: 'realtime_channel_joins', + label: 'Channel Joins', + }, + ] + + return { data: data?.data || [], attributes } + }, + }, + { + id: 'realtime_payload_size', + label: 'Broadcast Payload Size', + valuePrecision: 2, + showNewBadge: true, + hide: false, + showSumAsDefaultHighlight: false, + showTooltip: true, + showLegend: false, + showMaxValue: false, + hideChartType: false, + defaultChartStyle: 'line', + titleTooltip: 'Size of broadcast payloads sent through realtime.', + availableIn: ['free', 'pro', 'team', 'enterprise'], + YAxisProps: { + width: 50, + tickFormatter: (value: number) => `${value}B`, + }, + format: (value: unknown) => `${Number(value).toFixed(2)}B`, + dataProvider: async () => { + const data = await runInfraMonitoringQuery( + projectRef, + 'realtime_payload_size', + startDate, + endDate, + interval, + databaseIdentifier + ) + + const attributes = [ + { + attribute: 'realtime_payload_size', + label: 'Payload Size (bytes)', + }, + ] + + return { data: data?.data || [], attributes } + }, + }, + { + id: 'realtime_sum_connections_connected', + label: 'Connected Clients', + valuePrecision: 0, + hide: false, + showNewBadge: true, + showTooltip: true, + showLegend: false, + showMaxValue: true, + hideChartType: false, + defaultChartStyle: 'line', + titleTooltip: 'Total number of connected realtime clients.', + availableIn: ['free', 'pro', 'team', 'enterprise'], + dataProvider: async () => { + const data = await runInfraMonitoringQuery( + projectRef, + 'realtime_sum_connections_connected', + startDate, + endDate, + interval, + databaseIdentifier + ) + + const transformedData = (data?.data ?? []).map((p) => { + const valueAsNumber = Number(p.realtime_sum_connections_connected) + return { + ...p, + realtime_sum_connections_connected: Number.isNaN(valueAsNumber) ? 0 : valueAsNumber, + } + }) + + const attributes = [ + { + attribute: 'realtime_sum_connections_connected', + label: 'Connected Clients', + }, + ] + + return { data: transformedData, attributes } + }, + }, + { + id: 'realtime_replication_connection_lag', + label: 'Replication Connection Lag', + valuePrecision: 2, + showNewBadge: true, + hide: false, + showSumAsDefaultHighlight: false, + showTooltip: true, + showLegend: false, + showMaxValue: false, + hideChartType: false, + defaultChartStyle: 'line', + titleTooltip: 'Time between database commit and broadcast when using broadcast from database.', + availableIn: ['pro', 'team', 'enterprise'], + YAxisProps: { + width: 50, + tickFormatter: (value: number) => `${value}ms`, + }, + format: (value: unknown) => `${Number(value).toFixed(2)}ms`, + dataProvider: async () => { + const data = await runInfraMonitoringQuery( + projectRef, + 'realtime_replication_connection_lag', + startDate, + endDate, + interval, + databaseIdentifier + ) + + const attributes = [ + { + attribute: 'realtime_replication_connection_lag', + label: 'Replication Lag (ms)', + }, + ] + + return { data: data?.data || [], attributes } + }, + }, + { + id: 'realtime_authorization_rls_execution_time', + label: 'RLS Execution Time', + valuePrecision: 2, + showNewBadge: true, + hide: false, + showSumAsDefaultHighlight: false, + showTooltip: true, + showLegend: false, + showMaxValue: false, + hideChartType: false, + defaultChartStyle: 'line', + titleTooltip: 'Execution time of RLS (Row Level Security) checks for realtime authorization.', + availableIn: ['pro', 'team', 'enterprise'], + YAxisProps: { + width: 50, + tickFormatter: (value: number) => `${value}ms`, + }, + format: (value: unknown) => `${Number(value).toFixed(2)}ms`, + dataProvider: async () => { + const data = await runInfraMonitoringQuery( + projectRef, + 'realtime_authorization_rls_execution_time', + startDate, + endDate, + interval, + databaseIdentifier + ) + + const attributes = [ + { + attribute: 'realtime_authorization_rls_execution_time', + label: 'RLS Execution Time (ms)', + }, + ] + + return { data: data?.data || [], attributes } + }, + }, +] diff --git a/apps/studio/data/reports/v2/reports.types.ts b/apps/studio/data/reports/v2/reports.types.ts index 28377e0e1e3f8..81cbf00036aef 100644 --- a/apps/studio/data/reports/v2/reports.types.ts +++ b/apps/studio/data/reports/v2/reports.types.ts @@ -24,10 +24,16 @@ export interface ReportDataProvider { export interface ReportConfig { id: string label: string + /** + * dataProvider should handle *fetching* and *transforming* the data to the components. + * Avoid transforming data inside components. + * Functions can be extracted to helpers for transforming the data, which will make it easier to test. + */ dataProvider: ReportDataProvider valuePrecision: number hide: boolean hideHighlightedValue?: boolean + showSumAsDefaultHighlight?: boolean showTooltip: boolean showLegend: boolean showMaxValue: boolean @@ -39,4 +45,5 @@ export interface ReportConfig { YAxisProps?: YAxisProps xAxisKey?: string yAxisKey?: string + showNewBadge?: boolean } diff --git a/apps/studio/data/storage/s3-vectors-wrapper-create-mutation.ts b/apps/studio/data/storage/s3-vectors-wrapper-create-mutation.ts new file mode 100644 index 0000000000000..2007dbb013ba6 --- /dev/null +++ b/apps/studio/data/storage/s3-vectors-wrapper-create-mutation.ts @@ -0,0 +1,68 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' + +import { WRAPPERS } from 'components/interfaces/Integrations/Wrappers/Wrappers.constants' +import { getVectorURI } from 'components/interfaces/Storage/StorageSettings/StorageSettings.utils' +import { + getVectorBucketFDWName, + getVectorBucketS3KeyName, +} from 'components/interfaces/Storage/VectorBuckets/VectorBuckets.utils' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { FDWCreateVariables, useFDWCreateMutation } from 'data/fdw/fdw-create-mutation' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { useS3AccessKeyCreateMutation } from './s3-access-key-create-mutation' + +export const useS3VectorsWrapperCreateMutation = () => { + const { data: project } = useSelectedProjectQuery() + + const { data: settings } = useProjectSettingsV2Query({ projectRef: project?.ref }) + const protocol = settings?.app_config?.protocol ?? 'https' + const endpoint = settings?.app_config?.storage_endpoint || settings?.app_config?.endpoint + + const wrapperMeta = WRAPPERS.find((wrapper) => wrapper.name === 's3_vectors_wrapper') + + const { can: canCreateCredentials } = useAsyncCheckPermissions( + PermissionAction.STORAGE_ADMIN_WRITE, + '*' + ) + + const { mutateAsync: createS3AccessKey, isLoading: isCreatingS3AccessKey } = + useS3AccessKeyCreateMutation() + + const { mutateAsync: createFDW, isLoading: isCreatingFDW } = useFDWCreateMutation() + + const mutateAsync = async ({ bucketName }: { bucketName: string }) => { + const createS3KeyData = await createS3AccessKey({ + projectRef: project?.ref, + description: getVectorBucketS3KeyName(bucketName), + }) + + const wrapperName = getVectorBucketFDWName(bucketName) + + const params: FDWCreateVariables = { + projectRef: project?.ref, + connectionString: project?.connectionString, + wrapperMeta: wrapperMeta!, + formState: { + wrapper_name: wrapperName, + server_name: `${wrapperName}_server`, + vault_access_key_id: createS3KeyData?.access_key, + vault_secret_access_key: createS3KeyData?.secret_key, + aws_region: settings!.region, + endpoint_url: getVectorURI(project?.ref ?? '', protocol, endpoint), + }, + mode: 'skip', + tables: [], + sourceSchema: '', + targetSchema: '', + } + + await createFDW(params) + } + + return { + mutateAsync, + isLoading: isCreatingFDW || isCreatingS3AccessKey, + hasPermission: canCreateCredentials, + } +} diff --git a/apps/studio/hooks/misc/useQueryStateWithSelect.ts b/apps/studio/hooks/misc/useQueryStateWithSelect.ts new file mode 100644 index 0000000000000..f6afa54d7ae47 --- /dev/null +++ b/apps/studio/hooks/misc/useQueryStateWithSelect.ts @@ -0,0 +1,58 @@ +import { MutableRefObject, useEffect, useMemo } from 'react' +import { parseAsString, useQueryState } from 'nuqs' +import { toast } from 'sonner' + +/** + * Hook for managing URL query parameters with a custom select function and error handling. + * + * @param enabled - Whether error handling is active (shows error when selectedId exists but select returns undefined) + * @param urlKey - The query parameter key (e.g., 'edit', 'delete') + * @param select - Function to transform the selected ID into the desired value (returns undefined if not found) + * @param onError - Callback invoked when enabled is true and selectedId exists but select returns undefined + * + * @returns Object with: + * - value: The result of select(selectedId) or undefined + * - setValue: Function to set/clear the selected ID in the URL + */ +export function useQueryStateWithSelect({ + enabled, + urlKey, + select, + onError, +}: { + enabled: boolean + urlKey: string + select: (id: string) => T | undefined + onError: (error: Error, selectedId: string) => void +}) { + const [selectedId, setSelectedId] = useQueryState( + urlKey, + parseAsString.withOptions({ history: 'push', clearOnDefault: true }) + ) + + const value = useMemo(() => (selectedId ? select(selectedId) : undefined), [selectedId, select]) + + useEffect(() => { + if (enabled && selectedId && !value) { + onError(new Error(`not found`), selectedId) + setSelectedId(null) + } + }, [enabled, onError, selectedId, setSelectedId, value]) + + return { + value, + setValue: setSelectedId as (value: string | null) => void, + } +} + +export const handleErrorOnDelete = ( + deletingIdRef: MutableRefObject, + selectedId: string, + errorMessage: string +) => { + if (selectedId !== deletingIdRef.current) { + toast.error(errorMessage) + } else { + deletingIdRef.current = null + } +} diff --git a/apps/studio/lib/integration-utils.ts b/apps/studio/lib/integration-utils.ts index 2f845488e73e3..49a6d89f13e72 100644 --- a/apps/studio/lib/integration-utils.ts +++ b/apps/studio/lib/integration-utils.ts @@ -83,24 +83,26 @@ export async function getInitialMigrationSQLFromGitHubRepo( statements text[], name text ); - ${sortedFiles.map((file, i) => { - const migration = migrationFileResponses[i] - if (!isResponseOk(migration)) return '' - - const version = file.name.split('_')[0] - const statements = JSON.stringify( - migration - .split(';') - .map((statement) => statement.trim()) - .filter(Boolean) - ) - - return /* SQL */ ` + ${sortedFiles + .map((file, i) => { + const migration = migrationFileResponses[i] + if (!isResponseOk(migration)) return '' + + const version = file.name.split('_')[0] + const statements = JSON.stringify( + migration + .split(';') + .map((statement) => statement.trim()) + .filter(Boolean) + ) + + return /* SQL */ ` insert into supabase_migrations.schema_migrations (version, statements, name) select '${version}', array_agg(jsonb_statements)::text[], '${file.name}' from jsonb_array_elements_text($statements$${statements}$statements$::jsonb) as jsonb_statements; ` - })} + }) + .join('')} ` return `${migrations};${migrationsTableSql};${seed}` diff --git a/apps/studio/pages/project/[ref]/auth/policies.tsx b/apps/studio/pages/project/[ref]/auth/policies.tsx index d5a939e214374..90b519575662f 100644 --- a/apps/studio/pages/project/[ref]/auth/policies.tsx +++ b/apps/studio/pages/project/[ref]/auth/policies.tsx @@ -1,6 +1,6 @@ import type { PostgresPolicy, PostgresTable } from '@supabase/postgres-meta' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { Search } from 'lucide-react' +import { Search, X } from 'lucide-react' import { parseAsString, useQueryState } from 'nuqs' import { useCallback, useDeferredValue, useMemo, useState } from 'react' @@ -30,6 +30,7 @@ import { DOCS_URL } from 'lib/constants' import { useEditorPanelStateSnapshot } from 'state/editor-panel-state' import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state' import type { NextPageWithLayout } from 'types' +import { Button } from 'ui' import { Input } from 'ui-patterns/DataInputs/Input' /** @@ -198,7 +199,18 @@ const AuthPoliciesPage: NextPageWithLayout = () => { return ( -
+
+ { + setSchema(schemaName) + setSearchString('') + }} + /> { setSearchString(str) }} icon={} - /> - { - setSchema(schemaName) - setSearchString('') - }} + actions={ + searchString ? ( +
diff --git a/apps/studio/pages/project/[ref]/database/functions.tsx b/apps/studio/pages/project/[ref]/database/functions.tsx index d1446917a0ebb..a240f6207e0bc 100644 --- a/apps/studio/pages/project/[ref]/database/functions.tsx +++ b/apps/studio/pages/project/[ref]/database/functions.tsx @@ -1,135 +1,37 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useState } from 'react' -import { useIsInlineEditorEnabled } from 'components/interfaces/Account/Preferences/InlineEditorSettings' -import { CreateFunction } from 'components/interfaces/Database/Functions/CreateFunction' -import { DeleteFunction } from 'components/interfaces/Database/Functions/DeleteFunction' import FunctionsList from 'components/interfaces/Database/Functions/FunctionsList/FunctionsList' import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout' import DefaultLayout from 'components/layouts/DefaultLayout' -import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' import { FormHeader } from 'components/ui/Forms/FormHeader' import NoPermission from 'components/ui/NoPermission' -import { DatabaseFunction } from 'data/database-functions/database-functions-query' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { DOCS_URL } from 'lib/constants' -import { useEditorPanelStateSnapshot } from 'state/editor-panel-state' -import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state' import type { NextPageWithLayout } from 'types' const DatabaseFunctionsPage: NextPageWithLayout = () => { - const isInlineEditorEnabled = useIsInlineEditorEnabled() - const { openSidebar } = useSidebarManagerSnapshot() - const { - setValue: setEditorPanelValue, - setTemplates: setEditorPanelTemplates, - setInitialPrompt: setEditorPanelInitialPrompt, - } = useEditorPanelStateSnapshot() - const { can: canReadFunctions, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_READ, 'functions' ) - const [isDuplicating, setIsDuplicating] = useState(false) - const [selectedFunction, setSelectedFunction] = useState() - const [showCreateFunctionForm, setShowCreateFunctionForm] = useState(false) - const [showDeleteFunctionForm, setShowDeleteFunctionForm] = useState(false) - - const createFunction = () => { - setIsDuplicating(false) - if (isInlineEditorEnabled) { - setEditorPanelInitialPrompt('Create a new database function that...') - setEditorPanelValue(`create function function_name() -returns void -language plpgsql -as $$ -begin - -- Write your function logic here -end; -$$;`) - setEditorPanelTemplates([]) - openSidebar(SIDEBAR_KEYS.EDITOR_PANEL) - } else { - setSelectedFunction(undefined) - setShowCreateFunctionForm(true) - } - } - - const duplicateFunction = (fn: DatabaseFunction) => { - setIsDuplicating(true) - - const dupFn = { - ...fn, - name: `${fn.name}_duplicate`, - } - - if (isInlineEditorEnabled) { - setEditorPanelInitialPrompt('Create new database function that...') - setEditorPanelValue(dupFn.complete_statement) - setEditorPanelTemplates([]) - openSidebar(SIDEBAR_KEYS.EDITOR_PANEL) - } else { - setSelectedFunction(dupFn) - setShowCreateFunctionForm(true) - } - } - - const editFunction = (fn: DatabaseFunction) => { - setIsDuplicating(false) - if (isInlineEditorEnabled) { - setEditorPanelValue(fn.complete_statement) - setEditorPanelTemplates([]) - openSidebar(SIDEBAR_KEYS.EDITOR_PANEL) - } else { - setSelectedFunction(fn) - setShowCreateFunctionForm(true) - } - } - - const deleteFunction = (fn: any) => { - setSelectedFunction(fn) - setShowDeleteFunctionForm(true) - } - if (isPermissionsLoaded && !canReadFunctions) { return } return ( - <> - - -
- - -
-
-
- { - setShowCreateFunctionForm(false) - setIsDuplicating(false) - }} - isDuplicating={isDuplicating} - /> - - + + +
+ + +
+
+
) } diff --git a/apps/studio/pages/project/[ref]/functions/[functionSlug]/code.tsx b/apps/studio/pages/project/[ref]/functions/[functionSlug]/code.tsx index 910cf24f35e97..9814b767b444c 100644 --- a/apps/studio/pages/project/[ref]/functions/[functionSlug]/code.tsx +++ b/apps/studio/pages/project/[ref]/functions/[functionSlug]/code.tsx @@ -6,6 +6,7 @@ import { toast } from 'sonner' import { useParams } from 'common' import { DeployEdgeFunctionWarningModal } from 'components/interfaces/EdgeFunctions/DeployEdgeFunctionWarningModal' +import { EdgeFunctionFile } from 'components/interfaces/EdgeFunctions/EdgeFunction.types' import DefaultLayout from 'components/layouts/DefaultLayout' import EdgeFunctionDetailsLayout from 'components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout' import { ButtonTooltip } from 'components/ui/ButtonTooltip' @@ -58,9 +59,7 @@ const CodePage = () => { refetchIntervalInBackground: false, } ) - const [files, setFiles] = useState< - { id: number; name: string; content: string; selected?: boolean }[] - >([]) + const [files, setFiles] = useState([]) const { mutate: deployFunction, isLoading: isDeploying } = useEdgeFunctionDeployMutation({ onSuccess: () => { @@ -76,46 +75,14 @@ const CodePage = () => { const newEntrypointPath = selectedFunction.entrypoint_path?.split('/').pop() const newImportMapPath = selectedFunction.import_map_path?.split('/').pop() - const fallbackEntrypointPath = () => { - // when there's no matching entrypoint path is set, - // we use few heuristics to find an entrypoint file - // 1. If the function has only a single TS / JS file, if so set it as entrypoint - const jsFiles = files.filter(({ name }) => name.endsWith('.js') || name.endsWith('.ts')) - if (jsFiles.length === 1) { - return jsFiles[0].name - } else if (jsFiles.length) { - // 2. If function has a `index` or `main` file use it as the entrypoint - const regex = /^.*?(index|main).*$/i - const matchingFile = jsFiles.find(({ name }) => regex.test(name)) - // 3. if no valid index / main file found, we set the entrypoint expliclty to first JS file - return matchingFile ? matchingFile.name : jsFiles[0].name - } else { - // no potential entrypoint files found, this will most likely result in an error on deploy - return 'index.ts' - } - } - - const fallbackImportMapPath = () => { - // try to find a deno.json or import_map.json file - const regex = /^.*?(deno|import_map).json*$/i - return files.find(({ name }) => regex.test(name))?.name - } - deployFunction({ projectRef: ref, slug: selectedFunction.slug, metadata: { name: selectedFunction.name, verify_jwt: selectedFunction.verify_jwt, - entrypoint_path: files.some(({ name }) => name === newEntrypointPath) - ? (newEntrypointPath as string) - : fallbackEntrypointPath(), - import_map_path: files.some(({ name }) => name === newImportMapPath) - ? newImportMapPath - : fallbackImportMapPath(), - static_patterns: files - .filter(({ name }) => !name.match(/\.(js|ts|jsx|tsx|json|wasm)$/i)) - .map(({ name }) => name), + entrypoint_path: newEntrypointPath, + import_map_path: newImportMapPath, }, files: files.map(({ name, content }) => ({ name, content })), }) diff --git a/apps/studio/pages/project/[ref]/functions/new.tsx b/apps/studio/pages/project/[ref]/functions/new.tsx index 507a532cfe0ee..f2a58ee4e3992 100644 --- a/apps/studio/pages/project/[ref]/functions/new.tsx +++ b/apps/studio/pages/project/[ref]/functions/new.tsx @@ -7,6 +7,7 @@ import { toast } from 'sonner' import * as z from 'zod' import { useParams } from 'common' +import { EdgeFunctionFile } from 'components/interfaces/EdgeFunctions/EdgeFunction.types' import { EDGE_FUNCTION_TEMPLATES } from 'components/interfaces/Functions/Functions.templates' import DefaultLayout from 'components/layouts/DefaultLayout' import EdgeFunctionsLayout from 'components/layouts/EdgeFunctionsLayout/EdgeFunctionsLayout' @@ -108,9 +109,7 @@ const NewFunctionPage = () => { const showStripeExample = useIsFeatureEnabled('edge_functions:show_stripe_example') const { openSidebar } = useSidebarManagerSnapshot() - const [files, setFiles] = useState< - { id: number; name: string; content: string; selected?: boolean }[] - >([ + const [files, setFiles] = useState([ { id: 1, name: 'index.ts', @@ -154,13 +153,10 @@ const NewFunctionPage = () => { deployFunction({ projectRef: ref, slug: values.functionName, - metadata: { - entrypoint_path: 'index.ts', - name: values.functionName, - verify_jwt: true, - }, + metadata: { name: values.functionName, verify_jwt: true }, files: files.map(({ name, content }) => ({ name, content })), }) + sendEvent({ action: 'edge_function_deploy_button_clicked', properties: { origin: 'functions_editor' }, diff --git a/apps/studio/pages/project/[ref]/reports/realtime.tsx b/apps/studio/pages/project/[ref]/reports/realtime.tsx index d61c0259e1ad9..79d6920a993b2 100644 --- a/apps/studio/pages/project/[ref]/reports/realtime.tsx +++ b/apps/studio/pages/project/[ref]/reports/realtime.tsx @@ -2,31 +2,26 @@ import { useQueryClient } from '@tanstack/react-query' import { useParams } from 'common' import dayjs from 'dayjs' import { ArrowRight, RefreshCw } from 'lucide-react' -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import ReportFilterBar from 'components/interfaces/Reports/ReportFilterBar' import ReportHeader from 'components/interfaces/Reports/ReportHeader' import ReportPadding from 'components/interfaces/Reports/ReportPadding' -import { - DatePickerValue, - LogsDatePicker, -} from 'components/interfaces/Settings/Logs/Logs.DatePickers' +import { ReportChartV2 } from 'components/interfaces/Reports/v2/ReportChartV2' +import { LogsDatePicker } from 'components/interfaces/Settings/Logs/Logs.DatePickers' import DefaultLayout from 'components/layouts/DefaultLayout' import ReportsLayout from 'components/layouts/ReportsLayout/ReportsLayout' import { ButtonTooltip } from 'components/ui/ButtonTooltip' -import { LazyComposedChartHandler } from 'components/ui/Charts/ComposedChartHandler' import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' import { REPORT_DATERANGE_HELPER_LABELS } from 'components/interfaces/Reports/Reports.constants' import ReportStickyNav from 'components/interfaces/Reports/ReportStickyNav' import UpgradePrompt from 'components/interfaces/Settings/Logs/UpgradePrompt' -import { analyticsKeys } from 'data/analytics/keys' -import { getRealtimeReportAttributes } from 'data/reports/realtime-charts' import { useReportDateRange } from 'hooks/misc/useReportDateRange' import { SharedAPIReport } from 'components/interfaces/Reports/SharedAPIReport/SharedAPIReport' import { useSharedAPIReport } from 'components/interfaces/Reports/SharedAPIReport/SharedAPIReport.constants' -import type { MultiAttribute } from 'components/ui/Charts/ComposedChart.utils' +import { realtimeReports } from 'data/reports/v2/realtime.config' import type { NextPageWithLayout } from 'types' const RealtimeReport: NextPageWithLayout = () => { @@ -57,9 +52,7 @@ const RealtimeUsage = () => { datePickerHelpers, showUpgradePrompt, setShowUpgradePrompt, - handleDatePickerChange: handleDatePickerChangeFromHook, - isOrgPlanLoading, - orgPlan, + handleDatePickerChange, } = useReportDateRange(REPORT_DATERANGE_HELPER_LABELS.LAST_60_MINUTES) const queryClient = useQueryClient() const { @@ -79,38 +72,34 @@ const RealtimeUsage = () => { end: selectedDateRange?.period_end?.date, }) + const chartSyncId = `realtime-${ref}` + const state = useDatabaseSelectorStateSnapshot() - const isFreePlan = !isOrgPlanLoading && orgPlan?.id === 'free' - const REALTIME_REPORT_ATTRIBUTES = getRealtimeReportAttributes(isFreePlan) + const reportConfig = useMemo(() => { + return realtimeReports({ + projectRef: ref!, + startDate: selectedDateRange?.period_start?.date ?? '', + endDate: selectedDateRange?.period_end?.date ?? '', + interval: selectedDateRange?.interval ?? 'minute', + databaseIdentifier: state.selectedDatabaseId, + }) + }, [ref, selectedDateRange, state.selectedDatabaseId]) const onRefreshReport = async () => { if (!selectedDateRange) return - // [Joshen] Since we can't track individual loading states for each chart - // so for now we mock a loading state that only lasts for a second setIsRefreshing(true) - - const { period_start, period_end, interval } = selectedDateRange - REALTIME_REPORT_ATTRIBUTES.forEach((attr) => { - queryClient.invalidateQueries({ - queryKey: analyticsKeys.infraMonitoring(ref, { - attribute: attr?.id, - startDate: period_start.date, - endDate: period_end.date, - interval, - databaseIdentifier: state.selectedDatabaseId, - }), - }) - }) - + queryClient.invalidateQueries(['projects', ref, 'report-v2', { queryGroup: 'realtime' }]) refetch() - setTimeout(() => setIsRefreshing(false), 1000) } - // [Joshen] Empty dependency array as we only want this running once + const urlStateHasSyncedRef = useRef(false) useEffect(() => { + if (urlStateHasSyncedRef.current) return + urlStateHasSyncedRef.current = true + if (db !== undefined) { setTimeout(() => { // [Joshen] Adding a timeout here to support navigation from settings to reports @@ -125,11 +114,7 @@ const RealtimeUsage = () => { if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }) }, 200) } - }, []) - - const handleDatePickerChange = (values: DatePickerValue) => { - const promptShown = handleDatePickerChangeFromHook(values) - } + }, [db, chart, state]) const updateDateRange: UpdateDateRange = (from: string, to: string) => { updateDateRangeFromHook(from, to) @@ -179,19 +164,24 @@ const RealtimeUsage = () => {
} > - {selectedDateRange && - REALTIME_REPORT_ATTRIBUTES.filter((chart) => !chart.hide).map((chart) => ( - - ))} +
+ {selectedDateRange && + reportConfig + .filter((report) => !report.hide) + .map((report) => ( + + ))} +
Realtime API Gateway
diff --git a/apps/studio/scripts/__tests__/ratchet-eslint-rules.test.ts b/apps/studio/scripts/__tests__/ratchet-eslint-rules.test.ts new file mode 100644 index 0000000000000..b4624fe5aedee --- /dev/null +++ b/apps/studio/scripts/__tests__/ratchet-eslint-rules.test.ts @@ -0,0 +1,148 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { runRatchet } from '../ratchet-eslint-rules' + +const studioRoot = path.resolve(__dirname, '../..') +const repoRoot = path.resolve(studioRoot, '..', '..') +const scriptArgvPlaceholder = path.resolve(studioRoot, 'scripts', 'ratchet-eslint-rules.ts') + +const tempDirs: string[] = [] + +afterEach(() => { + vi.restoreAllMocks() + while (tempDirs.length) { + const dir = tempDirs.pop() + if (dir) { + rmSync(dir, { recursive: true, force: true }) + } + } +}) + +describe('ratchet-eslint-rules integration', () => { + it('captures per-file counts when initializing baselines', () => { + const tmp = createTempDir() + const metadataPath = path.join(tmp, 'baseline.json') + + const eslintResults = buildEslintResults([ + { filePath: repoPath('apps/studio/src/a.ts'), rules: { 'no-console': 1 } }, + { filePath: repoPath('apps/studio/src/b.ts'), rules: { 'no-console': 2 } }, + ]) + + const result = invokeRatchet( + ['--metadata', metadataPath, '--rule', 'no-console', '--init'], + eslintResults + ) + + expect(result).toBe(0) + + const metadata = JSON.parse(readFileSync(metadataPath, 'utf8')) + expect(metadata.rules['no-console']).toBe(3) + expect(metadata.ruleFiles['no-console']).toEqual({ + [relativeToCwd('apps/studio/src/a.ts')]: 1, + [relativeToCwd('apps/studio/src/b.ts')]: 2, + }) + }) + + it('reports offending files when regressions occur and metadata has per-file data', () => { + const tmp = createTempDir() + const metadataPath = path.join(tmp, 'baseline.json') + + writeFileSync( + metadataPath, + JSON.stringify( + { + rules: { 'no-console': 2 }, + ruleFiles: { + 'no-console': { + [relativeToCwd('apps/studio/src/a.ts')]: 2, + }, + }, + }, + null, + 2 + ) + ) + + const eslintResults = buildEslintResults([ + { filePath: repoPath('apps/studio/src/a.ts'), rules: { 'no-console': 3 } }, + { filePath: repoPath('apps/studio/src/b.ts'), rules: { 'no-console': 1 } }, + ]) + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const result = invokeRatchet( + ['--metadata', metadataPath, '--rule', 'no-console'], + eslintResults + ) + + expect(result).toBe(1) + const combinedErrors = errorSpy.mock.calls.map((args) => args.join(' ')).join('\n') + expect(combinedErrors).toContain(`${relativeToCwd('apps/studio/src/a.ts')} (+1)`) + expect(combinedErrors).toContain(`${relativeToCwd('apps/studio/src/b.ts')} (+1)`) + }) + + it('falls back gracefully when baseline is missing per-file data', () => { + const tmp = createTempDir() + const metadataPath = path.join(tmp, 'baseline.json') + + writeFileSync( + metadataPath, + JSON.stringify( + { + rules: { 'no-console': 1 }, + }, + null, + 2 + ) + ) + + const eslintResults = buildEslintResults([ + { filePath: repoPath('apps/studio/src/a.ts'), rules: { 'no-console': 2 } }, + ]) + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const result = invokeRatchet( + ['--metadata', metadataPath, '--rule', 'no-console'], + eslintResults + ) + + expect(result).toBe(1) + const combinedErrors = errorSpy.mock.calls.map((args) => args.join(' ')).join('\n') + expect(combinedErrors).toContain('baseline missing file breakdown') + expect(combinedErrors).toContain(`${relativeToCwd('apps/studio/src/a.ts')} (2 current)`) + }) +}) + +function buildEslintResults( + files: Array<{ filePath: string; rules: Record }> +): unknown[] { + return files.map(({ filePath, rules }) => ({ + filePath, + messages: Object.entries(rules).flatMap(([ruleId, count]) => + Array.from({ length: count }, () => ({ ruleId })) + ), + })) +} + +function createTempDir(): string { + const dir = mkdtempSync(path.join(os.tmpdir(), 'ratchet-eslint')) + tempDirs.push(dir) + return dir +} + +function repoPath(relPath: string): string { + return path.join(repoRoot, relPath) +} + +function invokeRatchet(args: string[], eslintResults: unknown[]): number { + const argv = ['node', scriptArgvPlaceholder, ...args] + return runRatchet(argv, () => ({ + results: eslintResults as any, + stderr: '', + })) +} + +function relativeToCwd(relPath: string): string { + return path.relative(process.cwd(), repoPath(relPath)).split(path.sep).join('/') +} diff --git a/apps/studio/scripts/ratchet-eslint-rules.ts b/apps/studio/scripts/ratchet-eslint-rules.ts index 09613141d43b8..a0004e740f121 100644 --- a/apps/studio/scripts/ratchet-eslint-rules.ts +++ b/apps/studio/scripts/ratchet-eslint-rules.ts @@ -32,6 +32,7 @@ import { spawnSync } from 'node:child_process' import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import path from 'node:path' +import { pathToFileURL } from 'node:url' interface Args { metadata: string @@ -47,6 +48,7 @@ interface ESLintMessage { } interface ESLintResult { + filePath?: string messages?: ESLintMessage[] } @@ -57,6 +59,12 @@ interface ESLintExecutionResult { interface BaselineData { rules: Record + ruleFiles?: Record> +} + +interface RuleSnapshot { + total: number + files: Record } function parseArgs(argv: string[]): Args { @@ -143,49 +151,74 @@ function dangerouslyRunEsLint(eslintCmd: string, eslintArgs: string): ESLintExec return { results, stderr } } -function countRules(results: ESLintResult[], ruleIds: string[]): Record { +function normalizeFilePath(filePath?: string | null): string | null { + if (!filePath) return null + const rel = path.relative(process.cwd(), filePath) + const normalized = rel || path.basename(filePath) + return normalized.split(path.sep).join('/') +} + +function collectRuleSnapshots( + results: ESLintResult[], + ruleIds: string[] +): Record { const checkedIds = new Set(ruleIds) - const counts: Record = {} + const snapshots: Record = {} for (const id of ruleIds) { - counts[id] = 0 + snapshots[id] = { total: 0, files: {} } } for (const file of results) { if (!file || !Array.isArray(file.messages)) continue + const normalizedPath = normalizeFilePath(file.filePath) for (const msg of file.messages) { const id = msg?.ruleId ?? '' if (id && checkedIds.has(id)) { - counts[id] += 1 + const snapshot = snapshots[id] ?? { total: 0, files: {} } + snapshot.total += 1 + if (normalizedPath) { + snapshot.files[normalizedPath] = (snapshot.files[normalizedPath] ?? 0) + 1 + } + snapshots[id] = snapshot } } } - return counts + + return snapshots } function readBaselines(fp: string): BaselineData { - if (!existsSync(fp)) return { rules: {} } + if (!existsSync(fp)) return { rules: {}, ruleFiles: {} } try { const data = JSON.parse(readFileSync(fp, 'utf8')) as Partial if (data && typeof data === 'object' && data.rules && typeof data.rules === 'object') { - return { rules: data.rules } + return { rules: data.rules, ruleFiles: data.ruleFiles ?? {} } } } catch { // ignore invalid metadata files and fall back to blank baselines } - return { rules: {} } + return { rules: {}, ruleFiles: {} } } -function writeBaselines(fp: string, updates: Record, merge = true): void { +function writeBaselines(fp: string, updates: Record, merge = true): void { const dir = path.dirname(fp) mkdirSync(dir, { recursive: true }) - let current: BaselineData = { rules: {} } + let current: BaselineData = { rules: {}, ruleFiles: {} } if (merge && existsSync(fp)) { current = readBaselines(fp) } - const next: BaselineData = { rules: { ...current.rules, ...updates } } + const nextRules = merge ? { ...current.rules } : {} + const nextRuleFiles = merge ? { ...(current.ruleFiles ?? {}) } : {} + + for (const [rule, snapshot] of Object.entries(updates)) { + nextRules[rule] = snapshot.total + nextRuleFiles[rule] = snapshot.files + } + + const next: BaselineData = { rules: nextRules, ruleFiles: nextRuleFiles } writeFileSync(fp, `${JSON.stringify(next, null, 2)}\n`, 'utf8') } @@ -200,17 +233,21 @@ function writeSummary(markdown: string): void { } } -function main(): void { - const args = parseArgs(process.argv) +export function runRatchet(argv: string[], runEslint = dangerouslyRunEsLint): number { + const args = parseArgs(argv) // SECURITY: // Offloaded to user. Must document that they should not pass untrusted input // via --eslint or --eslint-args. - const { results, stderr } = dangerouslyRunEsLint(args.eslint, args.eslintArgs) - const currentCounts = countRules(results, args.rules) + const { results, stderr } = runEslint(args.eslint, args.eslintArgs) + const currentSnapshots = collectRuleSnapshots(results, args.rules) + const currentCounts: Record = {} + for (const rule of args.rules) { + currentCounts[rule] = currentSnapshots[rule]?.total ?? 0 + } if (args.init) { - writeBaselines(args.metadata, currentCounts, true) + writeBaselines(args.metadata, currentSnapshots, true) const rows = Object.entries(currentCounts) .map(([rule, count]) => `| \`${rule}\` | **${count}** |`) @@ -231,28 +268,33 @@ function main(): void { console.log( `Initialized/updated baselines for: ${args.rules.join(', ')} (saved to ${args.metadata}).` ) - process.exit(0) + return 0 } - const baselines = readBaselines(args.metadata).rules || {} + const baselineData = readBaselines(args.metadata) + const baselineRules = baselineData.rules || {} + const baselineRuleFiles = baselineData.ruleFiles || {} - const missing = args.rules.filter((r) => typeof baselines[r] !== 'number') + const missing = args.rules.filter((r) => typeof baselineRules[r] !== 'number') if (missing.length) { const msg = `Missing baselines for: ${missing.join(', ')} in ${args.metadata}. Run with --init to set them.` console.error(msg) writeSummary(`### ESLint rule ratchet\n${msg}`) console.log(`::error title=Missing baselines::${msg}`) - process.exit(2) + return 2 } let failed = false const tableRows: string[] = [] const improvedRules: string[] = [] - const decreasedBaselines: Record = {} + const decreasedBaselines: Record = + {} for (const rule of args.rules) { - const baseline = baselines[rule] ?? 0 + const baseline = baselineRules[rule] ?? 0 const current = currentCounts[rule] ?? 0 const delta = current - baseline + const currentSnapshot = currentSnapshots[rule] ?? { total: 0, files: {} } + const baselineFiles = baselineRuleFiles[rule] ?? {} tableRows.push( `| \`${rule}\` | **${baseline}** | **${current}** | ${delta >= 0 ? '+' : '-'}${delta} |` @@ -261,13 +303,27 @@ function main(): void { if (current > baseline) { failed = true const delta = current - baseline - const msg = `You added ${delta === 1 ? 'a new violation' : `${delta} new violations`} of ${rule}. Please fix it: baseline=${baseline}, current=${current}` + const baselineHasFiles = Object.hasOwn(baselineRuleFiles, rule) + const fileSummary = describeFileRegression( + baselineFiles, + currentSnapshot.files, + baselineHasFiles + ) + const msgParts = [ + `You added ${delta === 1 ? 'a new violation' : `${delta} new violations`} of ${rule}. Please fix it: baseline=${baseline}, current=${current}`, + ] + if (fileSummary) { + msgParts.push( + `Affected files: ${fileSummary}${baselineHasFiles ? '' : ' (baseline missing file breakdown; rerun with --init to capture it)'}` + ) + } + const msg = msgParts.join(' ') console.error(msg) console.log(`::error title=New violations::${msg}`) } else if (current < baseline) { improvedRules.push(rule) if (args.decreaseBaselines) { - decreasedBaselines[rule] = { from: baseline, to: current } + decreasedBaselines[rule] = { from: baseline, to: current, snapshot: currentSnapshot } } } } @@ -283,11 +339,11 @@ function main(): void { ] if (args.decreaseBaselines && Object.keys(decreasedBaselines).length > 0) { - const updates: Record = {} + const updates: Record = {} const details: string[] = [] const logParts: string[] = [] - for (const [rule, { from, to }] of Object.entries(decreasedBaselines)) { - updates[rule] = to + for (const [rule, { from, to, snapshot }] of Object.entries(decreasedBaselines)) { + updates[rule] = snapshot details.push(`- \`${rule}\`: ${from} -> ${to}`) logParts.push(`${rule}: ${from} -> ${to}`) } @@ -300,15 +356,66 @@ function main(): void { if (failed) { if (stderr && stderr.trim()) console.error('\nESLint stderr:\n', stderr) - process.exit(1) + return 1 } else { console.log( improvedRules.length > 0 ? 'Nice! Some rules improved.' : 'Stable: No regressions for selected rules.' ) - process.exit(0) + return 0 } } -main() +function main(): void { + const exitCode = runRatchet(process.argv, dangerouslyRunEsLint) + process.exit(exitCode) +} + +if (process.argv[1]) { + const invokedPath = pathToFileURL(path.resolve(process.argv[1])).href + if (import.meta.url === invokedPath) { + main() + } +} + +function describeFileRegression( + baselineFiles: Record, + currentFiles: Record, + baselineHasFiles: boolean +): string { + const MAX_FILES = 5 + if (baselineHasFiles) { + const entries = Object.entries(currentFiles) + .map(([file, count]) => ({ + file, + delta: count - (baselineFiles[file] ?? 0), + })) + .filter(({ delta }) => delta > 0) + .sort((a, b) => b.delta - a.delta || a.file.localeCompare(b.file)) + + if (!entries.length) return '' + + return formatFileList( + entries.map(({ file, delta }) => `${file} (+${delta})`), + MAX_FILES + ) + } + + const currentEntries = Object.entries(currentFiles) + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .map(([file, count]) => `${file} (${count} current)`) + + if (!currentEntries.length) return '' + + return formatFileList(currentEntries, MAX_FILES) +} + +function formatFileList(entries: string[], maxFiles: number): string { + if (entries.length <= maxFiles) { + return entries.join(', ') + } + const remainder = entries.length - maxFiles + const plural = remainder === 1 ? 'file' : 'files' + return `${entries.slice(0, maxFiles).join(', ')}, +${remainder} more ${plural}` +} diff --git a/apps/studio/static-data/integrations/s3_vectors_wrapper/overview.md b/apps/studio/static-data/integrations/s3_vectors_wrapper/overview.md new file mode 100644 index 0000000000000..339668d30139d --- /dev/null +++ b/apps/studio/static-data/integrations/s3_vectors_wrapper/overview.md @@ -0,0 +1,3 @@ +AWS S3 Vectors is a managed service that stores and queries high-dimensional vectors at scale, optimized for machine learning and artificial intelligence applications. + +The S3 Vectors Wrapper allows you to read, write, and perform vector similarity search operations on S3 Vectors within your Postgres database. diff --git a/apps/www/_events/2025-11-20-supabase-agency-webinar.mdx b/apps/www/_events/2025-11-20-supabase-agency-webinar.mdx index fc7df69afb4e0..f100428e1579c 100644 --- a/apps/www/_events/2025-11-20-supabase-agency-webinar.mdx +++ b/apps/www/_events/2025-11-20-supabase-agency-webinar.mdx @@ -16,7 +16,7 @@ main_cta: target: '_blank', label: 'Register now', } -speakers: 'dave_wilson,harry_roper,shahed_islam,omar_moulani' +speakers: 'seth_kramer,harry_roper,shahed_islam,omar_moulani,dave_wilson,chris_caruso' --- ## Stop Competing on Speed diff --git a/apps/www/lib/authors.json b/apps/www/lib/authors.json index 331e26e189d4d..a45255368af82 100644 --- a/apps/www/lib/authors.json +++ b/apps/www/lib/authors.json @@ -485,6 +485,14 @@ "author_url": "https://github.com/chrischandler", "author_image_url": "https://github.com/chrischandler.png" }, + { + "author_id": "chris_caruso", + "author": "Chris Caruso", + "position": "Senior Solutions Architect", + "company": "Supabase", + "author_url": "https://www.linkedin.com/in/carusochristian/", + "author_image_url": "https://github.com/carusocv.png" + }, { "author_id": "bdon", "author": "Brandon Liu", @@ -723,5 +731,13 @@ "company": "Supabase", "author_url": "https://www.linkedin.com/in/david-wilson-285564140", "author_image_url": "/images/avatars/dave-wilson.jpg" + }, + { + "author_id": "seth_kramer", + "author": "Seth Kramer", + "position": "Founder", + "company": "No Code MBA", + "author_url": "https://www.linkedin.com/in/seth-kramer-62806b63/", + "author_image_url": "/images/avatars/seth-kramer.jpeg" } ] diff --git a/apps/www/public/images/avatars/seth-kramer.jpeg b/apps/www/public/images/avatars/seth-kramer.jpeg new file mode 100644 index 0000000000000..aa30108f0dc4f Binary files /dev/null and b/apps/www/public/images/avatars/seth-kramer.jpeg differ diff --git a/docker/CHANGELOG.md b/docker/CHANGELOG.md new file mode 100644 index 0000000000000..98986099606fe --- /dev/null +++ b/docker/CHANGELOG.md @@ -0,0 +1,28 @@ +# Changelog + +All notable changes to the Supabase self-hosted Docker configuration. + +Changes are grouped by service rather than by change type. See [versions.md](./versions.md) +for complete image version history and rollback information. + +## [2025-11-12] + +### Studio +- Updated to `2025.11.10-sha-5291fe3` - [Dashboard updates](https://github.com/orgs/supabase/discussions/40083) + +### Auth +- Updated to `v2.182.1` - [Changelog](https://github.com/supabase/auth/blob/master/CHANGELOG.md#21821-2025-11-05) | [Release](https://github.com/supabase/auth/releases/tag/v2.182.1) + +### Realtime +- Updated to `v2.63.0` - [Release](https://github.com/supabase/realtime/releases/tag/v2.63.0) + +### Storage +- Updated to `v1.29.0` - [Release](https://github.com/supabase/storage/releases/tag/v1.29.0) + +### Edge Runtime +- Updated to `v1.69.23` - [Release](https://github.com/supabase/edge-runtime/releases/tag/v1.69.23) + +### Supavisor +- Updated to `2.7.4` - [Release](https://github.com/supabase/supavisor/releases/tag/v2.7.4) + +--- diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 4a7448920cf5d..3ac053a937936 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -11,7 +11,7 @@ services: studio: container_name: supabase-studio - image: supabase/studio:2025.10.27-sha-85b84e0 + image: supabase/studio:2025.11.10-sha-5291fe3 restart: unless-stopped healthcheck: test: @@ -90,7 +90,7 @@ services: auth: container_name: supabase-auth - image: supabase/gotrue:v2.180.0 + image: supabase/gotrue:v2.182.1 restart: unless-stopped healthcheck: test: @@ -197,7 +197,7 @@ services: realtime: # This container name looks inconsistent but is correct because realtime constructs tenant id by parsing the subdomain container_name: realtime-dev.supabase-realtime - image: supabase/realtime:v2.57.2 + image: supabase/realtime:v2.63.0 restart: unless-stopped depends_on: db: @@ -242,7 +242,7 @@ services: # To use S3 backed storage: docker compose -f docker-compose.yml -f docker-compose.s3.yml up storage: container_name: supabase-storage - image: supabase/storage-api:v1.28.2 + image: supabase/storage-api:v1.29.0 restart: unless-stopped volumes: - ./volumes/storage:/var/lib/storage:z @@ -326,7 +326,7 @@ services: functions: container_name: supabase-edge-functions - image: supabase/edge-runtime:v1.69.15 + image: supabase/edge-runtime:v1.69.23 restart: unless-stopped volumes: - ./volumes/functions:/home/deno/functions:Z @@ -486,7 +486,7 @@ services: # Update the DATABASE_URL if you are using an external Postgres database supavisor: container_name: supabase-pooler - image: supabase/supavisor:2.7.3 + image: supabase/supavisor:2.7.4 restart: unless-stopped ports: - ${POSTGRES_PORT}:5432 diff --git a/docker/versions.md b/docker/versions.md new file mode 100644 index 0000000000000..4eae0c4da93ce --- /dev/null +++ b/docker/versions.md @@ -0,0 +1,56 @@ +# Docker Image Versions + +## 2025-11-12 +- supabase/studio:2025.11.10-sha-5291fe3 (prev 2025.10.27-sha-85b84e0) +- supabase/gotrue:v2.182.1 (prev v2.180.0) +- supabase/realtime:v2.63.0 (prev v2.57.2) +- supabase/storage-api:v1.29.0 (prev v1.28.2) +- supabase/edge-runtime:v1.69.23 (prev v1.69.15) +- supabase/supavisor:2.7.4 (prev 2.7.3) + +## 2025-10-28 +- supabase/studio:2025.10.27-sha-85b84e0 (prev 2025.10.20-sha-5005fc6) +- supabase/realtime:v2.57.2 (prev v2.56.0) +- supabase/storage-api:v1.28.2 (prev v1.28.1) +- supabase/postgres-meta:v0.93.1 (prev v0.93.0) +- supabase/edge-runtime:v1.69.15 (prev v1.69.14) + +## 2025-10-21 +- supabase/studio:2025.10.20-sha-5005fc6 (prev 2025.10.01-sha-8460121) +- supabase/realtime:v2.56.0 (prev v2.51.11) +- supabase/storage-api:v1.28.1 (prev v1.28.0) +- supabase/postgres-meta:v0.93.0 (prev v0.91.6) +- supabase/edge-runtime:v1.69.14 (prev v1.69.6) +- supabase/supavisor:2.7.3 (prev 2.7.0) + +## 2025-10-13 +- supabase/logflare:1.22.6 (prev 1.22.4) + +## 2025-10-08 +- supabase/studio:2025.10.01-sha-8460121 (prev 2025.06.30-sha-6f5982d) +- supabase/gotrue:v2.180.0 (prev v2.177.0) +- postgrest/postgrest:v13.0.7 (prev v12.2.12) +- supabase/realtime:v2.51.11 (prev v2.34.47) +- supabase/storage-api:v1.28.0 (prev v1.25.7) +- supabase/postgres-meta:v0.91.6 (prev v0.91.0) +- supabase/logflare:1.22.4 (prev 1.14.2) +- supabase/postgres:15.8.1.085 (prev 15.8.1.060) +- supabase/supavisor:2.7.0 (prev 2.5.7) + +## 2025-07-15 +- supabase/gotrue:v2.177.0 (prev v2.176.1) +- supabase/storage-api:v1.25.7 (prev v1.24.7) +- supabase/postgres-meta:v0.91.0 (prev v0.89.3) +- supabase/supavisor:2.5.7 (prev 2.5.6) + +## 2025-07-02 +- supabase/studio:2025.06.30-sha-6f5982d (prev 2025.06.02-sha-8f2993d) +- supabase/gotrue:v2.176.1 (prev v2.174.0) +- supabase/storage-api:v1.24.7 (prev v1.23.0) +- supabase/supavisor:2.5.6 (prev 2.5.1) + +## 2025-06-03 +- supabase/studio:2025.06.02-sha-8f2993d (prev 2025.05.19-sha-3487831) +- supabase/gotrue:v2.174.0 (prev v2.172.1) +- supabase/storage-api:v1.23.0 (prev v1.22.17) +- supabase/postgres-meta:v0.89.3 (prev v0.89.0) diff --git a/packages/ui-patterns/package.json b/packages/ui-patterns/package.json index 0ec9860f191ee..fb2309218dbb4 100644 --- a/packages/ui-patterns/package.json +++ b/packages/ui-patterns/package.json @@ -671,4 +671,4 @@ "peerDependencies": { "next": "catalog:" } -} \ No newline at end of file +} diff --git a/packages/ui-patterns/src/CommandMenu/api/CommandMenu.tsx b/packages/ui-patterns/src/CommandMenu/api/CommandMenu.tsx index 99fa8fa7f5ff1..d7e45e218559e 100644 --- a/packages/ui-patterns/src/CommandMenu/api/CommandMenu.tsx +++ b/packages/ui-patterns/src/CommandMenu/api/CommandMenu.tsx @@ -171,9 +171,11 @@ function CommandMenuTrigger({ children }: PropsWithChildren) { function CommandMenuTriggerInput({ className, placeholder = 'Search...', + showShortcut = true, }: { className?: string placeholder?: string | React.ReactNode + showShortcut?: boolean }) { return ( @@ -199,15 +201,17 @@ function CommandMenuTriggerInput({ />

{placeholder}

-
- + )} ) diff --git a/packages/ui-patterns/src/TimestampInfo/index.tsx b/packages/ui-patterns/src/TimestampInfo/index.tsx index 7fa5d6e3b2f73..22b8c591d764d 100644 --- a/packages/ui-patterns/src/TimestampInfo/index.tsx +++ b/packages/ui-patterns/src/TimestampInfo/index.tsx @@ -51,8 +51,8 @@ export const TimestampInfo = ({ utcTimestamp, className, displayAs = 'local', - format = 'DD MMM HH:mm:ss', - labelFormat = 'DD MMM HH:mm:ss', + format = 'DD MMM YY HH:mm:ss', + labelFormat = 'DD MMM YY HH:mm:ss', label, }: { className?: string diff --git a/packages/ui-patterns/src/admonition.tsx b/packages/ui-patterns/src/admonition.tsx index a04d8f99dc3a7..22cd48c6adcd0 100644 --- a/packages/ui-patterns/src/admonition.tsx +++ b/packages/ui-patterns/src/admonition.tsx @@ -103,6 +103,7 @@ export const Admonition = forwardRef< children, layout = 'vertical', actions, + childProps = {}, ...props }, ref @@ -135,28 +136,25 @@ export const Admonition = forwardRef< {label || title ? (
{label || title} {description && ( - + {description} )} {/* // children is to handle Docs and MDX issues with children and

elements */} {children && ( {children} diff --git a/scripts/actions/find-stale-dashboard-prs.js b/scripts/actions/find-stale-dashboard-prs.js new file mode 100644 index 0000000000000..afd0d2efc85bd --- /dev/null +++ b/scripts/actions/find-stale-dashboard-prs.js @@ -0,0 +1,214 @@ +/** + * Finds stale Dashboard PRs (older than 24 hours) and fetches their status + * including review status and mergeable state. + * + * @param {Object} github - GitHub API client from actions/github-script + * @param {Object} context - GitHub Actions context + * @param {Object} core - GitHub Actions core utilities + * @returns {Array} Array of stale PRs with status information + */ +module.exports = async ({ github, context, core }) => { + const TWENTY_FOUR_HOURS_AGO = new Date(Date.now() - 24 * 60 * 60 * 1000) + const DASHBOARD_PATH = 'apps/studio/' + + console.log(`Looking for PRs older than: ${TWENTY_FOUR_HOURS_AGO.toISOString()}`) + + const stalePRs = [] + let page = 1 + let hasMore = true + + // Fetch PRs page by page, newest first + while (hasMore && page <= 10) { + // Limit to 10 pages (1000 PRs) as safety measure + console.log(`Fetching page ${page}...`) + + const { data: prs } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + sort: 'created', + direction: 'desc', + per_page: 100, + page: page, + }) + + if (prs.length === 0) { + hasMore = false + break + } + + // Check each PR + for (const pr of prs) { + // Skip PRs from forks - only check internal PRs + if (pr.head.repo && pr.head.repo.full_name !== context.repo.owner + '/' + context.repo.repo) { + console.log(`PR #${pr.number} is from a fork, skipping...`) + continue + } + + // Skip dependabot PRs + if (pr.user.login === 'dependabot[bot]' || pr.user.login === 'dependabot') { + console.log(`PR #${pr.number} is from dependabot, skipping...`) + continue + } + + // Skip draft PRs + if (pr.draft) { + console.log(`PR #${pr.number} is a draft, skipping...`) + continue + } + + const createdAt = new Date(pr.created_at) + + // If this PR is newer than 24 hours, skip it + if (createdAt > TWENTY_FOUR_HOURS_AGO) { + console.log(`PR #${pr.number} is too new, skipping...`) + continue + } + + console.log(`Checking PR #${pr.number}: ${pr.title}`) + + // Fetch files changed in this PR + const { data: files } = await github.rest.pulls.listFiles({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + per_page: 100, + }) + + // Check if any file is under apps/studio/ + const touchesDashboard = files.some((file) => file.filename.startsWith(DASHBOARD_PATH)) + + if (touchesDashboard) { + const hoursOld = Math.floor((Date.now() - createdAt.getTime()) / (1000 * 60 * 60)) + const daysOld = Math.floor(hoursOld / 24) + + // Fetch review status + let reviewStatus = 'no-reviews' + let reviewEmoji = ':eyes:' + try { + const { data: reviews } = await github.rest.pulls.listReviews({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + per_page: 100, + }) + + if (reviews.length === 0) { + reviewStatus = 'no-reviews' + reviewEmoji = ':eyes:' + } else { + // Get the most recent review from each reviewer + const latestReviews = {} + reviews.forEach((review) => { + if ( + !latestReviews[review.user.login] || + new Date(review.submitted_at) > + new Date(latestReviews[review.user.login].submitted_at) + ) { + latestReviews[review.user.login] = review + } + }) + + // Check for most critical state (Changes Requested > Approved > Commented) + const states = Object.values(latestReviews).map((r) => r.state) + if (states.includes('CHANGES_REQUESTED')) { + reviewStatus = 'changes-requested' + reviewEmoji = ':warning:' + } else if (states.includes('APPROVED')) { + reviewStatus = 'approved' + reviewEmoji = ':heavy_check_mark:' + } else { + reviewStatus = 'commented' + reviewEmoji = ':speech_balloon:' + } + } + } catch (error) { + console.log( + `Warning: Could not fetch review status for PR #${pr.number}: ${error.message}` + ) + } + + // Get mergeable state + let mergeableStatus = 'unknown' + let mergeableEmoji = ':grey_question:' + + // Fetch full PR details to get mergeable state (it's not always in the list response) + try { + const { data: fullPR } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + }) + + const mergeableState = fullPR.mergeable_state + + switch (mergeableState) { + case 'clean': + mergeableStatus = 'ready' + mergeableEmoji = ':rocket:' + break + case 'dirty': + mergeableStatus = 'conflicts' + mergeableEmoji = ':collision:' + break + case 'blocked': + mergeableStatus = 'blocked' + mergeableEmoji = ':no_entry:' + break + case 'unstable': + mergeableStatus = 'unstable' + mergeableEmoji = ':warning:' + break + case 'behind': + mergeableStatus = 'behind' + mergeableEmoji = ':arrow_down:' + break + case 'draft': + mergeableStatus = 'draft' + mergeableEmoji = ':pencil2:' + break + default: + mergeableStatus = mergeableState || 'unknown' + mergeableEmoji = ':grey_question:' + } + } catch (error) { + console.log( + `Warning: Could not fetch mergeable state for PR #${pr.number}: ${error.message}` + ) + } + + stalePRs.push({ + number: pr.number, + title: pr.title, + url: pr.html_url, + author: pr.user.login, + createdAt: pr.created_at, + hoursOld: hoursOld, + daysOld: daysOld, + fileCount: files.filter((f) => f.filename.startsWith(DASHBOARD_PATH)).length, + reviewStatus: reviewStatus, + reviewEmoji: reviewEmoji, + mergeableStatus: mergeableStatus, + mergeableEmoji: mergeableEmoji, + }) + + console.log( + `✓ Found stale Dashboard PR #${pr.number} (Review: ${reviewStatus}, Mergeable: ${mergeableStatus})` + ) + } + } + + page++ + } + + console.log(`Found ${stalePRs.length} stale Dashboard PRs`) + + // Sort by age (newest first) + stalePRs.sort((a, b) => a.hoursOld - b.hoursOld) + + // Store results for next step + core.setOutput('stale_prs', JSON.stringify(stalePRs)) + core.setOutput('count', stalePRs.length) + + return stalePRs +} diff --git a/scripts/actions/send-slack-pr-notification.js b/scripts/actions/send-slack-pr-notification.js new file mode 100644 index 0000000000000..c9e56b0412377 --- /dev/null +++ b/scripts/actions/send-slack-pr-notification.js @@ -0,0 +1,110 @@ +/** + * Sends a Slack notification with stale Dashboard PRs + * + * @param {Array} stalePRs - Array of stale PRs from find-stale-dashboard-prs.js + * @param {string} webhookUrl - Slack webhook URL + */ +module.exports = async (stalePRs, webhookUrl) => { + const count = stalePRs.length + + // Build PR blocks with proper escaping for Slack mrkdwn + const prBlocks = stalePRs.map((pr) => { + // Format age display + const remainingHours = pr.hoursOld % 24 + const ageText = pr.daysOld > 0 ? `${pr.daysOld}d ${remainingHours}h` : `${pr.hoursOld}h` + + // Escape special characters for Slack mrkdwn (escape &, <, >) + const escapeSlack = (text) => { + return text.replace(/&/g, '&').replace(//g, '>') + } + + // Truncate title if too long (max 3000 chars for entire text field) + const maxTitleLength = 200 + const safeTitle = + pr.title.length > maxTitleLength + ? escapeSlack(pr.title.substring(0, maxTitleLength) + '...') + : escapeSlack(pr.title) + + // Format status text + const reviewStatusText = + pr.reviewStatus === 'approved' + ? 'Approved' + : pr.reviewStatus === 'changes-requested' + ? 'Changes Requested' + : pr.reviewStatus === 'commented' + ? 'Commented' + : 'Needs Review' + + const mergeableStatusText = + pr.mergeableStatus === 'ready' + ? 'Ready to Merge' + : pr.mergeableStatus === 'conflicts' + ? 'Has Conflicts' + : pr.mergeableStatus === 'blocked' + ? 'Blocked' + : pr.mergeableStatus === 'unstable' + ? 'Unstable' + : pr.mergeableStatus === 'behind' + ? 'Behind Base' + : pr.mergeableStatus === 'draft' + ? 'Draft' + : 'Unknown' + + return { + type: 'section', + text: { + type: 'mrkdwn', + text: `*<${pr.url}|#${pr.number}: ${safeTitle}>*\n:bust_in_silhouette: @${pr.author} • :clock3: ${ageText} old • :file_folder: ${pr.fileCount} Dashboard files\n${pr.reviewEmoji} ${reviewStatusText} • ${pr.mergeableEmoji} ${mergeableStatusText}`, + }, + } + }) + + // Slack has a 50 block limit, we use 3 for header/intro/divider + // So we can show max 47 PRs + const MAX_PRS_TO_SHOW = 47 + const prBlocksToShow = prBlocks.slice(0, MAX_PRS_TO_SHOW) + const hasMorePRs = prBlocks.length > MAX_PRS_TO_SHOW + + // Build complete Slack message + const slackMessage = { + text: 'Dashboard PRs needing attention', + blocks: [ + { + type: 'header', + text: { + type: 'plain_text', + text: 'Dashboard PRs Older Than 24 Hours', + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: `There are *${count}* open PRs affecting /apps/studio/ that are older than 24 hours:${hasMorePRs ? ` (showing first ${MAX_PRS_TO_SHOW})` : ''}`, + }, + }, + { + type: 'divider', + }, + ...prBlocksToShow, + ], + } + + // Send to Slack + const response = await fetch(webhookUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(slackMessage), + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error( + `Slack notification failed: ${response.status} ${response.statusText}\n${errorText}` + ) + } + + console.log('✓ Slack notification sent successfully!') +}