Merge branch 'master' into docs/pg_replicate

This commit is contained in:
Chris Chinchilla authored and GitHub committed 2025-09-02 10:29:46 +02:00
commit f27eae04cf
102 files changed
+3107 -1738

No files matched your search

@@ -2203,7 +2203,7 @@ export const platform: NavMenuConstant = {
url: '/guides/platform/project-transfer',
},
{
name: 'Duplicate Project',
name: 'Restore to a new project',
url: '/guides/platform/clone-project',
},
{
@@ -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.
<Admonition type="note" label="Read Replicas and Custom Config">
@@ -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.
<Admonition type="note" label="Beta Version">
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.
</Admonition>
**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.
@@ -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)
+2
View File
@@ -45,6 +45,7 @@ Eduardo Gurgel
Egor Romanov
Eleftheria Trivyzaki
Emmett Folger
Eric Kharitonashvili
Etienne Stalmans
Fabrizio Fenoglio
Felipe Stival
@@ -123,6 +124,7 @@ Taryn King
Terry Sutton
Thomas E
Thor Schaeff
Timothy Lim
Tom Ashley
Tom G
Tyler Hillery
@@ -214,10 +214,14 @@ export function useLoadTableEditorStateFromLocalStorageIntoUrl({
}
export const handleCopyCell = (
{ column, row }: { column: CalculatedColumn<any, unknown>; row: any },
{
mode,
column,
row,
}: { mode: 'SELECT' | 'EDIT'; column: CalculatedColumn<any, unknown>; 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)
@@ -128,9 +128,7 @@ export const Grid = memo(
}
}
const removeAllFilters = () => {
onApplyFilters([])
}
const removeAllFilters = () => onApplyFilters([])
return (
<div
@@ -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}
/>
@@ -162,7 +162,7 @@ export const CreateNewProjectDialog = ({
<FormControl_Shadcn_>
<Input
id="db-password"
label="Database Password"
label="Database password"
type="password"
placeholder="Type in a strong password"
value={field.value}
@@ -401,7 +401,7 @@ const CreateFunction = ({ func, visible, setVisible }: CreateFunctionProps) => {
disabled={isCreating || isUpdating}
loading={isCreating || isUpdating}
>
Confirm
{isEditing ? 'Save' : 'Create'} function
</Button>
</SheetFooter>
</div>
@@ -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 (
<>
<TextConfirmModal
variant={'warning'}
visible={visible}
onCancel={() => setVisible(!visible)}
onConfirm={onDelete}
title="Delete this destination"
loading={isLoading}
confirmLabel={`Delete destination`}
confirmPlaceholder="Type in name of destination"
confirmString={name ?? 'Unknown'}
text={
<>
<span>This will delete the destination</span>{' '}
</>
}
alert={{ title: 'You cannot recover this destination once deleted.' }}
/>
</>
<TextConfirmModal
variant="destructive"
visible={visible}
loading={isLoading}
title="Delete this destination"
confirmLabel={isLoading ? 'Deleting…' : `Delete destination`}
confirmPlaceholder="Type in name of destination"
confirmString={name ?? 'Unknown'}
text={`This will delete the destination "${name}"`}
alert={{ title: 'You cannot recover this destination once deleted.' }}
onCancel={() => setVisible(!visible)}
onConfirm={onDelete}
/>
)
}
export default DeleteDestination
@@ -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<z.infer<typeof FormSchema>>({
mode: 'onBlur',
reValidateMode: 'onBlur',
resolver: zodResolver(FormSchema),
defaultValues,
})
const isSaving = creatingDestinationPipeline || updatingDestinationPipeline || startingPipeline
const onSubmit = async (data: z.infer<typeof FormSchema>) => {
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 ? (
<>
<Sheet open={visible} onOpenChange={onClose}>
<SheetContent showClose={false} size="default">
<div className="flex flex-col h-full" tabIndex={-1}>
<SheetHeader className="flex justify-between items-center">
<div>
<SheetTitle>{editMode ? 'Edit Destination' : 'New Destination'}</SheetTitle>
<SheetDescription>
{editMode ? null : 'Send data to a new destination'}
</SheetDescription>
</div>
<div className="flex items-center gap-2">
<Switch
checked={enabled}
onCheckedChange={(checked) => {
form.setValue('enabled', checked)
}}
<Sheet open={visible} onOpenChange={onClose}>
<SheetContent showClose={false} size="default">
<div className="flex flex-col h-full" tabIndex={-1}>
<SheetHeader>
<SheetTitle>{editMode ? 'Edit destination' : 'Create a new destination'}</SheetTitle>
<SheetDescription>
{editMode ? null : 'Send data to a new destination'}
</SheetDescription>
</SheetHeader>
<SheetSection className="flex-grow overflow-auto px-0 pb-0">
<Form_Shadcn_ {...form}>
<form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
<div className="px-5 pb-4">
<FormField_Shadcn_
control={form.control}
name="name"
render={({ field }) => (
<FormItemLayout
className="mb-8"
label="Name"
layout="vertical"
description="A name you will use to identify this destination"
>
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} placeholder="Name" />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<h3 className="mb-4">What data to send</h3>
<FormField_Shadcn_
control={form.control}
name="publicationName"
render={({ field }) => (
<FormItemLayout
className="mb-4"
label="Publication"
layout="vertical"
description="A publication is a collection of tables that you want to replicate "
>
<FormControl_Shadcn_>
<PublicationsComboBox
publications={publications?.map((pub) => pub.name) || []}
loading={loadingPublications}
field={field}
onNewPublicationClick={() => setPublicationPanelVisible(true)}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<h3 className="mb-4 mt-8">Where to send that data</h3>
<FormField_Shadcn_
name="type"
control={form.control}
render={({ field }) => (
<FormItemLayout
className="mb-4"
label="Type"
layout="vertical"
description="The type of destination to send the data to"
>
<FormControl_Shadcn_>
<Select_Shadcn_ value={field.value}>
<SelectTrigger_Shadcn_>{field.value}</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
<SelectGroup_Shadcn_>
<SelectItem_Shadcn_ value="BigQuery">BigQuery</SelectItem_Shadcn_>
</SelectGroup_Shadcn_>
</SelectContent_Shadcn_>
</Select_Shadcn_>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="projectId"
render={({ field }) => (
<FormItemLayout
className="mb-4"
label="Project ID"
layout="vertical"
description="Which BigQuery project to send data to"
>
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} placeholder="Project ID" />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="datasetId"
render={({ field }) => (
<FormItemLayout
className="mb-4"
label="Project's Dataset ID"
layout="vertical"
>
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} placeholder="Dataset ID" />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="serviceAccountKey"
render={({ field }) => (
<FormItemLayout
label="Service Account Key"
layout="vertical"
description="The service account key for BigQuery"
>
<FormControl_Shadcn_>
<TextArea_Shadcn_
{...field}
rows={4}
maxLength={5000}
placeholder="Service account key"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<Label className="text-sm mx-2">Enable</Label>
</div>
</SheetHeader>
<SheetSection className="flex-grow overflow-auto">
<Form_Shadcn_ {...form}>
<form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
<FormField_Shadcn_
control={form.control}
name="name"
render={({ field }) => (
<FormItemLayout
className="mb-8"
label="Name"
layout="vertical"
description="A name you will use to identify this destination"
>
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} placeholder="Name" />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<h3 className="mb-4">What data to send</h3>
<FormField_Shadcn_
control={form.control}
name="publicationName"
render={({ field }) => (
<FormItemLayout
className="mb-4"
label="Publication"
layout="vertical"
description="A publication is a collection of tables that you want to replicate "
>
<FormControl_Shadcn_>
<PublicationsComboBox
publications={publications?.map((pub) => pub.name) || []}
loading={loadingPublications}
field={field}
onNewPublicationClick={() => setPublicationPanelVisible(true)}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<h3 className="mb-4 mt-8">Where to send that data</h3>
<Separator />
<FormField_Shadcn_
name="type"
control={form.control}
render={({ field }) => (
<FormItemLayout
className="mb-4"
label="Type"
layout="vertical"
description="The type of destination to send the data to"
>
<FormControl_Shadcn_>
<Select_Shadcn_ value={field.value}>
<SelectTrigger_Shadcn_>{field.value}</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
<SelectGroup_Shadcn_>
<SelectItem_Shadcn_ value="BigQuery">
BigQuery
</SelectItem_Shadcn_>
</SelectGroup_Shadcn_>
</SelectContent_Shadcn_>
</Select_Shadcn_>
</FormControl_Shadcn_>
</FormItemLayout>
)}
></FormField_Shadcn_>
<FormField_Shadcn_
control={form.control}
name="projectId"
render={({ field }) => (
<FormItemLayout
className="mb-4"
label="Project Id"
layout="vertical"
description="Which BigQuery project to send data to"
>
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} placeholder="Project id" />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="datasetId"
render={({ field }) => (
<FormItemLayout
className="mb-4"
label="Project's Dataset Id"
layout="vertical"
>
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} placeholder="Dataset id" />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="serviceAccountKey"
render={({ field }) => (
<FormItemLayout
label="Service Account Key"
layout="vertical"
description="The service account key for BigQuery"
>
<FormControl_Shadcn_>
<TextArea_Shadcn_
{...field}
rows={4}
maxLength={5000}
placeholder="Service account key"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<Accordion_Shadcn_ type="single" collapsible>
<AccordionItem_Shadcn_ value="item-1" className="border-none">
<AccordionTrigger_Shadcn_ className="font-normal gap-2 justify-start mb-0 mt-8">
Advanced Settings
</AccordionTrigger_Shadcn_>
<AccordionContent_Shadcn_ asChild className="!pb-0">
<FormField_Shadcn_
control={form.control}
name="maxSize"
render={({ field }) => (
<FormItemLayout
className="mb-4"
label="Max Size"
layout="vertical"
description="The maximum size of the data to send"
>
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
type="number"
{...form.register('maxSize', {
valueAsNumber: true, // Ensure the value is handled as a number
})}
placeholder="Max size"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="maxFillMs"
render={({ field }) => (
<FormItemLayout
className="mb-4"
label="Max Fill Seconds"
layout="vertical"
description="The maximum amount of time to fill the data"
>
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
type="number"
{...form.register('maxFillMs', {
valueAsNumber: true, // Ensure the value is handled as a number
})}
placeholder="Max fill seconds"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="maxStalenessMins"
render={({ field }) => (
<FormItemLayout
className="mb-4"
label="Max Staleness"
layout="vertical"
description="Maximum staleness time allowed"
>
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
type="number"
{...form.register('maxStalenessMins', {
valueAsNumber: true, // Ensure the value is handled as a number
})}
placeholder="Max staleness in minutes"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</AccordionContent_Shadcn_>
</AccordionItem_Shadcn_>
</Accordion_Shadcn_>
<div className="hidden">
<FormField_Shadcn_
control={form.control}
name="enabled"
render={({ field }) => (
<FormItemLayout className="mb-4" layout="vertical" label="Enabled">
<FormControl_Shadcn_>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</div>
</form>
</Form_Shadcn_>
</SheetSection>
<SheetFooter>
<Button disabled={isSubmitting} type="default" onClick={onClose}>
Cancel
</Button>
<Button
disabled={isSubmitting}
loading={isSubmitting}
form={formId}
htmlType="submit"
>
{editMode ? 'Update' : 'Create'}
</Button>
</SheetFooter>
</div>
</SheetContent>
</Sheet>
<NewPublicationPanel
visible={publicationPanelVisible}
sourceId={sourceId}
onClose={() => setPublicationPanelVisible(false)}
/>
</>
) : (
<>
<Sheet open={visible} onOpenChange={onClose}>
<SheetContent showClose={false} size="default">
<div className="flex flex-col h-full" tabIndex={-1}>
<SheetHeader>
<SheetTitle>New Destination</SheetTitle>
</SheetHeader>
<SheetSection className="flex-grow overflow-auto">
<Alert_Shadcn_>
<WarningIcon />
<AlertTitle_Shadcn_>
{/* Pricing to be decided yet */}
Enabling replication will cost additional $xx.xx
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
<span></span>
<div className="flex items-center gap-x-2 mt-3">
<Button type="default" onClick={onEnableReplication}>
Enable replication
</Button>
</div>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
</SheetSection>
<SheetFooter>
<Button disabled={isSubmitting} type="default" onClick={onClose}>
Cancel
</Button>
</SheetFooter>
</div>
</SheetContent>
</Sheet>
</>
)}
<div className="px-5">
<Accordion_Shadcn_ type="single" collapsible>
<AccordionItem_Shadcn_ value="item-1" className="border-none">
<AccordionTrigger_Shadcn_ className="font-normal gap-2 justify-between text-sm">
Advanced Settings
</AccordionTrigger_Shadcn_>
<AccordionContent_Shadcn_ asChild className="!pb-0">
<FormField_Shadcn_
control={form.control}
name="maxSize"
render={({ field }) => (
<FormItemLayout
className="mb-4"
label="Max size"
layout="vertical"
description="The maximum size of the data to send. Leave empty to use default value."
>
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
type="number"
value={field.value ?? ''}
onChange={(e) => {
const val = e.target.value
field.onChange(val === '' ? undefined : Number(val))
}}
placeholder="Leave empty for default"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="maxFillMs"
render={({ field }) => (
<FormItemLayout
className="mb-4"
label="Max fill milliseconds"
layout="vertical"
description="The maximum amount of time to fill the data in milliseconds. Leave empty to use default value."
>
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
type="number"
value={field.value ?? ''}
onChange={(e) => {
const val = e.target.value
field.onChange(val === '' ? undefined : Number(val))
}}
placeholder="Leave empty for default"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="maxStalenessMins"
render={({ field }) => (
<FormItemLayout
className="mb-4"
label="Max staleness minutes"
layout="vertical"
description="Maximum staleness time allowed in minutes. Leave empty to use default value."
>
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
type="number"
value={field.value ?? ''}
onChange={(e) => {
const val = e.target.value
field.onChange(val === '' ? undefined : Number(val))
}}
placeholder="Leave empty for default"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</AccordionContent_Shadcn_>
</AccordionItem_Shadcn_>
</Accordion_Shadcn_>
</div>
</form>
</Form_Shadcn_>
</SheetSection>
<SheetFooter>
<Button disabled={isSaving} type="default" onClick={onClose}>
Cancel
</Button>
<Button loading={isSaving} form={formId} htmlType="submit">
{editMode
? existingDestination?.enabled
? 'Apply and restart'
: 'Apply and start'
: 'Create and start'}
</Button>
</SheetFooter>
</div>
</SheetContent>
</Sheet>
<NewPublicationPanel
visible={publicationPanelVisible}
sourceId={sourceId}
onClose={() => setPublicationPanelVisible(false)}
/>
</>
) : (
<Sheet open={visible} onOpenChange={onClose}>
<SheetContent showClose={false} size="default">
<div className="flex flex-col h-full" tabIndex={-1}>
<SheetHeader>
<SheetTitle>Create a new destination</SheetTitle>
</SheetHeader>
<SheetSection className="flex-grow overflow-auto">
<Alert_Shadcn_>
<WarningIcon />
<AlertTitle_Shadcn_>
{/* Pricing to be decided yet */}
Enabling replication will cost additional $xx.xx
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
<span></span>
<div className="flex items-center gap-x-2 mt-3">
<Button
type="default"
loading={creatingTenantSource}
onClick={onEnableReplication}
>
Enable replication
</Button>
</div>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
</SheetSection>
<SheetFooter>
<Button disabled={creatingTenantSource} type="default" onClick={onClose}>
Cancel
</Button>
</SheetFooter>
</div>
</SheetContent>
</Sheet>
)
}
export default DestinationPanel
@@ -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 = ({
</Table.td>
<Table.td>
<div className="flex items-center justify-end gap-x-2">
<Button asChild type="default">
<Button asChild type="default" className="relative">
<Link href={`/project/${projectRef}/database/replication/${pipeline?.id}`}>
View status
<span className="inline-flex items-center gap-2">
<span>View status</span>
{hasTableErrors && (
<Tooltip>
<TooltipTrigger>
<AlertCircle size={14} />
</TooltipTrigger>
<TooltipContent side="bottom">
{errorCount} table{errorCount === 1 ? '' : 's'} have replication errors
</TooltipContent>
</Tooltip>
)}
</span>
</Link>
</Button>
<RowMenu
@@ -154,7 +184,7 @@ export const DestinationRow = ({
visible={showDeleteDestinationForm}
setVisible={setShowDeleteDestinationForm}
onDelete={onDeleteClick}
isLoading={isPipelineStatusLoading}
isLoading={isDeleting}
name={destinationName}
/>
<DestinationPanel
@@ -9,7 +9,7 @@ import { useReplicationPipelinesQuery } from 'data/replication/pipelines-query'
import { useReplicationSourcesQuery } from 'data/replication/sources-query'
import { Button, cn, Input_Shadcn_ } from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns'
import NewDestinationPanel from './DestinationPanel'
import { DestinationPanel } from './DestinationPanel'
import { DestinationRow } from './DestinationRow'
import { PIPELINE_ERROR_MESSAGES } from './Pipeline.utils'
@@ -159,7 +159,7 @@ export const Destinations = () => {
</div>
)}
<NewDestinationPanel
<DestinationPanel
visible={showNewDestinationPanel}
sourceId={sourceId}
onClose={() => setShowNewDestinationPanel(false)}
@@ -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':
@@ -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: <Loader2 className="animate-spin w-3 h-3 text-brand-600" />,
color: 'text-brand-600',
label: 'Restarting',
dot: <Loader2 className="animate-spin w-3 h-3 text-warning-600" />,
color: 'text-warning-600',
tooltip: stateMessages.message,
}
}
if (requestStatus === PipelineStatusRequestStatus.DisableRequested) {
if (requestStatus === PipelineStatusRequestStatus.StartRequested) {
return {
label: 'Disabling...',
label: 'Starting',
dot: <Loader2 className="animate-spin w-3 h-3 text-warning-600" />,
color: 'text-warning-600',
tooltip: stateMessages.message,
}
}
if (requestStatus === PipelineStatusRequestStatus.StopRequested) {
return {
label: 'Stopping',
dot: <Loader2 className="animate-spin w-3 h-3 text-warning-600" />,
color: 'text-warning-600',
tooltip: stateMessages.message,
@@ -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
</Button>
</div>
</div>
@@ -286,15 +289,10 @@ export const ReplicationPipelineStatus = () => {
Status unavailable while pipeline is {config.badge.toLowerCase()}
</p>
) : (
<div className="space-y-3">
<div className="space-y-1">
<div className="text-sm text-foreground">
{statusConfig.description}
</div>
{'lag' in table.state && (
<div className="text-xs text-foreground-light">
Lag: {table.state.lag}ms
</div>
)}
{table.state.name === 'error' && (
<ErroredTableDetails
state={table.state}
@@ -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 ? (
<Loader2 className="w-6 h-6 animate-spin" />
@@ -72,37 +73,38 @@ export const getDisabledStateConfig = ({
<Activity className="w-6 h-6" />
)
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 }
}
@@ -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) => {
@@ -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 ? (
<DropdownMenuItem className="space-x-2" onClick={onDisablePipeline}>
<Pause size={14} />
<p>Disable pipeline</p>
<p>Stop pipeline</p>
</DropdownMenuItem>
) : (
<DropdownMenuItem className="space-x-2" onClick={onEnablePipeline}>
<Play size={14} />
<p>Enable pipeline</p>
<p>Start pipeline</p>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
@@ -451,7 +451,7 @@ export const TriggerSheet = ({ selectedTrigger, open, setOpen }: TriggerSheetPro
Cancel
</Button>
<Button form={formId} htmlType="submit" loading={isCreating || isUpdating}>
Create trigger
{isEditing ? 'Save' : 'Create'} trigger
</Button>
</SheetFooter>
</SheetContent>
@@ -61,7 +61,7 @@ export const PostgresVersionSelector = ({
form,
type = 'create',
layout = 'horizontal',
label = 'Postgres Version',
label = 'Postgres version',
}: PostgresVersionSelectorProps) => {
const { data: project } = useSelectedProjectQuery()
@@ -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
@@ -87,9 +87,14 @@ export const RegionSelector = ({
<>
<p>Select the region closest to your users for the best performance.</p>
{showNonProdFields && (
<p className="text-warning">
Note: Only US (NV), Frankfurt and SG are supported for local/staging projects
</p>
<div className="mt-2 text-warning-600">
<p>Only these regions are supported for local/staging projects:</p>
<ul className="list-disc list-inside mt-1">
<li>East US (North Virginia)</li>
<li>Central EU (Frankfurt)</li>
<li>Southeast Asia (Singapore)</li>
</ul>
</div>
)}
</>
}
@@ -94,7 +94,7 @@ export const SecurityOptions = ({
control={form.control}
render={({ field }) => (
<>
<FormItemLayout className="mt-6" layout={layout} label="Data API Configuration">
<FormItemLayout className="mt-6" layout={layout} label="Data API configuration">
<FormControl_Shadcn_>
<RadioGroupStacked
defaultValue={field.value.toString()}
@@ -1,36 +1,13 @@
/**
* ReportChart
*
* A wrapper component that uses the useChartData hook to fetch data for a chart
* and then passes the data and loading state to the ComposedChartHandler.
*
* This component acts as a bridge between the data-fetching logic and the
* presentational chart component.
*/
import Link from 'next/link'
import { useRef, useState } from 'react'
import type { MultiAttribute } from 'components/ui/Charts/ComposedChart.utils'
import LogChartHandler from 'components/ui/Charts/LogChartHandler'
import Panel from 'components/ui/Panel'
import { useFillTimeseriesSorted } from 'hooks/analytics/useFillTimeseriesSorted'
import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useChartData } from 'hooks/useChartData'
import type { UpdateDateRange } from 'pages/project/[ref]/reports/database'
import { Button, cn } from 'ui'
import { ReportChartUpsell } from './v2/ReportChartUpsell'
const ReportChart = ({
chart,
startDate,
endDate,
interval,
updateDateRange,
functionIds,
isLoading,
className,
}: {
interface ReportChartProps {
chart: any
startDate: string
endDate: string
@@ -38,13 +15,28 @@ const ReportChart = ({
updateDateRange: UpdateDateRange
functionIds?: string[]
isLoading?: boolean
className?: string
}) => {
}
/**
* A wrapper component that uses the useChartData hook to fetch data for a chart
* and then passes the data and loading state to the ComposedChartHandler.
*
* This component acts as a bridge between the data-fetching logic and the
* presentational chart component.
*/
export const ReportChart = ({
chart,
startDate,
endDate,
interval,
updateDateRange,
functionIds,
isLoading,
}: ReportChartProps) => {
const { data: org } = useSelectedOrganizationQuery()
const { plan: orgPlan } = useCurrentOrgPlan()
const orgPlanId = orgPlan?.id
const [isHoveringUpgrade, setIsHoveringUpgrade] = useState(false)
const isAvailable =
chart.availableIn === undefined || (orgPlanId && chart.availableIn.includes(orgPlanId))
@@ -87,77 +79,8 @@ const ReportChart = ({
? filledData
: chartDataArray
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,
}))
// [Jordi] useRef to prevent re-rendering making the chart change.
const demoChartData = useRef(getDemoChartData())
const exponentialChartData = useRef(getExpDemoChartData())
const chartData = isHoveringUpgrade ? exponentialChartData.current : demoChartData.current
if (!isAvailable && !isLoading) {
return (
<Panel
title={<p className="text-sm">{chart.label}</p>}
className={cn('h-[260px] relative', className)}
>
<div className="z-10 flex flex-col items-center justify-center space-y-2 h-full absolute top-0 left-0 w-full bg-surface-100/70 backdrop-blur-md">
<h2>{chart.label}</h2>
<p className="text-sm text-foreground-light">
This chart is available from{' '}
<span className="capitalize">
{!!chart.availableIn?.length ? chart.availableIn[0] : 'Pro'}
</span>{' '}
plan and above
</p>
<Button
asChild
type="primary"
onMouseEnter={() => setIsHoveringUpgrade(true)}
onMouseLeave={() => setIsHoveringUpgrade(false)}
>
<Link href={`/org/${org?.slug}/billing?panel=subscriptionPlan&source=reports`}>
Upgrade to{' '}
<span className="capitalize">
{!!chart.availableIn?.length ? chart.availableIn[0] : 'Pro'}
</span>
</Link>
</Button>
</div>
<div className="absolute top-0 left-0 w-full h-full z-0">
<LogChartHandler
attributes={[
{
attribute: 'demo',
enabled: true,
label: 'Demo',
provider: 'logs',
},
]}
label={chart.label}
startDate={startDate}
endDate={endDate}
interval={interval}
data={chartData as any}
isLoading={false}
highlightedValue={0}
updateDateRange={updateDateRange}
/>
</div>
</Panel>
)
return <ReportChartUpsell report={chart} orgSlug={org?.slug ?? ''} />
}
return (
@@ -171,4 +94,3 @@ const ReportChart = ({
/>
)
}
export default ReportChart
@@ -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')
})
})
@@ -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<keyof PresetConfig['queries'], () => 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 = <T extends string>(
queries: BaseQueries<T>,
projectRef: string
@@ -35,3 +40,36 @@ export const queriesFactory = <T extends string>(
)
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'
}
}
@@ -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 (
<Card className={cn('h-[260px] relative')}>
<div className="z-10 flex flex-col items-center justify-center space-y-2 h-full absolute top-0 left-0 w-full bg-surface-100/70 backdrop-blur-md">
<h2 className="text-sm">{report.label}</h2>
<p className="text-sm text-foreground-light">
This chart is available from{' '}
<span className="capitalize">
{!!report.availableIn?.length ? report.availableIn[0] : 'Pro'}
</span>{' '}
plan and above
</p>
<Button
asChild
type="primary"
onMouseEnter={() => setIsHoveringUpgrade(true)}
onMouseLeave={() => setIsHoveringUpgrade(false)}
className="mt-4"
>
<Link href={`/org/${orgSlug}/billing?panel=subscriptionPlan&source=reports`}>
Upgrade to{' '}
<span className="capitalize">
{!!report.availableIn?.length ? report.availableIn[0] : 'Pro'}
</span>
</Link>
</Button>
</div>
<div className="absolute top-0 left-0 w-full h-full z-0">
<LogChartHandler
attributes={[
{
attribute: 'demo',
enabled: true,
label: 'Demo',
provider: 'logs',
},
]}
label={''}
startDate={startDate}
endDate={endDate}
interval={'1d'}
data={demoData as any}
isLoading={false}
highlightedValue={0}
updateDateRange={() => {}}
/>
</div>
</Card>
)
}
@@ -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<string>(report.defaultChartStyle)
if (!isAvailable && !isLoadingChart) {
return <ReportChartUpsell report={report} orgSlug={org?.slug ?? ''} />
}
const isErrorState = error && !isLoadingChart
const showEmptyState = (!finalChartData || finalChartData.length === 0) && !isLoadingChart
return (
<Card id={report.id} className={cn('relative w-full overflow-hidden scroll-mt-16', className)}>
<CardContent className="flex flex-col gap-4 min-h-[280px] items-center justify-center">
{isLoadingChart ? (
<Loader2 className="size-5 animate-spin text-foreground-light" />
) : showEmptyState ? (
<p className="text-sm text-foreground-light text-center h-full flex items-center justify-center">
No data available for the selected time range
</p>
) : isErrorState ? (
<p className="text-sm text-foreground-light text-center h-full flex items-center justify-center">
Error loading chart data
</p>
) : (
<div className="w-full">
<ComposedChart
attributes={dynamicAttributes}
data={finalChartData}
format={report.format ?? undefined}
xAxisKey={report.xAxisKey ?? 'timestamp'}
yAxisKey={report.yAxisKey ?? dynamicAttributes[0]?.attribute}
highlightedValue={0}
title={report.label}
customDateFormat={undefined}
chartHighlight={undefined}
chartStyle={chartStyle}
showTooltip={report.showTooltip}
showLegend={report.showLegend}
showTotal={false}
showMaxValue={report.showMaxValue}
onChartStyleChange={setChartStyle}
updateDateRange={updateDateRange}
valuePrecision={report.valuePrecision}
hideChartType={report.hideChartType}
titleTooltip={report.titleTooltip}
syncId={syncId}
sql={queryResult?.query}
/>
</div>
)}
</CardContent>
</Card>
)
}
@@ -676,7 +676,7 @@ export const SQLEditor = () => {
direction="vertical"
autoSaveId={LOCAL_STORAGE_KEYS.SQL_EDITOR_SPLIT_SIZE}
>
<ResizablePanel maxSize={70}>
<ResizablePanel defaultSize={50} maxSize={70}>
<div className="flex-grow overflow-y-auto border-b h-full">
{isLoading ? (
<div className="flex h-full w-full items-center justify-center">
@@ -793,7 +793,7 @@ export const SQLEditor = () => {
<ResizableHandle withHandle />
<ResizablePanel maxSize={70}>
<ResizablePanel defaultSize={50} maxSize={70}>
{isLoading ? (
<div className="flex h-full w-full items-center justify-center">
<Loader2 className="animate-spin text-brand" />
@@ -811,7 +811,7 @@ export const SQLEditor = () => {
)}
</ResizablePanel>
<div className="max-h-9">
<div className="h-9">
{results?.rows !== undefined && !isExecuting && (
<GridFooter className="flex items-center justify-between gap-2">
<Tooltip>
@@ -78,7 +78,7 @@ const UtilityActions = ({
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
data-testId="sql-editor-utility-actions"
data-testid="sql-editor-utility-actions"
type="default"
className={cn('px-1', isAiOpen ? 'block 2xl:hidden' : 'hidden')}
icon={<MoreVertical className="text-foreground-light" />}
@@ -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) {
@@ -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('/')
@@ -105,6 +105,9 @@ export function LogsSidebarMenuV2() {
},
{
enabled: enablePgReplicate,
retry: false,
refetchOnMount: false,
refetchOnWindowFocus: false,
}
)
@@ -133,7 +133,7 @@ export const SQLEditorMenu = () => {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
data-testId="sql-editor-new-query-button"
data-testid="sql-editor-new-query-button"
type="default"
icon={<Plus className="text-foreground" />}
className="w-[26px]"
@@ -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) => {
</span>
),
}}
isBranch={false}
isOpened={isOpened && !isPreview}
isSelected={isActive}
isPreview={isPreview}
@@ -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)
@@ -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'
@@ -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<D = Datum> extends CommonChartProps<D> {
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<number | null>(null)
// Transform data to ensure yAxisKey values are numbers
@@ -177,7 +176,7 @@ const BarChart = ({
/>
<Tooltip
content={(props) =>
syncId && syncTooltip && hoveredIndex !== null ? (
syncId && isHovered && isCurrentChart && hoveredIndex !== null ? (
<div className="bg-black/90 text-white p-2 rounded text-xs">
<div className="font-medium">
{dayjs(data[hoveredIndex]?.[xAxisKey]).format(customDateFormat)}
@@ -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}
</h3>
{docsUrl && (
<Tooltip>
<TooltipTrigger asChild>
<Link
href={docsUrl}
target="_blank"
className="text-foreground-lighter hover:text-foreground-light"
>
<InfoIcon className="w-3 h-3" />
</Link>
</TooltipTrigger>
<TooltipContent side="top" align="center">
Read docs
</TooltipContent>
</Tooltip>
<ButtonTooltip
type="text"
className="px-1"
asChild
tooltip={{
content: {
side: 'top',
text: 'Read docs',
},
}}
>
<Link href={docsUrl} target="_blank">
<InfoIcon className="w-4 h-4 text-foreground-lighter" />
</Link>
</ButtonTooltip>
)}
</div>
)
@@ -170,10 +190,6 @@ const ChartHeader = ({
className={`text-foreground text-xl font-normal ${minimalHeader ? 'text-base' : 'text-2xl'}`}
>
{localHighlightedValue !== undefined && formatHighlightedValue(localHighlightedValue)}
{format === 'seconds' ? ' ' : ''}
<span className="text-lg">
{typeof format === 'function' ? format(localHighlightedValue) : format}
</span>
</h4>
)
const label = <h4 className="text-foreground-lighter text-xs">{localHighlightedLabel}</h4>
@@ -202,43 +218,57 @@ const ChartHeader = ({
</div>
</div>
<div className="flex items-center gap-2">
{sql ? (
<ButtonTooltip
type="default"
className="px-1.5"
asChild
tooltip={{
content: {
side: 'top',
text: 'Open in Log Explorer',
},
}}
>
<Link href={`/project/${ref}/logs/explorer?q=${encodeURIComponent(sql)}`}>
<SquareTerminal className="w-4 h-4 text-foreground-lighter" />
</Link>
</ButtonTooltip>
) : null}
{!hideChartType && onChartStyleChange && (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="default"
className="px-1.5"
icon={chartStyle === 'bar' ? <Activity /> : <BarChartIcon />}
onClick={() => onChartStyleChange(chartStyle === 'bar' ? 'line' : 'bar')}
/>
</TooltipTrigger>
<TooltipContent side="top" align="center">
View as {chartStyle === 'bar' ? 'line chart' : 'bar chart'}
</TooltipContent>
</Tooltip>
<ButtonTooltip
type="default"
className="px-1.5"
icon={chartStyle === 'bar' ? <Activity /> : <BarChartIcon />}
onClick={() => onChartStyleChange(chartStyle === 'bar' ? 'line' : 'bar')}
tooltip={{
content: {
side: 'top',
text: `View as ${chartStyle === 'bar' ? 'line chart' : 'bar chart'}`,
},
}}
/>
)}
{setShowMaxValue && (
<Tooltip>
<TooltipTrigger asChild>
<Button
type={showMaxValue ? 'default' : 'dashed'}
className="px-1.5"
icon={
<GitCommitHorizontalIcon
className={showMaxValue ? 'text-foreground-light' : 'text-foreground-lighter'}
/>
}
onClick={() => setShowMaxValue(!showMaxValue)}
<ButtonTooltip
type={showMaxValue ? 'default' : 'dashed'}
className="px-1.5"
icon={
<GitCommitHorizontalIcon
className={showMaxValue ? 'text-foreground-light' : 'text-foreground-lighter'}
/>
</TooltipTrigger>
<TooltipContent side="top" align="center">
{showMaxValue ? 'Hide' : 'Show'} limit
</TooltipContent>
</Tooltip>
}
onClick={() => setShowMaxValue(!showMaxValue)}
tooltip={{
content: {
side: 'top',
text: `${showMaxValue ? 'Hide' : 'Show'} limit`,
},
}}
/>
)}
</div>
</div>
)
}
export default ChartHeader
@@ -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
*
@@ -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<D = Datum> extends CommonChartProps<D> {
attributes: MultiAttribute[]
@@ -64,9 +63,10 @@ export interface ComposedChartProps<D = Datum> extends CommonChartProps<D> {
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}
/>
<Container className="relative z-10">
<RechartComposedChart
@@ -37,7 +37,7 @@ export type Provider = 'infra-monitoring' | 'daily-stats' | 'mock' | 'reference-
export type MultiAttribute = {
attribute: string
provider: Provider
provider?: Provider
label?: string
color?: {
light: string
@@ -4,7 +4,7 @@ import React, { PropsWithChildren, useEffect, useMemo, useRef, useState } from '
import { cn, WarningIcon } from 'ui'
import Panel from 'components/ui/Panel'
import ComposedChart from './ComposedChart'
import { ComposedChart } from './ComposedChart'
import { AnalyticsInterval, DataPoint } from 'data/analytics/constants'
import { useInfraMonitoringQueries } from 'data/analytics/infra-monitoring-queries'
@@ -353,7 +353,7 @@ const useAttributeQueries = (
return [...infraQueries, ...dailyStatsQueries, ...referenceLineQueries]
}
export default function LazyComposedChartHandler(props: ComposedChartHandlerProps) {
export function LazyComposedChartHandler(props: ComposedChartHandlerProps) {
if (props.hide) return null
return (
@@ -3,7 +3,7 @@ import { Loader2 } from 'lucide-react'
import { cn, WarningIcon } from 'ui'
import Panel from 'components/ui/Panel'
import ComposedChart from './ComposedChart'
import { ComposedChart } from './ComposedChart'
import { AnalyticsInterval, DataPoint } from 'data/analytics/constants'
import { InfraMonitoringAttribute } from 'data/analytics/infra-monitoring-query'
@@ -40,6 +40,7 @@ interface LogChartHandlerProps {
isVisible?: boolean
titleTooltip?: string
docsUrl?: string
syncId?: string
}
/**
@@ -87,7 +88,7 @@ const LazyChartWrapper = ({ children }: PropsWithChildren) => {
*
* 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<LogChartHandlerProps>) => {
const [chartStyle, setChartStyle] = useState<string>(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}
/>
</Panel.Content>
@@ -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 {
@@ -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<any> {
xAxisKey: string
+3
View File
@@ -15,6 +15,9 @@ interface PanelProps {
titleClasses?: string
}
/**
* @deprecated Use Card component from ui package instead
*/
function Panel(props: PropsWithChildren<PanelProps>) {
const content = (
<div
@@ -44,6 +44,7 @@ const PasswordStrengthBar = ({
>
Generate a password
</span>
.
</p>
</>
)
@@ -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
@@ -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
@@ -15,7 +15,7 @@ export async function deleteContents(
headers: { Version: '2' },
params: {
path: { ref: projectRef },
query: { ids },
query: { ids: ids.join(',') },
},
signal,
})
@@ -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
}
}
@@ -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)
@@ -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(
@@ -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],
},
}
@@ -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) {
@@ -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) {
@@ -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) {
-70
View File
@@ -1,70 +0,0 @@
import { ReportAttributes } from 'components/ui/Charts/ComposedChart.utils'
export const getEdgeFunctionReportAttributes = (): ReportAttributes[] => [
{
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',
},
],
},
]
-415
View File
@@ -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<string>(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<string>(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<string>(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<string>(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<string>(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,
}
}
@@ -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, (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(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<string>()
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<string, any>, 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<string, any>, 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<string, any>, 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 }
},
},
]
@@ -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',
])
})
})
@@ -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
}
@@ -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,
}))
@@ -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[]
}
@@ -7,5 +7,7 @@
"logs:default_query": null,
"connect:frameworks": null
"connect:frameworks": null,
"infra:cloud_providers": ["AWS", "AWS_K8S", "FLY"]
}
@@ -72,5 +72,7 @@
]
}
]
}
},
"infra:cloud_providers": ["AWS_NIMBUS"]
}
@@ -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
}
@@ -28,16 +28,16 @@ const useCustomContent = <T extends CustomContent[]>(
contents: T
): {
[key in CustomContentToCamelCase<T[number]>]:
| (typeof customContentStaticObj)[CustomContent]
| CustomContentTypes[CustomContentToCamelCase<T[number]>]
| 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<T[number]>]:
| (typeof customContentStaticObj)[CustomContent]
| CustomContentTypes[CustomContentToCamelCase<T[number]>]
| null
}
}
+2 -1
View File
@@ -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
+3 -20
View File
@@ -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
+1 -1
View File
@@ -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'
)
+22 -5
View File
@@ -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: {
+9 -6
View File
@@ -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')
})
})
})
+17 -4
View File
@@ -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)
+13 -3
View File
@@ -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 (
<ErrorBoundary FallbackComponent={GlobalErrorBoundaryState} onError={errorBoundaryHandler}>
<QueryClientProvider client={queryClient}>
+2 -4
View File
@@ -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' })
@@ -224,7 +224,7 @@ const CreateProject = () => {
<div className="py-2">
<Input
id="dbPass"
label="Database Password"
label="Database password"
type="password"
placeholder="Type in a strong password"
value={dbPass}
+29 -19
View File
@@ -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])
@@ -489,7 +494,7 @@ const Wizard: NextPageWithLayout = () => {
canCreateProject &&
additionalMonthlySpend > 0 && (
<div className="flex justify-between text-sm">
<span>Additional Costs</span>
<span>Additional costs</span>
<div className="text-brand flex gap-1 items-center font-mono font-medium">
<span>${additionalMonthlySpend}/m</span>
<InfoTooltip side="top" className="max-w-[450px] p-0">
@@ -707,15 +712,20 @@ const Wizard: NextPageWithLayout = () => {
</FormControl_Shadcn_>
<SelectContent_Shadcn_>
<SelectGroup_Shadcn_>
{Object.values(PROVIDERS).map((providerObj) => {
const label = providerObj['name']
const value = providerObj['id']
return (
<SelectItem_Shadcn_ key={value} value={value}>
{label}
</SelectItem_Shadcn_>
{Object.values(PROVIDERS)
.filter(
(provider) =>
validCloudProviders?.includes(provider.id) ?? true
)
})}
.map((providerObj) => {
const label = providerObj['name']
const value = providerObj['id']
return (
<SelectItem_Shadcn_ key={value} value={value}>
{label}
</SelectItem_Shadcn_>
)
})}
</SelectGroup_Shadcn_>
</SelectContent_Shadcn_>
</Select_Shadcn_>
@@ -735,7 +745,7 @@ const Wizard: NextPageWithLayout = () => {
layout="horizontal"
label={
<div className="flex flex-col gap-y-4">
<span>Compute Size</span>
<span>Compute size</span>
<div className="flex flex-col gap-y-2">
<Link
@@ -744,7 +754,7 @@ const Wizard: NextPageWithLayout = () => {
href="https://supabase.com/docs/guides/platform/compute-add-ons"
>
<div className="flex items-center space-x-2 opacity-75 hover:opacity-100 transition">
<p className="text-sm m-0">Compute Add-Ons</p>
<p className="text-sm m-0">Compute add-ons</p>
<ExternalLink size={16} strokeWidth={1.5} />
</div>
</Link>
@@ -754,7 +764,7 @@ const Wizard: NextPageWithLayout = () => {
href="https://supabase.com/docs/guides/platform/manage-your-usage/compute"
>
<div className="flex items-center space-x-2 opacity-75 hover:opacity-100 transition">
<p className="text-sm m-0">Compute Billing</p>
<p className="text-sm m-0">Compute billing</p>
<ExternalLink size={16} strokeWidth={1.5} />
</div>
</Link>
@@ -845,7 +855,7 @@ const Wizard: NextPageWithLayout = () => {
return (
<FormItemLayout
label="Database Password"
label="Database password"
layout="horizontal"
description={
<>
@@ -926,7 +936,7 @@ const Wizard: NextPageWithLayout = () => {
<FormItemLayout
label="Custom Postgres version"
layout="horizontal"
description="Specify a custom version of Postgres (Defaults to the latest). This is only applicable for local/staging projects"
description="Specify a custom version of Postgres (defaults to the latest). This is only applicable for local/staging projects."
>
<FormControl_Shadcn_>
<Input_Shadcn_
@@ -106,7 +106,7 @@ const AuthPoliciesPage: NextPageWithLayout = () => {
}
return (
<ScaffoldContainer size="full">
<ScaffoldContainer size="large">
<ScaffoldSection isFullWidth>
<div className="mb-4 flex flex-row gap-2 justify-between">
<Input
@@ -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 (
<div className="flex flex-col h-full">
@@ -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'
@@ -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) => (
<ComposedChartHandler
<LazyComposedChartHandler
key={chart.id}
{...chart}
attributes={chart.attributes as MultiAttribute[]}
@@ -288,7 +288,7 @@ const DatabaseUsage = () => {
))
: REPORT_ATTRIBUTES.filter((chart) => !chart.hide).map((chart, i) =>
chart.availableIn?.includes(orgPlan?.id) ? (
<ComposedChartHandler
<LazyComposedChartHandler
key={chart.id}
{...chart}
attributes={chart.attributes as MultiAttribute[]}
@@ -310,7 +310,6 @@ const DatabaseUsage = () => {
<ReportChart
key={`${chart.id}-${i}`}
chart={chart}
className="!mb-0"
interval={selectedDateRange.interval}
startDate={selectedDateRange?.period_start?.date}
endDate={selectedDateRange?.period_end?.date}
@@ -2,10 +2,10 @@ import { useQueryClient } from '@tanstack/react-query'
import { useParams } from 'common'
import dayjs from 'dayjs'
import { ArrowRight, ChevronDown, RefreshCw } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Label } from '@ui/components/shadcn/ui/label'
import ReportChart from 'components/interfaces/Reports/ReportChart'
import { ReportChartV2 } from 'components/interfaces/Reports/v2/ReportChartV2'
import ReportHeader from 'components/interfaces/Reports/ReportHeader'
import ReportPadding from 'components/interfaces/Reports/ReportPadding'
import ReportStickyNav from 'components/interfaces/Reports/ReportStickyNav'
@@ -14,17 +14,19 @@ import DefaultLayout from 'components/layouts/DefaultLayout'
import ReportsLayout from 'components/layouts/ReportsLayout/ReportsLayout'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { Button, Checkbox, DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from 'ui'
import { useChartHoverState } from 'components/ui/Charts/useChartHoverState'
import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query'
import { getEdgeFunctionReportAttributes } from 'data/reports/edgefn-charts'
import { edgeFunctionReports } from 'data/reports/v2/edge-functions.config'
import { REPORT_DATERANGE_HELPER_LABELS } from 'components/interfaces/Reports/Reports.constants'
import UpgradePrompt from 'components/interfaces/Settings/Logs/UpgradePrompt'
import { useReportDateRange } from 'hooks/misc/useReportDateRange'
import type { NextPageWithLayout } from 'types'
import { ReportSettings } from 'components/ui/Charts/ReportSettings'
const EdgeFunctionsReport: NextPageWithLayout = () => {
const EdgeFunctionsReportV2: NextPageWithLayout = () => {
return (
<ReportPadding>
<EdgeFunctionsUsage />
@@ -32,20 +34,23 @@ const EdgeFunctionsReport: NextPageWithLayout = () => {
)
}
EdgeFunctionsReport.getLayout = (page) => (
EdgeFunctionsReportV2.getLayout = (page) => (
<DefaultLayout>
<ReportsLayout title="Edge Functions">{page}</ReportsLayout>
</DefaultLayout>
)
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<string[]>([])
const [tempFunctionIds, setTempFunctionIds] = useState<string[]>(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 (
<>
<ReportHeader title="Edge Functions" showDatabaseSelector={false} />
@@ -101,6 +110,8 @@ const EdgeFunctionsUsage = () => {
tooltip={{ content: { side: 'bottom', text: 'Refresh report' } }}
onClick={onRefreshReport}
/>
<ReportSettings chartId="edge-functions-charts" />
<LogsDatePicker
onSubmit={handleDatePickerChange}
value={datePickerValue}
@@ -214,17 +225,21 @@ const EdgeFunctionsUsage = () => {
>
<div className="mt-8 flex flex-col gap-4">
{selectedDateRange &&
EDGEFN_CHARTS.filter((attr) => !attr.hide).map((attr, i) => (
<ReportChart
key={`${attr.id}-${i}`}
chart={attr}
interval={selectedDateRange.interval}
startDate={selectedDateRange?.period_start?.date}
endDate={selectedDateRange?.period_end?.date}
updateDateRange={updateDateRange}
functionIds={functionIds}
/>
))}
reportConfig
.filter((report) => !report.hide)
.map((report) => (
<ReportChartV2
key={`${report.id}`}
report={report}
projectRef={ref!}
interval={selectedDateRange.interval}
startDate={selectedDateRange?.period_start?.date}
endDate={selectedDateRange?.period_end?.date}
updateDateRange={updateDateRange}
functionIds={functionIds}
syncId={chartSyncId}
/>
))}
</div>
</ReportStickyNav>
</>
@@ -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) => (
<ComposedChartHandler
<LazyComposedChartHandler
key={chart.id}
{...chart}
attributes={chart.attributes as MultiAttribute[]}
@@ -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<number, PipelineStatusRequestStatus>
setRequestStatus: (pipelineId: number, status: PipelineStatusRequestStatus) => void
pipelineStatusSnapshot: Record<number, string | undefined>
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<number, PipelineStatusRequestStatus>
>({})
const [pipelineStatusSnapshot, setPipelineStatusSnapshot] = useState<
Record<number, string | undefined>
>({})
const timeoutsRef = useRef<Record<number, number>>({})
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 (
<PipelineRequestStatusContext.Provider
value={{
requestStatus,
pipelineStatusSnapshot,
setRequestStatus,
getRequestStatus,
updatePipelineStatus,
+4
View File
@@ -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
@@ -2,8 +2,235 @@ import {
checkForILIKEClause,
checkForWildcard,
checkForWithClause,
fillTimeseries,
unixMicroToIsoTimestamp,
} from 'components/interfaces/Settings/Logs/Logs.utils'
import { describe, test, expect } from 'vitest'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
dayjs.extend(utc)
describe('fillTimeseries', () => {
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', () => {
+9 -1
View File
@@ -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 : {}),
@@ -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://zoom.us/webinar/register/WN_cMSVDLRSRKS5cH8EmmUUQg', # 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.
-2
View File
@@ -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()
+1 -1
View File
@@ -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'
+16
View File
@@ -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"
}
]
+3 -2
View File
@@ -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) {
/>
<AuthProvider>
<FeatureFlagProvider API_URL={API_URL} enabled={IS_PLATFORM}>
<FeatureFlagProvider API_URL={API_URL} getConfigCatFlags={getConfigCatFlags}>
<ThemeProvider
themes={themes.map((theme) => theme.value)}
enableSystem
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 18 KiB

+175 -5
View File
@@ -337,6 +337,23 @@ export interface paths {
patch?: never
trace?: never
}
'/v1/projects/{ref}/analytics/endpoints/functions.combined-stats': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
/** Gets a project's function combined statistics */
get: operations['v1-get-project-function-combined-stats']
put?: never
post?: never
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/v1/projects/{ref}/analytics/endpoints/logs.all': {
parameters: {
query?: never
@@ -946,7 +963,11 @@ export interface paths {
* @description Modifies the roles that can be assumed and for how long
*/
put: operations['v1-update-jit-access']
post?: never
/**
* Authorize user-id to role mappings for JIT access
* @description Authorizes the request to assume a role in the project database
*/
post: operations['v1-authorize-jit-access']
delete?: never
options?: never
head?: never
@@ -1828,6 +1849,8 @@ export interface components {
mfa_totp_verify_enabled: boolean | null
mfa_web_authn_enroll_enabled: boolean | null
mfa_web_authn_verify_enabled: boolean | null
nimbus_oauth_client_id: string | null
nimbus_oauth_client_secret: string | null
password_hibp_enabled: boolean | null
password_min_length: number | null
/** @enum {string|null} */
@@ -1894,6 +1917,10 @@ export interface components {
smtp_user: string | null
uri_allow_list: string | null
}
AuthorizeJitAccessBody: {
rhost: string
role: string
}
BranchActionBody: {
migration_version?: string
}
@@ -1959,6 +1986,7 @@ export interface components {
| 'FUNCTIONS_FAILED'
/** Format: date-time */
updated_at: string
with_data: boolean
}
BranchUpdateResponse: {
/** @enum {string} */
@@ -2336,16 +2364,48 @@ export interface components {
/** Format: uuid */
user_id: string
user_roles: {
expires_at?: string
allowed_networks?: {
allowed_cidrs?: {
cidr: string
}[]
allowed_cidrs_v6?: {
cidr: string
}[]
}
expires_at?: number
role: string
}[]
}
JitAuthorizeAccessResponse: {
/** Format: uuid */
user_id: string
user_role: {
allowed_networks?: {
allowed_cidrs?: {
cidr: string
}[]
allowed_cidrs_v6?: {
cidr: string
}[]
}
expires_at?: number
role: string
}
}
JitListAccessResponse: {
items: {
/** Format: uuid */
user_id: string
user_roles: {
expires_at?: string
allowed_networks?: {
allowed_cidrs?: {
cidr: string
}[]
allowed_cidrs_v6?: {
cidr: string
}[]
}
expires_at?: number
role: string
}[]
}[]
@@ -2981,6 +3041,8 @@ export interface components {
mfa_totp_verify_enabled?: boolean | null
mfa_web_authn_enroll_enabled?: boolean | null
mfa_web_authn_verify_enabled?: boolean | null
nimbus_oauth_client_id?: string | null
nimbus_oauth_client_secret?: string | null
password_hibp_enabled?: boolean | null
password_min_length?: number | null
/** @enum {string|null} */
@@ -3107,7 +3169,15 @@ export interface components {
}
UpdateJitAccessBody: {
roles: {
expires_at?: string
allowed_networks?: {
allowed_cidrs?: {
cidr: string
}[]
allowed_cidrs_v6?: {
cidr: string
}[]
}
expires_at?: number
role: string
}[]
/** Format: uuid */
@@ -4283,6 +4353,44 @@ export interface operations {
}
}
}
'v1-get-project-function-combined-stats': {
parameters: {
query: {
function_id: string
interval: '15min' | '1hr' | '3hr' | '1day'
}
header?: never
path: {
/** @description Project ref */
ref: string
}
cookie?: never
}
requestBody?: never
responses: {
200: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['AnalyticsResponse']
}
}
403: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Failed to get project's function combined statistics */
500: {
headers: {
[name: string]: unknown
}
content?: never
}
}
}
'v1-get-project-logs': {
parameters: {
query?: {
@@ -4692,7 +4800,30 @@ export interface operations {
query?: never
header?: never
path: {
addon_variant: unknown
addon_variant:
| (
| 'ci_micro'
| 'ci_small'
| 'ci_medium'
| 'ci_large'
| 'ci_xlarge'
| 'ci_2xlarge'
| 'ci_4xlarge'
| 'ci_8xlarge'
| 'ci_12xlarge'
| 'ci_16xlarge'
| 'ci_24xlarge'
| 'ci_24xlarge_optimized_cpu'
| 'ci_24xlarge_optimized_memory'
| 'ci_24xlarge_high_memory'
| 'ci_48xlarge'
| 'ci_48xlarge_optimized_cpu'
| 'ci_48xlarge_optimized_memory'
| 'ci_48xlarge_high_memory'
)
| 'cd_default'
| ('pitr_7' | 'pitr_14' | 'pitr_28')
| 'ipv4_default'
/** @description Project ref */
ref: string
}
@@ -6229,6 +6360,45 @@ export interface operations {
}
}
}
'v1-authorize-jit-access': {
parameters: {
query?: never
header?: never
path: {
/** @description Project ref */
ref: string
}
cookie?: never
}
requestBody: {
content: {
'application/json': components['schemas']['AuthorizeJitAccessBody']
}
}
responses: {
200: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['JitAuthorizeAccessResponse']
}
}
403: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Failed to authorize database jit access */
500: {
headers: {
[name: string]: unknown
}
content?: never
}
}
}
'v1-delete-jit-access': {
parameters: {
query?: never
+365 -295
View File
File diff suppressed because it is too large. Load diff
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff. Show more