From a448877f2468768f6b6b60b8ed42c24874c9478a Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Fri, 29 Aug 2025 19:36:29 +1000 Subject: [PATCH 01/24] Fix policy alignment (#38297) fix alignment --- apps/studio/pages/project/[ref]/auth/policies.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/studio/pages/project/[ref]/auth/policies.tsx b/apps/studio/pages/project/[ref]/auth/policies.tsx index 8a602e108d8..e5e73f13189 100644 --- a/apps/studio/pages/project/[ref]/auth/policies.tsx +++ b/apps/studio/pages/project/[ref]/auth/policies.tsx @@ -106,7 +106,7 @@ const AuthPoliciesPage: NextPageWithLayout = () => { } return ( - +
Date: Fri, 29 Aug 2025 12:20:51 +0200 Subject: [PATCH 02/24] feat(replication): Significantly improve the replication UI behavior (#38237) --- .../Replication/DeleteDestination.tsx | 44 +- .../Database/Replication/DestinationPanel.tsx | 777 +++++++++--------- .../Database/Replication/DestinationRow.tsx | 42 +- .../Database/Replication/Destinations.tsx | 4 +- .../Database/Replication/Pipeline.utils.ts | 43 +- .../Database/Replication/PipelineStatus.tsx | 22 +- .../Replication/ReplicationPipelineStatus.tsx | 31 +- .../ReplicationPipelineStatus.utils.tsx | 58 +- .../Replication/RetryOptionsDropdown.tsx | 2 +- .../Database/Replication/RowMenu.tsx | 32 +- .../analytics/functions-req-stats-query.ts | 2 +- .../functions-resource-usage-query.ts | 2 +- .../data/content/content-delete-mutation.ts | 2 +- .../data/lint/delete-lint-rule-mutation.ts | 2 +- .../create-destination-pipeline-mutation.ts | 35 +- .../delete-destination-pipeline-mutation.ts | 17 +- .../update-destination-pipeline-mutation.ts | 31 +- .../replication-pipeline-request-status.tsx | 93 ++- packages/api-types/types/api.d.ts | 180 +++- packages/api-types/types/platform.d.ts | 660 ++++++++------- 20 files changed, 1234 insertions(+), 845 deletions(-) diff --git a/apps/studio/components/interfaces/Database/Replication/DeleteDestination.tsx b/apps/studio/components/interfaces/Database/Replication/DeleteDestination.tsx index 4782500013a..1c1f633505f 100644 --- a/apps/studio/components/interfaces/Database/Replication/DeleteDestination.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DeleteDestination.tsx @@ -2,40 +2,32 @@ import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal' interface DeleteDestinationProps { visible: boolean - setVisible: (value: boolean) => void - onDelete: () => void isLoading: boolean name: string + setVisible: (value: boolean) => void + onDelete: () => void } -const DeleteDestination = ({ +export const DeleteDestination = ({ visible, - setVisible, - onDelete, isLoading, name, + setVisible, + onDelete, }: DeleteDestinationProps) => { return ( - <> - setVisible(!visible)} - onConfirm={onDelete} - title="Delete this destination" - loading={isLoading} - confirmLabel={`Delete destination`} - confirmPlaceholder="Type in name of destination" - confirmString={name ?? 'Unknown'} - text={ - <> - This will delete the destination{' '} - - } - alert={{ title: 'You cannot recover this destination once deleted.' }} - /> - + setVisible(!visible)} + onConfirm={onDelete} + /> ) } - -export default DeleteDestination diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel.tsx index 21f58cd2d20..68199c42788 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel.tsx @@ -1,10 +1,21 @@ import { zodResolver } from '@hookform/resolvers/zod' -import { useParams } from 'common' -import { useCreateTenantSourceMutation } from 'data/replication/create-tenant-source-mutation' -import { useReplicationPublicationsQuery } from 'data/replication/publications-query' -import { useStartPipelineMutation } from 'data/replication/start-pipeline-mutation' +import { useEffect, useMemo, useState } from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' +import * as z from 'zod' + +import { useParams } from 'common' +import { useCreateDestinationPipelineMutation } from 'data/replication/create-destination-pipeline-mutation' +import { useCreateTenantSourceMutation } from 'data/replication/create-tenant-source-mutation' +import { useReplicationDestinationByIdQuery } from 'data/replication/destination-by-id-query' +import { useReplicationPipelineByIdQuery } from 'data/replication/pipeline-by-id-query' +import { useReplicationPublicationsQuery } from 'data/replication/publications-query' +import { useStartPipelineMutation } from 'data/replication/start-pipeline-mutation' +import { useUpdateDestinationPipelineMutation } from 'data/replication/update-destination-pipeline-mutation' +import { + PipelineStatusRequestStatus, + usePipelineRequestStatus, +} from 'state/replication-pipeline-request-status' import { Accordion_Shadcn_, AccordionContent_Shadcn_, @@ -23,6 +34,7 @@ import { SelectGroup_Shadcn_, SelectItem_Shadcn_, SelectTrigger_Shadcn_, + Separator, Sheet, SheetContent, SheetDescription, @@ -30,21 +42,28 @@ import { SheetHeader, SheetSection, SheetTitle, - Switch, TextArea_Shadcn_, WarningIcon, - Label_Shadcn_ as Label, } from 'ui' -import * as z from 'zod' -import PublicationsComboBox from './PublicationsComboBox' -import NewPublicationPanel from './NewPublicationPanel' -import { useState, useMemo, useEffect } from 'react' -import { useReplicationDestinationByIdQuery } from 'data/replication/destination-by-id-query' -import { useReplicationPipelineByIdQuery } from 'data/replication/pipeline-by-id-query' -import { useStopPipelineMutation } from 'data/replication/stop-pipeline-mutation' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' -import { useCreateDestinationPipelineMutation } from 'data/replication/create-destination-pipeline-mutation' -import { useUpdateDestinationPipelineMutation } from 'data/replication/update-destination-pipeline-mutation' +import NewPublicationPanel from './NewPublicationPanel' +import PublicationsComboBox from './PublicationsComboBox' + +const formId = 'destination-editor' +const types = ['BigQuery'] as const +const TypeEnum = z.enum(types) + +const FormSchema = z.object({ + type: TypeEnum, + name: z.string().min(1, 'Name is required'), + projectId: z.string().min(1, 'Project id is required'), + datasetId: z.string().min(1, 'Dataset id is required'), + serviceAccountKey: z.string().min(1, 'Service account key is required'), + publicationName: z.string().min(1, 'Publication is required'), + maxSize: z.number().min(1, 'Max Size must be greater than 0').int().optional(), + maxFillMs: z.number().min(1, 'Max Fill milliseconds should be greater than 0').int().optional(), + maxStalenessMins: z.number().nonnegative().optional(), +}) interface DestinationPanelProps { visible: boolean @@ -58,22 +77,33 @@ interface DestinationPanelProps { } } -const DestinationPanel = ({ +export const DestinationPanel = ({ visible, sourceId, onClose, existingDestination, }: DestinationPanelProps) => { const { ref: projectRef } = useParams() + const { setRequestStatus } = usePipelineRequestStatus() + + const editMode = !!existingDestination const [publicationPanelVisible, setPublicationPanelVisible] = useState(false) + const { mutateAsync: createTenantSource, isLoading: creatingTenantSource } = useCreateTenantSourceMutation() + const { mutateAsync: createDestinationPipeline, isLoading: creatingDestinationPipeline } = - useCreateDestinationPipelineMutation() - const { mutateAsync: startPipeline, isLoading: startingPipeline } = useStartPipelineMutation() - const { mutateAsync: stopPipeline, isLoading: stoppingPipeline } = useStopPipelineMutation() + useCreateDestinationPipelineMutation({ + onSuccess: () => form.reset(defaultValues), + }) + const { mutateAsync: updateDestinationPipeline, isLoading: updatingDestinationPipeline } = - useUpdateDestinationPipelineMutation() + useUpdateDestinationPipelineMutation({ + onSuccess: () => form.reset(defaultValues), + }) + + const { mutateAsync: startPipeline, isLoading: startingPipeline } = useStartPipelineMutation() + const { data: publications, isLoading: loadingPublications } = useReplicationPublicationsQuery({ projectRef, sourceId, @@ -89,26 +119,6 @@ const DestinationPanel = ({ pipelineId: existingDestination?.pipelineId, }) - const isCreating = creatingTenantSource || creatingDestinationPipeline || startingPipeline - const isUpdating = updatingDestinationPipeline || stoppingPipeline || startingPipeline - const isSubmitting = isCreating || isUpdating - const editMode = !!existingDestination - - const formId = 'destination-editor' - const types = ['BigQuery'] as const - const TypeEnum = z.enum(types) - const FormSchema = z.object({ - type: TypeEnum, - name: z.string().min(1, 'Name is required'), - projectId: z.string().min(1, 'Project id is required'), - datasetId: z.string().min(1, 'Dataset id is required'), - serviceAccountKey: z.string().min(1, 'Service account key is required'), - publicationName: z.string().min(1, 'Publication is required'), - maxSize: z.number().min(1, 'Max Size must be greater than 0').int(), - maxFillMs: z.number().min(1, 'Max Fill milliseconds should be greater than 0').int(), - maxStalenessMins: z.number().nonnegative(), - enabled: z.boolean(), - }) const defaultValues = useMemo( () => ({ type: TypeEnum.enum.BigQuery, @@ -118,403 +128,406 @@ const DestinationPanel = ({ // For now, the password will always be set as empty for security reasons. serviceAccountKey: destinationData?.config?.big_query?.service_account_key ?? '', publicationName: pipelineData?.config.publication_name ?? '', - maxSize: pipelineData?.config?.batch?.max_size ?? 1000, - maxFillMs: pipelineData?.config?.batch?.max_fill_ms ?? 10, - maxStalenessMins: destinationData?.config?.big_query?.max_staleness_mins ?? 5, - enabled: existingDestination?.enabled ?? true, + maxSize: pipelineData?.config?.batch?.max_size, + maxFillMs: pipelineData?.config?.batch?.max_fill_ms, + maxStalenessMins: destinationData?.config?.big_query?.max_staleness_mins, }), - [destinationData, pipelineData, existingDestination] + [destinationData, pipelineData] ) + const form = useForm>({ mode: 'onBlur', reValidateMode: 'onBlur', resolver: zodResolver(FormSchema), defaultValues, }) + const isSaving = creatingDestinationPipeline || updatingDestinationPipeline || startingPipeline + const onSubmit = async (data: z.infer) => { if (!projectRef) return console.error('Project ref is required') + if (!sourceId) return console.error('Source id is required') + try { if (editMode && existingDestination) { - if (!sourceId) { - console.error('Source id is required') - return + if (!existingDestination.pipelineId) return console.error('Pipeline id is required') + + const bigQueryConfig: any = { + projectId: data.projectId, + datasetId: data.datasetId, + serviceAccountKey: data.serviceAccountKey, } - if (!existingDestination.pipelineId) { - console.error('Pipeline id is required') - return + if (!!data.maxStalenessMins) { + bigQueryConfig.maxStalenessMins = data.maxStalenessMins } - // Update existing destination + + const batchConfig: any = {} + if (!!data.maxSize) batchConfig.maxSize = data.maxSize + if (!!data.maxFillMs) batchConfig.maxFillMs = data.maxFillMs + const hasBothBatchFields = Object.keys(batchConfig).length === 2 + await updateDestinationPipeline({ destinationId: existingDestination.destinationId, pipelineId: existingDestination.pipelineId, projectRef, destinationName: data.name, - destinationConfig: { - bigQuery: { - projectId: data.projectId, - datasetId: data.datasetId, - serviceAccountKey: data.serviceAccountKey, - maxStalenessMins: data.maxStalenessMins, - }, - }, + destinationConfig: { bigQuery: bigQueryConfig }, pipelineConfig: { publicationName: data.publicationName, - batch: { maxSize: data.maxSize, maxFillMs: data.maxFillMs }, + ...(hasBothBatchFields ? { batch: batchConfig } : {}), }, sourceId, }) - if (data.enabled) { - await startPipeline({ projectRef, pipelineId: existingDestination.pipelineId }) + // Set request status only right before starting, then fire and close + const snapshot = existingDestination.enabled ? 'started' : 'stopped' + if (existingDestination.enabled) { + setRequestStatus( + existingDestination.pipelineId, + PipelineStatusRequestStatus.RestartRequested, + snapshot + ) + toast.success('Settings applied. Restarting the pipeline...') } else { - await stopPipeline({ projectRef, pipelineId: existingDestination.pipelineId }) + setRequestStatus( + existingDestination.pipelineId, + PipelineStatusRequestStatus.StartRequested, + snapshot + ) + toast.success('Settings applied. Starting the pipeline...') + } + startPipeline({ projectRef, pipelineId: existingDestination.pipelineId }) + onClose() + } else { + const bigQueryConfig: any = { + projectId: data.projectId, + datasetId: data.datasetId, + serviceAccountKey: data.serviceAccountKey, + } + if (!!data.maxStalenessMins) { + bigQueryConfig.maxStalenessMins = data.maxStalenessMins } - toast.success('Successfully updated destination') - } else { - // Create new destination - if (!sourceId) { - console.error('Source id is required') - return - } + const batchConfig: any = {} + if (!!data.maxSize) batchConfig.maxSize = data.maxSize + if (!!data.maxFillMs) batchConfig.maxFillMs = data.maxFillMs + const hasBothBatchFields = Object.keys(batchConfig).length === 2 + const { pipeline_id: pipelineId } = await createDestinationPipeline({ projectRef, destinationName: data.name, - destinationConfig: { - bigQuery: { - projectId: data.projectId, - datasetId: data.datasetId, - serviceAccountKey: data.serviceAccountKey, - maxStalenessMins: data.maxStalenessMins, - }, - }, + destinationConfig: { bigQuery: bigQueryConfig }, sourceId, pipelineConfig: { publicationName: data.publicationName, - batch: { maxSize: data.maxSize, maxFillMs: data.maxFillMs }, + ...(hasBothBatchFields ? { batch: batchConfig } : {}), }, }) - if (data.enabled) { - await startPipeline({ projectRef, pipelineId }) - } - toast.success('Successfully created destination') + // Set request status only right before starting, then fire and close + setRequestStatus(pipelineId, PipelineStatusRequestStatus.StartRequested, undefined) + toast.success('Destination created. Starting the pipeline...') + startPipeline({ projectRef, pipelineId }) + onClose() } - onClose() } catch (error) { - toast.error(`Failed to ${editMode ? 'update' : 'create'} destination`) + const action = editMode ? 'apply and run' : 'create and start' + toast.error(`Failed to ${action} destination`) } } + const onEnableReplication = async () => { if (!projectRef) return console.error('Project ref is required') await createTenantSource({ projectRef }) } - const { enabled } = form.watch() - useEffect(() => { if (editMode && destinationData && pipelineData) { form.reset(defaultValues) } }, [destinationData, pipelineData, editMode, defaultValues, form]) - return ( + return sourceId ? ( <> - {sourceId ? ( - <> - - -
- -
- {editMode ? 'Edit Destination' : 'New Destination'} - - {editMode ? null : 'Send data to a new destination'} - -
-
- { - form.setValue('enabled', checked) - }} + + +
+ + {editMode ? 'Edit destination' : 'Create a new destination'} + + {editMode ? null : 'Send data to a new destination'} + + + + +
+
+ ( + + + + + + )} + /> + +

What data to send

+ + ( + + + pub.name) || []} + loading={loadingPublications} + field={field} + onNewPublicationClick={() => setPublicationPanelVisible(true)} + /> + + + )} + /> + +

Where to send that data

+ + ( + + + + {field.value} + + + BigQuery + + + + + + )} + /> + + ( + + + + + + )} + /> + + ( + + + + + + )} + /> + + ( + + + + + + )} /> -
- - - - - ( - - - - - - )} - /> -

What data to send

- ( - - - pub.name) || []} - loading={loadingPublications} - field={field} - onNewPublicationClick={() => setPublicationPanelVisible(true)} - /> - - - )} - /> -

Where to send that data

