diff --git a/apps/studio/components/interfaces/Reports/Reports.constants.ts b/apps/studio/components/interfaces/Reports/Reports.constants.ts index 69edacca9cb..844b50dcd6c 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 36538a71681..52c2033dcba 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 00000000000..55173bfe521 --- /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 00000000000..87d4b34d8bf --- /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 b81e86ba18d..4200143336d 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 df6511047b3..5fc052c620b 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 2e65c925232..d5e824b6c09 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 e1217d7b7a0..6b790dd4a78 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 5edd0b23791..cfa86deab93 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 16b4705e043..3b1b577741a 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 00000000000..3189d8e2dc3 --- /dev/null +++ b/apps/studio/public/img/regions/us-west-2.svg @@ -0,0 +1,10 @@ + + + + + + + + + +