From 2ee1aec3b8dc32e2423dd789d197ba49f976fe2b Mon Sep 17 00:00:00 2001 From: Lakshan Perera Date: Thu, 4 Sep 2025 15:21:24 +1000 Subject: [PATCH 1/7] chore: update edge functions Launch Week blog post (#38424) chore: update edge functions lw blog post --- ...025-07-18-persistent-storage-for-faster-edge-functions.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/www/_blog/2025-07-18-persistent-storage-for-faster-edge-functions.mdx b/apps/www/_blog/2025-07-18-persistent-storage-for-faster-edge-functions.mdx index 6f2e351433595..239626d91bb6b 100644 --- a/apps/www/_blog/2025-07-18-persistent-storage-for-faster-edge-functions.mdx +++ b/apps/www/_blog/2025-07-18-persistent-storage-for-faster-edge-functions.mdx @@ -148,6 +148,6 @@ Deno.serve(() => { }) ``` -## Try it on Preview Today +## How to try -These changes will be rolled out along with the Deno 2 upgrade to all clusters within the next 2 weeks. Meanwhile, you can use the Preview cluster if you'd like to try them out today. Please see [this guide](https://github.com/orgs/supabase/discussions/36814) on how to test your functions in Preview cluster. +These changes are already deployed and available to use on all regions. From 793784f57adef041aae9ce40ba2c183febd92f1e Mon Sep 17 00:00:00 2001 From: Lakshan Perera Date: Thu, 4 Sep 2025 16:00:56 +1000 Subject: [PATCH 2/7] chore: remove admonition on deno 2.1 (#38425) --- ...-supabase-edge-functions-deploy-dashboard-deno-2-1.mdx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/apps/www/_blog/2025-04-01-supabase-edge-functions-deploy-dashboard-deno-2-1.mdx b/apps/www/_blog/2025-04-01-supabase-edge-functions-deploy-dashboard-deno-2-1.mdx index d941752723b4f..5438cb6b06465 100644 --- a/apps/www/_blog/2025-04-01-supabase-edge-functions-deploy-dashboard-deno-2-1.mdx +++ b/apps/www/_blog/2025-04-01-supabase-edge-functions-deploy-dashboard-deno-2-1.mdx @@ -105,18 +105,12 @@ The ability to deploy without Docker in both the Edge Functions editor and Supab You can check [the Changelog announcement](https://github.com/orgs/supabase/discussions/33720) for more details and official references to these API endpoints. -## Deno 2.1 Preview +## Deno 2.1 support Last, but not least, we have added Deno 2.1 support for Supabase Edge Runtime. With Deno 2.1, you can use built-in Deno commands to scaffold a new project, manage dependencies, run tests, and lints. Check [our guide on how to start using Deno 2.1](https://github.com/orgs/supabase/discussions/34054) tooling for your Edge Functions. - - -Note that the Supabase hosted platform is still using Deno 1.45. In the coming weeks, we will provide more details on deploying Deno 2.1 projects in the hosted platform. - - - ## Conclusion These changes to Supabase Edge Functions make it easier and more accessible for all developers to build powerful functionality into their applications. From 3dd065efce5fa675a806d99ddda58dbba65e57c4 Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Thu, 4 Sep 2025 09:03:40 +0200 Subject: [PATCH 3/7] Add Edge Function Report Filters (#38369) * add regions * update chart * add numeric filter * add select filter * update types * update edge fn ui * update report config to use filters * remove unused event * export wherefn * add where fn tests * rm unnecessary memo * make type optional * rm any * fix keys, add missing flag, add flags to region filter * bye div * bye div2 * pass refresh state * type err * Minor style fix --------- Co-authored-by: Joshen Lim --- .../interfaces/Reports/Reports.constants.ts | 63 ++++++ .../interfaces/Reports/v2/ReportChartV2.tsx | 36 +-- .../Reports/v2/ReportsNumericFilter.tsx | 212 ++++++++++++++++++ .../Reports/v2/ReportsSelectFilter.tsx | 146 ++++++++++++ .../Settings/Logs/Logs.DatePickers.tsx | 8 +- .../components/ui/Charts/ReportSettings.tsx | 14 +- .../data/reports/v2/edge-functions.config.ts | 146 +++++++----- .../data/reports/v2/edge-functions.test.tsx | 188 ++++++++++++++++ apps/studio/data/reports/v2/reports.types.ts | 14 +- .../project/[ref]/reports/edge-functions.tsx | 196 ++++++++-------- apps/studio/public/img/regions/us-west-2.svg | 10 + 11 files changed, 846 insertions(+), 187 deletions(-) create mode 100644 apps/studio/components/interfaces/Reports/v2/ReportsNumericFilter.tsx create mode 100644 apps/studio/components/interfaces/Reports/v2/ReportsSelectFilter.tsx create mode 100644 apps/studio/public/img/regions/us-west-2.svg diff --git a/apps/studio/components/interfaces/Reports/Reports.constants.ts b/apps/studio/components/interfaces/Reports/Reports.constants.ts index 69edacca9cb66..844b50dcd6c3c 100644 --- a/apps/studio/components/interfaces/Reports/Reports.constants.ts +++ b/apps/studio/components/interfaces/Reports/Reports.constants.ts @@ -531,3 +531,66 @@ export const DEPRECATED_REPORTS = [ 'total_storage_patch_requests', 'total_options_requests', ] + +export const EDGE_FUNCTION_REGIONS = [ + { + key: 'ap-northeast-1', + label: 'Tokyo', + }, + { + key: 'ap-northeast-2', + label: 'Seoul', + }, + { + key: 'ap-south-1', + label: 'Mumbai', + }, + { + key: 'ap-southeast-1', + label: 'Singapore', + }, + { + key: 'ap-southeast-2', + label: 'Sydney', + }, + { + key: 'ca-central-1', + label: 'Canada Central', + }, + { + key: 'us-east-1', + label: 'N. Virginia', + }, + { + key: 'us-west-1', + label: 'N. California', + }, + { + key: 'ap-northeast-2', + label: 'Seoul', + }, + { + key: 'us-west-2', + label: 'Oregon', + }, + { + key: 'eu-central-1', + label: 'Frankfurt', + }, + { + key: 'eu-west-1', + label: 'Ireland', + }, + { + key: 'eu-west-2', + label: 'London', + }, + { + key: 'eu-west-3', + label: 'Paris', + }, + { + key: 'sa-east-1', + label: 'São Paulo', + }, +] diff --git a/apps/studio/components/interfaces/Reports/v2/ReportChartV2.tsx b/apps/studio/components/interfaces/Reports/v2/ReportChartV2.tsx index 36538a7168180..52c2033dcbaf5 100644 --- a/apps/studio/components/interfaces/Reports/v2/ReportChartV2.tsx +++ b/apps/studio/components/interfaces/Reports/v2/ReportChartV2.tsx @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query' import { Loader2 } from 'lucide-react' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { ComposedChart } from 'components/ui/Charts/ComposedChart' import type { AnalyticsInterval } from 'data/analytics/constants' @@ -10,7 +10,6 @@ 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 @@ -18,10 +17,9 @@ export interface ReportChartV2Props { endDate: string interval: AnalyticsInterval updateDateRange: (from: string, to: string) => void - functionIds?: string[] - edgeFnIdToName?: (id: string) => string | undefined className?: string syncId?: string + filters?: any } export const ReportChartV2 = ({ @@ -31,10 +29,9 @@ export const ReportChartV2 = ({ endDate, interval, updateDateRange, - functionIds, - edgeFnIdToName, className, syncId, + filters, }: ReportChartV2Props) => { const { data: org } = useSelectedOrganizationQuery() const { plan: orgPlan } = useCurrentOrgPlan() @@ -49,21 +46,21 @@ export const ReportChartV2 = ({ data: queryResult, isLoading: isLoadingChart, error, + isFetching, } = useQuery( - ['projects', projectRef, 'report-v2', report.id, { startDate, endDate, interval, functionIds }], + [ + 'projects', + projectRef, + 'report-v2', + { reportId: report.id, startDate, endDate, interval, filters }, + ], async () => { - return await report.dataProvider( - projectRef, - startDate, - endDate, - interval, - functionIds, - edgeFnIdToName - ) + return await report.dataProvider(projectRef, startDate, endDate, interval, filters) }, { enabled: Boolean(projectRef && canFetch && isAvailable), refetchOnWindowFocus: false, + staleTime: 0, } ) @@ -95,12 +92,17 @@ export const ReportChartV2 = ({ return ( - + {isLoadingChart ? ( ) : showEmptyState ? (

- No data available for the selected time range + No data available for the selected time range and filters

) : isErrorState ? (

diff --git a/apps/studio/components/interfaces/Reports/v2/ReportsNumericFilter.tsx b/apps/studio/components/interfaces/Reports/v2/ReportsNumericFilter.tsx new file mode 100644 index 0000000000000..55173bfe52115 --- /dev/null +++ b/apps/studio/components/interfaces/Reports/v2/ReportsNumericFilter.tsx @@ -0,0 +1,212 @@ +import { ChevronDown } from 'lucide-react' +import { useEffect, useState } from 'react' + +import { Label } from '@ui/components/shadcn/ui/label' +import { Popover, PopoverContent, PopoverTrigger } from '@ui/components/shadcn/ui/popover' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@ui/components/shadcn/ui/select' +import { Button, cn } from 'ui' +import { Input } from 'ui-patterns/DataInputs/Input' + +export type ComparisonOperator = '=' | '!=' | '>' | '>=' | '<' | '<=' + +const OPERATOR_LABELS = { + '=': 'Equals', + '>=': 'Greater than or equal to', + '<=': 'Less than or equal to', + '>': 'Greater than', + '<': 'Less than', + '!=': 'Not equal to', +} satisfies Record + +export interface NumericFilter { + operator: ComparisonOperator + value: number +} + +interface ReportsNumericFilterProps { + label: string + value?: NumericFilter + onChange: (value: NumericFilter | undefined) => void + operators?: ComparisonOperator[] + defaultOperator?: ComparisonOperator + placeholder?: string + min?: number + max?: number + step?: number + isLoading?: boolean + className?: string +} + +export const ReportsNumericFilter = ({ + label, + value, + onChange, + operators = ['=', '>=', '<=', '>', '<', '!='], + defaultOperator = '=', + placeholder = 'Enter value', + min, + max, + step = 1, + isLoading = false, + className, +}: ReportsNumericFilterProps) => { + const [open, setOpen] = useState(false) + const [tempValue, setTempValue] = useState(value) + + const isActive = value !== undefined + + useEffect(() => { + if (!open) { + setTempValue(value) + } + }, [open, value]) + + const handleClear = (e: React.MouseEvent) => { + e.stopPropagation() + onChange(undefined) + } + + const handleApply = () => { + onChange(tempValue) + setOpen(false) + } + + const getDisplayValue = () => { + if (value) { + return `${value.operator} ${value.value}` + } + return '' + } + + const handleOperatorChange = (operator: ComparisonOperator) => { + setTempValue({ + operator, + value: tempValue?.value || 0, + }) + } + + const handleValueChange = (inputValue: string) => { + const numericValue = parseFloat(inputValue) || 0 + setTempValue({ + operator: tempValue?.operator || defaultOperator, + value: numericValue, + }) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + handleApply() + } + } + + const handleClearAll = () => { + setTempValue(undefined) + } + + return ( + + + + + +

+ {label} + +
+ +
{ + e.preventDefault() + handleApply() + }} + className="px-3 py-3 flex flex-col gap-y-3" + > +
+ + +
+ +
+ + handleValueChange(e.target.value)} + onKeyDown={handleKeyDown} + min={min} + max={max} + step={step} + /> + {(min !== undefined || max !== undefined) && ( +

+ {min !== undefined && max !== undefined + ? `Range: ${min} - ${max}` + : min !== undefined + ? `Min: ${min}` + : `Max: ${max}`} +

+ )} +
+
+ +
+ + +
+ + + ) +} diff --git a/apps/studio/components/interfaces/Reports/v2/ReportsSelectFilter.tsx b/apps/studio/components/interfaces/Reports/v2/ReportsSelectFilter.tsx new file mode 100644 index 0000000000000..87d4b34d8bf78 --- /dev/null +++ b/apps/studio/components/interfaces/Reports/v2/ReportsSelectFilter.tsx @@ -0,0 +1,146 @@ +import { ChevronDown } from 'lucide-react' +import { useEffect, useState } from 'react' + +import { Checkbox } from '@ui/components/shadcn/ui/checkbox' +import { Label } from '@ui/components/shadcn/ui/label' +import { Popover, PopoverContent, PopoverTrigger } from '@ui/components/shadcn/ui/popover' +import { Button, cn } from 'ui' + +export interface ReportSelectOption { + key: string + label: React.ReactNode + description?: string +} + +export interface SelectFilters { + [key: string]: boolean +} + +interface ReportsSelectFilterProps { + label: string + options: ReportSelectOption[] + value: SelectFilters + onChange: (value: SelectFilters) => void + isLoading?: boolean + className?: string +} + +export const ReportsSelectFilter = ({ + label, + options, + value, + onChange, + isLoading = false, + className, +}: ReportsSelectFilterProps) => { + const [open, setOpen] = useState(false) + const [tempValue, setTempValue] = useState(value) + + const selectedCount = Object.values(value).filter(Boolean).length + const isActive = selectedCount > 0 + + useEffect(() => { + if (!open) { + setTempValue(value) + } + }, [open, value]) + + const handleApply = () => { + onChange(tempValue) + setOpen(false) + } + + const handleClearAll = () => { + setTempValue({}) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + handleApply() + } + } + + return ( + + + + + +
+
+ {label} + +
+
+ +
+ {options.length === 0 ? ( +
+ {isLoading ? 'Loading options...' : 'No options available'} +
+ ) : ( + options.map((option) => ( + + )) + )} +
+ +
+ + +
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx b/apps/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx index b81e86ba18df2..4200143336db4 100644 --- a/apps/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx @@ -29,13 +29,14 @@ export type DatePickerValue = { text?: string } -interface Props { +interface LogsDatePickerProps { value: DatePickerValue helpers: DatetimeHelper[] onSubmit: (value: DatePickerValue) => void buttonTriggerProps?: ButtonProps popoverContentProps?: typeof PopoverContent_Shadcn_ hideWarnings?: boolean + align?: 'start' | 'end' | 'center' } export const LogsDatePicker = ({ @@ -45,7 +46,8 @@ export const LogsDatePicker = ({ buttonTriggerProps, popoverContentProps, hideWarnings, -}: PropsWithChildren) => { + align = 'end', +}: PropsWithChildren) => { const [open, setOpen] = useState(false) const todayButtonRef = useRef(null) @@ -253,7 +255,7 @@ export const LogsDatePicker = ({ diff --git a/apps/studio/components/ui/Charts/ReportSettings.tsx b/apps/studio/components/ui/Charts/ReportSettings.tsx index df6511047b3f5..5fc052c620b3c 100644 --- a/apps/studio/components/ui/Charts/ReportSettings.tsx +++ b/apps/studio/components/ui/Charts/ReportSettings.tsx @@ -1,9 +1,15 @@ +import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { Settings } from 'lucide-react' import { useState } from 'react' -import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, Label_Shadcn_, Switch } from 'ui' -import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, + Label_Shadcn_, + Switch, +} from 'ui' import { useChartHoverState } from './useChartHoverState' -import { cn } from 'ui' interface ReportSettingsProps { chartId: string @@ -23,7 +29,7 @@ export const ReportSettings = ({ chartId }: ReportSettingsProps) => { tooltip={{ content: { side: 'bottom', text: 'Report settings' } }} /> - +
diff --git a/apps/studio/data/reports/v2/edge-functions.config.ts b/apps/studio/data/reports/v2/edge-functions.config.ts index 2e65c9252326a..d5e824b6c09c4 100644 --- a/apps/studio/data/reports/v2/edge-functions.config.ts +++ b/apps/studio/data/reports/v2/edge-functions.config.ts @@ -12,11 +12,59 @@ import { } from 'data/reports/report.utils' import { getHttpStatusCodeInfo } from 'lib/http-status-codes' import { ReportConfig } from './reports.types' +import { NumericFilter } from 'components/interfaces/Reports/v2/ReportsNumericFilter' +import { SelectFilters } from 'components/interfaces/Reports/v2/ReportsSelectFilter' -const METRIC_SQL: Record string> = - { - TotalInvocations: (interval, functionIds) => { - return ` +type EdgeFunctionReportFilters = { + status_code?: NumericFilter + region?: SelectFilters + execution_time?: NumericFilter + functions?: SelectFilters +} + +export function filterToWhereClause(filters?: EdgeFunctionReportFilters): string { + const whereClauses: string[] = [] + + if (filters?.functions) { + const selectedFunctions = Object.keys(filters.functions).filter( + (key) => filters.functions![key] + ) + if (selectedFunctions.length > 0) { + whereClauses.push(`function_id IN (${selectedFunctions.map((id) => `'${id}'`).join(',')})`) + } + } + + if (filters?.status_code) { + whereClauses.push( + `response.status_code ${filters.status_code.operator} ${filters.status_code.value}` + ) + } + + if (filters?.region) { + const selectedRegions = Object.keys(filters.region).filter((key) => filters.region![key]) + if (selectedRegions.length > 0) { + whereClauses.push( + `h.x_sb_edge_region IN (${selectedRegions.map((region) => `'${region}'`).join(',')})` + ) + } + } + + if (filters?.execution_time) { + whereClauses.push( + `m.execution_time_ms ${filters.execution_time.operator} ${filters.execution_time.value}` + ) + } + + return whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '' +} + +const METRIC_SQL: Record< + string, + (interval: AnalyticsInterval, filters?: EdgeFunctionReportFilters) => string +> = { + TotalInvocations: (interval, filters) => { + const whereClause = filterToWhereClause(filters) + return ` --edgefn-report-invocations select timestamp_trunc(timestamp, ${analyticsIntervalToGranularity(interval)}) as timestamp, @@ -27,20 +75,18 @@ from 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(',')})` - : '' - } + CROSS JOIN UNNEST(response.headers) AS h + ${whereClause} group by timestamp, function_id order by timestamp desc; ` - }, - ExecutionStatusCodes: (interval, functionIds) => { - return ` + }, + ExecutionStatusCodes: (interval, filters) => { + const whereClause = filterToWhereClause(filters) + return ` --edgefn-report-execution-status-codes select timestamp_trunc(timestamp, ${analyticsIntervalToGranularity(interval)}) as timestamp, @@ -50,21 +96,24 @@ 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(',')})` - : '' - } + cross join unnest(response.headers) as h + ${whereClause} group by timestamp, status_code order by timestamp desc ` - }, - InvocationsByRegion: (interval, functionIds) => { - const granularity = analyticsIntervalToGranularity(interval) - return ` + }, + InvocationsByRegion: (interval, filters) => { + const granularity = analyticsIntervalToGranularity(interval) + const whereClause = filterToWhereClause(filters) + const hasWhere = whereClause.includes('WHERE') + const regionCondition = hasWhere + ? 'AND h.x_sb_edge_region is not null' + : 'WHERE h.x_sb_edge_region is not null' + + return ` --edgefn-report-invocations-by-region select timestamp_trunc(timestamp, ${granularity}) as timestamp, @@ -75,41 +124,40 @@ from 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(',')})` - : '' - } + ${whereClause} + ${regionCondition} group by timestamp, region order by timestamp desc ` - }, - ExecutionTime: (interval, functionIds) => { - const granularity = analyticsIntervalToGranularity(interval) - const hasFunctions = functionIds && functionIds.length > 0 - return ` + }, + ExecutionTime: (interval, filters) => { + const granularity = analyticsIntervalToGranularity(interval) + const whereClause = filterToWhereClause(filters) + + return ` --edgefn-report-execution-time select timestamp_trunc(timestamp, ${granularity}) as timestamp, - ${hasFunctions ? 'function_id,' : ''} + 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(',')})` : ''} + cross join unnest(m.response) as response + cross join unnest(response.headers) as h + ${whereClause} group by - timestamp - ${hasFunctions ? ', function_id' : ''} + timestamp, + 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`, { @@ -216,10 +264,8 @@ export const edgeFunctionReports = ({ startDate: string endDate: string interval: AnalyticsInterval - filters: { - functionIds?: string[] - } -}): ReportConfig[] => [ + filters: EdgeFunctionReportFilters +}): ReportConfig[] => [ { id: 'total-invocations', label: 'Total Edge Function Invocations', @@ -233,7 +279,7 @@ export const edgeFunctionReports = ({ 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 sql = METRIC_SQL.TotalInvocations(interval, filters) const response = await runQuery(projectRef, sql, startDate, endDate) if (!response?.result) return { data: [] } @@ -264,14 +310,8 @@ export const edgeFunctionReports = ({ 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) + dataProvider: async () => { + const sql = METRIC_SQL.ExecutionStatusCodes(interval, filters) const rawData = await runQuery(projectRef, sql, startDate, endDate) if (!rawData?.result) return { data: [] } @@ -308,7 +348,7 @@ export const edgeFunctionReports = ({ }, format: (value: unknown) => `${Number(value).toFixed(0)}ms`, dataProvider: async () => { - const sql = METRIC_SQL.ExecutionTime(interval, filters.functionIds) + const sql = METRIC_SQL.ExecutionTime(interval, filters) const rawData = await runQuery(projectRef, sql, startDate, endDate) if (!rawData?.result) return { data: [] } @@ -365,7 +405,7 @@ export const edgeFunctionReports = ({ 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 sql = METRIC_SQL.InvocationsByRegion(interval, filters) const rawData = await runQuery(projectRef, sql, startDate, endDate) const data = rawData.result?.map((point: any) => ({ ...point, diff --git a/apps/studio/data/reports/v2/edge-functions.test.tsx b/apps/studio/data/reports/v2/edge-functions.test.tsx index e1217d7b7a024..6b790dd4a78f7 100644 --- a/apps/studio/data/reports/v2/edge-functions.test.tsx +++ b/apps/studio/data/reports/v2/edge-functions.test.tsx @@ -5,6 +5,7 @@ import { transformStatusCodeData, transformInvocationData, aggregateInvocationsByTimestamp, + filterToWhereClause, } from './edge-functions.config' describe('extractStatusCodesFromData', () => { @@ -249,3 +250,190 @@ describe('aggregateInvocationsByTimestamp', () => { ]) }) }) + +describe('filterToWhereClause', () => { + it('should return empty string when no filters are provided', () => { + const result = filterToWhereClause() + expect(result).toBe('') + }) + + it('should return empty string when filters object is empty', () => { + const result = filterToWhereClause({}) + expect(result).toBe('') + }) + + it('should generate WHERE clause for functions filter', () => { + const filters = { + functions: { + func1: true, + func2: true, + func3: false, + }, + } + const result = filterToWhereClause(filters) + expect(result).toBe("WHERE function_id IN ('func1','func2')") + }) + + it('should generate WHERE clause for status_code filter', () => { + const filters = { + status_code: { + operator: '>=' as const, + value: 400, + }, + } + const result = filterToWhereClause(filters) + expect(result).toBe('WHERE response.status_code >= 400') + }) + + it('should generate WHERE clause for region filter', () => { + const filters = { + region: { + 'us-east-1': true, + 'eu-west-1': true, + 'ap-southeast-1': false, + }, + } + const result = filterToWhereClause(filters) + expect(result).toBe("WHERE h.x_sb_edge_region IN ('us-east-1','eu-west-1')") + }) + + it('should generate WHERE clause for execution_time filter', () => { + const filters = { + execution_time: { + operator: '<' as const, + value: 1000, + }, + } + const result = filterToWhereClause(filters) + expect(result).toBe('WHERE m.execution_time_ms < 1000') + }) + + it('should combine multiple filters with AND', () => { + const filters = { + functions: { + func1: true, + func2: false, + }, + status_code: { + operator: '=' as const, + value: 200, + }, + region: { + 'us-east-1': true, + }, + execution_time: { + operator: '<=' as const, + value: 500, + }, + } + const result = filterToWhereClause(filters) + expect(result).toBe( + "WHERE function_id IN ('func1') AND response.status_code = 200 AND h.x_sb_edge_region IN ('us-east-1') AND m.execution_time_ms <= 500" + ) + }) + + it('should handle functions filter with no selected functions', () => { + const filters = { + functions: { + func1: false, + func2: false, + }, + } + const result = filterToWhereClause(filters) + expect(result).toBe('') + }) + + it('should handle region filter with no selected regions', () => { + const filters = { + region: { + 'us-east-1': false, + 'eu-west-1': false, + }, + } + const result = filterToWhereClause(filters) + expect(result).toBe('') + }) + + it('should handle single function selection', () => { + const filters = { + functions: { + 'single-func': true, + }, + } + const result = filterToWhereClause(filters) + expect(result).toBe("WHERE function_id IN ('single-func')") + }) + + it('should handle single region selection', () => { + const filters = { + region: { + 'single-region': true, + }, + } + const result = filterToWhereClause(filters) + expect(result).toBe("WHERE h.x_sb_edge_region IN ('single-region')") + }) + + it('should handle all comparison operators for status_code', () => { + const operators = ['=', '!=', '>', '>=', '<', '<='] as const + + operators.forEach((operator) => { + const filters = { + status_code: { + operator, + value: 200, + }, + } + const result = filterToWhereClause(filters) + expect(result).toBe(`WHERE response.status_code ${operator} 200`) + }) + }) + + it('should handle all comparison operators for execution_time', () => { + const operators = ['=', '!=', '>', '>=', '<', '<='] as const + + operators.forEach((operator) => { + const filters = { + execution_time: { + operator, + value: 100, + }, + } + const result = filterToWhereClause(filters) + expect(result).toBe(`WHERE m.execution_time_ms ${operator} 100`) + }) + }) + + it('should handle numeric values correctly', () => { + const filters = { + status_code: { + operator: '>' as const, + value: 0, + }, + execution_time: { + operator: '>=' as const, + value: 1.5, + }, + } + const result = filterToWhereClause(filters) + expect(result).toBe('WHERE response.status_code > 0 AND m.execution_time_ms >= 1.5') + }) + + it('should handle special characters in function IDs and regions', () => { + const filters = { + functions: { + 'func-with-dash': true, + func_with_underscore: true, + 'func.with.dots': true, + }, + region: { + 'region-with-dash': true, + region_with_underscore: true, + }, + } + const result = filterToWhereClause(filters) + expect(result).toBe( + "WHERE function_id IN ('func-with-dash','func_with_underscore','func.with.dots') AND h.x_sb_edge_region IN ('region-with-dash','region_with_underscore')" + ) + }) +}) diff --git a/apps/studio/data/reports/v2/reports.types.ts b/apps/studio/data/reports/v2/reports.types.ts index 5edd0b2379167..cfa86deab9375 100644 --- a/apps/studio/data/reports/v2/reports.types.ts +++ b/apps/studio/data/reports/v2/reports.types.ts @@ -1,19 +1,13 @@ import { AnalyticsInterval } from 'data/analytics/constants' import { YAxisProps } from 'recharts' -type ReportDataProviderFilter = { - functionIds?: string[] -} - -export interface ReportDataProvider { +export interface ReportDataProvider { ( projectRef: string, startDate: string, endDate: string, interval: AnalyticsInterval, - functionIds?: string[], - edgeFnIdToName?: (id: string) => string | undefined, - filters?: ReportDataProviderFilter[] + filters?: FiltersType ): Promise<{ data: any attributes?: { @@ -25,10 +19,10 @@ export interface ReportDataProvider { }> // [jordi] would be cool to have a type that forces data keys to match the attributes } -export interface ReportConfig { +export interface ReportConfig { id: string label: string - dataProvider: ReportDataProvider + dataProvider: ReportDataProvider valuePrecision: number hide: boolean showTooltip: boolean diff --git a/apps/studio/pages/project/[ref]/reports/edge-functions.tsx b/apps/studio/pages/project/[ref]/reports/edge-functions.tsx index 16b4705e04348..3b1b577741a7d 100644 --- a/apps/studio/pages/project/[ref]/reports/edge-functions.tsx +++ b/apps/studio/pages/project/[ref]/reports/edge-functions.tsx @@ -1,19 +1,25 @@ import { useQueryClient } from '@tanstack/react-query' import { useParams } from 'common' import dayjs from 'dayjs' -import { ArrowRight, ChevronDown, RefreshCw } from 'lucide-react' -import { useEffect, useMemo, useState } from 'react' +import { ArrowRight, RefreshCw } from 'lucide-react' +import { useMemo, useState } from 'react' -import { Label } from '@ui/components/shadcn/ui/label' -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' +import { ReportChartV2 } from 'components/interfaces/Reports/v2/ReportChartV2' +import { + ReportsNumericFilter, + type NumericFilter, +} from 'components/interfaces/Reports/v2/ReportsNumericFilter' +import { + ReportsSelectFilter, + type SelectFilters, +} from 'components/interfaces/Reports/v2/ReportsSelectFilter' import { LogsDatePicker } from 'components/interfaces/Settings/Logs/Logs.DatePickers' import DefaultLayout from 'components/layouts/DefaultLayout' import ReportsLayout from 'components/layouts/ReportsLayout/ReportsLayout' import { ButtonTooltip } from 'components/ui/ButtonTooltip' -import { Button, Checkbox, DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from 'ui' import { useChartHoverState } from 'components/ui/Charts/useChartHoverState' import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query' @@ -23,8 +29,10 @@ import { REPORT_DATERANGE_HELPER_LABELS } from 'components/interfaces/Reports/Re import UpgradePrompt from 'components/interfaces/Settings/Logs/UpgradePrompt' import { useReportDateRange } from 'hooks/misc/useReportDateRange' -import type { NextPageWithLayout } from 'types' +import { EDGE_FUNCTION_REGIONS } from 'components/interfaces/Reports/Reports.constants' import { ReportSettings } from 'components/ui/Charts/ReportSettings' +import { BASE_PATH } from 'lib/constants' +import type { NextPageWithLayout } from 'types' const EdgeFunctionsReportV2: NextPageWithLayout = () => { return ( @@ -44,22 +52,18 @@ export default EdgeFunctionsReportV2 const EdgeFunctionsUsage = () => { const { ref } = useParams() - const { data: functions, isLoading: isLoadingFunctions } = useEdgeFunctionsQuery({ + const { data: functions } = useEdgeFunctionsQuery({ projectRef: ref, }) const chartSyncId = `edge-functions-${ref}` useChartHoverState(chartSyncId) - const [isOpen, setIsOpen] = useState(false) - const [functionIds, setFunctionIds] = useState([]) - const [tempFunctionIds, setTempFunctionIds] = useState(functionIds) - - useEffect(() => { - if (isOpen) { - setTempFunctionIds(functionIds) - } - }, [isOpen, functionIds]) + // Filters + const [statusCodeFilter, setStatusCodeFilter] = useState() + const [regionFilter, setRegionFilter] = useState({}) + const [executionTimeFilter, setExecutionTimeFilter] = useState() + const [functionFilter, setFunctionFilter] = useState({}) const { selectedDateRange, @@ -82,16 +86,27 @@ const EdgeFunctionsUsage = () => { endDate: selectedDateRange?.period_end?.date ?? '', interval: selectedDateRange?.interval ?? 'minute', filters: { - functionIds, + functions: functionFilter, + status_code: statusCodeFilter, + region: regionFilter, + execution_time: executionTimeFilter, }, }) - }, [ref, functions, selectedDateRange, functionIds]) + }, [ + ref, + functions, + selectedDateRange, + functionFilter, + statusCodeFilter, + regionFilter, + executionTimeFilter, + ]) const onRefreshReport = async () => { if (!selectedDateRange) return setIsRefreshing(true) - queryClient.invalidateQueries(['report-v2']) + queryClient.invalidateQueries({ queryKey: ['projects', ref, 'report-v2'] }) setTimeout(() => setIsRefreshing(false), 1000) } @@ -110,12 +125,14 @@ const EdgeFunctionsUsage = () => { tooltip={{ content: { side: 'bottom', text: 'Refresh report' } }} onClick={onRefreshReport} /> + { description="Report data can be stored for a maximum of 3 months depending on the plan that your project is on." source="edgeFunctionsReportDateRange" /> + {selectedDateRange && (

@@ -138,87 +156,60 @@ const EdgeFunctionsUsage = () => {

)}
-
- - - - - -
-
} @@ -236,8 +227,13 @@ const EdgeFunctionsUsage = () => { startDate={selectedDateRange?.period_start?.date} endDate={selectedDateRange?.period_end?.date} updateDateRange={updateDateRange} - functionIds={functionIds} syncId={chartSyncId} + filters={{ + functions: functionFilter, + status_code: statusCodeFilter, + region: regionFilter, + execution_time: executionTimeFilter, + }} /> ))}
diff --git a/apps/studio/public/img/regions/us-west-2.svg b/apps/studio/public/img/regions/us-west-2.svg new file mode 100644 index 0000000000000..3189d8e2dc3a7 --- /dev/null +++ b/apps/studio/public/img/regions/us-west-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + + From b1e617c5c18d71d3f201d7aa2da2115107a6fdf5 Mon Sep 17 00:00:00 2001 From: Sam Meech-Ward Date: Thu, 4 Sep 2025 02:33:10 -0600 Subject: [PATCH 4/7] Add Sam Meech-Ward to humans.txt (#38417) --- apps/docs/public/humans.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index 6d07b1a9a3155..922a7d2565206 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -111,6 +111,7 @@ Riccardo Busetti Rodrigo Mansueli Ronan Lehane Rory Wilding +Sam Meech-Ward Sam Rose Sean Oliver Sergio Cioban Filho From a90bc51be1f06472b5f562a65029be29f241c5b7 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Thu, 4 Sep 2025 15:35:36 +0700 Subject: [PATCH 5/7] Fix table editor incorrectly showing type change notice for foreign key (#38404) * Fix table editor incorrectly showing type change notice for foreign key * nit * remove log --- .../ForeignKeySelector/ForeignKeySelector.tsx | 21 +++++++------------ 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.tsx index 9659674e3fc19..91496959b01e4 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.tsx @@ -164,13 +164,7 @@ export const ForeignKeySelector = ({ // [Joshen] Doing this way so that its more readable // If either source or target not selected yet, thats okay - if (source === '' || target === '') { - return typeErrors.push(undefined) - } - - if (sourceColumn?.isNewColumn && targetType !== '') { - return typeNotice.push({ sourceType, targetType }) - } + if (source === '' || target === '') return // If source and target are in the same type of data types, thats okay if ( @@ -178,13 +172,14 @@ export const ForeignKeySelector = ({ (TEXT_TYPES.includes(sourceType) && TEXT_TYPES.includes(targetType)) || (TEXT_TYPES.includes(sourceType) && TEXT_TYPES.includes(targetType)) || (sourceType === 'uuid' && targetType === 'uuid') - ) { - return typeErrors.push(undefined) - } + ) + return // Otherwise just check if the format is equal to each other - if (sourceType === targetType) { - return typeErrors.push(undefined) + if (sourceType === targetType) return + + if (sourceColumn?.isNewColumn && targetType !== '') { + return typeNotice.push({ sourceType, targetType }) } typeErrors.push({ sourceType, targetType }) @@ -419,7 +414,7 @@ export const ForeignKeySelector = ({
  • {fk.columns[idx]?.source}{' '} - {x.targetType} + {x.targetType}
  • ) From cf90f2e250d351d8079239aea3c606701e7b42e0 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Thu, 4 Sep 2025 15:43:26 +0700 Subject: [PATCH 6/7] Self remediate project scoped roles issue (#38428) --- .../UpdateRolesConfirmationModal.tsx | 15 +++++++++++++++ .../UpdateRolesPanel/UpdateRolesPanel.utils.ts | 5 ++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesConfirmationModal.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesConfirmationModal.tsx index 1b7a0a482dea2..1ba27891feb9a 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesConfirmationModal.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesConfirmationModal.tsx @@ -76,7 +76,22 @@ export const UpdateRolesConfirmationModal = ({ .map((id) => { return [...org_scoped_roles, ...project_scoped_roles].find((r) => r.id === id) }) + .map((x) => { + // [Joshen] This is merely a patch to handle a issue on the BE whereby for a project-scoped member, + // if one of the projects that the member is deleted, the roles isn't cleaned up on the BE + // Hence adding an FE patch here for dashboard to self-remediate by omitting any project IDs from the role + // which no longer exists in the organization projects list + if (!!x?.project_ids) { + return { + ...x, + project_ids: x.project_ids.filter((id) => orgProjects.some((p) => id === p.id)), + } + } else { + return x + } + }) .filter(Boolean) as OrganizationRole[] + const isChangeWithinOrgScope = projectsRoleConfiguration.length === 1 && projectsRoleConfiguration[0].ref === undefined diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesPanel.utils.ts b/apps/studio/components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesPanel.utils.ts index 89104c2c2d529..7b2cb3e2d2703 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesPanel.utils.ts +++ b/apps/studio/components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesPanel.utils.ts @@ -41,7 +41,10 @@ export const formatMemberRoleToProjectRoleConfiguration = ( } }) .filter(Boolean) - .flat() as ProjectRoleConfiguration[] + .flat() + .filter( + (p) => p !== undefined && 'baseRoleId' in p && p.ref !== undefined + ) as ProjectRoleConfiguration[] return roleConfiguration } From 10a1deca66e2fc1b8036cc424305bf264562786b Mon Sep 17 00:00:00 2001 From: Han Qiao Date: Thu, 4 Sep 2025 16:59:47 +0800 Subject: [PATCH 7/7] fix: estimate time needed to create data branch (#38426) * fix: estimate time needed to create data branch * nit --------- Co-authored-by: Joshen Lim --- .../BranchManagement/CreateBranchModal.tsx | 55 +++++++++++++++++-- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx b/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx index 7bfc6942c37b6..b69109218a9d1 100644 --- a/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx +++ b/apps/studio/components/interfaces/BranchManagement/CreateBranchModal.tsx @@ -1,6 +1,6 @@ import { zodResolver } from '@hookform/resolvers/zod' import { useQueryClient } from '@tanstack/react-query' -import { DollarSign, GitMerge, Github, Loader2 } from 'lucide-react' +import { DatabaseZap, DollarSign, GitMerge, Github, Loader2 } from 'lucide-react' import Image from 'next/image' import Link from 'next/link' import { useRouter } from 'next/router' @@ -21,6 +21,7 @@ import { useBranchCreateMutation } from 'data/branches/branch-create-mutation' import { useBranchesQuery } from 'data/branches/branches-query' import { useCheckGithubBranchValidity } from 'data/integrations/github-branch-check-query' import { useGitHubConnectionsQuery } from 'data/integrations/github-connections-query' +import { useCloneBackupsQuery } from 'data/projects/clone-query' import { projectKeys } from 'data/projects/keys' import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' @@ -45,6 +46,9 @@ import { Input_Shadcn_, Label_Shadcn_ as Label, Switch, + Tooltip, + TooltipContent, + TooltipTrigger, cn, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' @@ -85,6 +89,11 @@ export const CreateBranchModal = () => { useCheckGithubBranchValidity({ onError: () => {}, }) + const { data: cloneBackups, error: cloneBackupsError } = useCloneBackupsQuery({ projectRef }) + const targetVolumeSizeGb = cloneBackups?.target_volume_size_gb ?? 0 + const noPhysicalBackups = cloneBackupsError?.message.startsWith( + 'Physical backups need to be enabled' + ) const { mutate: sendEvent } = useSendEventMutation() @@ -172,6 +181,7 @@ export const CreateBranchModal = () => { resolver: zodResolver(FormSchema), defaultValues: { branchName: '', gitBranchName: '', withData: false }, }) + const withData = form.watch('withData') const canSubmit = !isCreating && !isChecking const isDisabled = @@ -344,9 +354,22 @@ export const CreateBranchModal = () => { layout="flex-row-reverse" description="Clone production data into this branch" > - - - + + + + + + + {noPhysicalBackups && ( + + PITR is required for the project to clone data into the branch + + )} + )} /> @@ -362,6 +385,30 @@ export const CreateBranchModal = () => { promptProPlanUpgrade && 'opacity-25 pointer-events-none' )} > + {withData && ( +
    +
    +
    + +
    +
    +
    +

    + Data branch takes longer time to create +

    +

    + Since your target database volume size is{' '} + {targetVolumeSizeGb} GB, creating a + data branch is estimated to take around{' '} + + {Math.round((720 / 21000) * targetVolumeSizeGb) + 3} minutes + + . +

    +
    +
    + )} + {githubConnection && (