+ - ( - - - - {field.value} - - - - BigQuery - - - - - - - )} - > - - ( - - - - - - )} - /> - ( - - - - - - )} - /> - ( - - - - - - )} - /> - - - - - Advanced Settings - - - ( - - - - - - )} - /> - ( - - - - - - )} - /> - ( - - - - - - )} - /> - - - -
- ( - - - - - - )} - /> -
- -
-
- - - - -
-
-
- setPublicationPanelVisible(false)} - /> - - ) : ( - <> - - -
- - New Destination - - - - - - {/* Pricing to be decided yet */} - Enabling replication will cost additional $xx.xx - - - -
- -
-
-
-
- - - -
-
-
- - )} +
+ + + + Advanced Settings + + + ( + + + { + const val = e.target.value + field.onChange(val === '' ? undefined : Number(val)) + }} + placeholder="Leave empty for default" + /> + + + )} + /> + ( + + + { + const val = e.target.value + field.onChange(val === '' ? undefined : Number(val)) + }} + placeholder="Leave empty for default" + /> + + + )} + /> + ( + + + { + const val = e.target.value + field.onChange(val === '' ? undefined : Number(val)) + }} + placeholder="Leave empty for default" + /> + + + )} + /> + + + +
+ + + + + + + +
+ + + setPublicationPanelVisible(false)} + /> + ) : ( + + +
+ + Create a new destination + + + + + + {/* Pricing to be decided yet */} + Enabling replication will cost additional $xx.xx + + + +
+ +
+
+
+
+ + + +
+
+
) } - -export default DestinationPanel diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx index 85d315f7ef7..16437c30b5b 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationRow.tsx @@ -6,18 +6,20 @@ import { useParams } from 'common' import Table from 'components/to-be-cleaned/Table' import AlertError from 'components/ui/AlertError' import { useDeleteDestinationPipelineMutation } from 'data/replication/delete-destination-pipeline-mutation' +import { useReplicationPipelineReplicationStatusQuery } from 'data/replication/pipeline-replication-status-query' import { useReplicationPipelineStatusQuery } from 'data/replication/pipeline-status-query' import { Pipeline } from 'data/replication/pipelines-query' import { useStopPipelineMutation } from 'data/replication/stop-pipeline-mutation' +import { AlertCircle } from 'lucide-react' import { PipelineStatusRequestStatus, usePipelineRequestStatus, } from 'state/replication-pipeline-request-status' import { ResponseError } from 'types' -import { Button } from 'ui' +import { Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui' import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' -import DeleteDestination from './DeleteDestination' -import DestinationPanel from './DestinationPanel' +import { DeleteDestination } from './DeleteDestination' +import { DestinationPanel } from './DestinationPanel' import { getStatusName, PIPELINE_ERROR_MESSAGES } from './Pipeline.utils' import { PipelineStatus, PipelineStatusName } from './PipelineStatus' import { STATUS_REFRESH_FREQUENCY_MS } from './Replication.constants' @@ -48,6 +50,7 @@ export const DestinationRow = ({ }: DestinationRowProps) => { const { ref: projectRef } = useParams() const [showDeleteDestinationForm, setShowDeleteDestinationForm] = useState(false) + const [isDeleting, setIsDeleting] = useState(false) const [showEditDestinationPanel, setShowEditDestinationPanel] = useState(false) const { @@ -74,6 +77,15 @@ export const DestinationRow = ({ const pipelineStatus = pipelineStatusData?.status const statusName = getStatusName(pipelineStatus) + // Fetch table-level replication status to surface errors in list view + const { data: replicationStatusData } = useReplicationPipelineReplicationStatusQuery( + { projectRef, pipelineId: pipeline?.id }, + { refetchInterval: STATUS_REFRESH_FREQUENCY_MS } + ) + const tableStatuses = replicationStatusData?.table_statuses ?? [] + const errorCount = tableStatuses.filter((t) => t.state?.name === 'error').length + const hasTableErrors = errorCount > 0 + const onDeleteClick = async () => { if (!projectRef) { return console.error('Project ref is required') @@ -83,14 +95,20 @@ export const DestinationRow = ({ } try { + setIsDeleting(true) await stopPipeline({ projectRef, pipelineId: pipeline.id }) await deleteDestinationPipeline({ projectRef, destinationId: destinationId, pipelineId: pipeline.id, }) + // Close dialog after successful deletion + setShowDeleteDestinationForm(false) + toast.success(`Deleted destination "${destinationName}"`) } catch (error) { toast.error(PIPELINE_ERROR_MESSAGES.DELETE_DESTINATION) + } finally { + setIsDeleting(false) } } @@ -132,9 +150,21 @@ export const DestinationRow = ({
- {
)} - setShowNewDestinationPanel(false)} diff --git a/apps/studio/components/interfaces/Database/Replication/Pipeline.utils.ts b/apps/studio/components/interfaces/Database/Replication/Pipeline.utils.ts index 2d3efaf2337..eb69b88e689 100644 --- a/apps/studio/components/interfaces/Database/Replication/Pipeline.utils.ts +++ b/apps/studio/components/interfaces/Database/Replication/Pipeline.utils.ts @@ -22,16 +22,25 @@ export const getStatusName = ( return undefined } +export const PIPELINE_ENABLE_ALLOWED_FROM = ['stopped'] as const +export const PIPELINE_DISABLE_ALLOWED_FROM = ['started', 'failed'] as const +export const PIPELINE_ACTIONABLE_STATES = ['failed', 'started', 'stopped'] as const + const PIPELINE_STATE_MESSAGES = { enabling: { - title: 'Pipeline enabling', - message: 'Starting the pipeline. Table replication will resume once enabled.', - badge: 'Enabling', + title: 'Starting pipeline', + message: 'Starting the pipeline. Table replication will resume once running.', + badge: 'Starting', }, disabling: { - title: 'Pipeline disabling', - message: 'Stopping the pipeline. Table replication will be paused once disabled.', - badge: 'Disabling', + title: 'Stopping pipeline', + message: 'Stopping the pipeline. Table replication will be paused once stopped.', + badge: 'Stopping', + }, + restarting: { + title: 'Restarting pipeline', + message: 'Applying settings and restarting the pipeline.', + badge: 'Restarting', }, failed: { title: 'Pipeline failed', @@ -40,7 +49,7 @@ const PIPELINE_STATE_MESSAGES = { }, stopped: { title: 'Pipeline stopped', - message: 'Replication is paused. Enable the pipeline to resume data synchronization.', + message: 'Replication is paused. Start the pipeline to resume data synchronization.', badge: 'Stopped', }, starting: { @@ -60,8 +69,8 @@ const PIPELINE_STATE_MESSAGES = { }, notRunning: { title: 'Pipeline not running', - message: 'Replication is not active. Enable the pipeline to start data synchronization.', - badge: 'Disabled', + message: 'Replication is not active. Start the pipeline to begin data synchronization.', + badge: 'Stopped', }, } as const @@ -69,23 +78,25 @@ export const getPipelineStateMessages = ( requestStatus: PipelineStatusRequestStatus | undefined, statusName: string | undefined ) => { - // Always prioritize request status (enabling/disabling) over pipeline status - if (requestStatus === PipelineStatusRequestStatus.EnableRequested) { + // Reflect optimistic request intent immediately after click + if (requestStatus === PipelineStatusRequestStatus.RestartRequested) { + return PIPELINE_STATE_MESSAGES.restarting + } + if (requestStatus === PipelineStatusRequestStatus.StartRequested) { return PIPELINE_STATE_MESSAGES.enabling } - - if (requestStatus === PipelineStatusRequestStatus.DisableRequested) { + if (requestStatus === PipelineStatusRequestStatus.StopRequested) { return PIPELINE_STATE_MESSAGES.disabling } - // Only check pipeline status if no request is in progress + // Fall back to steady states switch (statusName) { + case 'starting': + return PIPELINE_STATE_MESSAGES.starting case 'failed': return PIPELINE_STATE_MESSAGES.failed case 'stopped': return PIPELINE_STATE_MESSAGES.stopped - case 'starting': - return PIPELINE_STATE_MESSAGES.starting case 'started': return PIPELINE_STATE_MESSAGES.running case 'unknown': diff --git a/apps/studio/components/interfaces/Database/Replication/PipelineStatus.tsx b/apps/studio/components/interfaces/Database/Replication/PipelineStatus.tsx index 69a988996d7..14e8b9a9969 100644 --- a/apps/studio/components/interfaces/Database/Replication/PipelineStatus.tsx +++ b/apps/studio/components/interfaces/Database/Replication/PipelineStatus.tsx @@ -45,18 +45,26 @@ export const PipelineStatus = ({ // Get consistent tooltip message using the same logic as other components const stateMessages = getPipelineStateMessages(requestStatus, statusName) - if (requestStatus === PipelineStatusRequestStatus.EnableRequested) { + // Show optimistic request state while backend still reports steady states + if (requestStatus === PipelineStatusRequestStatus.RestartRequested) { return { - label: 'Enabling...', - dot: , - color: 'text-brand-600', + label: 'Restarting', + dot: , + color: 'text-warning-600', tooltip: stateMessages.message, } } - - if (requestStatus === PipelineStatusRequestStatus.DisableRequested) { + if (requestStatus === PipelineStatusRequestStatus.StartRequested) { return { - label: 'Disabling...', + label: 'Starting', + dot: , + color: 'text-warning-600', + tooltip: stateMessages.message, + } + } + if (requestStatus === PipelineStatusRequestStatus.StopRequested) { + return { + label: 'Stopping', dot: , color: 'text-warning-600', tooltip: stateMessages.message, diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.tsx index dd098652c76..3d62d5f710a 100644 --- a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.tsx +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.tsx @@ -1,7 +1,3 @@ -// @ts-nocheck [Joshen] Temporarily silencing the TS checks here but please eventually remove -// it's cause the API types are conflicting a bit - API types have probably been updated for the UI here -// but the UI hasn't been updated yet to fit the new API types - import { Activity, ChevronLeft, ExternalLink, Search, X } from 'lucide-react' import Link from 'next/link' import { useEffect, useState } from 'react' @@ -24,7 +20,13 @@ import { Badge, Button, cn } from 'ui' import { GenericSkeletonLoader } from 'ui-patterns' import { Input } from 'ui-patterns/DataInputs/Input' import { ErroredTableDetails } from './ErroredTableDetails' -import { getStatusName, PIPELINE_ERROR_MESSAGES } from './Pipeline.utils' +import { + PIPELINE_ACTIONABLE_STATES, + PIPELINE_DISABLE_ALLOWED_FROM, + PIPELINE_ENABLE_ALLOWED_FROM, + PIPELINE_ERROR_MESSAGES, + getStatusName, +} from './Pipeline.utils' import { PipelineStatus } from './PipelineStatus' import { STATUS_REFRESH_FREQUENCY_MS } from './Replication.constants' import { TableState } from './ReplicationPipelineStatus.types' @@ -97,8 +99,9 @@ export const ReplicationPipelineStatus = () => { const isPipelineRunning = statusName === 'started' const hasTableData = tableStatuses.length > 0 const isEnablingDisabling = - requestStatus === PipelineStatusRequestStatus.EnableRequested || - requestStatus === PipelineStatusRequestStatus.DisableRequested + requestStatus === PipelineStatusRequestStatus.StartRequested || + requestStatus === PipelineStatusRequestStatus.StopRequested || + requestStatus === PipelineStatusRequestStatus.RestartRequested const showDisabledState = !isPipelineRunning || isEnablingDisabling const onTogglePipeline = async () => { @@ -110,12 +113,12 @@ export const ReplicationPipelineStatus = () => { } try { - if (statusName === 'stopped') { + if (PIPELINE_ENABLE_ALLOWED_FROM.includes(statusName as any)) { + setRequestStatus(pipeline.id, PipelineStatusRequestStatus.StartRequested, statusName) await startPipeline({ projectRef, pipelineId: pipeline.id }) - setRequestStatus(pipeline.id, PipelineStatusRequestStatus.EnableRequested) - } else if (statusName === 'started') { + } else if (PIPELINE_DISABLE_ALLOWED_FROM.includes(statusName as any)) { + setRequestStatus(pipeline.id, PipelineStatusRequestStatus.StopRequested, statusName) await stopPipeline({ projectRef, pipelineId: pipeline.id }) - setRequestStatus(pipeline.id, PipelineStatusRequestStatus.DisableRequested) } } catch (error) { toast.error(PIPELINE_ERROR_MESSAGES.ENABLE_DESTINATION) @@ -181,9 +184,9 @@ export const ReplicationPipelineStatus = () => { type={statusName === 'stopped' ? 'primary' : 'default'} onClick={() => onTogglePipeline()} loading={isPipelineError || isStartingPipeline || isStoppingPipeline} - disabled={!['failed', 'started', 'stopped', 'stopping'].includes(statusName ?? '')} + disabled={!PIPELINE_ACTIONABLE_STATES.includes((statusName ?? '') as any)} > - {statusName === 'stopped' ? 'Enable' : 'Disable'} pipeline + {statusName === 'stopped' ? 'Start' : 'Stop'} pipeline
@@ -286,7 +289,7 @@ export const ReplicationPipelineStatus = () => { Status unavailable while pipeline is {config.badge.toLowerCase()}

) : ( -
+
{statusConfig.description}
diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.utils.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.utils.tsx index af4207cc5c5..cb237042615 100644 --- a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.utils.tsx +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.utils.tsx @@ -56,9 +56,10 @@ export const getDisabledStateConfig = ({ const { title, message, badge } = getPipelineStateMessages(requestStatus, statusName) // Get icon and colors based on current state - const isEnabling = requestStatus === PipelineStatusRequestStatus.EnableRequested - const isDisabling = requestStatus === PipelineStatusRequestStatus.DisableRequested - const isTransitioning = isEnabling || isDisabling + const isEnabling = requestStatus === PipelineStatusRequestStatus.StartRequested + const isDisabling = requestStatus === PipelineStatusRequestStatus.StopRequested + const isRestarting = requestStatus === PipelineStatusRequestStatus.RestartRequested + const isTransitioning = isEnabling || isDisabling || isRestarting const icon = isTransitioning ? ( @@ -72,37 +73,38 @@ export const getDisabledStateConfig = ({ ) - const colors = isEnabling - ? { - bg: 'bg-brand-50', - text: 'text-brand-900', - subtext: 'text-brand-700', - iconBg: 'bg-brand-600', - icon: 'text-white dark:text-black', - } - : isDisabling || statusName === 'starting' || statusName === 'unknown' + const colors = + isEnabling || isRestarting ? { - bg: 'bg-warning-50', - text: 'text-warning-900', - subtext: 'text-warning-700', - iconBg: 'bg-warning-600', + bg: 'bg-brand-50', + text: 'text-brand-900', + subtext: 'text-brand-700', + iconBg: 'bg-brand-600', icon: 'text-white dark:text-black', } - : statusName === 'failed' + : isDisabling || statusName === 'starting' || statusName === 'unknown' ? { - bg: 'bg-destructive-50', - text: 'text-destructive-900', - subtext: 'text-destructive-700', - iconBg: 'bg-destructive-600', - icon: 'text-white dark:text-black', - } - : { - bg: 'bg-surface-100', - text: 'text-foreground', - subtext: 'text-foreground-light', - iconBg: 'bg-foreground-lighter', + bg: 'bg-warning-50', + text: 'text-warning-900', + subtext: 'text-warning-700', + iconBg: 'bg-warning-600', icon: 'text-white dark:text-black', } + : statusName === 'failed' + ? { + bg: 'bg-destructive-50', + text: 'text-destructive-900', + subtext: 'text-destructive-700', + iconBg: 'bg-destructive-600', + icon: 'text-white dark:text-black', + } + : { + bg: 'bg-surface-100', + text: 'text-foreground', + subtext: 'text-foreground-light', + iconBg: 'bg-foreground-lighter', + icon: 'text-white dark:text-black', + } return { title, message, badge, icon, colors } } diff --git a/apps/studio/components/interfaces/Database/Replication/RetryOptionsDropdown.tsx b/apps/studio/components/interfaces/Database/Replication/RetryOptionsDropdown.tsx index 350bd910bb4..147f8cc5bef 100644 --- a/apps/studio/components/interfaces/Database/Replication/RetryOptionsDropdown.tsx +++ b/apps/studio/components/interfaces/Database/Replication/RetryOptionsDropdown.tsx @@ -40,7 +40,7 @@ export const RetryOptionsDropdown = ({ tableId, tableName }: RetryOptionsDropdow const { mutate: rollbackTable, isLoading: isRollingBack } = useRollbackTableMutation({ onSuccess: (_, vars) => { const { projectRef, pipelineId } = vars - toast.success(`Table "${tableName}" rolled back successfully`) + toast.success(`Table "${tableName}" rolled back successfully and pipeline is being restarted`) startPipeline({ projectRef, pipelineId }) }, onError: (error, vars) => { diff --git a/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx b/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx index ae5734b2e9c..7e083058ed5 100644 --- a/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx +++ b/apps/studio/components/interfaces/Database/Replication/RowMenu.tsx @@ -20,7 +20,12 @@ import { DropdownMenuTrigger, } from 'ui' import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' -import { PIPELINE_ERROR_MESSAGES } from './Pipeline.utils' +import { + PIPELINE_DISABLE_ALLOWED_FROM, + PIPELINE_ENABLE_ALLOWED_FROM, + PIPELINE_ERROR_MESSAGES, + getStatusName, +} from './Pipeline.utils' import { PipelineStatusName } from './PipelineStatus' interface RowMenuProps { @@ -44,13 +49,6 @@ export const RowMenu = ({ }: RowMenuProps) => { const { ref: projectRef } = useParams() - const getStatusName = (status: any) => { - if (status && typeof status === 'object' && 'name' in status) { - return status.name - } - return status - } - const statusName = getStatusName(pipelineStatus) const pipelineEnabled = statusName !== PipelineStatusName.STOPPED @@ -67,10 +65,13 @@ export const RowMenu = ({ } try { + // Only show 'enabling' when transitioning from allowed states + if (PIPELINE_ENABLE_ALLOWED_FROM.includes(statusName as any)) { + setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.StartRequested, statusName) + } await startPipeline({ projectRef, pipelineId: pipeline.id }) - toast(`Enabling pipeline ${pipeline.destination_name}`) - setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.EnableRequested) } catch (error) { + setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.None) toast.error(PIPELINE_ERROR_MESSAGES.ENABLE_DESTINATION) } } @@ -86,10 +87,13 @@ export const RowMenu = ({ } try { + // Only show 'disabling' when transitioning from allowed states + if (PIPELINE_DISABLE_ALLOWED_FROM.includes(statusName as any)) { + setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.StopRequested, statusName) + } await stopPipeline({ projectRef, pipelineId: pipeline.id }) - toast(`Disabling pipeline ${pipeline.destination_name}`) - setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.DisableRequested) } catch (error) { + setGlobalRequestStatus(pipeline.id, PipelineStatusRequestStatus.None) toast.error(PIPELINE_ERROR_MESSAGES.DISABLE_DESTINATION) } } @@ -109,12 +113,12 @@ export const RowMenu = ({ {pipelineEnabled ? ( -

Disable pipeline

+

Stop pipeline

) : ( -

Enable pipeline

+

Start pipeline

)} diff --git a/apps/studio/data/analytics/functions-req-stats-query.ts b/apps/studio/data/analytics/functions-req-stats-query.ts index d386a07b465..5a765b4148f 100644 --- a/apps/studio/data/analytics/functions-req-stats-query.ts +++ b/apps/studio/data/analytics/functions-req-stats-query.ts @@ -6,7 +6,7 @@ import { analyticsKeys } from './keys' export type FunctionsReqStatsVariables = { projectRef?: string functionId?: string - interval?: operations['FunctionRequestLogsController_getStatus']['parameters']['query']['interval'] + interval?: operations['FunctionsLogsController_getRequestStats']['parameters']['query']['interval'] } export type FunctionsReqStatsResponse = any diff --git a/apps/studio/data/analytics/functions-resource-usage-query.ts b/apps/studio/data/analytics/functions-resource-usage-query.ts index cbb71f1d78e..ae767d52ebb 100644 --- a/apps/studio/data/analytics/functions-resource-usage-query.ts +++ b/apps/studio/data/analytics/functions-resource-usage-query.ts @@ -6,7 +6,7 @@ import { analyticsKeys } from './keys' export type FunctionsResourceUsageVariables = { projectRef?: string functionId?: string - interval?: operations['FunctionResourceLogsController_getStatus']['parameters']['query']['interval'] + interval?: operations['FunctionsLogsController_getRequestStats']['parameters']['query']['interval'] } export type FunctionsResourceUsageResponse = any diff --git a/apps/studio/data/content/content-delete-mutation.ts b/apps/studio/data/content/content-delete-mutation.ts index d5fd129fa3f..a8110e1e8f6 100644 --- a/apps/studio/data/content/content-delete-mutation.ts +++ b/apps/studio/data/content/content-delete-mutation.ts @@ -15,7 +15,7 @@ export async function deleteContents( headers: { Version: '2' }, params: { path: { ref: projectRef }, - query: { ids }, + query: { ids: ids.join(',') }, }, signal, }) diff --git a/apps/studio/data/lint/delete-lint-rule-mutation.ts b/apps/studio/data/lint/delete-lint-rule-mutation.ts index 70dca1fdd73..cf6538fdff5 100644 --- a/apps/studio/data/lint/delete-lint-rule-mutation.ts +++ b/apps/studio/data/lint/delete-lint-rule-mutation.ts @@ -12,7 +12,7 @@ export type LintRuleDeleteVariables = { export async function deleteLintRule({ projectRef, ids }: LintRuleDeleteVariables) { const { data, error } = await del('/platform/projects/{ref}/notifications/advisor/exceptions', { - params: { path: { ref: projectRef }, query: { ids } }, + params: { path: { ref: projectRef }, query: { ids: ids.join(',') } }, }) if (error) handleError(error) diff --git a/apps/studio/data/replication/create-destination-pipeline-mutation.ts b/apps/studio/data/replication/create-destination-pipeline-mutation.ts index 67866a6f18b..ce9e285b092 100644 --- a/apps/studio/data/replication/create-destination-pipeline-mutation.ts +++ b/apps/studio/data/replication/create-destination-pipeline-mutation.ts @@ -1,15 +1,15 @@ import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' +import { handleError, post } from 'data/fetchers' import type { ResponseError } from 'types' import { replicationKeys } from './keys' -import { handleError, post } from 'data/fetchers' export type BigQueryDestinationConfig = { projectId: string datasetId: string serviceAccountKey: string - maxStalenessMins: number + maxStalenessMins?: number } export type CreateDestinationPipelineParams = { @@ -21,7 +21,7 @@ export type CreateDestinationPipelineParams = { sourceId: number pipelineConfig: { publicationName: string - batch: { + batch?: { maxSize: number maxFillMs: number } @@ -35,10 +35,7 @@ async function createDestinationPipeline( destinationConfig: { bigQuery: { projectId, datasetId, serviceAccountKey, maxStalenessMins }, }, - pipelineConfig: { - publicationName, - batch: { maxSize, maxFillMs }, - }, + pipelineConfig: { publicationName, batch }, sourceId, }: CreateDestinationPipelineParams, signal?: AbortSignal @@ -48,23 +45,27 @@ async function createDestinationPipeline( const { data, error } = await post('/platform/replication/{ref}/destinations-pipelines', { params: { path: { ref: projectRef } }, body: { + source_id: sourceId, destination_name: destinationName, destination_config: { big_query: { project_id: projectId, dataset_id: datasetId, service_account_key: serviceAccountKey, - max_staleness_mins: maxStalenessMins, + ...(maxStalenessMins != null && { max_staleness_mins: maxStalenessMins }), }, }, pipeline_config: { publication_name: publicationName, - batch: { - max_size: maxSize, - max_fill_ms: maxFillMs, - }, + ...(batch + ? { + batch: { + max_size: batch.maxSize, + max_fill_ms: batch.maxFillMs, + }, + } + : {}), }, - source_id: sourceId, }, signal, }) @@ -92,8 +93,12 @@ export const useCreateDestinationPipelineMutation = ({ { async onSuccess(data, variables, context) { const { projectRef } = variables - await queryClient.invalidateQueries(replicationKeys.destinations(projectRef)) - await queryClient.invalidateQueries(replicationKeys.pipelines(projectRef)) + + await Promise.all([ + queryClient.invalidateQueries(replicationKeys.destinations(projectRef)), + queryClient.invalidateQueries(replicationKeys.pipelines(projectRef)), + ]) + await onSuccess?.(data, variables, context) }, async onError(data, variables, context) { diff --git a/apps/studio/data/replication/delete-destination-pipeline-mutation.ts b/apps/studio/data/replication/delete-destination-pipeline-mutation.ts index 2ccb2473423..91766e5a80d 100644 --- a/apps/studio/data/replication/delete-destination-pipeline-mutation.ts +++ b/apps/studio/data/replication/delete-destination-pipeline-mutation.ts @@ -1,9 +1,9 @@ import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' +import { del, handleError } from 'data/fetchers' import type { ResponseError } from 'types' import { replicationKeys } from './keys' -import { handleError, del } from 'data/fetchers' export type DeleteDestinationPipelineParams = { projectRef: string @@ -47,8 +47,19 @@ export const useDeleteDestinationPipelineMutation = ({ (vars) => deleteDestinationPipeline(vars), { async onSuccess(data, variables, context) { - const { projectRef } = variables - await queryClient.invalidateQueries(replicationKeys.destinations(projectRef)) + const { projectRef, destinationId, pipelineId } = variables + + await Promise.all([ + queryClient.invalidateQueries(replicationKeys.destinations(projectRef)), + queryClient.invalidateQueries(replicationKeys.pipelines(projectRef)), + queryClient.invalidateQueries(replicationKeys.pipelineById(projectRef, pipelineId)), + queryClient.invalidateQueries(replicationKeys.pipelinesStatus(projectRef, pipelineId)), + queryClient.invalidateQueries( + replicationKeys.pipelinesReplicationStatus(projectRef, pipelineId) + ), + queryClient.invalidateQueries(replicationKeys.destinationById(projectRef, destinationId)), + ]) + await onSuccess?.(data, variables, context) }, async onError(data, variables, context) { diff --git a/apps/studio/data/replication/update-destination-pipeline-mutation.ts b/apps/studio/data/replication/update-destination-pipeline-mutation.ts index ee0cd6539ce..2c04b48db4c 100644 --- a/apps/studio/data/replication/update-destination-pipeline-mutation.ts +++ b/apps/studio/data/replication/update-destination-pipeline-mutation.ts @@ -1,15 +1,15 @@ import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' +import { handleError, post } from 'data/fetchers' import type { ResponseError } from 'types' import { replicationKeys } from './keys' -import { handleError, post } from 'data/fetchers' export type BigQueryDestinationConfig = { projectId: string datasetId: string serviceAccountKey: string - maxStalenessMins: number + maxStalenessMins?: number } export type UpdateDestinationPipelineParams = { @@ -23,7 +23,7 @@ export type UpdateDestinationPipelineParams = { sourceId: number pipelineConfig: { publicationName: string - batch: { + batch?: { maxSize: number maxFillMs: number } @@ -39,10 +39,7 @@ async function updateDestinationPipeline( destinationConfig: { bigQuery: { projectId, datasetId, serviceAccountKey, maxStalenessMins }, }, - pipelineConfig: { - publicationName, - batch: { maxSize, maxFillMs }, - }, + pipelineConfig: { publicationName, batch }, sourceId, }: UpdateDestinationPipelineParams, signal?: AbortSignal @@ -60,15 +57,17 @@ async function updateDestinationPipeline( project_id: projectId, dataset_id: datasetId, service_account_key: serviceAccountKey, - max_staleness_mins: maxStalenessMins, + ...(maxStalenessMins != null && { max_staleness_mins: maxStalenessMins }), }, }, pipeline_config: { publication_name: publicationName, - batch: { - max_size: maxSize, - max_fill_ms: maxFillMs, - }, + ...(batch && { + batch: { + max_size: batch.maxSize, + max_fill_ms: batch.maxFillMs, + }, + }), }, source_id: sourceId, }, @@ -99,8 +98,12 @@ export const useUpdateDestinationPipelineMutation = ({ { async onSuccess(data, variables, context) { const { projectRef } = variables - await queryClient.invalidateQueries(replicationKeys.destinations(projectRef)) - await queryClient.invalidateQueries(replicationKeys.pipelines(projectRef)) + + await Promise.all([ + queryClient.invalidateQueries(replicationKeys.destinations(projectRef)), + queryClient.invalidateQueries(replicationKeys.pipelines(projectRef)), + ]) + await onSuccess?.(data, variables, context) }, async onError(data, variables, context) { diff --git a/apps/studio/state/replication-pipeline-request-status.tsx b/apps/studio/state/replication-pipeline-request-status.tsx index 722c4de9120..36561ab5444 100644 --- a/apps/studio/state/replication-pipeline-request-status.tsx +++ b/apps/studio/state/replication-pipeline-request-status.tsx @@ -1,14 +1,28 @@ -import { createContext, useContext, useState, ReactNode, useCallback } from 'react' +import { + createContext, + useContext, + useState, + ReactNode, + useCallback, + useRef, + useEffect, +} from 'react' export enum PipelineStatusRequestStatus { None = 'None', - EnableRequested = 'EnableRequested', - DisableRequested = 'DisableRequested', + StartRequested = 'StartRequested', + StopRequested = 'StopRequested', + RestartRequested = 'RestartRequested', } interface PipelineRequestStatusContextType { requestStatus: Record - setRequestStatus: (pipelineId: number, status: PipelineStatusRequestStatus) => void + pipelineStatusSnapshot: Record + setRequestStatus: ( + pipelineId: number, + status: PipelineStatusRequestStatus, + snapshotStatus?: string + ) => void getRequestStatus: (pipelineId: number) => PipelineStatusRequestStatus updatePipelineStatus: (pipelineId: number, backendStatus: string | undefined) => void } @@ -25,12 +39,58 @@ export const PipelineRequestStatusProvider = ({ children }: PipelineRequestStatu const [requestStatus, setRequestStatusState] = useState< Record >({}) + const [pipelineStatusSnapshot, setPipelineStatusSnapshot] = useState< + Record + >({}) + const timeoutsRef = useRef>({}) + const REQUEST_TIMEOUT_MS = 10_000 - const setRequestStatus = (pipelineId: number, status: PipelineStatusRequestStatus) => { + const setRequestStatus = ( + pipelineId: number, + status: PipelineStatusRequestStatus, + snapshotStatus?: string + ) => { setRequestStatusState((prev) => ({ ...prev, [pipelineId]: status, })) + setPipelineStatusSnapshot((prev) => { + if (status === PipelineStatusRequestStatus.None) { + const { [pipelineId]: _omit, ...rest } = prev + return rest + } + // Only set snapshot when provided to avoid undefined entries + if (snapshotStatus !== undefined) { + return { ...prev, [pipelineId]: snapshotStatus } + } + return prev + }) + + // Clear existing timeout for this pipeline + const existing = timeoutsRef.current[pipelineId] + if (existing !== undefined) { + clearTimeout(existing) + delete timeoutsRef.current[pipelineId] + } + + // Start auto-reset timer for non-None states + if (status !== PipelineStatusRequestStatus.None) { + const id = window.setTimeout(() => { + // If still pending, clear to None to show backend state + setRequestStatusState((prev) => { + if (prev[pipelineId] && prev[pipelineId] !== PipelineStatusRequestStatus.None) { + return { ...prev, [pipelineId]: PipelineStatusRequestStatus.None } + } + return prev + }) + setPipelineStatusSnapshot((prev) => { + const { [pipelineId]: _omit, ...rest } = prev + return rest + }) + delete timeoutsRef.current[pipelineId] + }, REQUEST_TIMEOUT_MS) + timeoutsRef.current[pipelineId] = id + } } const getRequestStatus = (pipelineId: number): PipelineStatusRequestStatus => { @@ -38,25 +98,32 @@ export const PipelineRequestStatusProvider = ({ children }: PipelineRequestStatu } const updatePipelineStatus = useCallback( - (pipelineId: number, backendStatus: string | undefined) => { + (pipelineId: number, newStatus: string | undefined) => { const currentRequestStatus = requestStatus[pipelineId] || PipelineStatusRequestStatus.None + if (currentRequestStatus === PipelineStatusRequestStatus.None) return - if ( - (currentRequestStatus === PipelineStatusRequestStatus.EnableRequested && - (backendStatus === 'started' || backendStatus === 'failed')) || - (currentRequestStatus === PipelineStatusRequestStatus.DisableRequested && - (backendStatus === 'stopped' || backendStatus === 'failed')) - ) { + // Only remove when backend status differs from snapshot + const snapshotStatus = pipelineStatusSnapshot[pipelineId] + if (newStatus !== snapshotStatus) { setRequestStatus(pipelineId, PipelineStatusRequestStatus.None) } }, - [requestStatus, setRequestStatus] + [requestStatus, pipelineStatusSnapshot] ) + // Cleanup all timers on unmount + useEffect(() => { + return () => { + Object.values(timeoutsRef.current).forEach((id) => clearTimeout(id)) + timeoutsRef.current = {} + } + }, []) + return ( Date: Fri, 29 Aug 2025 17:27:14 +0100 Subject: [PATCH 03/24] Update clone-project.mdx to show whats transferred clearer (#38173) * Update clone-project.mdx to show whats transferred clearer * Added updates to include auth data and change the navigation bar * adding beta version * The formatted commit --------- Co-authored-by: Rodrigo Mansueli --- .../NavigationMenu.constants.ts | 2 +- .../content/guides/platform/clone-project.mdx | 24 +++++++++++++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index baa5678f569..87102c911f3 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -2193,7 +2193,7 @@ export const platform: NavMenuConstant = { url: '/guides/platform/project-transfer', }, { - name: 'Duplicate Project', + name: 'Restore to a new project', url: '/guides/platform/clone-project', }, { diff --git a/apps/docs/content/guides/platform/clone-project.mdx b/apps/docs/content/guides/platform/clone-project.mdx index 8a67d30ac43..f2399770ae5 100644 --- a/apps/docs/content/guides/platform/clone-project.mdx +++ b/apps/docs/content/guides/platform/clone-project.mdx @@ -1,9 +1,29 @@ --- -title: Duplicate project +title: Restore to a new project subtitle: How to clone your existing Supabase project --- -You can clone your Supabase project by restoring your data from an existing project into a completely new one. Note that this only duplicates your database, not your project configuration. + + +You can clone your Supabase project by restoring your data from an existing project into a completely new one. This process creates a database-only copy and requires manual reconfiguration to fully replicate your original project. + + + +**What will be transferred?** + +- Database schema (tables, views, procedures) +- All data and indexes +- Database roles, permissions and users +- Auth user data (user accounts, hashed passwords, and authentication records from the auth schema) + +**What needs manual reconfiguration?** + +- Storage objects & settings (Your S3/storage files and bucket configurations are **NOT** copied) +- Edge Functions +- Auth settings & API keys +- Realtime settings +- Database extensions and settings +- Read replicas Whether you're using physical backups or Point-in-Time recovery (PITR), this feature allows you to duplicate project data with ease, perform testing safely, or recover data for analysis. Access to this feature is exclusive to users on paid plans and requires that physical backups are enabled for the source project. From 0d885c8b12253f4cb3323526432a942672a0e716 Mon Sep 17 00:00:00 2001 From: Prashant Sridharan <914007+CoolAssPuppy@users.noreply.github.com> Date: Fri, 29 Aug 2025 22:59:39 +0100 Subject: [PATCH 04/24] Webinar setup (#38311) * Added Mobbin Firebase webinar landing page * Added the correct Zoom Webinar link * pretty --------- Co-authored-by: Francesco Sansalvadore --- ...5-09-10-migrating-from-firebase-mobbin.mdx | 45 +++++++++++++++++++ apps/www/lib/authors.json | 16 +++++++ 2 files changed, 61 insertions(+) create mode 100644 apps/www/_events/2025-09-10-migrating-from-firebase-mobbin.mdx diff --git a/apps/www/_events/2025-09-10-migrating-from-firebase-mobbin.mdx b/apps/www/_events/2025-09-10-migrating-from-firebase-mobbin.mdx new file mode 100644 index 00000000000..19863e27806 --- /dev/null +++ b/apps/www/_events/2025-09-10-migrating-from-firebase-mobbin.mdx @@ -0,0 +1,45 @@ +--- +title: 'Firebase to Postgres: Breaking through the NoSQL ceiling with Mobbin' +meta_title: 'Firebase to Postgres: Breaking through the NoSQL ceiling with Mobbin' +subtitle: 'How Mobbin scaled beyond Firebase with Supabase and Postgres' +meta_description: 'Learn how to overcome Firebase limitations and migrate to Postgres with Supabase for better performance, scalability, and cost control.' +type: 'webinar' +onDemand: true +date: '2025-09-10T08:00:00.000-07:00' +timezone: 'America/Los_Angeles' +duration: '45 mins' +company: + { + name: 'Mobbin', + website_url: 'https://mobbin.com/', + logo: '/images/customers/logos/light/mobbin.png', + logo_light: '/images/customers/logos/mobbin.png', + } +categories: + - webinar +main_cta: { + url: 'https://events.zoom.us/ev/AsCYBtbPIBcdwQ6c871Vq4nxQlANWpKfu33ZOabQI5I0pzlIwLMp~AujOF_USe12uH1PnzC4QLd0k7fzJSwuqAapzXDUQ5MFZuu8R6x8mgiM1Ag', # Replace with actual registration link + target: '_blank', + label: 'Register now', + } +speakers: 'dventimi,jian_jie' +--- + +## Firebase to Postgres: Breaking through the NoSQL ceiling + +Firebase is great for MVPs, but most teams hit the ceiling fast. When your app starts to scale, Firestore’s NoSQL model can slow you down, limit your queries, and drive up costs without warning. + +In this 45-minute session, Supabase engineers and Mobbin CEO Liau Jian Jie break down the technical challenges of growing on Firebase and show how Postgres gives you the tools to scale with confidence. You’ll learn how to migrate your app to Supabase on AWS using modern SQL, real-time features, and flexible auth and storage, all with better performance and cost control. + +This session is built for startup founders and technical leads who want to stay fast without giving up control. + +### Key takeaways + +- What breaks when Firebase apps start to scale +- How SQL makes complex queries simple +- How to migrate data, auth, and storage in a weekend +- Why Postgres improves speed and observability +- What it really costs to stay on Firebase +- Lessons from a founder who already made the switch + +Join us live to participate in the Q&A afterwards. Can’t make it to the event? We’ll send you a link to the recording. diff --git a/apps/www/lib/authors.json b/apps/www/lib/authors.json index ab0e0d3d741..faa1a33114a 100644 --- a/apps/www/lib/authors.json +++ b/apps/www/lib/authors.json @@ -653,5 +653,21 @@ "position": "Security Engineer", "author_url": "https://github.com/staaldraad", "author_image_url": "https://avatars.githubusercontent.com/u/4200832?v=4" + }, + { + "author_id": "dventimi", + "author": "David Ventimiglia", + "username": "dventimi", + "position": "Engineering", + "author_url": "https://github.com/dventimi", + "author_image_url": "https://avatars.githubusercontent.com/u/97983663?v=4" + }, + { + "author_id": "jian_jie", + "author": "Jian Jie Liau", + "username": "jianji", + "position": "CTO and Co-Founder Mobbin", + "author_url": "https://github.com/liaujianjie", + "author_image_url": "https://avatars.githubusercontent.com/u/3143132?v=4" } ] From 54c1115dc4b09e2e2156b09a8de9ca90f7d23710 Mon Sep 17 00:00:00 2001 From: Prashant Sridharan <914007+CoolAssPuppy@users.noreply.github.com> Date: Sun, 31 Aug 2025 10:22:08 +0100 Subject: [PATCH 05/24] Changed Mobbin logo (#38330) --- .../images/customers/logos/light/mobbin.png | Bin 5418 -> 15704 bytes .../public/images/customers/logos/mobbin.png | Bin 9501 -> 18083 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/apps/www/public/images/customers/logos/light/mobbin.png b/apps/www/public/images/customers/logos/light/mobbin.png index 49cfe40567d2041bcefff6d579744005f23724af..4432df06a216f5919b31a5a809550839f0292aca 100644 GIT binary patch literal 15704 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 R1270kQ%(0#r3xJR{{RPY2A2Q; literal 5418 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 Date: Mon, 1 Sep 2025 09:07:59 +1000 Subject: [PATCH 06/24] chore: minor clarity improvements on new project creation (#38299) * feat: basic clarity improvements * feat: all switches * feat: basic errors * fix: simplify * fix: properly separate sections * fix: remove smart toggle closure * fix: remember file size unit * fix: validation * fix: UpgradeToPro styles * fix: UpgradeToPro styles for full-width * feat: create modal match * fix: tests * chore: periods and color contrast * fix: grammar * fix: revert unrelated changes --- .../RestoreToNewProject/CreateNewProjectDialog.tsx | 2 +- .../ProjectCreation/PostgresVersionSelector.tsx | 2 +- .../interfaces/ProjectCreation/RegionSelector.tsx | 11 ++++++++--- .../interfaces/ProjectCreation/SecurityOptions.tsx | 2 +- apps/studio/components/ui/PasswordStrengthBar.tsx | 1 + .../vercel/[slug]/deploy-button/new-project.tsx | 2 +- apps/studio/pages/new/[slug].tsx | 12 ++++++------ 7 files changed, 19 insertions(+), 13 deletions(-) diff --git a/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/CreateNewProjectDialog.tsx b/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/CreateNewProjectDialog.tsx index f76fe3ab7d6..22f464b6bfe 100644 --- a/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/CreateNewProjectDialog.tsx +++ b/apps/studio/components/interfaces/Database/Backups/RestoreToNewProject/CreateNewProjectDialog.tsx @@ -162,7 +162,7 @@ export const CreateNewProjectDialog = ({ { const { data: project } = useSelectedProjectQuery() diff --git a/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx b/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx index 678dcd776d5..144f72ff090 100644 --- a/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/RegionSelector.tsx @@ -87,9 +87,14 @@ export const RegionSelector = ({ <>

Select the region closest to your users for the best performance.

{showNonProdFields && ( -

- Note: Only US (NV), Frankfurt and SG are supported for local/staging projects -

+
+

Only these regions are supported for local/staging projects:

+
    +
  • East US (North Virginia)
  • +
  • Central EU (Frankfurt)
  • +
  • Southeast Asia (Singapore)
  • +
+
)} } diff --git a/apps/studio/components/interfaces/ProjectCreation/SecurityOptions.tsx b/apps/studio/components/interfaces/ProjectCreation/SecurityOptions.tsx index 90ffe223822..3a92bbad827 100644 --- a/apps/studio/components/interfaces/ProjectCreation/SecurityOptions.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/SecurityOptions.tsx @@ -94,7 +94,7 @@ export const SecurityOptions = ({ control={form.control} render={({ field }) => ( <> - + Generate a password + .

) diff --git a/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx b/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx index 51a50a4b11d..52e49b7aa04 100644 --- a/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx +++ b/apps/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx @@ -224,7 +224,7 @@ const CreateProject = () => {
{ canCreateProject && additionalMonthlySpend > 0 && (
- Additional Costs + Additional costs
${additionalMonthlySpend}/m @@ -735,7 +735,7 @@ const Wizard: NextPageWithLayout = () => { layout="horizontal" label={
- Compute Size + Compute size
{ href="https://supabase.com/docs/guides/platform/compute-add-ons" >
-

Compute Add-Ons

+

Compute add-ons

@@ -754,7 +754,7 @@ const Wizard: NextPageWithLayout = () => { href="https://supabase.com/docs/guides/platform/manage-your-usage/compute" >
-

Compute Billing

+

Compute billing

@@ -845,7 +845,7 @@ const Wizard: NextPageWithLayout = () => { return ( @@ -926,7 +926,7 @@ const Wizard: NextPageWithLayout = () => { Date: Mon, 1 Sep 2025 14:51:03 +0700 Subject: [PATCH 07/24] Fix SQL Editor search not showing snippets in folders (#38308) --- .../layouts/SQLEditorLayout/SQLEditorNavV2/SearchList.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SearchList.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SearchList.tsx index 3faee123796..1f6020cd8ef 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SearchList.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SearchList.tsx @@ -53,7 +53,11 @@ export const SearchList = ({ search }: SearchListProps) => { ) const totalNumber = (count as unknown as { count: number })?.count ?? 0 - const snippets = useMemo(() => data?.pages.flatMap((page) => page.content), [data?.pages]) + const snippets = useMemo( + // [Joshen] Set folder_id to null to ensure flat list + () => data?.pages.flatMap((page) => page.content.map((x) => ({ ...x, folder_id: null }))), + [data?.pages] + ) const treeState = formatFolderResponseForTreeView({ folders: [], contents: snippets as any }) const snippetsLastItemIds = useMemo(() => getLastItemIds(treeState), [treeState]) @@ -117,6 +121,7 @@ export const SearchList = ({ search }: SearchListProps) => { ), }} + isBranch={false} isOpened={isOpened && !isPreview} isSelected={isActive} isPreview={isPreview} From 0ed15ee3973a8c81aece49a3848a33403b641fdb Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Mon, 1 Sep 2025 10:06:43 +0200 Subject: [PATCH 08/24] feat: add aws nimbus cloud provider (#38312) * feat: add aws nimbus cloud provider * Fix return type of useCustomContent hook --------- Co-authored-by: Joshen Lim --- .../ProjectCreation/ProjectCreation.utils.ts | 1 + .../organization-available-regions-query.ts | 2 +- .../custom-content/CustomContent.types.ts | 11 +++--- .../hooks/custom-content/custom-content.json | 4 ++- .../custom-content/custom-content.sample.json | 4 ++- .../custom-content/custom-content.schema.json | 12 ++++++- .../hooks/custom-content/useCustomContent.ts | 4 +-- apps/studio/lib/constants/infrastructure.ts | 27 +++++++++++--- apps/studio/pages/new/[slug].tsx | 36 ++++++++++++------- packages/shared-data/regions.ts | 2 +- 10 files changed, 72 insertions(+), 31 deletions(-) diff --git a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts index c1e11c59e66..47d4233c511 100644 --- a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts +++ b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts @@ -15,6 +15,7 @@ export function getAvailableRegions(cloudProvider: CloudProvider): Region { switch (cloudProvider) { case 'AWS': case 'AWS_K8S': + case 'AWS_NIMBUS': return AWS_REGIONS case 'FLY': return FLY_REGIONS diff --git a/apps/studio/data/organizations/organization-available-regions-query.ts b/apps/studio/data/organizations/organization-available-regions-query.ts index 93eed9a4f0e..01bcd9af28f 100644 --- a/apps/studio/data/organizations/organization-available-regions-query.ts +++ b/apps/studio/data/organizations/organization-available-regions-query.ts @@ -6,7 +6,7 @@ import { organizationKeys } from './keys' export type OrganizationAvailableRegionsVariables = { slug?: string - cloudProvider: 'AWS' | 'FLY' | 'AWS_K8S' + cloudProvider: 'AWS' | 'FLY' | 'AWS_K8S' | 'AWS_NIMBUS' } export async function getOrganizationAvailableRegions( diff --git a/apps/studio/hooks/custom-content/CustomContent.types.ts b/apps/studio/hooks/custom-content/CustomContent.types.ts index 66816dffa96..28568d054b3 100644 --- a/apps/studio/hooks/custom-content/CustomContent.types.ts +++ b/apps/studio/hooks/custom-content/CustomContent.types.ts @@ -1,4 +1,5 @@ -import { ConnectionType } from 'components/interfaces/Connect/Connect.constants' +import { CONNECTION_TYPES } from 'components/interfaces/Connect/Connect.constants' +import type { CloudProvider } from 'shared-data' export type CustomContentTypes = { organizationLegalDocuments: { @@ -25,9 +26,7 @@ export type CustomContentTypes = { * * These could be helpful in rendering, for e.g an environment file like `.env` */ - connectFrameworks: { - key: string - label: string - obj: ConnectionType[] - } + connectFrameworks: (typeof CONNECTION_TYPES)[number] + + infraCloudProviders: CloudProvider[] } diff --git a/apps/studio/hooks/custom-content/custom-content.json b/apps/studio/hooks/custom-content/custom-content.json index 1378a1822a9..57bd6db86b7 100644 --- a/apps/studio/hooks/custom-content/custom-content.json +++ b/apps/studio/hooks/custom-content/custom-content.json @@ -7,5 +7,7 @@ "logs:default_query": null, - "connect:frameworks": null + "connect:frameworks": null, + + "infra:cloud_providers": ["AWS", "AWS_K8S", "FLY"] } diff --git a/apps/studio/hooks/custom-content/custom-content.sample.json b/apps/studio/hooks/custom-content/custom-content.sample.json index 7992201514d..da5a880f342 100644 --- a/apps/studio/hooks/custom-content/custom-content.sample.json +++ b/apps/studio/hooks/custom-content/custom-content.sample.json @@ -72,5 +72,7 @@ ] } ] - } + }, + + "infra:cloud_providers": ["AWS_NIMBUS"] } diff --git a/apps/studio/hooks/custom-content/custom-content.schema.json b/apps/studio/hooks/custom-content/custom-content.schema.json index 6d7020a3447..adea1a5f9fe 100644 --- a/apps/studio/hooks/custom-content/custom-content.schema.json +++ b/apps/studio/hooks/custom-content/custom-content.schema.json @@ -72,13 +72,23 @@ } } } + }, + + "infra:cloud_providers": { + "type": ["array"], + "description": "Filters the valid cloud providers", + "items": { + "type": "string", + "enum": ["AWS", "AWS_K8S", "AWS_NIMBUS", "FLY"] + } } }, "required": [ "organization:legal_documents", "project_homepage:example_projects", "logs:default_query", - "connect:frameworks" + "connect:frameworks", + "infra:cloud_providers" ], "additionalProperties": false } diff --git a/apps/studio/hooks/custom-content/useCustomContent.ts b/apps/studio/hooks/custom-content/useCustomContent.ts index 0d1b2342642..8f26fe1f397 100644 --- a/apps/studio/hooks/custom-content/useCustomContent.ts +++ b/apps/studio/hooks/custom-content/useCustomContent.ts @@ -28,16 +28,16 @@ const useCustomContent = ( contents: T ): { [key in CustomContentToCamelCase]: - | (typeof customContentStaticObj)[CustomContent] | CustomContentTypes[CustomContentToCamelCase] + | null } => { // [Joshen] Running into some TS errors without the `as` here - must be overlooking something super simple return Object.fromEntries( contents.map((content) => [contentToCamelCase(content), customContentStaticObj[content]]) ) as { [key in CustomContentToCamelCase]: - | (typeof customContentStaticObj)[CustomContent] | CustomContentTypes[CustomContentToCamelCase] + | null } } diff --git a/apps/studio/lib/constants/infrastructure.ts b/apps/studio/lib/constants/infrastructure.ts index 4d2d4ffbf66..d9343193b46 100644 --- a/apps/studio/lib/constants/infrastructure.ts +++ b/apps/studio/lib/constants/infrastructure.ts @@ -2,6 +2,7 @@ import type { CloudProvider } from 'shared-data' import { AWS_REGIONS, FLY_REGIONS } from 'shared-data' import type { components } from 'data/api' +import { useCustomContent } from 'hooks/custom-content/useCustomContent' export const AWS_REGIONS_DEFAULT = process.env.NEXT_PUBLIC_ENVIRONMENT !== 'prod' @@ -33,11 +34,21 @@ export const PRICING_TIER_PRODUCT_IDS = { ENTERPRISE: 'tier_enterprise', } -export const DEFAULT_PROVIDER: CloudProvider = - process.env.NEXT_PUBLIC_ENVIRONMENT && - ['staging', 'preview'].includes(process.env.NEXT_PUBLIC_ENVIRONMENT) - ? 'AWS_K8S' - : 'AWS' +export function useDefaultProvider() { + const defaultProvider: CloudProvider = + process.env.NEXT_PUBLIC_ENVIRONMENT && + ['staging', 'preview'].includes(process.env.NEXT_PUBLIC_ENVIRONMENT) + ? 'AWS_K8S' + : 'AWS' + + const { infraCloudProviders: validCloudProviders } = useCustomContent(['infra:cloud_providers']) + + if (validCloudProviders?.includes(defaultProvider)) { + return defaultProvider + } + + return (validCloudProviders?.[0] ?? 'AWS') as CloudProvider +} export const PROVIDERS = { FLY: { @@ -60,6 +71,12 @@ export const PROVIDERS = { default_region: AWS_REGIONS_DEFAULT, regions: { ...AWS_REGIONS }, }, + AWS_NIMBUS: { + id: 'AWS_NIMBUS', + name: 'AWS (Nimbus)', + default_region: AWS_REGIONS_DEFAULT, + regions: { ...AWS_REGIONS }, + }, } as const export const PROJECT_STATUS: { diff --git a/apps/studio/pages/new/[slug].tsx b/apps/studio/pages/new/[slug].tsx index fc11b85f88a..297dc3d709d 100644 --- a/apps/studio/pages/new/[slug].tsx +++ b/apps/studio/pages/new/[slug].tsx @@ -48,6 +48,7 @@ import { } from 'data/projects/project-create-mutation' import { useProjectsQuery } from 'data/projects/projects-query' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import { useCustomContent } from 'hooks/custom-content/useCustomContent' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' @@ -57,11 +58,11 @@ import { getCloudProviderArchitecture } from 'lib/cloudprovider-utils' import { AWS_REGIONS_DEFAULT, DEFAULT_MINIMUM_PASSWORD_STRENGTH, - DEFAULT_PROVIDER, FLY_REGIONS_DEFAULT, MANAGED_BY, PROJECT_STATUS, PROVIDERS, + useDefaultProvider, } from 'lib/constants' import passwordStrength from 'lib/password-strength' import { generateStrongPassword } from 'lib/project' @@ -141,6 +142,8 @@ const Wizard: NextPageWithLayout = () => { const showAdvancedConfig = useIsFeatureEnabled('project_creation:show_advanced_config') + const { infraCloudProviders: validCloudProviders } = useCustomContent(['infra:cloud_providers']) + // This is to make the database.new redirect work correctly. The database.new redirect should be set to supabase.com/dashboard/new/last-visited-org if (slug === 'last-visited-org') { if (lastVisitedOrganization) { @@ -221,9 +224,11 @@ const Wizard: NextPageWithLayout = () => { project.organization_id === currentOrg?.id && project.status !== PROJECT_STATUS.INACTIVE ) ?? [] + const defaultProvider = useDefaultProvider() + const { data: _defaultRegion, error: defaultRegionError } = useDefaultRegionQuery( { - cloudProvider: PROVIDERS[DEFAULT_PROVIDER].id, + cloudProvider: PROVIDERS[defaultProvider].id, }, { enabled: !smartRegionEnabled, @@ -239,7 +244,7 @@ const Wizard: NextPageWithLayout = () => { useOrganizationAvailableRegionsQuery( { slug: slug, - cloudProvider: PROVIDERS[DEFAULT_PROVIDER].id, + cloudProvider: PROVIDERS[defaultProvider].id, }, { enabled: smartRegionEnabled, @@ -304,7 +309,7 @@ const Wizard: NextPageWithLayout = () => { organization: slug, projectName: projectName || '', postgresVersion: '', - cloudProvider: PROVIDERS[DEFAULT_PROVIDER].id, + cloudProvider: PROVIDERS[defaultProvider].id, dbPass: '', dbPassStrength: 0, dbRegion: defaultRegion || undefined, @@ -455,7 +460,7 @@ const Wizard: NextPageWithLayout = () => { useEffect(() => { if (regionError) { - form.setValue('dbRegion', PROVIDERS[DEFAULT_PROVIDER].default_region.displayName) + form.setValue('dbRegion', PROVIDERS[defaultProvider].default_region.displayName) } }, [regionError]) @@ -707,15 +712,20 @@ const Wizard: NextPageWithLayout = () => { - {Object.values(PROVIDERS).map((providerObj) => { - const label = providerObj['name'] - const value = providerObj['id'] - return ( - - {label} - + {Object.values(PROVIDERS) + .filter( + (provider) => + validCloudProviders?.includes(provider.id) ?? true ) - })} + .map((providerObj) => { + const label = providerObj['name'] + const value = providerObj['id'] + return ( + + {label} + + ) + })} diff --git a/packages/shared-data/regions.ts b/packages/shared-data/regions.ts index 27caf5b2659..d38d8db339d 100644 --- a/packages/shared-data/regions.ts +++ b/packages/shared-data/regions.ts @@ -1,4 +1,4 @@ -export type CloudProvider = 'FLY' | 'AWS' | 'AWS_K8S' +export type CloudProvider = 'FLY' | 'AWS' | 'AWS_K8S' | 'AWS_NIMBUS' export type Region = typeof AWS_REGIONS | typeof FLY_REGIONS export const AWS_REGIONS = { From c53dc5c159dc3746137cc78861f6d832e363dfa4 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Mon, 1 Sep 2025 16:08:00 +0700 Subject: [PATCH 09/24] Small fix to prevent layout shift in SQL editor when running query (#38270) --- apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx | 6 +++--- .../interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx | 2 +- .../components/layouts/SQLEditorLayout/SQLEditorMenu.tsx | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx index 12383978073..52f5444c228 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx @@ -676,7 +676,7 @@ export const SQLEditor = () => { direction="vertical" autoSaveId={LOCAL_STORAGE_KEYS.SQL_EDITOR_SPLIT_SIZE} > - +
{isLoading ? (
@@ -793,7 +793,7 @@ export const SQLEditor = () => { - + {isLoading ? (
@@ -811,7 +811,7 @@ export const SQLEditor = () => { )} -
+
{results?.rows !== undefined && !isExecuting && ( diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx index d7704a00b44..8e933e22d5d 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx @@ -78,7 +78,7 @@ const UtilityActions = ({ -
-
- -
- - ) + return } return ( @@ -171,4 +94,3 @@ const ReportChart = ({ /> ) } -export default ReportChart diff --git a/apps/studio/components/interfaces/Reports/Reports.utils.test.ts b/apps/studio/components/interfaces/Reports/Reports.utils.test.ts new file mode 100644 index 00000000000..8884c0e8a70 --- /dev/null +++ b/apps/studio/components/interfaces/Reports/Reports.utils.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest' +import { formatTimestamp } from './Reports.utils' +import dayjs from 'dayjs' +import utc from 'dayjs/plugin/utc' + +dayjs.extend(utc) + +describe('formatTimestamp', () => { + it('formats milliseconds timestamp correctly', () => { + const timestamp = 1640995200000 // 2022-01-01 00:00:00 UTC in milliseconds + const result = formatTimestamp(timestamp, { returnUtc: true }) + expect(result).toBe('Jan 1, 12:00am') + }) + + it('formats microseconds timestamp correctly', () => { + const timestamp = 1640995200000000 // 2022-01-01 00:00:00 UTC in microseconds + const result = formatTimestamp(timestamp, { returnUtc: true }) + expect(result).toBe('Jan 1, 12:00am') + }) + + it('formats seconds timestamp correctly', () => { + const timestamp = 1640995200 // 2022-01-01 00:00:00 UTC in seconds + const result = formatTimestamp(timestamp, { returnUtc: true }) + expect(result).toBe('Jan 1, 12:00am') + }) + + it('handles string timestamp input', () => { + const timestamp = '1640995200000' + const result = formatTimestamp(timestamp, { returnUtc: true }) + expect(result).toBe('Jan 1, 12:00am') + }) + + it('handles invalid string timestamp', () => { + const timestamp = 'invalid-timestamp' + const result = formatTimestamp(timestamp, { returnUtc: true }) + expect(result).toBe('Invalid Date') + }) + + it('handles zero timestamp', () => { + const timestamp = 0 + const result = formatTimestamp(timestamp, { returnUtc: true }) + expect(result).toBe('Jan 1, 12:00am') + }) +}) diff --git a/apps/studio/components/interfaces/Reports/Reports.utils.tsx b/apps/studio/components/interfaces/Reports/Reports.utils.tsx index eb2337e6ce1..f0eef8b93ad 100644 --- a/apps/studio/components/interfaces/Reports/Reports.utils.tsx +++ b/apps/studio/components/interfaces/Reports/Reports.utils.tsx @@ -1,3 +1,5 @@ +import dayjs from 'dayjs' + import useDbQuery, { DbQueryHook } from 'hooks/analytics/useDbQuery' import useLogsQuery, { LogsQueryHook } from 'hooks/analytics/useLogsQuery' import type { BaseQueries, PresetConfig, ReportQuery } from './Reports.types' @@ -9,10 +11,13 @@ export const queryParamsToObject = (params: string) => { return Object.fromEntries(new URLSearchParams(params)) } -// generate hooks based on preset config export type PresetHookResult = LogsQueryHook | DbQueryHook type PresetHooks = Record PresetHookResult> - +/** + * @deprecated + * Queries are hooks, avoid generating hooks dynamically + * Generate fetch functions instead, and pass it to a hook inside the component + */ export const queriesFactory = ( queries: BaseQueries, projectRef: string @@ -35,3 +40,36 @@ export const queriesFactory = ( ) return hooks } + +/** + * Formats a timestamp to a human readable format in UTC + * + * @param timestamp - The timestamp to format + * @param returnUtc - Whether to return the timestamp in UTC + * @param format - The format to use for the timestamp + * @returns The formatted timestamp string + */ +export const formatTimestamp = ( + timestamp: number | string, + { returnUtc = false, format = 'MMM D, h:mma' }: { returnUtc?: boolean; format?: string } = {} +) => { + try { + const isSeconds = String(timestamp).length === 10 + const isMicroseconds = String(timestamp).length === 16 + + const timestampInMs = isSeconds + ? Number(timestamp) * 1000 + : isMicroseconds + ? Number(timestamp) / 1000 + : Number(timestamp) + + if (returnUtc) { + return dayjs.utc(timestampInMs).format(format) + } else { + return dayjs(timestampInMs).format(format) + } + } catch (error) { + console.error(error) + return 'Invalid Date' + } +} diff --git a/apps/studio/components/interfaces/Reports/v2/ReportChartUpsell.tsx b/apps/studio/components/interfaces/Reports/v2/ReportChartUpsell.tsx new file mode 100644 index 00000000000..5a31fa5a00e --- /dev/null +++ b/apps/studio/components/interfaces/Reports/v2/ReportChartUpsell.tsx @@ -0,0 +1,81 @@ +import Link from 'next/link' +import { useRef, useState } from 'react' + +import { LogChartHandler } from 'components/ui/Charts/LogChartHandler' +import { ReportConfig } from 'data/reports/v2/reports.types' +import { Button, Card, cn } from 'ui' + +export function ReportChartUpsell({ report, orgSlug }: { report: ReportConfig; orgSlug: string }) { + const [isHoveringUpgrade, setIsHoveringUpgrade] = useState(false) + + const startDate = '2025-01-01' + const endDate = '2025-01-02' + + const getExpDemoChartData = () => + new Array(20).fill(0).map((_, index) => ({ + period_start: new Date(startDate).getTime() + index * 1000, + demo: Math.floor(Math.pow(1.25, index) * 10), + max_demo: 1000, + })) + + const getDemoChartData = () => + new Array(20).fill(0).map((_, index) => ({ + period_start: new Date(startDate).getTime() + index * 1000, + demo: Math.floor(Math.random() * 10) + 1, + max_demo: 1000, + })) + + const demoChartData = useRef(getDemoChartData()) + const exponentialChartData = useRef(getExpDemoChartData()) + + const demoData = isHoveringUpgrade ? exponentialChartData.current : demoChartData.current + + return ( + +
+

{report.label}

+

+ This chart is available from{' '} + + {!!report.availableIn?.length ? report.availableIn[0] : 'Pro'} + {' '} + plan and above +

+ +
+
+ {}} + /> +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Reports/v2/ReportChartV2.tsx b/apps/studio/components/interfaces/Reports/v2/ReportChartV2.tsx new file mode 100644 index 00000000000..36538a71681 --- /dev/null +++ b/apps/studio/components/interfaces/Reports/v2/ReportChartV2.tsx @@ -0,0 +1,139 @@ +import { useQuery } from '@tanstack/react-query' +import { Loader2 } from 'lucide-react' +import { useState } from 'react' + +import { ComposedChart } from 'components/ui/Charts/ComposedChart' +import type { AnalyticsInterval } from 'data/analytics/constants' +import type { ReportConfig } from 'data/reports/v2/reports.types' +import { useFillTimeseriesSorted } from 'hooks/analytics/useFillTimeseriesSorted' +import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan' +import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' +import { Card, CardContent, cn } from 'ui' +import { ReportChartUpsell } from './ReportChartUpsell' + +export interface ReportChartV2Props { + report: ReportConfig + projectRef: string + startDate: string + endDate: string + interval: AnalyticsInterval + updateDateRange: (from: string, to: string) => void + functionIds?: string[] + edgeFnIdToName?: (id: string) => string | undefined + className?: string + syncId?: string +} + +export const ReportChartV2 = ({ + report, + projectRef, + startDate, + endDate, + interval, + updateDateRange, + functionIds, + edgeFnIdToName, + className, + syncId, +}: ReportChartV2Props) => { + const { data: org } = useSelectedOrganizationQuery() + const { plan: orgPlan } = useCurrentOrgPlan() + const orgPlanId = orgPlan?.id + + const isAvailable = + report.availableIn === undefined || (orgPlanId && report.availableIn.includes(orgPlanId)) + + const canFetch = orgPlanId !== undefined + + const { + data: queryResult, + isLoading: isLoadingChart, + error, + } = useQuery( + ['projects', projectRef, 'report-v2', report.id, { startDate, endDate, interval, functionIds }], + async () => { + return await report.dataProvider( + projectRef, + startDate, + endDate, + interval, + functionIds, + edgeFnIdToName + ) + }, + { + enabled: Boolean(projectRef && canFetch && isAvailable), + refetchOnWindowFocus: false, + } + ) + + const chartData = queryResult?.data || [] + const dynamicAttributes = queryResult?.attributes || [] + + const { data: filledChartData, isError: isFillError } = useFillTimeseriesSorted( + chartData, + 'timestamp', + (dynamicAttributes as any[]).map((attr: any) => attr.attribute), + 0, + startDate, + endDate, + undefined, + interval + ) + + const finalChartData = + filledChartData && filledChartData.length > 0 && !isFillError ? filledChartData : chartData + + const [chartStyle, setChartStyle] = useState(report.defaultChartStyle) + + if (!isAvailable && !isLoadingChart) { + return + } + + const isErrorState = error && !isLoadingChart + const showEmptyState = (!finalChartData || finalChartData.length === 0) && !isLoadingChart + + return ( + + + {isLoadingChart ? ( + + ) : showEmptyState ? ( +

+ No data available for the selected time range +

+ ) : isErrorState ? ( +

+ Error loading chart data +

+ ) : ( +
+ +
+ )} +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts index 2a32d88cc81..1d61aeefbce 100644 --- a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts @@ -15,7 +15,7 @@ import type { Filters, LogData, LogsEndpointParams } from './Logs.types' * Convert a micro timestamp from number/string to iso timestamp */ export const unixMicroToIsoTimestamp = (unix: string | number): string => { - return dayjs.unix(Number(unix) / 1000 / 1000).toISOString() + return dayjs.utc(Number(unix) / 1000).toISOString() } export const isUnixMicro = (unix: string | number): boolean => { @@ -483,19 +483,22 @@ export const fillTimeseries = ( // If we have more points than minPointsToFill, just normalize timestamps and return if (timeseriesData.length > minPointsToFill) { return timeseriesData.map((datum) => { - const iso = dayjs.utc(datum[timestampKey]).toISOString() + const timestamp = datum[timestampKey] + const iso = isUnixMicro(timestamp) + ? unixMicroToIsoTimestamp(timestamp) + : dayjs.utc(timestamp).toISOString() datum[timestampKey] = iso return datum }) } - if (timeseriesData.length <= 1 && !(min || max)) return timeseriesData + if (timeseriesData.length <= 1 && !(min && max)) return timeseriesData const dates: unknown[] = timeseriesData.map((datum) => dayjs.utc(datum[timestampKey])) const maxDate = max ? dayjs.utc(max) : dayjs.utc(Math.max.apply(null, dates as number[])) const minDate = min ? dayjs.utc(min) : dayjs.utc(Math.min.apply(null, dates as number[])) - // const truncationSample = timeseriesData.length > 0 ? timeseriesData[0][timestampKey] : min || max + // When no data exists but min/max are provided, we need to determine truncation from the time range const truncationSamples = timeseriesData.length > 0 ? dates : [minDate, maxDate] let truncation: 'second' | 'minute' | 'hour' | 'day' let step = 1 @@ -515,11 +518,21 @@ export const fillTimeseries = ( truncation = getTimestampTruncation(truncationSamples as Dayjs[]) } + // If no data exists and no interval specified, default to minute precision + if (timeseriesData.length === 0 && !interval) { + truncation = 'minute' + } + const newData = timeseriesData.map((datum) => { const timestamp = datum[timestampKey] const iso = isUnixMicro(timestamp) ? unixMicroToIsoTimestamp(timestamp) : dayjs.utc(timestamp).toISOString() + + if (Array.isArray(valueKey) && valueKey.length === 0) { + return { [timestampKey]: iso } + } + datum[timestampKey] = iso return datum }) @@ -533,7 +546,8 @@ export const fillTimeseries = ( d_date.month() === currentDate.month() && d_date.date() === currentDate.date() && d_date.hour() === currentDate.hour() && - d_date.minute() === currentDate.minute() + d_date.minute() === currentDate.minute() && + d_date.second() === currentDate.second() ) }) if (!found) { diff --git a/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx b/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx index c5f086fc59d..9ba27b4b8a3 100644 --- a/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx +++ b/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx @@ -105,6 +105,9 @@ export function LogsSidebarMenuV2() { }, { enabled: enablePgReplicate, + retry: false, + refetchOnMount: false, + refetchOnWindowFocus: false, } ) diff --git a/apps/studio/components/ui/Charts/AreaChart.tsx b/apps/studio/components/ui/Charts/AreaChart.tsx index 4328b6ba580..8336b681818 100644 --- a/apps/studio/components/ui/Charts/AreaChart.tsx +++ b/apps/studio/components/ui/Charts/AreaChart.tsx @@ -4,7 +4,7 @@ import { Area, AreaChart as RechartAreaChart, Tooltip, XAxis } from 'recharts' import { useChartHoverState } from './useChartHoverState' import { CHART_COLORS, DateTimeFormats } from 'components/ui/Charts/Charts.constants' -import ChartHeader from './ChartHeader' +import { ChartHeader } from './ChartHeader' import type { CommonChartProps, Datum } from './Charts.types' import { numberFormatter, useChartSize } from './Charts.utils' import NoDataPlaceholder from './NoDataPlaceholder' diff --git a/apps/studio/components/ui/Charts/BarChart.tsx b/apps/studio/components/ui/Charts/BarChart.tsx index 5977709174e..e626d833c46 100644 --- a/apps/studio/components/ui/Charts/BarChart.tsx +++ b/apps/studio/components/ui/Charts/BarChart.tsx @@ -1,6 +1,5 @@ import dayjs from 'dayjs' -import { ComponentProps, useState, useMemo } from 'react' -import { useChartHoverState } from './useChartHoverState' +import { ComponentProps, useMemo, useState } from 'react' import { Bar, CartesianGrid, @@ -14,10 +13,11 @@ import { import { CHART_COLORS, DateTimeFormats } from 'components/ui/Charts/Charts.constants' import type { CategoricalChartState } from 'recharts/types/chart/types' -import ChartHeader from './ChartHeader' +import { ChartHeader } from './ChartHeader' import type { CommonChartProps, Datum } from './Charts.types' import { numberFormatter, useChartSize } from './Charts.utils' import NoDataPlaceholder from './NoDataPlaceholder' +import { useChartHoverState } from './useChartHoverState' export interface BarChartProps extends CommonChartProps { yAxisKey: string @@ -57,10 +57,9 @@ const BarChart = ({ showGrid = false, syncId, }: BarChartProps) => { + const { hoveredIndex, isHovered, isCurrentChart, setHover, clearHover } = + useChartHoverState('default') const { Container } = useChartSize(size) - const { hoveredIndex, syncTooltip, setHover, clearHover } = useChartHoverState( - syncId || 'default' - ) const [focusDataIndex, setFocusDataIndex] = useState(null) // Transform data to ensure yAxisKey values are numbers @@ -177,7 +176,7 @@ const BarChart = ({ /> - syncId && syncTooltip && hoveredIndex !== null ? ( + syncId && isHovered && isCurrentChart && hoveredIndex !== null ? (
{dayjs(data[hoveredIndex]?.[xAxisKey]).format(customDateFormat)} diff --git a/apps/studio/components/ui/Charts/ChartHeader.tsx b/apps/studio/components/ui/Charts/ChartHeader.tsx index 92840ba3a0e..60b3e870c3e 100644 --- a/apps/studio/components/ui/Charts/ChartHeader.tsx +++ b/apps/studio/components/ui/Charts/ChartHeader.tsx @@ -1,11 +1,20 @@ -import { Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui' -import { Activity, BarChartIcon, GitCommitHorizontalIcon, InfoIcon } from 'lucide-react' -import Link from 'next/link' -import { useEffect, useState } from 'react' import dayjs from 'dayjs' +import { + Activity, + BarChartIcon, + GitCommitHorizontalIcon, + InfoIcon, + SquareTerminal, +} from 'lucide-react' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { useEffect, useState } from 'react' + +import { useParams } from 'common' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { formatBytes } from 'lib/helpers' -import { useChartHoverState } from './useChartHoverState' import { numberFormatter } from './Charts.utils' +import { useChartHoverState } from './useChartHoverState' export interface ChartHeaderProps { title?: string @@ -31,9 +40,10 @@ export interface ChartHeaderProps { shouldFormatBytes?: boolean isNetworkChart?: boolean attributes?: any[] + sql?: string } -const ChartHeader = ({ +export const ChartHeader = ({ format, highlightedValue, highlightedLabel, @@ -57,16 +67,25 @@ const ChartHeader = ({ shouldFormatBytes = false, isNetworkChart = false, attributes, + sql, }: ChartHeaderProps) => { - const { hoveredIndex, syncHover } = useChartHoverState(syncId || 'default') + const { hoveredIndex, isHovered, isCurrentChart, setHover, clearHover } = useChartHoverState( + syncId || 'default' + ) const [localHighlightedValue, setLocalHighlightedValue] = useState(highlightedValue) const [localHighlightedLabel, setLocalHighlightedLabel] = useState(highlightedLabel) + const { ref } = useParams() + const router = useRouter() const formatHighlightedValue = (value: any) => { if (typeof value !== 'number') { return value } + if (typeof format === 'function') { + return format(value) + } + if (shouldFormatBytes) { const bytesValue = isNetworkChart ? Math.abs(value) : value return formatBytes(bytesValue, valuePrecision) @@ -76,7 +95,7 @@ const ChartHeader = ({ } useEffect(() => { - if (syncId && hoveredIndex !== null && syncHover && data && xAxisKey && yAxisKey) { + if (syncId && hoveredIndex !== null && isHovered && data && xAxisKey && yAxisKey) { const activeDataPoint = data[hoveredIndex] if (activeDataPoint) { // For stacked charts, we need to calculate the total of all attributes @@ -128,7 +147,7 @@ const ChartHeader = ({ } }, [ hoveredIndex, - syncHover, + isHovered, syncId, data, xAxisKey, @@ -147,20 +166,21 @@ const ChartHeader = ({ {title} {docsUrl && ( - - - - - - - - Read docs - - + + + + + )}
) @@ -170,10 +190,6 @@ const ChartHeader = ({ className={`text-foreground text-xl font-normal ${minimalHeader ? 'text-base' : 'text-2xl'}`} > {localHighlightedValue !== undefined && formatHighlightedValue(localHighlightedValue)} - {format === 'seconds' ? ' ' : ''} - - {typeof format === 'function' ? format(localHighlightedValue) : format} - ) const label =

{localHighlightedLabel}

@@ -202,43 +218,57 @@ const ChartHeader = ({
+ {sql ? ( + + + + + + ) : null} + {!hideChartType && onChartStyleChange && ( - - -
) } - -export default ChartHeader diff --git a/apps/studio/components/ui/Charts/Charts.utils.tsx b/apps/studio/components/ui/Charts/Charts.utils.tsx index 43082aad54e..31463e0adc6 100644 --- a/apps/studio/components/ui/Charts/Charts.utils.tsx +++ b/apps/studio/components/ui/Charts/Charts.utils.tsx @@ -1,13 +1,10 @@ import dayjs from 'dayjs' -import utc from 'dayjs/plugin/utc' import { FC, PropsWithChildren, useMemo } from 'react' import { ResponsiveContainer } from 'recharts' import { DateTimeFormats } from './Charts.constants' import type { CommonChartProps, StackedChartProps } from './Charts.types' -dayjs.extend(utc) - /** * Auto formats a number to a default precision if it is a float * diff --git a/apps/studio/components/ui/Charts/ComposedChart.tsx b/apps/studio/components/ui/Charts/ComposedChart.tsx index 619e7ddf7c2..4eb959ad95d 100644 --- a/apps/studio/components/ui/Charts/ComposedChart.tsx +++ b/apps/studio/components/ui/Charts/ComposedChart.tsx @@ -1,10 +1,7 @@ -'use client' - import dayjs from 'dayjs' import { formatBytes } from 'lib/helpers' import { useTheme } from 'next-themes' import { ComponentProps, useEffect, useState } from 'react' -import { useChartHoverState } from './useChartHoverState' import { Area, Bar, @@ -18,9 +15,10 @@ import { XAxis, YAxis, } from 'recharts' + import { CategoricalChartState } from 'recharts/types/chart/types' import { cn } from 'ui' -import ChartHeader from './ChartHeader' +import { ChartHeader } from './ChartHeader' import ChartHighlightActions from './ChartHighlightActions' import { CHART_COLORS, @@ -38,6 +36,7 @@ import { } from './ComposedChart.utils' import NoDataPlaceholder from './NoDataPlaceholder' import { ChartHighlight } from './useChartHighlight' +import { useChartHoverState } from './useChartHoverState' export interface ComposedChartProps extends CommonChartProps { attributes: MultiAttribute[] @@ -64,9 +63,10 @@ export interface ComposedChartProps extends CommonChartProps { hideHighlightedValue?: boolean syncId?: string docsUrl?: string + sql?: string } -export default function ComposedChart({ +export function ComposedChart({ data, attributes, yAxisKey, @@ -100,6 +100,7 @@ export default function ComposedChart({ hideHighlightedValue, syncId, docsUrl, + sql, }: ComposedChartProps) { const { resolvedTheme } = useTheme() const { hoveredIndex, syncTooltip, setHover, clearHover } = useChartHoverState( @@ -325,6 +326,7 @@ export default function ComposedChart({ shouldFormatBytes={shouldFormatBytes} isNetworkChart={isNetworkChart} attributes={attributes} + sql={sql} /> { * * Provided data must be in the expected chart format. */ -const LogChartHandler = ({ +export const LogChartHandler = ({ label, attributes, customDateFormat, @@ -107,6 +108,7 @@ const LogChartHandler = ({ valuePrecision, titleTooltip, id, + syncId, ...otherProps }: PropsWithChildren) => { const [chartStyle, setChartStyle] = useState(defaultChartStyle) @@ -143,7 +145,7 @@ const LogChartHandler = ({ data={data as any} format={format} xAxisKey="period_start" - yAxisKey={attributes[0].attribute} + yAxisKey={attributes[0]?.attribute} highlightedValue={highlightedValue} title={label} customDateFormat={customDateFormat} @@ -158,6 +160,7 @@ const LogChartHandler = ({ valuePrecision={valuePrecision} hideChartType={hideChartType} titleTooltip={titleTooltip} + syncId={syncId} {...otherProps} /> diff --git a/apps/studio/components/ui/Charts/NoDataPlaceholder.tsx b/apps/studio/components/ui/Charts/NoDataPlaceholder.tsx index f50878890a7..31ff9b9f669 100644 --- a/apps/studio/components/ui/Charts/NoDataPlaceholder.tsx +++ b/apps/studio/components/ui/Charts/NoDataPlaceholder.tsx @@ -1,7 +1,7 @@ import { BarChart2 } from 'lucide-react' import { cn } from 'ui' -import ChartHeader from './ChartHeader' +import { ChartHeader } from './ChartHeader' import { useChartSize } from './Charts.utils' interface NoDataPlaceholderProps { diff --git a/apps/studio/components/ui/Charts/StackedBarChart.tsx b/apps/studio/components/ui/Charts/StackedBarChart.tsx index 0ef800be4b3..ac12386f3b2 100644 --- a/apps/studio/components/ui/Charts/StackedBarChart.tsx +++ b/apps/studio/components/ui/Charts/StackedBarChart.tsx @@ -1,9 +1,8 @@ +import dayjs from 'dayjs' import { useState } from 'react' import { Bar, BarChart, Cell, Legend, Tooltip, XAxis } from 'recharts' -import dayjs from 'dayjs' -import utc from 'dayjs/plugin/utc' -import { useChartHoverState } from './useChartHoverState' -import ChartHeader from './ChartHeader' + +import { ChartHeader } from './ChartHeader' import { CHART_COLORS, DateTimeFormats, @@ -20,7 +19,7 @@ import { useStacked, } from './Charts.utils' import NoDataPlaceholder from './NoDataPlaceholder' -dayjs.extend(utc) +import { useChartHoverState } from './useChartHoverState' interface Props extends CommonChartProps { xAxisKey: string diff --git a/apps/studio/components/ui/Panel.tsx b/apps/studio/components/ui/Panel.tsx index e17362b66fb..bf802d991b1 100644 --- a/apps/studio/components/ui/Panel.tsx +++ b/apps/studio/components/ui/Panel.tsx @@ -15,6 +15,9 @@ interface PanelProps { titleClasses?: string } +/** + * @deprecated Use Card component from ui package instead + */ function Panel(props: PropsWithChildren) { const content = (
[ - { - id: 'execution-status-codes', - label: 'Edge Function Status Codes', - valuePrecision: 0, - hide: false, - showTooltip: true, - showLegend: true, - showMaxValue: false, - hideChartType: false, - defaultChartStyle: 'bar', - titleTooltip: 'The total number of edge function executions by status code.', - availableIn: ['free', 'pro', 'team', 'enterprise'], - attributes: [ - { - attribute: 'ExecutionStatusCodes', - provider: 'logs', - label: 'Execution Status Codes', - }, - ], - }, - { - id: 'execution-time', - label: 'Edge Function Execution Time', - valuePrecision: 0, - hide: false, - showTooltip: true, - showLegend: true, - showMaxValue: false, - hideChartType: false, - defaultChartStyle: 'line', - titleTooltip: 'Average execution time for edge functions.', - availableIn: ['free', 'pro', 'team', 'enterprise'], - format: 'ms', - YAxisProps: { - width: 50, - tickFormatter: (value: number) => `${value}ms`, - }, - attributes: [ - { - attribute: 'ExecutionTime', - label: 'Avg. Execution Time (ms)', - provider: 'logs', - enabled: true, - }, - ], - }, - { - id: 'invocations-by-region', - label: 'Edge Function Invocations by Region', - valuePrecision: 0, - hide: false, - showTooltip: true, - showLegend: true, - showMaxValue: false, - hideChartType: false, - defaultChartStyle: 'bar', - titleTooltip: 'The total number of edge function invocations by region.', - availableIn: ['pro', 'team', 'enterprise'], - attributes: [ - { - attribute: 'InvocationsByRegion', - provider: 'logs', - label: 'Invocations by Region', - }, - ], - }, -] diff --git a/apps/studio/data/reports/edgefn-query.ts b/apps/studio/data/reports/edgefn-query.ts deleted file mode 100644 index 1671f7e4ec4..00000000000 --- a/apps/studio/data/reports/edgefn-query.ts +++ /dev/null @@ -1,415 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import { get } from 'data/fetchers' -import { AnalyticsInterval } from 'data/analytics/constants' -import type { MultiAttribute } from 'components/ui/Charts/ComposedChart.utils' -import { getHttpStatusCodeInfo } from 'lib/http-status-codes' -import { analyticsIntervalToGranularity, useEdgeFnIdToName } from './report.utils' -import { REPORT_STATUS_CODE_COLORS } from './report.utils' - -/** - * METRICS - * Each chart in the UI has a corresponding metric key. - */ - -const METRIC_KEYS = [ - 'TotalInvocations', - 'ExecutionStatusCodes', - 'InvocationsByRegion', - 'ExecutionTime', -] - -type MetricKey = (typeof METRIC_KEYS)[number] - -/** - * SQL - * Each metric has a corresponding SQL query. - */ - -const METRIC_SQL: Record< - MetricKey, - (interval: AnalyticsInterval, functionIds?: string[]) => string -> = { - TotalInvocations: (interval, functionIds) => { - return ` ---edgefn-report-invocations -select - timestamp_trunc(timestamp, ${analyticsIntervalToGranularity(interval)}) as timestamp, - function_id, - count(*) as count -from - function_edge_logs - CROSS JOIN UNNEST(metadata) AS m - CROSS JOIN UNNEST(m.request) AS request - CROSS JOIN UNNEST(m.response) AS response - ${ - functionIds && functionIds.length > 0 - ? `WHERE function_id IN (${functionIds.map((id) => `'${id}'`).join(',')})` - : '' - } -group by - timestamp, - function_id -order by - timestamp desc; - - ` - }, - ExecutionStatusCodes: (interval, functionIds) => { - return ` ---edgefn-report-execution-status-codes -select - timestamp_trunc(timestamp, ${analyticsIntervalToGranularity(interval)}) as timestamp, - response.status_code AS status_code, - COUNT(*) AS count -FROM - function_edge_logs - CROSS JOIN UNNEST(metadata) AS m - CROSS JOIN UNNEST(m.response) AS response - CROSS JOIN UNNEST(m.request) AS request - ${ - functionIds && functionIds.length > 0 - ? `WHERE function_id IN (${functionIds.map((id) => `'${id}'`).join(',')})` - : '' - } -group by - timestamp, - status_code -order by - timestamp desc; - ` - }, - InvocationsByRegion: (interval, functionIds) => { - const granularity = analyticsIntervalToGranularity(interval) - return ` ---edgefn-report-invocations-by-region -select - timestamp_trunc(timestamp, ${granularity}) as timestamp, - h.x_sb_edge_region as region, - count(*) as count -from - function_edge_logs - cross join unnest(metadata) as m - cross join unnest(m.response) as r - cross join unnest(r.headers) as h - where h.x_sb_edge_region is not null - ${ - functionIds && functionIds.length > 0 - ? `and function_id IN (${functionIds.map((id) => `'${id}'`).join(',')})` - : '' - } -group by - timestamp, - region -order by - timestamp desc - ` - }, - ExecutionTime: (interval, functionIds) => { - const granularity = analyticsIntervalToGranularity(interval) - const hasFunctions = functionIds && functionIds.length > 0 - return ` ---edgefn-report-execution-time -select - timestamp_trunc(timestamp, ${granularity}) as timestamp, - ${hasFunctions ? 'function_id,' : ''} - avg(m.execution_time_ms) as avg_execution_time -from - function_edge_logs - cross join unnest(metadata) as m - cross join unnest(m.request) as request - ${hasFunctions ? `where function_id IN (${functionIds.map((id) => `'${id}'`).join(',')})` : ''} -group by - timestamp - ${hasFunctions ? ', function_id' : ''} -order by - timestamp desc - ` - }, -} - -/** - * FORMATTERS. - * Metrics need to be formatted before being passed on to the UI charts. - */ - -function defaultFormatter(rawData: any, attributes: MultiAttribute[]) { - const chartAttributes = attributes - if (!rawData) return { data: undefined, chartAttributes } - const result = rawData.result || [] - const timestamps = new Set(result.map((p: any) => p.timestamp)) - const data = Array.from(timestamps) - .sort() - .map((timestamp) => { - const point: any = { period_start: timestamp } - chartAttributes.forEach((attr) => { - point[attr.attribute] = 0 - }) - const matchingPoints = result.filter((p: any) => p.timestamp === timestamp) - matchingPoints.forEach((p: any) => { - point[attributes[0].attribute] = p.count - }) - return point - }) - return { data, chartAttributes } -} - -const METRIC_FORMATTER: Record< - MetricKey, - ( - rawData: any, - attributes: MultiAttribute[], - logsMetric: string, - functionIds?: string[], - edgeFnIdToName?: (id: string) => string | undefined - ) => { data: any; chartAttributes: any } -> = { - TotalInvocations: (rawData, attributes, logsMetric, functionIds, edgeFnIdToName) => { - // Always use dynamic attributes, so the chart can show per-function stats. - if (!rawData) return { data: undefined, chartAttributes: attributes } - const result = rawData.result || [] - - const functionIdsInData = Array.from(new Set(result.map((p: any) => p.function_id))) as string[] - - const chartFunctionIds = functionIds && functionIds.length > 0 ? functionIds : functionIdsInData - - if (chartFunctionIds.length === 0) { - return { data: [], chartAttributes: [] } // No data, empty chart - } - - const chartAttributes = chartFunctionIds.map((id: string) => ({ - attribute: id, - label: edgeFnIdToName?.(id) ?? id, - provider: 'logs', - enabled: true, - })) - - const timestamps = new Set(result.map((p: any) => p.timestamp)) - const data = Array.from(timestamps) - .sort() - .map((timestamp) => { - const point: any = { period_start: timestamp } - chartAttributes.forEach((attr) => { - point[attr.attribute] = 0 - }) - const matchingPoints = result.filter((p: any) => p.timestamp === timestamp) - matchingPoints.forEach((p: any) => { - point[p.function_id as string] = p.count - }) - return point - }) - - return { data, chartAttributes } - }, - ExecutionStatusCodes: (rawData, attributes) => { - if (!rawData) return { data: undefined, chartAttributes: attributes } - const result = rawData.result || [] - - const statusCodes = Array.from(new Set(result.map((p: any) => p.status_code))) - - const chartAttributes = statusCodes.map((statusCode) => { - const statusCodeInfo = getHttpStatusCodeInfo(Number(statusCode)) - const color = - REPORT_STATUS_CODE_COLORS[String(statusCode)] || REPORT_STATUS_CODE_COLORS.default - - return { - attribute: `status_${statusCode}`, - label: `${statusCode} ${statusCodeInfo.label}`, - provider: 'logs', - enabled: true, - color: color, - statusCode: String(statusCode), - } - }) - - const timestamps = new Set(result.map((p: any) => p.timestamp)) - const data = Array.from(timestamps) - .sort() - .map((timestamp) => { - const point: any = { period_start: timestamp } - chartAttributes.forEach((attr) => { - point[attr.attribute] = 0 - }) - const matchingPoints = result.filter((p: any) => p.timestamp === timestamp) - matchingPoints.forEach((p: any) => { - point[`status_${p.status_code}`] = p.count - }) - return point - }) - - return { data, chartAttributes } - }, - InvocationsByRegion: (rawData, attributes) => { - if (!rawData) return { data: undefined, chartAttributes: attributes } - const result = rawData.result || [] - - const regions = Array.from(new Set(result.map((p: any) => p.region))).filter(Boolean) - - if (regions.length === 0) { - return { data: [], chartAttributes: [] } // No data, empty chart - } - - const chartAttributes = regions.map((region) => { - return { - attribute: region, - label: region, - provider: 'logs', - enabled: true, - } - }) - - const timestamps = new Set(result.map((p: any) => p.timestamp)) - const data = Array.from(timestamps) - .sort() - .map((timestamp) => { - const point: any = { period_start: timestamp } - chartAttributes.forEach((attr) => { - point[attr.attribute as string] = 0 - }) - const matchingPoints = result.filter((p: any) => p.timestamp === timestamp) - matchingPoints.forEach((p: any) => { - point[p.region] = p.count - }) - return point - }) - - return { data, chartAttributes } - }, - ExecutionTime: (rawData, attributes, logsMetric, functionIds, edgeFnIdToName) => { - if (!rawData) return { data: undefined, chartAttributes: attributes } - const result = rawData.result || [] - const hasFunctions = functionIds && functionIds.length > 0 - - if (hasFunctions) { - const chartAttributes = functionIds.map((id: string) => ({ - attribute: id, - label: edgeFnIdToName?.(id) ?? id, - provider: 'logs', - enabled: true, - })) - - if (result.length === 0) { - return { data: [], chartAttributes } - } - - const timestamps = new Set(result.map((p: any) => p.timestamp)) - const data = Array.from(timestamps) - .sort() - .map((timestamp) => { - const point: any = { period_start: timestamp } - chartAttributes.forEach((attr) => { - point[attr.attribute] = 0 - }) - const matchingPoints = result.filter((p: any) => p.timestamp === timestamp) - matchingPoints.forEach((p: any) => { - point[p.function_id as string] = p.avg_execution_time - }) - return point - }) - - return { data, chartAttributes } - } else { - const chartAttributes = [ - { - attribute: 'avg_execution_time', - label: 'Avg. execution time (ms)', - provider: 'logs', - enabled: true, - }, - ] - - const data = result - .map((p: any) => ({ - period_start: p.timestamp, - avg_execution_time: p.avg_execution_time, - })) - .sort( - (a: { period_start: string }, b: { period_start: string }) => - new Date(a.period_start).getTime() - new Date(b.period_start).getTime() - ) - - return { data, chartAttributes } - } - }, -} - -/** - * REPORT QUERY. - * Fetching and state management for the report. - */ - -export function useEdgeFunctionReport({ - projectRef, - attributes, - startDate, - endDate, - interval, - enabled = true, - functionIds, -}: { - projectRef: string - attributes: MultiAttribute[] - startDate: string - endDate: string - interval: AnalyticsInterval - enabled?: boolean - functionIds?: string[] -}) { - const logsMetric = attributes.length > 0 ? attributes[0].attribute : '' - const { edgeFnIdToName } = useEdgeFnIdToName({ projectRef }) - const isEdgeFnMetric = METRIC_KEYS.includes(logsMetric) - - const sql = isEdgeFnMetric ? METRIC_SQL[logsMetric as MetricKey](interval, functionIds) : '' - - const { - data: rawData, - error, - isLoading, - } = useQuery( - [ - 'edge-function-report', - projectRef, - logsMetric, - startDate, - endDate, - interval, - sql, - functionIds, - ], - async () => { - const { data, error } = await get(`/platform/projects/{ref}/analytics/endpoints/logs.all`, { - params: { - path: { ref: projectRef }, - query: { - sql, - iso_timestamp_start: startDate, - iso_timestamp_end: endDate, - }, - }, - }) - if (error) throw error - return data - }, - { - enabled: Boolean(projectRef && sql && enabled && isEdgeFnMetric), - refetchOnWindowFocus: false, - } - ) - - // Use formatter if available - const formatter = - (isEdgeFnMetric ? METRIC_FORMATTER[logsMetric as MetricKey] : undefined) || defaultFormatter - const { data, chartAttributes } = formatter( - rawData, - attributes, - logsMetric, - functionIds, - edgeFnIdToName - ) - - return { - data, - attributes: chartAttributes, - isLoading, - error, - } -} diff --git a/apps/studio/data/reports/v2/edge-functions.config.ts b/apps/studio/data/reports/v2/edge-functions.config.ts new file mode 100644 index 00000000000..2e65c925232 --- /dev/null +++ b/apps/studio/data/reports/v2/edge-functions.config.ts @@ -0,0 +1,395 @@ +import dayjs from 'dayjs' + +import { + isUnixMicro, + unixMicroToIsoTimestamp, +} from 'components/interfaces/Settings/Logs/Logs.utils' +import type { AnalyticsInterval } from 'data/analytics/constants' +import { get } from 'data/fetchers' +import { + analyticsIntervalToGranularity, + REPORT_STATUS_CODE_COLORS, +} from 'data/reports/report.utils' +import { getHttpStatusCodeInfo } from 'lib/http-status-codes' +import { ReportConfig } from './reports.types' + +const METRIC_SQL: Record string> = + { + TotalInvocations: (interval, functionIds) => { + return ` +--edgefn-report-invocations +select + timestamp_trunc(timestamp, ${analyticsIntervalToGranularity(interval)}) as timestamp, + function_id, + count(*) as count +from + function_edge_logs + CROSS JOIN UNNEST(metadata) AS m + CROSS JOIN UNNEST(m.request) AS request + CROSS JOIN UNNEST(m.response) AS response + ${ + functionIds && functionIds.length > 0 + ? `WHERE function_id IN (${functionIds.map((id) => `'${id}'`).join(',')})` + : '' + } +group by + timestamp, + function_id +order by + timestamp desc; +` + }, + ExecutionStatusCodes: (interval, functionIds) => { + return ` +--edgefn-report-execution-status-codes +select + timestamp_trunc(timestamp, ${analyticsIntervalToGranularity(interval)}) as timestamp, + response.status_code as status_code, + count(response.status_code) as count +from + function_edge_logs + cross join unnest(metadata) as m + cross join unnest(m.response) as response + ${ + functionIds && functionIds.length > 0 + ? `where function_id in (${functionIds.map((id) => `'${id}'`).join(',')})` + : '' + } +group by + timestamp, + status_code +order by + timestamp desc +` + }, + InvocationsByRegion: (interval, functionIds) => { + const granularity = analyticsIntervalToGranularity(interval) + return ` +--edgefn-report-invocations-by-region +select + timestamp_trunc(timestamp, ${granularity}) as timestamp, + h.x_sb_edge_region as region, + count(*) as count +from + function_edge_logs + cross join unnest(metadata) as m + cross join unnest(m.response) as r + cross join unnest(r.headers) as h + where h.x_sb_edge_region is not null + ${ + functionIds && functionIds.length > 0 + ? `and function_id IN (${functionIds.map((id) => `'${id}'`).join(',')})` + : '' + } +group by + timestamp, + region +order by + timestamp desc +` + }, + ExecutionTime: (interval, functionIds) => { + const granularity = analyticsIntervalToGranularity(interval) + const hasFunctions = functionIds && functionIds.length > 0 + return ` +--edgefn-report-execution-time +select + timestamp_trunc(timestamp, ${granularity}) as timestamp, + ${hasFunctions ? 'function_id,' : ''} + avg(m.execution_time_ms) as avg_execution_time +from + function_edge_logs + cross join unnest(metadata) as m + cross join unnest(m.request) as request + ${hasFunctions ? `where function_id IN (${functionIds.map((id) => `'${id}'`).join(',')})` : ''} +group by + timestamp + ${hasFunctions ? ', function_id' : ''} +order by + timestamp desc +` + }, + } + +async function runQuery(projectRef: string, sql: string, startDate: string, endDate: string) { + const { data, error } = await get(`/platform/projects/{ref}/analytics/endpoints/logs.all`, { + params: { + path: { ref: projectRef }, + query: { + sql, + iso_timestamp_start: startDate, + iso_timestamp_end: endDate, + }, + }, + }) + if (error) throw error + return data +} + +export function extractStatusCodesFromData(data: any[]): string[] { + const statusCodes = new Set() + + data.forEach((item: any) => { + if (item.status_code) { + statusCodes.add(String(item.status_code)) + } + }) + + return Array.from(statusCodes).sort() +} + +export function generateStatusCodeAttributes(statusCodes: string[]) { + return statusCodes.map((code) => ({ + attribute: code, + label: `${code} ${getHttpStatusCodeInfo(parseInt(code)).label}`, + color: REPORT_STATUS_CODE_COLORS[code] || REPORT_STATUS_CODE_COLORS.default, + })) +} + +/** + * Converts a list of { timestamp, status_code, count } + * to a list of { timestamp, [status_code]: count } + * That we can pass to the chart for rendering + */ +export function transformStatusCodeData(data: any[], statusCodes: string[]) { + const pivotedData = data.reduce((acc: Record, d: any) => { + const timestamp = isUnixMicro(d.timestamp) + ? unixMicroToIsoTimestamp(d.timestamp) + : dayjs.utc(d.timestamp).toISOString() + if (!acc[timestamp]) { + acc[timestamp] = { timestamp } + statusCodes.forEach((code) => { + acc[timestamp][code] = 0 + }) + } + acc[timestamp][d.status_code] = d.count + return acc + }, {}) + + return Object.values(pivotedData) +} + +/** + * Transforms raw invocation data by normalizing timestamps and adding function names + * @param data - Raw data from the database + * @param functions - Array of function objects with id and name + * @returns Transformed data with normalized timestamps and function names + */ +export function transformInvocationData(data: any[], functions: { id: string; name: string }[]) { + return data.map((log: any) => ({ + ...log, + timestamp: isUnixMicro(log.timestamp) + ? unixMicroToIsoTimestamp(log.timestamp) + : dayjs.utc(log.timestamp).toISOString(), + function_name: functions.find((f) => f.id === log.function_id)?.name ?? log.function_id, + })) +} + +/** + * Aggregates invocation data by timestamp, summing counts for each timestamp + * @param data - Transformed invocation data + * @returns Aggregated data with one entry per timestamp + */ +export function aggregateInvocationsByTimestamp(data: any[]) { + const aggregatedData = data.reduce((acc: Record, item: any) => { + const timestamp = item.timestamp + if (!acc[timestamp]) { + acc[timestamp] = { timestamp, count: 0 } + } + acc[timestamp].count += item.count + return acc + }, {}) + + return Object.values(aggregatedData) +} + +export const edgeFunctionReports = ({ + projectRef, + functions, + startDate, + endDate, + interval, + filters, +}: { + projectRef: string + functions: { id: string; name: string }[] + startDate: string + endDate: string + interval: AnalyticsInterval + filters: { + functionIds?: string[] + } +}): ReportConfig[] => [ + { + id: 'total-invocations', + label: 'Total Edge Function Invocations', + valuePrecision: 0, + hide: false, + showTooltip: true, + showLegend: true, + showMaxValue: false, + hideChartType: false, + defaultChartStyle: 'line', + titleTooltip: 'The total number of edge function invocations over time.', + availableIn: ['free', 'pro', 'team', 'enterprise'], + dataProvider: async () => { + const sql = METRIC_SQL.TotalInvocations(interval, filters.functionIds) + const response = await runQuery(projectRef, sql, startDate, endDate) + + if (!response?.result) return { data: [] } + + // Transform and aggregate the data using extracted functions + const transformedData = transformInvocationData(response.result, functions) + const data = aggregateInvocationsByTimestamp(transformedData) + + const attributes = [ + { + attribute: 'count', + label: 'Count', + }, + ] + + return { data, attributes, query: sql } + }, + }, + { + id: 'execution-status-codes', + label: 'Edge Function Status Codes', + valuePrecision: 0, + hide: false, + showTooltip: true, + showLegend: true, + showMaxValue: false, + hideChartType: false, + defaultChartStyle: 'line', + titleTooltip: 'The total number of edge function executions by status code.', + availableIn: ['free', 'pro', 'team', 'enterprise'], + dataProvider: async ( + projectRef: string, + startDate: string, + endDate: string, + interval: AnalyticsInterval, + functionIds?: string[] + ) => { + const sql = METRIC_SQL.ExecutionStatusCodes(interval, functionIds) + const rawData = await runQuery(projectRef, sql, startDate, endDate) + + if (!rawData?.result) return { data: [] } + + /** + * The query returns { timestamp, status_code: 500, count: 10 } + * and we have to transform it to { timestamp, 500: 10 } + * to be able to render the chart. + */ + + const statusCodes = extractStatusCodesFromData(rawData.result) + const attributes = generateStatusCodeAttributes(statusCodes) + + const data = transformStatusCodeData(rawData.result, statusCodes) + + return { data, attributes, query: sql } + }, + }, + { + id: 'execution-time', + label: 'Edge Function Execution Time', + valuePrecision: 0, + hide: false, + showTooltip: true, + showLegend: true, + showMaxValue: false, + hideChartType: false, + defaultChartStyle: 'line', + titleTooltip: 'Average execution time for edge functions.', + availableIn: ['free', 'pro', 'team', 'enterprise'], + YAxisProps: { + width: 50, + tickFormatter: (value: number) => `${value}ms`, + }, + format: (value: unknown) => `${Number(value).toFixed(0)}ms`, + dataProvider: async () => { + const sql = METRIC_SQL.ExecutionTime(interval, filters.functionIds) + const rawData = await runQuery(projectRef, sql, startDate, endDate) + + if (!rawData?.result) return { data: [] } + + // Transform the raw data to ensure one data point per timestamp + const transformedData = rawData.result?.map((point: any) => ({ + ...point, + timestamp: isUnixMicro(point.timestamp) + ? unixMicroToIsoTimestamp(point.timestamp) + : dayjs.utc(point.timestamp).toISOString(), + function_name: functions.find((f) => f.id === point.function_id)?.name ?? point.function_id, + })) + + // If we have multiple function IDs, we need to aggregate the execution times per timestamp + const aggregatedData = transformedData.reduce((acc: Record, item: any) => { + const timestamp = item.timestamp + if (!acc[timestamp]) { + acc[timestamp] = { + timestamp, + avg_execution_time: item.avg_execution_time, + count: 1, + } + } else { + // Calculate weighted average for multiple functions at the same timestamp + const totalTime = + acc[timestamp].avg_execution_time * acc[timestamp].count + item.avg_execution_time + acc[timestamp].count += 1 + acc[timestamp].avg_execution_time = totalTime / acc[timestamp].count + } + return acc + }, {}) + + const data = Object.values(aggregatedData).map(({ count, ...item }) => item) + + const attributes = [ + { + attribute: 'avg_execution_time', + label: 'Avg. execution time (ms)', + }, + ] + return { data, attributes, query: sql } + }, + }, + { + id: 'invocations-by-region', + label: 'Edge Function Invocations by Region', + valuePrecision: 0, + hide: false, + showTooltip: true, + showLegend: true, + showMaxValue: false, + hideChartType: false, + defaultChartStyle: 'line', + titleTooltip: 'The total number of edge function invocations by region.', + availableIn: ['pro', 'team', 'enterprise'], + dataProvider: async () => { + const sql = METRIC_SQL.InvocationsByRegion(interval, filters.functionIds) + const rawData = await runQuery(projectRef, sql, startDate, endDate) + const data = rawData.result?.map((point: any) => ({ + ...point, + timestamp: isUnixMicro(point.timestamp) + ? unixMicroToIsoTimestamp(point.timestamp) + : dayjs.utc(point.timestamp).toISOString(), + })) + + const attributes = [ + { + attribute: 'region', + label: 'Region', + provider: 'logs', + enabled: true, + }, + { + attribute: 'count', + label: 'Count', + provider: 'logs', + enabled: true, + }, + ] + + return { data, attributes, query: sql } + }, + }, +] diff --git a/apps/studio/data/reports/v2/edge-functions.test.tsx b/apps/studio/data/reports/v2/edge-functions.test.tsx new file mode 100644 index 00000000000..e1217d7b7a0 --- /dev/null +++ b/apps/studio/data/reports/v2/edge-functions.test.tsx @@ -0,0 +1,251 @@ +import { describe, expect, it } from 'vitest' +import { + extractStatusCodesFromData, + generateStatusCodeAttributes, + transformStatusCodeData, + transformInvocationData, + aggregateInvocationsByTimestamp, +} from './edge-functions.config' + +describe('extractStatusCodesFromData', () => { + it('should extract and sort unique status codes from the data', () => { + const data = [ + { status_code: 200 }, + { status_code: 500 }, + { status_code: 200 }, + { status_code: 404 }, + ] + const result = extractStatusCodesFromData(data) + expect(result).toEqual(['200', '404', '500']) + }) + + it('should handle an empty array', () => { + const data: any[] = [] + const result = extractStatusCodesFromData(data) + expect(result).toEqual([]) + }) + + it('should handle data with missing status_code properties', () => { + const data = [{ status_code: 200 }, {}, { status_code: 500 }] + const result = extractStatusCodesFromData(data) + expect(result).toEqual(['200', '500']) + }) + + it('should handle various data types for status_code', () => { + const data = [{ status_code: 200 }, { status_code: '500' }, { status_code: 404 }] + const result = extractStatusCodesFromData(data) + expect(result).toEqual(['200', '404', '500']) + }) +}) + +describe('generateStatusCodeAttributes', () => { + it('should generate the correct attributes for a list of status codes', () => { + const statusCodes = ['200', '404', '500'] + const result = generateStatusCodeAttributes(statusCodes) + expect(result.map(({ color, ...rest }) => rest)).toEqual([ + { + attribute: '200', + label: '200 OK', + }, + { + attribute: '404', + label: '404 Not Found', + }, + { + attribute: '500', + label: '500 Internal Server Error', + }, + ]) + }) + + it('should handle an empty array', () => { + const statusCodes: string[] = [] + const result = generateStatusCodeAttributes(statusCodes) + expect(result).toEqual([]) + }) +}) + +describe('transformStatusCodeData', () => { + it('should pivot the data correctly', () => { + const data = [ + { timestamp: '2023-01-01T00:00:00Z', status_code: 200, count: 10 }, + { timestamp: '2023-01-01T00:00:00Z', status_code: 500, count: 5 }, + { timestamp: '2023-01-02T00:00:00Z', status_code: 200, count: 20 }, + ] + const result = transformStatusCodeData(data, ['200', '500']) + expect(result).toEqual([ + { timestamp: '2023-01-01T00:00:00.000Z', '200': 10, '500': 5 }, + { timestamp: '2023-01-02T00:00:00.000Z', '200': 20, '500': 0 }, + ]) + }) + + it('should handle an empty array', () => { + const data: any[] = [] + const result = transformStatusCodeData(data, []) + expect(result).toEqual([]) + }) + + it('should handle a single entry', () => { + const data = [{ timestamp: '2023-01-01T00:00:00Z', status_code: 200, count: 10 }] + const result = transformStatusCodeData(data, ['200', '404']) + expect(result).toEqual([{ timestamp: '2023-01-01T00:00:00.000Z', '200': 10, '404': 0 }]) + }) +}) + +describe('transformInvocationData', () => { + const mockFunctions = [ + { id: 'func1', name: 'Function One' }, + { id: 'func2', name: 'Function Two' }, + ] + + it('should transform raw invocation data with function names', () => { + const rawData = [ + { + timestamp: '2023-01-01T00:00:00Z', + function_id: 'func1', + count: 10, + }, + { + timestamp: '2023-01-01T01:00:00Z', + function_id: 'func2', + count: 5, + }, + ] + + const result = transformInvocationData(rawData, mockFunctions) + expect(result).toEqual([ + { + timestamp: '2023-01-01T00:00:00.000Z', + function_id: 'func1', + count: 10, + function_name: 'Function One', + }, + { + timestamp: '2023-01-01T01:00:00.000Z', + function_id: 'func2', + count: 5, + function_name: 'Function Two', + }, + ]) + }) + + it('should use function_id as fallback when function name not found', () => { + const rawData = [ + { + timestamp: '2023-01-01T00:00:00Z', + function_id: 'unknown_func', + count: 10, + }, + ] + + const result = transformInvocationData(rawData, mockFunctions) + expect(result).toEqual([ + { + timestamp: '2023-01-01T00:00:00.000Z', + function_id: 'unknown_func', + count: 10, + function_name: 'unknown_func', + }, + ]) + }) + + it('should handle unix micro timestamps', () => { + const rawData = [ + { + timestamp: 1672531200000000, // Unix micro timestamp for 2023-01-01T00:00:00Z + function_id: 'func1', + count: 10, + }, + ] + + const result = transformInvocationData(rawData, mockFunctions) + expect(result[0].timestamp).toBe('2023-01-01T00:00:00.000Z') + expect(result[0].function_name).toBe('Function One') + }) + + it('should handle empty data array', () => { + const result = transformInvocationData([], mockFunctions) + expect(result).toEqual([]) + }) + + it('should handle empty functions array', () => { + const rawData = [ + { + timestamp: '2023-01-01T00:00:00Z', + function_id: 'func1', + count: 10, + }, + ] + + const result = transformInvocationData(rawData, []) + expect(result).toEqual([ + { + timestamp: '2023-01-01T00:00:00.000Z', + function_id: 'func1', + count: 10, + function_name: 'func1', + }, + ]) + }) +}) + +describe('aggregateInvocationsByTimestamp', () => { + it('should aggregate counts by timestamp', () => { + const data = [ + { timestamp: '2023-01-01T00:00:00.000Z', function_id: 'func1', count: 10 }, + { timestamp: '2023-01-01T00:00:00.000Z', function_id: 'func2', count: 5 }, + { timestamp: '2023-01-01T01:00:00.000Z', function_id: 'func1', count: 20 }, + ] + + const result = aggregateInvocationsByTimestamp(data) + expect(result).toEqual([ + { timestamp: '2023-01-01T00:00:00.000Z', count: 15 }, + { timestamp: '2023-01-01T01:00:00.000Z', count: 20 }, + ]) + }) + + it('should handle single entry per timestamp', () => { + const data = [ + { timestamp: '2023-01-01T00:00:00.000Z', function_id: 'func1', count: 10 }, + { timestamp: '2023-01-01T01:00:00.000Z', function_id: 'func2', count: 5 }, + ] + + const result = aggregateInvocationsByTimestamp(data) + expect(result).toEqual([ + { timestamp: '2023-01-01T00:00:00.000Z', count: 10 }, + { timestamp: '2023-01-01T01:00:00.000Z', count: 5 }, + ]) + }) + + it('should handle empty data array', () => { + const result = aggregateInvocationsByTimestamp([]) + expect(result).toEqual([]) + }) + + it('should handle multiple entries with same timestamp and different counts', () => { + const data = [ + { timestamp: '2023-01-01T00:00:00.000Z', function_id: 'func1', count: 1 }, + { timestamp: '2023-01-01T00:00:00.000Z', function_id: 'func2', count: 2 }, + { timestamp: '2023-01-01T00:00:00.000Z', function_id: 'func3', count: 3 }, + ] + + const result = aggregateInvocationsByTimestamp(data) + expect(result).toEqual([{ timestamp: '2023-01-01T00:00:00.000Z', count: 6 }]) + }) + + it('should preserve timestamp order from reduce operation', () => { + const data = [ + { timestamp: '2023-01-01T02:00:00.000Z', function_id: 'func1', count: 30 }, + { timestamp: '2023-01-01T00:00:00.000Z', function_id: 'func1', count: 10 }, + { timestamp: '2023-01-01T01:00:00.000Z', function_id: 'func1', count: 20 }, + ] + + const result = aggregateInvocationsByTimestamp(data) + expect(result).toHaveLength(3) + expect(result.map((item) => item.timestamp)).toEqual([ + '2023-01-01T02:00:00.000Z', + '2023-01-01T00:00:00.000Z', + '2023-01-01T01:00:00.000Z', + ]) + }) +}) diff --git a/apps/studio/data/reports/v2/reports.types.ts b/apps/studio/data/reports/v2/reports.types.ts new file mode 100644 index 00000000000..5edd0b23791 --- /dev/null +++ b/apps/studio/data/reports/v2/reports.types.ts @@ -0,0 +1,45 @@ +import { AnalyticsInterval } from 'data/analytics/constants' +import { YAxisProps } from 'recharts' + +type ReportDataProviderFilter = { + functionIds?: string[] +} + +export interface ReportDataProvider { + ( + projectRef: string, + startDate: string, + endDate: string, + interval: AnalyticsInterval, + functionIds?: string[], + edgeFnIdToName?: (id: string) => string | undefined, + filters?: ReportDataProviderFilter[] + ): Promise<{ + data: any + attributes?: { + attribute: string + label: string + color?: { light: string; dark: string } + }[] + query?: string // The SQL used to fetch the data if any + }> // [jordi] would be cool to have a type that forces data keys to match the attributes +} + +export interface ReportConfig { + id: string + label: string + dataProvider: ReportDataProvider + valuePrecision: number + hide: boolean + showTooltip: boolean + showLegend: boolean + showMaxValue: boolean + hideChartType: boolean + defaultChartStyle: string + titleTooltip: string + availableIn: string[] + format?: (value: unknown) => string + YAxisProps?: YAxisProps + xAxisKey?: string + yAxisKey?: string +} diff --git a/apps/studio/hooks/misc/useReportDateRange.ts b/apps/studio/hooks/misc/useReportDateRange.ts index af9502de124..bd1be26821c 100644 --- a/apps/studio/hooks/misc/useReportDateRange.ts +++ b/apps/studio/hooks/misc/useReportDateRange.ts @@ -9,6 +9,7 @@ import { } from 'components/interfaces/Reports/Reports.constants' import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan' import { maybeShowUpgradePrompt } from 'components/interfaces/Settings/Logs/Logs.utils' +import { AnalyticsInterval } from 'data/analytics/constants' export const DATERANGE_LIMITS: { [key: string]: number } = { free: 1, @@ -20,7 +21,7 @@ export const DATERANGE_LIMITS: { [key: string]: number } = { export interface ReportDateRange { period_start: { date: string; time_period: string } period_end: { date: string; time_period: string } - interval: string + interval: AnalyticsInterval } // Create parsers for individual URL parameters diff --git a/apps/studio/hooks/useChartData.ts b/apps/studio/hooks/useChartData.ts index 63998abc939..29f129b464a 100644 --- a/apps/studio/hooks/useChartData.ts +++ b/apps/studio/hooks/useChartData.ts @@ -15,7 +15,6 @@ import type { AnalyticsInterval, DataPoint } from 'data/analytics/constants' import { useAuthLogsReport } from 'data/reports/auth-report-query' import type { ChartData } from 'components/ui/Charts/Charts.types' import type { MultiAttribute } from 'components/ui/Charts/ComposedChart.utils' -import { useEdgeFunctionReport } from 'data/reports/edgefn-query' export const useChartData = ({ attributes, @@ -56,25 +55,9 @@ export const useChartData = ({ enabled: enabled && logsAttributes.length > 0 && !isEdgeFunctionRoute, }) - const { - data: edgeFunctionData, - attributes: edgeFunctionChartAttributes, - isLoading: isEdgeFunctionLoading, - } = useEdgeFunctionReport({ - projectRef: ref as string, - attributes: logsAttributes, - startDate, - endDate, - interval: interval as AnalyticsInterval, - enabled: enabled && logsAttributes.length > 0 && isEdgeFunctionRoute, - functionIds, - }) - - const logsData = isEdgeFunctionRoute ? edgeFunctionData : authData - const logsChartAttributes = isEdgeFunctionRoute - ? edgeFunctionChartAttributes - : authChartAttributes - const isLogsLoading = isEdgeFunctionRoute ? isEdgeFunctionLoading : isAuthLoading + const logsData = authData + const logsChartAttributes = authChartAttributes + const isLogsLoading = isAuthLoading const combinedData = useMemo(() => { if (data) return data diff --git a/apps/studio/pages/project/[ref]/reports/auth.tsx b/apps/studio/pages/project/[ref]/reports/auth.tsx index 3c65f9ca8f2..34838356c35 100644 --- a/apps/studio/pages/project/[ref]/reports/auth.tsx +++ b/apps/studio/pages/project/[ref]/reports/auth.tsx @@ -4,7 +4,7 @@ import dayjs from 'dayjs' import { ArrowRight, RefreshCw } from 'lucide-react' import { useState } from 'react' -import ReportChart from 'components/interfaces/Reports/ReportChart' +import { ReportChart } from 'components/interfaces/Reports/ReportChart' import ReportHeader from 'components/interfaces/Reports/ReportHeader' import ReportPadding from 'components/interfaces/Reports/ReportPadding' import ReportStickyNav from 'components/interfaces/Reports/ReportStickyNav' diff --git a/apps/studio/pages/project/[ref]/reports/database.tsx b/apps/studio/pages/project/[ref]/reports/database.tsx index 48ab74b916f..959967b54aa 100644 --- a/apps/studio/pages/project/[ref]/reports/database.tsx +++ b/apps/studio/pages/project/[ref]/reports/database.tsx @@ -7,7 +7,7 @@ import { useEffect, useState } from 'react' import { toast } from 'sonner' import { useFlag, useParams } from 'common' -import ReportChart from 'components/interfaces/Reports/ReportChart' +import { ReportChart } from 'components/interfaces/Reports/ReportChart' import ReportHeader from 'components/interfaces/Reports/ReportHeader' import ReportPadding from 'components/interfaces/Reports/ReportPadding' import { REPORT_DATERANGE_HELPER_LABELS } from 'components/interfaces/Reports/Reports.constants' @@ -22,7 +22,7 @@ import Table from 'components/to-be-cleaned/Table' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import ChartHandler from 'components/ui/Charts/ChartHandler' import type { MultiAttribute } from 'components/ui/Charts/ComposedChart.utils' -import ComposedChartHandler from 'components/ui/Charts/ComposedChartHandler' +import { LazyComposedChartHandler } from 'components/ui/Charts/ComposedChartHandler' import { ReportSettings } from 'components/ui/Charts/ReportSettings' import GrafanaPromoBanner from 'components/ui/GrafanaPromoBanner' import Panel from 'components/ui/Panel' @@ -269,7 +269,7 @@ const DatabaseUsage = () => { orgPlan?.id && (showChartsV2 ? REPORT_ATTRIBUTES_V2.filter((chart) => !chart.hide).map((chart) => ( - { )) : REPORT_ATTRIBUTES.filter((chart) => !chart.hide).map((chart, i) => chart.availableIn?.includes(orgPlan?.id) ? ( - { { +const EdgeFunctionsReportV2: NextPageWithLayout = () => { return ( @@ -32,20 +34,23 @@ const EdgeFunctionsReport: NextPageWithLayout = () => { ) } -EdgeFunctionsReport.getLayout = (page) => ( +EdgeFunctionsReportV2.getLayout = (page) => ( {page} ) -export type UpdateDateRange = (from: string, to: string) => void -export default EdgeFunctionsReport +export default EdgeFunctionsReportV2 const EdgeFunctionsUsage = () => { const { ref } = useParams() const { data: functions, isLoading: isLoadingFunctions } = useEdgeFunctionsQuery({ projectRef: ref, }) + + const chartSyncId = `edge-functions-${ref}` + useChartHoverState(chartSyncId) + const [isOpen, setIsOpen] = useState(false) const [functionIds, setFunctionIds] = useState([]) const [tempFunctionIds, setTempFunctionIds] = useState(functionIds) @@ -61,8 +66,6 @@ const EdgeFunctionsUsage = () => { updateDateRange, datePickerValue, datePickerHelpers, - isOrgPlanLoading, - orgPlan, showUpgradePrompt, setShowUpgradePrompt, handleDatePickerChange, @@ -71,21 +74,27 @@ const EdgeFunctionsUsage = () => { const queryClient = useQueryClient() const [isRefreshing, setIsRefreshing] = useState(false) - const EDGEFN_CHARTS = getEdgeFunctionReportAttributes() + const reportConfig = useMemo(() => { + return edgeFunctionReports({ + projectRef: ref!, + functions: functions ?? [], + startDate: selectedDateRange?.period_start?.date ?? '', + endDate: selectedDateRange?.period_end?.date ?? '', + interval: selectedDateRange?.interval ?? 'minute', + filters: { + functionIds, + }, + }) + }, [ref, functions, selectedDateRange, functionIds]) const onRefreshReport = async () => { if (!selectedDateRange) return setIsRefreshing(true) - queryClient.invalidateQueries(['edge-function-report', ref]) + queryClient.invalidateQueries(['report-v2']) setTimeout(() => setIsRefreshing(false), 1000) } - if (!ref) { - // Prevent rendering charts until the ref is available - return <> - } - return ( <> @@ -101,6 +110,8 @@ const EdgeFunctionsUsage = () => { tooltip={{ content: { side: 'bottom', text: 'Refresh report' } }} onClick={onRefreshReport} /> + + { >
{selectedDateRange && - EDGEFN_CHARTS.filter((attr) => !attr.hide).map((attr, i) => ( - - ))} + reportConfig + .filter((report) => !report.hide) + .map((report) => ( + + ))}
diff --git a/apps/studio/pages/project/[ref]/reports/realtime.tsx b/apps/studio/pages/project/[ref]/reports/realtime.tsx index 27b2c81abce..14ef2d580bd 100644 --- a/apps/studio/pages/project/[ref]/reports/realtime.tsx +++ b/apps/studio/pages/project/[ref]/reports/realtime.tsx @@ -19,7 +19,7 @@ import { TopApiRoutesRenderer, TotalRequestsChartRenderer, } from 'components/interfaces/Reports/renderers/ApiRenderers' -import ComposedChartHandler from 'components/ui/Charts/ComposedChartHandler' +import { LazyComposedChartHandler } from 'components/ui/Charts/ComposedChartHandler' import ReportWidget from 'components/interfaces/Reports/ReportWidget' import ReportFilterBar from 'components/interfaces/Reports/ReportFilterBar' @@ -187,7 +187,7 @@ const RealtimeUsage = () => { > {selectedDateRange && REALTIME_REPORT_ATTRIBUTES.filter((chart) => !chart.hide).map((chart) => ( - { + test('should return empty array for empty data without min/max', () => { + const result = fillTimeseries([], 'timestamp', 'value', 0) + expect(result).toEqual([]) + }) + + test('should return empty array for empty data with min/max', () => { + const min = '2023-01-01T00:00:00.000Z' + const max = '2023-01-01T01:00:00.000Z' + const result = fillTimeseries([], 'timestamp', 'value', 0, min, max) + + // When min/max are provided, the function fills the time range with default values + // This creates 61 data points (one for each minute from 00:00 to 01:00) + expect(result).toHaveLength(61) + expect(result[0]).toEqual({ timestamp: '2023-01-01T00:00:00.000Z', value: 0 }) + expect(result[60]).toEqual({ timestamp: '2023-01-01T01:00:00.000Z', value: 0 }) + }) + + test('should normalize timestamps when data exceeds minPointsToFill', () => { + const data = [ + { timestamp: '2023-01-01T00:00:00.000Z', value: 1 }, + { timestamp: '2023-01-01T00:01:00.000Z', value: 2 }, + { timestamp: '2023-01-01T00:02:00.000Z', value: 3 }, + { timestamp: '2023-01-01T00:03:00.000Z', value: 4 }, + { timestamp: '2023-01-01T00:04:00.000Z', value: 5 }, + { timestamp: '2023-01-01T00:05:00.000Z', value: 6 }, + { timestamp: '2023-01-01T00:06:00.000Z', value: 7 }, + { timestamp: '2023-01-01T00:07:00.000Z', value: 8 }, + { timestamp: '2023-01-01T00:08:00.000Z', value: 9 }, + { timestamp: '2023-01-01T00:09:00.000Z', value: 10 }, + { timestamp: '2023-01-01T00:10:00.000Z', value: 11 }, + { timestamp: '2023-01-01T00:11:00.000Z', value: 12 }, + { timestamp: '2023-01-01T00:12:00.000Z', value: 13 }, + { timestamp: '2023-01-01T00:13:00.000Z', value: 14 }, + { timestamp: '2023-01-01T00:14:00.000Z', value: 15 }, + { timestamp: '2023-01-01T00:15:00.000Z', value: 16 }, + { timestamp: '2023-01-01T00:16:00.000Z', value: 17 }, + { timestamp: '2023-01-01T00:17:00.000Z', value: 18 }, + { timestamp: '2023-01-01T00:18:00.000Z', value: 19 }, + { timestamp: '2023-01-01T00:19:00.000Z', value: 20 }, + { timestamp: '2023-01-01T00:20:00.000Z', value: 21 }, + ] + const result = fillTimeseries(data, 'timestamp', 'value', 0, undefined, undefined, 20) + + // Should return normalized data without filling gaps + expect(result).toHaveLength(21) + result.forEach((item) => { + expect(item.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/) + }) + }) + + test('should fill gaps in sparse data with 1-minute intervals', () => { + const min = '2023-01-01T00:00:00.000Z' + const max = '2023-01-01T00:04:00.000Z' + const data = [ + { timestamp: '2023-01-01T00:01:00.000Z', value: 10 }, + { timestamp: '2023-01-01T00:03:00.000Z', value: 30 }, + ] + + const result = fillTimeseries(data, 'timestamp', 'value', 0, min, max, 20, '2m') + + expect(result).toHaveLength(5) + const sortedResult = result.sort((a, b) => a.timestamp.localeCompare(b.timestamp)) + + expect(sortedResult[0]).toEqual({ timestamp: '2023-01-01T00:00:00.000Z', value: 0 }) + expect(sortedResult[1]).toEqual({ timestamp: '2023-01-01T00:01:00.000Z', value: 10 }) + expect(sortedResult[2]).toEqual({ timestamp: '2023-01-01T00:02:00.000Z', value: 0 }) + expect(sortedResult[3]).toEqual({ timestamp: '2023-01-01T00:03:00.000Z', value: 30 }) + expect(sortedResult[4]).toEqual({ timestamp: '2023-01-01T00:04:00.000Z', value: 0 }) + }) + + test('should handle multiple value keys', () => { + const min = '2023-01-01T00:00:00.000Z' + const max = '2023-01-01T00:02:00.000Z' + const data = [{ timestamp: '2023-01-01T00:01:00.000Z', count1: 10, count2: 100 }] + + const result = fillTimeseries(data, 'timestamp', ['count1', 'count2'], 5, min, max, 20, '1m') + + expect(result).toHaveLength(3) + const sortedResult = result.sort((a, b) => a.timestamp.localeCompare(b.timestamp)) + + expect(sortedResult[0]).toEqual({ timestamp: '2023-01-01T00:00:00.000Z', count1: 5, count2: 5 }) + expect(sortedResult[1]).toEqual({ + timestamp: '2023-01-01T00:01:00.000Z', + count1: 10, + count2: 100, + }) + expect(sortedResult[2]).toEqual({ timestamp: '2023-01-01T00:02:00.000Z', count1: 5, count2: 5 }) + }) + + test('should handle different interval formats', () => { + const min = '2023-01-01T00:00:00.000Z' + const max = '2023-01-01T00:10:00.000Z' + const data = [{ timestamp: '2023-01-01T00:05:00.000Z', value: 50 }] + + // Test 5-minute intervals: 00:00, 00:05, 00:10 = 3 points + const result5m = fillTimeseries(data, 'timestamp', 'value', 0, min, max, 20, '5m') + expect(result5m).toHaveLength(3) + + // Test 2-minute intervals: 00:00, 00:02, 00:04, 00:05, 00:06, 00:08, 00:10 = 7 points + const result2m = fillTimeseries(data, 'timestamp', 'value', 0, min, max, 20, '2m') + console.log( + '2m intervals:', + result2m.map((r) => r.timestamp) + ) + expect(result2m).toHaveLength(7) + + // Test 1-hour intervals: 00:00, 01:00, 02:00 with existing data at 00:05 = 4 points + const maxHour = '2023-01-01T02:00:00.000Z' + const result1h = fillTimeseries(data, 'timestamp', 'value', 0, min, maxHour, 20, '1h') + expect(result1h).toHaveLength(4) + }) + + test('should handle microsecond timestamps correctly', () => { + const now = dayjs.utc('2023-01-01T00:00:00.000Z') + const data = [ + { timestamp: now.valueOf() * 1000, value: 1 }, + { timestamp: now.add(1, 'minute').valueOf() * 1000, value: 2 }, + ] + + const result = fillTimeseries(data, 'timestamp', 'value', 0, undefined, undefined, 1) + + expect(result).toHaveLength(2) + result.forEach((item) => { + expect(item.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/) + }) + }) + + test('should handle mixed timestamp formats', () => { + const data = [ + { timestamp: '2023-01-01T00:00:00.000Z', value: 1 }, + { timestamp: dayjs.utc('2023-01-01T00:01:00.000Z').valueOf() * 1000, value: 2 }, + { timestamp: '2023-01-01T00:02:00.000Z', value: 3 }, + ] + + const result = fillTimeseries(data, 'timestamp', 'value', 0, undefined, undefined, 1) + + expect(result).toHaveLength(3) + result.forEach((item) => { + expect(item.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/) + }) + }) + + test('should not fill gaps when data is dense enough', () => { + const data = Array.from({ length: 25 }, (_, i) => ({ + timestamp: dayjs.utc('2023-01-01T00:00:00.000Z').add(i, 'minute').toISOString(), + value: i, + })) + + const result = fillTimeseries(data, 'timestamp', 'value', 0, undefined, undefined, 20) + + expect(result).toHaveLength(25) + expect(result).toEqual(data) + }) + + test('should handle edge case with single data point', () => { + const data = [{ timestamp: '2023-01-01T00:00:00.000Z', value: 1 }] + + const result = fillTimeseries(data, 'timestamp', 'value', 0) + + expect(result).toEqual(data) + }) + + test('should handle edge case with single data point and min/max', () => { + const min = '2023-01-01T00:00:00.000Z' + const max = '2023-01-01T00:02:00.000Z' + const data = [{ timestamp: '2023-01-01T00:01:00.000Z', value: 1 }] + + const result = fillTimeseries(data, 'timestamp', 'value', 0, min, max, 20, '1m') + + expect(result).toHaveLength(3) + const sortedResult = result.sort((a, b) => a.timestamp.localeCompare(b.timestamp)) + + expect(sortedResult[0]).toEqual({ timestamp: '2023-01-01T00:00:00.000Z', value: 0 }) + expect(sortedResult[1]).toEqual({ timestamp: '2023-01-01T00:01:00.000Z', value: 1 }) + expect(sortedResult[2]).toEqual({ timestamp: '2023-01-01T00:02:00.000Z', value: 0 }) + }) + + test('should handle invalid interval format gracefully', () => { + const min = '2023-01-01T00:00:00.000Z' + const max = '2023-01-01T00:02:00.000Z' + const data = [{ timestamp: '2023-01-01T00:01:00.000Z', value: 1 }] + + const result = fillTimeseries(data, 'timestamp', 'value', 0, min, max, 20, 'invalid') + + // Should fall back to default behavior + expect(result.length).toBeGreaterThan(1) + }) + + test('should preserve all properties of original data', () => { + const data = [ + { + timestamp: '2023-01-01T00:00:00.000Z', + value: 1, + extra: 'data', + nested: { prop: 'value' }, + }, + ] + + const result = fillTimeseries(data, 'timestamp', 'value', 0) + + expect(result[0]).toEqual(data[0]) + expect(result[0].extra).toBe('data') + expect(result[0].nested).toEqual({ prop: 'value' }) + }) + + test('should handle empty value keys array', () => { + const min = '2023-01-01T00:00:00.000Z' + const max = '2023-01-01T00:01:00.000Z' + const data = [{ timestamp: '2023-01-01T00:00:30.000Z', value: 1 }] + + const result = fillTimeseries(data, 'timestamp', [], 0, min, max, 20, '30s') + + expect(result).toHaveLength(3) + result.forEach((item) => { + expect(item).toHaveProperty('timestamp') + expect(item).not.toHaveProperty('value') + }) + }) +}) describe('checkForWithClause', () => { test('basic queries', () => { diff --git a/apps/studio/tests/vitestSetup.ts b/apps/studio/tests/vitestSetup.ts index f309589b528..df9816a4bba 100644 --- a/apps/studio/tests/vitestSetup.ts +++ b/apps/studio/tests/vitestSetup.ts @@ -4,6 +4,14 @@ import { createDynamicRouteParser } from 'next-router-mock/dist/dynamic-routes' import { afterAll, afterEach, beforeAll, vi } from 'vitest' import { routerMock } from './lib/route-mock' import { mswServer } from './lib/msw' +import dayjs from 'dayjs' +import utc from 'dayjs/plugin/utc' +import timezone from 'dayjs/plugin/timezone' +import relativeTime from 'dayjs/plugin/relativeTime' + +dayjs.extend(utc) +dayjs.extend(timezone) +dayjs.extend(relativeTime) // Uncomment this if HTML in errors are being annoying. // @@ -38,7 +46,7 @@ beforeAll(() => { vi.mock('next/compat/router', () => require('next-router-mock')) // Mock the useParams hook from common module globally - vi.mock('common', async (importOriginal) => { + vi.mock('common', async (importOriginal: any) => { const actual = await importOriginal() return { ...(typeof actual === 'object' ? actual : {}), From 32f55e82386b0dee6ea305d10ddfbeb7ae757931 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Mon, 1 Sep 2025 16:17:45 +0700 Subject: [PATCH 11/24] Chore/finalize setup configcat www (#38236) * Finalize setup configcat for www * Add comments * Add comment * Add a console error if env var is missing for DX clarity * Nit --- apps/www/components/Hero/Hero.tsx | 2 -- apps/www/components/Nav/index.tsx | 2 +- apps/www/pages/_app.tsx | 5 +++-- packages/common/configcat.ts | 30 +++++++++++++++++++++++------- 4 files changed, 27 insertions(+), 12 deletions(-) diff --git a/apps/www/components/Hero/Hero.tsx b/apps/www/components/Hero/Hero.tsx index 7146986b559..94d27bf0582 100644 --- a/apps/www/components/Hero/Hero.tsx +++ b/apps/www/components/Hero/Hero.tsx @@ -3,8 +3,6 @@ import Link from 'next/link' import { Button } from 'ui' import SectionContainer from '~/components/Layouts/SectionContainer' import { useSendTelemetryEvent } from '~/lib/telemetry' -import AnnouncementBadge from '../Announcement/Badge' -import { announcement } from 'ui-patterns' const Hero = () => { const sendTelemetryEvent = useSendTelemetryEvent() diff --git a/apps/www/components/Nav/index.tsx b/apps/www/components/Nav/index.tsx index 8746eb592b9..c9232b6bc05 100644 --- a/apps/www/components/Nav/index.tsx +++ b/apps/www/components/Nav/index.tsx @@ -9,10 +9,10 @@ import { useIsLoggedIn, useUser } from 'common' import { Button, buttonVariants, cn } from 'ui' import { AuthenticatedDropdownMenu } from 'ui-patterns' +import { useSendTelemetryEvent } from 'lib/telemetry' import GitHubButton from './GitHubButton' import HamburgerButton from './HamburgerMenu' import RightClickBrandLogo from './RightClickBrandLogo' -import { useSendTelemetryEvent } from 'lib/telemetry' import useDropdownMenu from './useDropdownMenu' import { getMenu } from 'data/nav' diff --git a/apps/www/pages/_app.tsx b/apps/www/pages/_app.tsx index f033817bc8a..be7a9e4042e 100644 --- a/apps/www/pages/_app.tsx +++ b/apps/www/pages/_app.tsx @@ -5,11 +5,12 @@ import '../styles/index.css' import { AuthProvider, FeatureFlagProvider, + getFlags as getConfigCatFlags, IS_PLATFORM, PageTelemetry, + TelemetryTagManager, ThemeProvider, useThemeSandbox, - TelemetryTagManager, } from 'common' import { DefaultSeo } from 'next-seo' import { AppProps } from 'next/app' @@ -86,7 +87,7 @@ export default function App({ Component, pageProps }: AppProps) { /> - + theme.value)} enableSystem diff --git a/packages/common/configcat.ts b/packages/common/configcat.ts index 534a3b0a77d..89046050d73 100644 --- a/packages/common/configcat.ts +++ b/packages/common/configcat.ts @@ -3,6 +3,17 @@ import * as configcat from 'configcat-js' let client: configcat.IConfigCatClient const endpoint = '/configuration-files/configcat-proxy/frontend-v2/config_v6.json' +/** + * To set up ConfigCat for another app + * - Declare `FeatureFlagProvider` at the _app level + * - Pass in `getFlags` as `getConfigCatFlags` into `FeatureFlagProvider` + * - [Joshen] Wondering if this should just be baked into FeatureFlagProvider, rather than passed as a prop + * - Ensure that your app has the `NEXT_PUBLIC_CONFIGCAT_PROXY_URL` env var + * - [Joshen] Wondering if we can just set a default value for each env var, so can skip setting up env var in Vercel + * - Verify that your flags are now loading by console logging `flagValues` in `FeatureFlagProvider`'s useEffect + * - Can now use ConfigCat feature flags with the `useFlag` hook + */ + export const fetchHandler: typeof fetch = async (input, init) => { try { return await fetch(input, init) @@ -16,10 +27,14 @@ export const fetchHandler: typeof fetch = async (input, init) => { } async function getClient() { - if (client) { - return client + if (!process.env.NEXT_PUBLIC_CONFIGCAT_PROXY_URL) { + console.error( + 'Failed to get ConfigCat client: missing env var "NEXT_PUBLIC_CONFIGCAT_PROXY_URL"' + ) } + if (client) return client + const response = await fetchHandler(process.env.NEXT_PUBLIC_CONFIGCAT_PROXY_URL + endpoint) const options = { pollIntervalSeconds: 7 * 60 } // 7 minutes if (response.status !== 200) { @@ -40,10 +55,11 @@ async function getClient() { } export async function getFlags(userEmail: string = '') { - if (userEmail) { - const client = await getClient() - return client.getAllValuesAsync(new configcat.User(userEmail)) - } + const client = await getClient() - return [] + if (userEmail) { + return client.getAllValuesAsync(new configcat.User(userEmail)) + } else { + return client.getAllValuesAsync() + } } From 9917ff517fc75113fa32bb84b80791cf110693c3 Mon Sep 17 00:00:00 2001 From: Eric Kharitonashvili <33439489+ekhar@users.noreply.github.com> Date: Mon, 1 Sep 2025 05:23:38 -0400 Subject: [PATCH 12/24] Eric Finally Updates humans.txt (#38283) Update humans.txt --- apps/docs/public/humans.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index e6b97587952..f2bcd4d90e1 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -45,6 +45,7 @@ Eduardo Gurgel Egor Romanov Eleftheria Trivyzaki Emmett Folger +Eric Kharitonashvili Etienne Stalmans Fabrizio Fenoglio Felipe Stival From 367cf17ca8a3cfc3eb3d6ef91d3c25a1dbd2937d Mon Sep 17 00:00:00 2001 From: Riccardo Busetti Date: Mon, 1 Sep 2025 11:51:34 +0200 Subject: [PATCH 13/24] ref(replication): Remove replication lag (#38346) --- .../Database/Replication/ReplicationPipelineStatus.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.tsx index 3d62d5f710a..c3e4ca8117e 100644 --- a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.tsx +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus.tsx @@ -293,11 +293,6 @@ export const ReplicationPipelineStatus = () => {
{statusConfig.description}
- {'lag' in table.state && ( -
- Lag: {table.state.lag}ms -
- )} {table.state.name === 'error' && ( Date: Mon, 1 Sep 2025 12:30:05 +0200 Subject: [PATCH 14/24] chore: add ConfigCat custom attributes (#38347) chore: add configcat custom attributes --- apps/studio/pages/_app.tsx | 16 +++++++++++++--- packages/common/configcat.ts | 6 ++++-- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/apps/studio/pages/_app.tsx b/apps/studio/pages/_app.tsx index 19e0af38490..34b2be5f2d1 100644 --- a/apps/studio/pages/_app.tsx +++ b/apps/studio/pages/_app.tsx @@ -30,12 +30,12 @@ import timezone from 'dayjs/plugin/timezone' import utc from 'dayjs/plugin/utc' import Head from 'next/head' import { NuqsAdapter } from 'nuqs/adapters/next/pages' -import { ErrorInfo } from 'react' +import { ErrorInfo, useCallback } from 'react' import { ErrorBoundary } from 'react-error-boundary' import { FeatureFlagProvider, - getFlags as getConfigCatFlags, + getFlags, TelemetryTagManager, ThemeProvider, useThemeSandbox, @@ -51,7 +51,7 @@ import { GlobalErrorBoundaryState } from 'components/ui/GlobalErrorBoundaryState import { useRootQueryClient } from 'data/query-client' import { customFont, sourceCodePro } from 'fonts' import { AuthProvider } from 'lib/auth' -import { API_URL, BASE_PATH, IS_PLATFORM } from 'lib/constants' +import { API_URL, BASE_PATH, IS_PLATFORM, useDefaultProvider } from 'lib/constants' import { ProfileProvider } from 'lib/profile' import { Telemetry } from 'lib/telemetry' import { AppPropsWithLayout } from 'types' @@ -100,6 +100,16 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) { const isTestEnv = process.env.NEXT_PUBLIC_NODE_ENV === 'test' + const cloudProvider = useDefaultProvider() + const getConfigCatFlags = useCallback( + (userEmail?: string) => { + const customAttributes = cloudProvider ? { cloud_provider: cloudProvider } : undefined + + return getFlags(userEmail, customAttributes) + }, + [cloudProvider] + ) + return ( diff --git a/packages/common/configcat.ts b/packages/common/configcat.ts index 89046050d73..410f40addcc 100644 --- a/packages/common/configcat.ts +++ b/packages/common/configcat.ts @@ -54,11 +54,13 @@ async function getClient() { return client } -export async function getFlags(userEmail: string = '') { +export async function getFlags(userEmail: string = '', customAttributes?: Record) { const client = await getClient() if (userEmail) { - return client.getAllValuesAsync(new configcat.User(userEmail)) + return client.getAllValuesAsync( + new configcat.User(userEmail, undefined, undefined, customAttributes) + ) } else { return client.getAllValuesAsync() } From 8e64dd7ed5f9b0ffe2d772452d0bb8e8c4ed765a Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Mon, 1 Sep 2025 13:01:25 +0200 Subject: [PATCH 15/24] fix: nimbus compute sizes (#38348) --- .../data/projects/new-project.constants.ts | 71 ++++++++++++++----- 1 file changed, 53 insertions(+), 18 deletions(-) diff --git a/apps/studio/data/projects/new-project.constants.ts b/apps/studio/data/projects/new-project.constants.ts index 58f55cf9faa..accac2b6757 100644 --- a/apps/studio/data/projects/new-project.constants.ts +++ b/apps/studio/data/projects/new-project.constants.ts @@ -31,7 +31,12 @@ export const instanceSizeSpecs: Record< cpu: '2-core', priceHourly: 0.01344, priceMonthly: 10, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.FLY.id], + cloud_providers: [ + PROVIDERS.AWS.id, + PROVIDERS.AWS_K8S.id, + PROVIDERS.AWS_NIMBUS.id, + PROVIDERS.FLY.id, + ], }, small: { label: 'Small', @@ -39,7 +44,12 @@ export const instanceSizeSpecs: Record< cpu: '2-core', priceHourly: 0.0206, priceMonthly: 15, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.FLY.id], + cloud_providers: [ + PROVIDERS.AWS.id, + PROVIDERS.AWS_K8S.id, + PROVIDERS.AWS_NIMBUS.id, + PROVIDERS.FLY.id, + ], }, medium: { label: 'Medium', @@ -47,7 +57,12 @@ export const instanceSizeSpecs: Record< cpu: '2-core', priceHourly: 0.0822, priceMonthly: 60, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.FLY.id], + cloud_providers: [ + PROVIDERS.AWS.id, + PROVIDERS.AWS_K8S.id, + PROVIDERS.AWS_NIMBUS.id, + PROVIDERS.FLY.id, + ], }, large: { label: 'Large', @@ -55,7 +70,12 @@ export const instanceSizeSpecs: Record< cpu: '2-core', priceHourly: 0.1517, priceMonthly: 110, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.FLY.id], + cloud_providers: [ + PROVIDERS.AWS.id, + PROVIDERS.AWS_K8S.id, + PROVIDERS.AWS_NIMBUS.id, + PROVIDERS.FLY.id, + ], }, xlarge: { label: 'XL', @@ -63,7 +83,12 @@ export const instanceSizeSpecs: Record< cpu: '4-core', priceHourly: 0.2877, priceMonthly: 210, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.FLY.id], + cloud_providers: [ + PROVIDERS.AWS.id, + PROVIDERS.AWS_K8S.id, + PROVIDERS.AWS_NIMBUS.id, + PROVIDERS.FLY.id, + ], }, '2xlarge': { label: '2XL', @@ -71,7 +96,12 @@ export const instanceSizeSpecs: Record< cpu: '8-core', priceHourly: 0.562, priceMonthly: 410, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.FLY.id], + cloud_providers: [ + PROVIDERS.AWS.id, + PROVIDERS.AWS_K8S.id, + PROVIDERS.AWS_NIMBUS.id, + PROVIDERS.FLY.id, + ], }, '4xlarge': { label: '4XL', @@ -79,7 +109,12 @@ export const instanceSizeSpecs: Record< cpu: '16-core', priceHourly: 1.32, priceMonthly: 960, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.FLY.id], + cloud_providers: [ + PROVIDERS.AWS.id, + PROVIDERS.AWS_K8S.id, + PROVIDERS.AWS_NIMBUS.id, + PROVIDERS.FLY.id, + ], }, '8xlarge': { label: '8XL', @@ -87,7 +122,7 @@ export const instanceSizeSpecs: Record< cpu: '32-core', priceHourly: 2.562, priceMonthly: 1870, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.AWS_NIMBUS.id], }, '12xlarge': { label: '12XL', @@ -95,7 +130,7 @@ export const instanceSizeSpecs: Record< cpu: '48-core', priceHourly: 3.836, priceMonthly: 2800, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.AWS_NIMBUS.id], }, '16xlarge': { label: '16XL', @@ -103,7 +138,7 @@ export const instanceSizeSpecs: Record< cpu: '64-core', priceHourly: 5.12, priceMonthly: 3730, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.AWS_NIMBUS.id], }, '24xlarge': { label: '24XL', @@ -111,7 +146,7 @@ export const instanceSizeSpecs: Record< cpu: '96-core', priceHourly: 9.73, priceMonthly: 7100, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.AWS_NIMBUS.id], }, '24xlarge_optimized_cpu': { label: '24XL - Optimized CPU', @@ -119,7 +154,7 @@ export const instanceSizeSpecs: Record< cpu: '96-core', priceHourly: 8.9, priceMonthly: 6500, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.AWS_NIMBUS.id], }, '24xlarge_optimized_memory': { label: '24XL - Optimized Memory', @@ -127,7 +162,7 @@ export const instanceSizeSpecs: Record< cpu: '96-core', priceHourly: 13.84, priceMonthly: 10100, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.AWS_NIMBUS.id], }, '24xlarge_high_memory': { label: '24XL - High Memory', @@ -135,7 +170,7 @@ export const instanceSizeSpecs: Record< cpu: '96-core', priceHourly: 21.91, priceMonthly: 16000, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.AWS_NIMBUS.id], }, '48xlarge': { label: '48XL', @@ -143,7 +178,7 @@ export const instanceSizeSpecs: Record< cpu: '192-core', priceHourly: 19.47, priceMonthly: 14200, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.AWS_NIMBUS.id], }, '48xlarge_optimized_cpu': { label: '48XL - Optimized CPU', @@ -151,7 +186,7 @@ export const instanceSizeSpecs: Record< cpu: '192-core', priceHourly: 17.8, priceMonthly: 13000, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.AWS_NIMBUS.id], }, '48xlarge_optimized_memory': { label: '48XL - Optimized Memory', @@ -159,7 +194,7 @@ export const instanceSizeSpecs: Record< cpu: '192-core', priceHourly: 27.68, priceMonthly: 20200, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.AWS_NIMBUS.id], }, '48xlarge_high_memory': { label: '48XL - High Memory', @@ -167,6 +202,6 @@ export const instanceSizeSpecs: Record< cpu: '192-core', priceHourly: 43.84, priceMonthly: 32000, - cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id], + cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_K8S.id, PROVIDERS.AWS_NIMBUS.id], }, } From 9ef6d0d916fb4bd5ce7c6e400174ee16121c6c95 Mon Sep 17 00:00:00 2001 From: Monica Khoury <99693443+monicakh@users.noreply.github.com> Date: Mon, 1 Sep 2025 14:44:01 +0300 Subject: [PATCH 16/24] Docs: clarify CLI v2 config change behavior (#38303) * Docs: clarify CLI v2 config change behavior * fix linter warnings --- .../database/custom-postgres-config.mdx | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/apps/docs/content/guides/database/custom-postgres-config.mdx b/apps/docs/content/guides/database/custom-postgres-config.mdx index d650c5db263..26e1fc9b6ff 100644 --- a/apps/docs/content/guides/database/custom-postgres-config.mdx +++ b/apps/docs/content/guides/database/custom-postgres-config.mdx @@ -165,7 +165,32 @@ supabase --experimental \ postgres-config delete --config shared_buffers,work_mem ``` -By default, changing the configuration, whether by updating or deleting, causes the database and all associated read replicas to restart. You can use the `--no-restart` flag to prevent this behavior, and attempt to reload the updated configuration without a restart. Refer to the Postgres documentation to determine if a given parameter can be reloaded without a restart. +By default, CLI v2 (≥ 2.0.0) checks the parameter’s context and requests the correct action (reload or restart): + +- If the setting can be reloaded (`pg_settings.context = 'sighup'`), then the Management API will detect this and apply the change with a configuration reload. +- If the setting requires a restart (`pg_settings.context = 'postmaster'`), then both the primary and any read replicas will restart to apply the change. + +To check whether a parameter can be reloaded without a restart, see the [Postgres docs](https://www.postgresql.org/docs/current/runtime-config.html). + +You can verify whether changes have been applied with the following checks: + +```bash +supabase --version; +``` + +```sql +-- Check whether the parameters were updated (and if a restart is pending): +select name, setting, context, pending_restart +from pg_settings +where name in ('max_slot_wal_keep_size', 'shared_buffers', 'max_connections'); +``` + +```sql +-- If the timestamp hasn’t changed, no restart occurred +select pg_postmaster_start_time(); +``` + +You can also pass the `--no-restart` flag to attempt a reload-only apply. If the parameter cannot be reloaded, the change stays pending until the next restart. From ea0bd567f4658c941d0959f2a2969f3961e49b70 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Mon, 1 Sep 2025 22:50:49 +0700 Subject: [PATCH 17/24] Fix TableEditor keyboard copy action copies who cell value even when only a subset is highlighted while editing cell (#38350) --- apps/studio/components/grid/SupabaseGrid.utils.ts | 8 ++++++-- apps/studio/components/grid/components/grid/Grid.tsx | 4 +--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/studio/components/grid/SupabaseGrid.utils.ts b/apps/studio/components/grid/SupabaseGrid.utils.ts index 92aa3043d0b..5f6ae0cf5fa 100644 --- a/apps/studio/components/grid/SupabaseGrid.utils.ts +++ b/apps/studio/components/grid/SupabaseGrid.utils.ts @@ -214,10 +214,14 @@ export function useLoadTableEditorStateFromLocalStorageIntoUrl({ } export const handleCopyCell = ( - { column, row }: { column: CalculatedColumn; row: any }, + { + mode, + column, + row, + }: { mode: 'SELECT' | 'EDIT'; column: CalculatedColumn; row: any }, event: CellKeyboardEvent ) => { - if (event.code === 'KeyC' && (event.metaKey || event.ctrlKey)) { + if (mode === 'SELECT' && event.code === 'KeyC' && (event.metaKey || event.ctrlKey)) { const colKey = column.key const cellValue = row[colKey] ?? '' const value = formatClipboardValue(cellValue) diff --git a/apps/studio/components/grid/components/grid/Grid.tsx b/apps/studio/components/grid/components/grid/Grid.tsx index ffc35913bae..1818e82ebba 100644 --- a/apps/studio/components/grid/components/grid/Grid.tsx +++ b/apps/studio/components/grid/components/grid/Grid.tsx @@ -128,9 +128,7 @@ export const Grid = memo( } } - const removeAllFilters = () => { - onApplyFilters([]) - } + const removeAllFilters = () => onApplyFilters([]) return (
Date: Mon, 1 Sep 2025 18:03:18 +0200 Subject: [PATCH 18/24] typo (#38334) --- apps/docs/content/guides/realtime/authorization.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/docs/content/guides/realtime/authorization.mdx b/apps/docs/content/guides/realtime/authorization.mdx index 3dd1d59b336..9fb4f3aa8aa 100644 --- a/apps/docs/content/guides/realtime/authorization.mdx +++ b/apps/docs/content/guides/realtime/authorization.mdx @@ -28,7 +28,7 @@ To enforce private channels you need to disable the 'Allow public access' settin Realtime uses the `messages` table in your database's `realtime` schema to generate access policies for your clients when they connect to a Channel topic. -By creating RLS polices on the `realtime.messages` table you can control the access users have to a Channel topic, and features within a Channel topic. +By creating RLS policies on the `realtime.messages` table you can control the access users have to a Channel topic, and features within a Channel topic. The validation is done when the user connects. When their WebSocket connection is established and a Channel topic is joined, their permissions are calculated based on: @@ -373,9 +373,9 @@ When using Postgres Changes with RLS, database records are sent only to clients ## Updating RLS policies -Client access polices are cached for the duration of the connection. Your database is not queried for every Channel message. +Client access policies are cached for the duration of the connection. Your database is not queried for every Channel message. -Realtime updates the access policy cache for a client based on your RLS polices when: +Realtime updates the access policy cache for a client based on your RLS policies when: - A client connects to Realtime and subscribes to a Channel - A new JWT is sent to Realtime from a client via the [`access_token` message](/docs/guides/realtime/protocol#access-token) From da9f50101393408c9dbb069c8aa0a02fe27ab647 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Mon, 1 Sep 2025 23:50:59 +0700 Subject: [PATCH 19/24] Add comment to remove case check (#38355) --- apps/studio/state/storage-explorer.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/studio/state/storage-explorer.tsx b/apps/studio/state/storage-explorer.tsx index 90ceef7b787..70ad6420716 100644 --- a/apps/studio/state/storage-explorer.tsx +++ b/apps/studio/state/storage-explorer.tsx @@ -1654,6 +1654,10 @@ function createStorageExplorerState({ const currentColumnItems = currentColumn.items.filter( (item) => item.status !== STORAGE_ROW_STATUS.EDITING ) + // [Joshen] JFYI storage does support folders of the same name with different casing + // but its an issue with the List V1 endpoint that's causing an issue with fetching contents + // for folders of the same name with different casing + // We should remove this check once all projects are on the List V2 endpoint const hasSameNameInColumn = currentColumnItems.filter((item) => item.name.toLowerCase() === name.toLowerCase()).length > 0 From 21f213c8b6087dbc158897c4a302243604eb8591 Mon Sep 17 00:00:00 2001 From: Prashant Sridharan <914007+CoolAssPuppy@users.noreply.github.com> Date: Mon, 1 Sep 2025 21:46:33 +0100 Subject: [PATCH 20/24] Switched to Zoom and changed registration link (#38360) --- apps/www/_events/2025-09-10-migrating-from-firebase-mobbin.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/www/_events/2025-09-10-migrating-from-firebase-mobbin.mdx b/apps/www/_events/2025-09-10-migrating-from-firebase-mobbin.mdx index 19863e27806..5859478796c 100644 --- a/apps/www/_events/2025-09-10-migrating-from-firebase-mobbin.mdx +++ b/apps/www/_events/2025-09-10-migrating-from-firebase-mobbin.mdx @@ -18,7 +18,7 @@ company: categories: - webinar main_cta: { - url: 'https://events.zoom.us/ev/AsCYBtbPIBcdwQ6c871Vq4nxQlANWpKfu33ZOabQI5I0pzlIwLMp~AujOF_USe12uH1PnzC4QLd0k7fzJSwuqAapzXDUQ5MFZuu8R6x8mgiM1Ag', # Replace with actual registration link + url: 'https://zoom.us/webinar/register/WN_cMSVDLRSRKS5cH8EmmUUQg', # Replace with actual registration link target: '_blank', label: 'Register now', } From d29db695733bd722609cb28083e33521f1a863fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=83=A5=EB=83=90=EC=B1=A0?= Date: Tue, 2 Sep 2025 10:23:22 +0900 Subject: [PATCH 21/24] fix(edge-functions): parse eszip made from edge-runtime v1.69.x correctly (#38296) * fix(edge-functions): parse eszip made from edge-runtime v1.69.x correctly * stamp: update test * stamp: format * stamp: meow * stamp: meowmeow * stamp: format --- .../EdgeFunctionsDiffPanel.tsx | 15 +++++--- .../EdgeFunctionDetailsLayout.tsx | 35 ++++++++++--------- .../edge-function-body-query.ts | 10 ++++-- .../hooks/branches/useEdgeFunctionsDiff.ts | 12 ++++--- apps/studio/lib/eszip-parser.test.ts | 15 ++++---- apps/studio/lib/eszip-parser.ts | 21 ++++++++--- apps/studio/pages/api/edge-functions/body.ts | 6 ++-- .../[ref]/functions/[functionSlug]/code.tsx | 34 +++++++++++++----- 8 files changed, 96 insertions(+), 52 deletions(-) diff --git a/apps/studio/components/interfaces/BranchManagement/EdgeFunctionsDiffPanel.tsx b/apps/studio/components/interfaces/BranchManagement/EdgeFunctionsDiffPanel.tsx index ad99d760f6c..d91ae01541e 100644 --- a/apps/studio/components/interfaces/BranchManagement/EdgeFunctionsDiffPanel.tsx +++ b/apps/studio/components/interfaces/BranchManagement/EdgeFunctionsDiffPanel.tsx @@ -13,6 +13,11 @@ import { EMPTY_ARR } from 'lib/void' import { basename } from 'path' import { Card, CardContent, CardHeader, CardTitle, cn, Skeleton } from 'ui' +const EMPTY_FUNCTION_BODY: EdgeFunctionBodyData = { + version: 0, + files: EMPTY_ARR, +} + interface EdgeFunctionsDiffPanelProps { diffResults: EdgeFunctionsDiffResult currentBranchRef?: string @@ -81,11 +86,11 @@ const FunctionDiff = ({ } }, [allFileKeys, activeFileKey]) - const currentFile = currentBody.find( - (f: EdgeFunctionBodyData[number]) => fileKey(f.name) === activeFileKey + const currentFile = currentBody.files.find( + (f: EdgeFunctionBodyData['files'][number]) => fileKey(f.name) === activeFileKey ) - const mainFile = mainBody.find( - (f: EdgeFunctionBodyData[number]) => fileKey(f.name) === activeFileKey + const mainFile = mainBody.files.find( + (f: EdgeFunctionBodyData['files'][number]) => fileKey(f.name) === activeFileKey ) const language = useMemo(() => { @@ -189,7 +194,7 @@ const EdgeFunctionsDiffPanel = ({ key={slug} functionSlug={slug} currentBody={diffResults.addedBodiesMap[slug]!} - mainBody={EMPTY_ARR} + mainBody={EMPTY_FUNCTION_BODY} currentBranchRef={currentBranchRef} fileInfos={diffResults.functionFileInfo[slug] || EMPTY_ARR} /> diff --git a/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx b/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx index d42be8ca5ae..ae035f65138 100644 --- a/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx +++ b/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx @@ -56,22 +56,23 @@ const EdgeFunctionDetailsLayout = ({ isError, } = useEdgeFunctionQuery({ projectRef: ref, slug: functionSlug }) - const { data: functionFiles = [], error: filesError } = useEdgeFunctionBodyQuery( - { - projectRef: ref, - slug: functionSlug, - }, - { - retry: false, - retryOnMount: true, - refetchOnWindowFocus: false, - staleTime: Infinity, - refetchOnMount: false, - refetchOnReconnect: false, - refetchInterval: false, - refetchIntervalInBackground: false, - } - ) + const { data: functionBody = { version: 0, files: [] }, error: filesError } = + useEdgeFunctionBodyQuery( + { + projectRef: ref, + slug: functionSlug, + }, + { + retry: false, + retryOnMount: true, + refetchOnWindowFocus: false, + staleTime: Infinity, + refetchOnMount: false, + refetchOnReconnect: false, + refetchInterval: false, + refetchIntervalInBackground: false, + } + ) const name = selectedFunction?.name || '' @@ -112,7 +113,7 @@ const EdgeFunctionDetailsLayout = ({ const zipFileWriter = new BlobWriter('application/zip') const zipWriter = new ZipWriter(zipFileWriter, { bufferedWrite: true }) - functionFiles.forEach((file) => { + functionBody.files.forEach((file) => { const nameSections = file.name.split('/') const slugIndex = nameSections.indexOf(functionSlug ?? '') const fileName = nameSections.slice(slugIndex + 1).join('/') 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 d604b96395f..ddd91121faf 100644 --- a/apps/studio/data/edge-functions/edge-function-body-query.ts +++ b/apps/studio/data/edge-functions/edge-function-body-query.ts @@ -15,6 +15,7 @@ export type EdgeFunctionFile = { } export type EdgeFunctionBodyResponse = { + version: number files: EdgeFunctionFile[] } @@ -51,11 +52,14 @@ export async function getEdgeFunctionBody( ) } - const { files } = await parseResponse.json() - return files as EdgeFunctionFile[] + const response = (await parseResponse.json()) as EdgeFunctionBodyResponse + return response } catch (error) { handleError(error) - return [] + return { + version: 0, + files: [], + } as EdgeFunctionBodyResponse } } diff --git a/apps/studio/hooks/branches/useEdgeFunctionsDiff.ts b/apps/studio/hooks/branches/useEdgeFunctionsDiff.ts index 4319a90f7cb..7f8629b974d 100644 --- a/apps/studio/hooks/branches/useEdgeFunctionsDiff.ts +++ b/apps/studio/hooks/branches/useEdgeFunctionsDiff.ts @@ -201,13 +201,15 @@ export const useEdgeFunctionsDiff = ({ const mainBody = mainBodiesMap[slug] if (!currentBody || !mainBody) return - const allFileKeys = new Set([...currentBody, ...mainBody].map((f) => fileKey(f.name))) + const allFileKeys = new Set( + [...currentBody.files, ...mainBody.files].map((f) => fileKey(f.name)) + ) const fileInfos: FileInfo[] = [] let hasModifications = false for (const key of allFileKeys) { - const currentFile = currentBody.find((f) => fileKey(f.name) === key) - const mainFile = mainBody.find((f) => fileKey(f.name) === key) + const currentFile = currentBody.files.find((f) => fileKey(f.name) === key) + const mainFile = mainBody.files.find((f) => fileKey(f.name) === key) let status: FileStatus = 'unchanged' @@ -236,7 +238,7 @@ export const useEdgeFunctionsDiff = ({ addedSlugs.forEach((slug) => { const body = addedBodiesMap[slug] if (body) { - functionFileInfo[slug] = body.map((file) => ({ + functionFileInfo[slug] = body.files.map((file) => ({ key: fileKey(file.name), status: 'added' as FileStatus, })) @@ -247,7 +249,7 @@ export const useEdgeFunctionsDiff = ({ removedSlugs.forEach((slug) => { const body = removedBodiesMap[slug] if (body) { - functionFileInfo[slug] = body.map((file) => ({ + functionFileInfo[slug] = body.files.map((file) => ({ key: fileKey(file.name), status: 'removed' as FileStatus, })) diff --git a/apps/studio/lib/eszip-parser.test.ts b/apps/studio/lib/eszip-parser.test.ts index 4577549e85c..ec38531a19d 100644 --- a/apps/studio/lib/eszip-parser.test.ts +++ b/apps/studio/lib/eszip-parser.test.ts @@ -62,6 +62,7 @@ describe('eszip-parser', () => { mockParser.parseBytes.mockResolvedValue(mockSpecifiers) mockParser.load.mockResolvedValue(undefined) mockParser.getModuleSource + .mockResolvedValueOnce(null) .mockResolvedValueOnce(mockModuleSource1) .mockResolvedValueOnce(mockModuleSource2) @@ -70,12 +71,13 @@ describe('eszip-parser', () => { expect(Parser.createInstance).toHaveBeenCalledTimes(1) expect(mockParser.parseBytes).toHaveBeenCalledWith(mockBytes) expect(mockParser.load).toHaveBeenCalled() - expect(result).toHaveLength(2) - expect(result[0]).toEqual({ + expect(result.version).toEqual(0) + expect(result.files).toHaveLength(2) + expect(result.files[0]).toEqual({ name: 'file1.ts', content: mockModuleSource1, }) - expect(result[1]).toEqual({ + expect(result.files[1]).toEqual({ name: 'file2.ts', content: mockModuleSource2, }) @@ -111,9 +113,10 @@ describe('eszip-parser', () => { const result = await parseEszip(mockBytes) // Only file1.ts and file2.ts should be included - expect(result).toHaveLength(2) - expect(result[0].name).toBe('file1.ts') - expect(result[1].name).toBe('file2.ts') + expect(result.version).toEqual(0) + expect(result.files).toHaveLength(2) + expect(result.files[0].name).toBe('file1.ts') + expect(result.files[1].name).toBe('file2.ts') }) }) }) diff --git a/apps/studio/lib/eszip-parser.ts b/apps/studio/lib/eszip-parser.ts index bf84bc38c6a..7aa5428e747 100644 --- a/apps/studio/lib/eszip-parser.ts +++ b/apps/studio/lib/eszip-parser.ts @@ -63,8 +63,14 @@ export async function parseEszip(bytes: Uint8Array) { throw loadError } + // Extract version + let version = parseInt(await parser.getModuleSource('---SUPABASE-ESZIP-VERSION-ESZIP---')) + if (isNaN(version)) { + version = 0 + } + // Extract files from the eszip - const files = await extractEszip(parser, specifiers) + const files = await extractEszip(parser, specifiers, version >= 2) // Convert files to the expected format const responseFiles = await Promise.all( @@ -77,14 +83,17 @@ export async function parseEszip(bytes: Uint8Array) { }) ) - return responseFiles + return { + version, + files: responseFiles, + } } catch (error) { console.error('Error in parseEszip:', error) throw error } } -async function extractEszip(parser: any, specifiers: string[]) { +async function extractEszip(parser: any, specifiers: string[], isDeno2: boolean) { const files = [] // First, filter out the specifiers we want to keep @@ -114,9 +123,13 @@ async function extractEszip(parser: any, specifiers: string[]) { try { // Try to get the module source const moduleSource = await parser.getModuleSource(specifier) + let qualifiedSpecifier = specifier // Get the file path - const filePath = url2path(specifier) + if (isDeno2 && !specifier.startsWith('file://')) { + qualifiedSpecifier = `file://${specifier}` + } + const filePath = url2path(qualifiedSpecifier) // Create a file object const file = new File([moduleSource], filePath) diff --git a/apps/studio/pages/api/edge-functions/body.ts b/apps/studio/pages/api/edge-functions/body.ts index 317bddc189f..ed220a07144 100644 --- a/apps/studio/pages/api/edge-functions/body.ts +++ b/apps/studio/pages/api/edge-functions/body.ts @@ -81,11 +81,9 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse) { const uint8Array = new Uint8Array(arrayBuffer) // Parse the eszip file using our utility - const files = await parseEszip(uint8Array) + const parsed = await parseEszip(uint8Array) - return res.status(200).json({ - files, - }) + return res.status(200).json(parsed) } catch (error) { console.error('Error processing edge function body:', error) return res.status(500).json({ error: 'Internal server error' }) diff --git a/apps/studio/pages/project/[ref]/functions/[functionSlug]/code.tsx b/apps/studio/pages/project/[ref]/functions/[functionSlug]/code.tsx index be79cc42624..21ab897ec44 100644 --- a/apps/studio/pages/project/[ref]/functions/[functionSlug]/code.tsx +++ b/apps/studio/pages/project/[ref]/functions/[functionSlug]/code.tsx @@ -35,7 +35,7 @@ const CodePage = () => { const { data: selectedFunction } = useEdgeFunctionQuery({ projectRef: ref, slug: functionSlug }) const { - data: functionFiles, + data: functionBody, isLoading: isLoadingFiles, isError: isErrorLoadingFiles, isSuccess: isSuccessLoadingFiles, @@ -123,15 +123,29 @@ const CodePage = () => { } } - function getBasePath(entrypoint: string | undefined): string { + function getBasePath( + entrypoint: string | undefined, + fileNames: string[], + version: number + ): string { if (!entrypoint) { return '/' } + let qualifiedEntrypoint = entrypoint + + if (version >= 2) { + const candidate = fileNames.find((name) => entrypoint.endsWith(name)) + if (candidate) { + qualifiedEntrypoint = `file://${candidate}` + } else { + qualifiedEntrypoint = entrypoint + } + } try { - return dirname(new URL(entrypoint).pathname) + return dirname(new URL(qualifiedEntrypoint).pathname) } catch (e) { - console.error('Failed to parse entrypoint', entrypoint) + console.error('Failed to parse entrypoint', qualifiedEntrypoint) return '/' } } @@ -155,9 +169,13 @@ const CodePage = () => { useEffect(() => { // Set files from API response when available - if (selectedFunction?.entrypoint_path && functionFiles) { - const base_path = getBasePath(selectedFunction?.entrypoint_path) - const filesWithRelPath = functionFiles + if (selectedFunction?.entrypoint_path && functionBody) { + const base_path = getBasePath( + selectedFunction?.entrypoint_path, + functionBody.files.map((file) => file.name), + functionBody.version + ) + const filesWithRelPath = functionBody.files // ignore empty files .filter((file: { name: string; content: string }) => !!file.content.length) // set file paths relative to entrypoint @@ -192,7 +210,7 @@ const CodePage = () => { }) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [functionFiles]) + }, [functionBody]) return (
From 73e6bdf1b68f35d6a902060ae9cd40544066216b Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Tue, 2 Sep 2025 10:41:14 +0700 Subject: [PATCH 22/24] Address Sentry issue (#38364) * Address https://supabase.sentry.io/issues/6826153016 * Address https://supabase.sentry.io/issues/6785459028 --- apps/studio/components/layouts/Tabs/Tabs.utils.ts | 4 ++-- apps/studio/instrumentation-client.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/studio/components/layouts/Tabs/Tabs.utils.ts b/apps/studio/components/layouts/Tabs/Tabs.utils.ts index f67aea00456..852baa2187d 100644 --- a/apps/studio/components/layouts/Tabs/Tabs.utils.ts +++ b/apps/studio/components/layouts/Tabs/Tabs.utils.ts @@ -50,7 +50,7 @@ export function useTableEditorTabsCleanUp() { // e.g Using the SQL editor to rename the entity const openTabs = openTabsRef.current .map((id) => tabMapRef.current[id]) - .filter((tab) => editorEntityTypes['table']?.includes(tab.type)) + .filter((tab) => !!tab && editorEntityTypes['table']?.includes(tab.type)) openTabs.forEach((tab) => { const entity = entities?.find((x) => tab.metadata?.tableId === x.id) @@ -97,7 +97,7 @@ export function useSqlEditorTabsCleanup() { // e.g for a shared snippet, the owner could've updated the name of the snippet const openSqlTabs = openTabsRef.current .map((id) => tabMapRef.current[id]) - .filter((tab) => editorEntityTypes['sql']?.includes(tab.type)) + .filter((tab) => !!tab && editorEntityTypes['sql']?.includes(tab.type)) openSqlTabs.forEach((tab) => { const snippet = snippets?.find((x) => tab.metadata?.sqlId === x.id) diff --git a/apps/studio/instrumentation-client.ts b/apps/studio/instrumentation-client.ts index e1543a0e150..bafe2feee59 100644 --- a/apps/studio/instrumentation-client.ts +++ b/apps/studio/instrumentation-client.ts @@ -64,7 +64,7 @@ Sentry.init({ `Failed to construct 'URL': Invalid URL` ) // [Joshen] Similar behaviour for this error from SessionTimeoutModal to control the quota usage - const isSessionTimeoutEvent = (hint.originalException as any)?.message.includes( + const isSessionTimeoutEvent = (hint.originalException as any)?.message?.includes( 'Session error detected' ) From 8bc5378f84c4c4076cad327c96a2b42dc6de65c4 Mon Sep 17 00:00:00 2001 From: Timothy Lim Date: Tue, 2 Sep 2025 15:36:57 +0800 Subject: [PATCH 23/24] Add Tim to humans.txt (#38365) --- apps/docs/public/humans.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index f2bcd4d90e1..dab101fd1c2 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -124,6 +124,7 @@ Taryn King Terry Sutton Thomas E Thor Schaeff +Timothy Lim Tom Ashley Tom G Tyler Hillery From ecb9fdef051486cb06c05e663ea6c167c89e4bac Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Tue, 2 Sep 2025 14:46:37 +0700 Subject: [PATCH 24/24] Fix button text consistency for database triggers (#38367) --- .../interfaces/Database/Functions/CreateFunction/index.tsx | 2 +- .../components/interfaces/Database/Triggers/TriggerSheet.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx b/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx index 8c8bc254bb2..829952493cd 100644 --- a/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx +++ b/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx @@ -401,7 +401,7 @@ const CreateFunction = ({ func, visible, setVisible }: CreateFunctionProps) => { disabled={isCreating || isUpdating} loading={isCreating || isUpdating} > - Confirm + {isEditing ? 'Save' : 'Create'} function
diff --git a/apps/studio/components/interfaces/Database/Triggers/TriggerSheet.tsx b/apps/studio/components/interfaces/Database/Triggers/TriggerSheet.tsx index 37206dbaccb..f7214e1d0fe 100644 --- a/apps/studio/components/interfaces/Database/Triggers/TriggerSheet.tsx +++ b/apps/studio/components/interfaces/Database/Triggers/TriggerSheet.tsx @@ -451,7 +451,7 @@ export const TriggerSheet = ({ selectedTrigger, open, setOpen }: TriggerSheetPro Cancel