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 <joshenlimek@gmail.com>
This commit is contained in:
Jordi EnricandJoshen Lim authored and GitHub committed 2025-09-04 14:03:40 +07:00
1 parent 793784f57a
commit 3dd065efce
11 files changed
+846 -187

No files matched your search

@@ -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',
},
]
@@ -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 (
<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">
<CardContent
className={cn(
'flex flex-col gap-4 min-h-[280px] items-center justify-center',
isFetching && 'opacity-50'
)}
>
{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
No data available for the selected time range and filters
</p>
) : isErrorState ? (
<p className="text-sm text-foreground-light text-center h-full flex items-center justify-center">
@@ -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<ComparisonOperator, string>
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<NumericFilter | undefined>(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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type={isActive ? 'default' : 'outline'}
className={cn(
'min-w-20 border-dashed relative group justify-between',
{ 'border-solid': isActive },
className
)}
iconRight={<ChevronDown size={14} />}
>
<span>
{label}
{getDisplayValue() && (
<span className="ml-1 text-xs font-mono text-foreground-light">
{getDisplayValue()}
</span>
)}
</span>
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="p-0 w-72" portal={true}>
<div className="p-2 border-b border-default flex items-center justify-between">
<span className="text-xs text-foreground">{label}</span>
<Button size="tiny" type="outline" onClick={handleClearAll}>
Clear
</Button>
</div>
<form
onSubmit={(e) => {
e.preventDefault()
handleApply()
}}
className="px-3 py-3 flex flex-col gap-y-3"
>
<div className="flex flex-col gap-y-1">
<Label className="text-xs">Operator</Label>
<Select
value={tempValue?.operator || defaultOperator}
onValueChange={handleOperatorChange}
>
<SelectTrigger className="text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{operators.map((op) => (
<SelectItem key={op} value={op} className="text-xs flex items-center">
<span className="font-mono">{op}</span>
<span className="text-foreground-light ml-2">{OPERATOR_LABELS[op]}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-y-1">
<Label className="text-xs">Value</Label>
<Input
autoFocus
type="number"
placeholder={placeholder}
value={tempValue?.value || ''}
onChange={(e) => handleValueChange(e.target.value)}
onKeyDown={handleKeyDown}
min={min}
max={max}
step={step}
/>
{(min !== undefined || max !== undefined) && (
<p className="text-xs text-foreground-light">
{min !== undefined && max !== undefined
? `Range: ${min} - ${max}`
: min !== undefined
? `Min: ${min}`
: `Max: ${max}`}
</p>
)}
</div>
</form>
<div className="flex items-center justify-end gap-2 border-t border-default p-2">
<Button size="tiny" type="default" onClick={() => setOpen(false)} htmlType="button">
Cancel
</Button>
<Button
loading={isLoading}
size="tiny"
type="primary"
onClick={handleApply}
htmlType="button"
>
Apply
</Button>
</div>
</PopoverContent>
</Popover>
)
}
@@ -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<SelectFilters>(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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type={isActive ? 'default' : 'outline'}
className={cn(
'min-w-20 border-dashed relative group justify-between',
{ 'border-solid': isActive },
className
)}
iconRight={<ChevronDown size={14} />}
>
<span>
{label}
{selectedCount > 0 && <span className="ml-1 text-xs">({selectedCount})</span>}
</span>
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="p-0 w-72" portal={true}>
<div className="p-2 border-b border-default">
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-foreground">{label}</span>
<Button size="tiny" type="outline" onClick={handleClearAll} disabled={isLoading}>
Clear
</Button>
</div>
</div>
<div className="max-h-60 overflow-y-auto p-1" onKeyDown={handleKeyDown}>
{options.length === 0 ? (
<div className="p-4 text-center text-sm text-foreground-light">
{isLoading ? 'Loading options...' : 'No options available'}
</div>
) : (
options.map((option) => (
<Label
key={option.key}
htmlFor={`${label}-${option.key}`}
className={cn(
'flex items-center hover:bg-overlay-hover overflow-hidden px-1.5 py-1.5 rounded-sm gap-x-3',
'transition-all duration-150 ease-in-out cursor-pointer'
)}
>
<Checkbox
id={`${label}-${option.key}`}
checked={Boolean(tempValue[option.key])}
onCheckedChange={(checked) => {
setTempValue({
...tempValue,
[option.key]: Boolean(checked),
})
}}
onKeyDown={handleKeyDown}
/>
<div className="flex flex-col text-xs">
{option.label}
{option.description && (
<span className="text-foreground-lighter">{option.description}</span>
)}
</div>
</Label>
))
)}
</div>
<div className="flex items-center justify-end gap-2 border-t border-default p-2">
<Button size="tiny" type="default" onClick={() => setOpen(false)} htmlType="button">
Cancel
</Button>
<Button
loading={isLoading}
size="tiny"
type="primary"
onClick={handleApply}
htmlType="button"
>
Apply
</Button>
</div>
</PopoverContent>
</Popover>
)
}
@@ -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<Props>) => {
align = 'end',
}: PropsWithChildren<LogsDatePickerProps>) => {
const [open, setOpen] = useState(false)
const todayButtonRef = useRef<HTMLButtonElement>(null)
@@ -253,7 +255,7 @@ export const LogsDatePicker = ({
<PopoverContent_Shadcn_
className="flex w-full p-0"
side="bottom"
align="end"
align={align}
portal={true}
{...popoverContentProps}
>
@@ -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' } }}
/>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" className="w-64 p-3">
<DropdownMenuContent align="start" side="bottom" className="w-64 p-3">
<div className="space-y-4">
<Label_Shadcn_ htmlFor="sync-hover" className="text-sm font-normal">
<div className="flex items-center justify-between space-x-2">
@@ -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, (interval: AnalyticsInterval, functionIds?: string[]) => 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<EdgeFunctionReportFilters>[] => [
{
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,
@@ -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')"
)
})
})
+4 -10
View File
@@ -1,19 +1,13 @@
import { AnalyticsInterval } from 'data/analytics/constants'
import { YAxisProps } from 'recharts'
type ReportDataProviderFilter = {
functionIds?: string[]
}
export interface ReportDataProvider {
export interface ReportDataProvider<FiltersType> {
(
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<FiltersType = any> {
id: string
label: string
dataProvider: ReportDataProvider
dataProvider: ReportDataProvider<FiltersType>
valuePrecision: number
hide: boolean
showTooltip: boolean
@@ -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<string[]>([])
const [tempFunctionIds, setTempFunctionIds] = useState<string[]>(functionIds)
useEffect(() => {
if (isOpen) {
setTempFunctionIds(functionIds)
}
}, [isOpen, functionIds])
// Filters
const [statusCodeFilter, setStatusCodeFilter] = useState<NumericFilter | undefined>()
const [regionFilter, setRegionFilter] = useState<SelectFilters>({})
const [executionTimeFilter, setExecutionTimeFilter] = useState<NumericFilter | undefined>()
const [functionFilter, setFunctionFilter] = useState<SelectFilters>({})
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}
/>
<ReportSettings chartId="edge-functions-charts" />
<LogsDatePicker
onSubmit={handleDatePickerChange}
align="start"
value={datePickerValue}
helpers={datePickerHelpers}
onSubmit={handleDatePickerChange}
/>
<UpgradePrompt
show={showUpgradePrompt}
@@ -124,6 +141,7 @@ const EdgeFunctionsUsage = () => {
description="Report data can be stored for a maximum of 3 months depending on the plan that your project is on."
source="edgeFunctionsReportDateRange"
/>
{selectedDateRange && (
<div className="flex items-center gap-x-2 text-xs">
<p className="text-foreground-light">
@@ -138,87 +156,60 @@ const EdgeFunctionsUsage = () => {
</div>
)}
</div>
<div>
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger asChild>
<Button type="default" iconRight={<ChevronDown />}>
{functionIds.length === 0
? 'All Functions'
: `${functionIds.length} function${
functionIds.length > 1 ? 's' : ''
} selected`}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="start" className="w-72 p-0">
<div className="space-y-px max-h-60 overflow-y-auto p-1">
<Label
key="all-functions"
htmlFor="all-functions"
className="flex items-center hover:bg-overlay-hover overflow-hidden p-2 rounded-sm"
>
<Checkbox
id="all-functions"
checked={tempFunctionIds.length === 0}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.checked) {
setTempFunctionIds([])
}
}}
<div className="w-full flex items-center gap-2 flex-wrap">
<ReportsSelectFilter
label="Function"
options={
functions?.map((fn: { name: string; id: string }) => ({
label: fn.name,
key: fn.id,
})) ?? []
}
value={functionFilter}
onChange={setFunctionFilter}
isLoading={isRefreshing}
/>
<ReportsNumericFilter
label="Status Code"
value={statusCodeFilter}
onChange={setStatusCodeFilter}
defaultOperator="="
isLoading={isRefreshing}
/>
<ReportsNumericFilter
label="Execution Time"
value={executionTimeFilter}
onChange={setExecutionTimeFilter}
placeholder="Enter time in ms"
min={0}
max={99999}
defaultOperator=">="
isLoading={isRefreshing}
/>
<ReportsSelectFilter
label="Region"
options={EDGE_FUNCTION_REGIONS.map((region) => ({
key: region.key,
label: (
<div className="flex items-center gap-x-2">
<img
src={`${BASE_PATH}/img/regions/${region.key}.svg`}
alt={region.key}
className="w-4 h-4"
/>
<div className="flex flex-col">
<span>All Functions</span>
<div className="flex flex-wrap gap-x-2 items-center">
<span className="text-foreground text-xs">{region.label}</span>
<span className="text-foreground-lighter text-xs">{region.key}</span>
</div>
</Label>
{functions
?.filter((fn: { slug: string }) => typeof fn.slug === 'string')
.map((fn) => (
<Label
key={fn.id}
htmlFor={fn.id}
className="flex items-center hover:bg-overlay-hover overflow-hidden p-2 rounded-sm"
>
<div>
<Checkbox
id={fn.id}
checked={tempFunctionIds.includes(fn.id)}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.checked) {
setTempFunctionIds([...tempFunctionIds, fn.id])
} else {
setTempFunctionIds(tempFunctionIds.filter((id) => id !== fn.id))
}
}}
/>
</div>
<div className="flex flex-col">
<span>{fn.slug}</span>
</div>
</Label>
))}
</div>
<div className="flex items-center justify-end gap-2 border-t border-default p-2">
<Button
size="tiny"
type="default"
onClick={() => setIsOpen(false)}
htmlType="button"
>
Cancel
</Button>
<Button
size="tiny"
type="primary"
htmlType="button"
onClick={() => {
setFunctionIds(tempFunctionIds)
setIsOpen(false)
}}
>
Apply
</Button>
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
),
}))}
value={regionFilter}
onChange={setRegionFilter}
/>
</div>
</div>
}
@@ -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,
}}
/>
))}
</div>
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-us" viewBox="0 0 640 480">
<g fill-rule="evenodd">
<g stroke-width="1pt">
<path fill="#bd3d44" d="M0 0h972.8v39.4H0zm0 78.8h972.8v39.4H0zm0 78.7h972.8V197H0zm0 78.8h972.8v39.4H0zm0 78.8h972.8v39.4H0zm0 78.7h972.8v39.4H0zm0 78.8h972.8V512H0z" transform="scale(.9375)"/>
<path fill="#fff" d="M0 39.4h972.8v39.4H0zm0 78.8h972.8v39.3H0zm0 78.7h972.8v39.4H0zm0 78.8h972.8v39.4H0zm0 78.8h972.8v39.4H0zm0 78.7h972.8v39.4H0z" transform="scale(.9375)"/>
</g>
<path fill="#192f5d" d="M0 0h389.1v275.7H0z" transform="scale(.9375)"/>
<path fill="#fff" d="M32.4 11.8 36 22.7h11.4l-9.2 6.7 3.5 11-9.3-6.8-9.2 6.7 3.5-10.9-9.3-6.7H29zm64.9 0 3.5 10.9h11.5l-9.3 6.7 3.5 11-9.2-6.8-9.3 6.7 3.5-10.9-9.2-6.7h11.4zm64.8 0 3.6 10.9H177l-9.2 6.7 3.5 11-9.3-6.8-9.2 6.7 3.5-10.9-9.3-6.7h11.5zm64.9 0 3.5 10.9H242l-9.3 6.7 3.6 11-9.3-6.8-9.3 6.7 3.6-10.9-9.3-6.7h11.4zm64.8 0 3.6 10.9h11.4l-9.2 6.7 3.5 11-9.3-6.8-9.2 6.7 3.5-10.9-9.2-6.7h11.4zm64.9 0 3.5 10.9h11.5l-9.3 6.7 3.6 11-9.3-6.8-9.3 6.7 3.6-10.9-9.3-6.7h11.5zM64.9 39.4l3.5 10.9h11.5L70.6 57 74 67.9l-9-6.7-9.3 6.7L59 57l-9-6.7h11.4zm64.8 0 3.6 10.9h11.4l-9.3 6.7 3.6 10.9-9.3-6.7-9.3 6.7L124 57l-9.3-6.7h11.5zm64.9 0 3.5 10.9h11.5l-9.3 6.7 3.5 10.9-9.2-6.7-9.3 6.7 3.5-10.9-9.2-6.7H191zm64.8 0 3.6 10.9h11.4l-9.3 6.7 3.6 10.9-9.3-6.7-9.2 6.7 3.5-10.9-9.3-6.7H256zm64.9 0 3.5 10.9h11.5L330 57l3.5 10.9-9.2-6.7-9.3 6.7 3.5-10.9-9.2-6.7h11.4zM32.4 66.9 36 78h11.4l-9.2 6.7 3.5 10.9-9.3-6.8-9.2 6.8 3.5-11-9.3-6.7H29zm64.9 0 3.5 11h11.5l-9.3 6.7 3.5 10.9-9.2-6.8-9.3 6.8 3.5-11-9.2-6.7h11.4zm64.8 0 3.6 11H177l-9.2 6.7 3.5 10.9-9.3-6.8-9.2 6.8 3.5-11-9.3-6.7h11.5zm64.9 0 3.5 11H242l-9.3 6.7 3.6 10.9-9.3-6.8-9.3 6.8 3.6-11-9.3-6.7h11.4zm64.8 0 3.6 11h11.4l-9.2 6.7 3.5 10.9-9.3-6.8-9.2 6.8 3.5-11-9.2-6.7h11.4zm64.9 0 3.5 11h11.5l-9.3 6.7 3.6 10.9-9.3-6.8-9.3 6.8 3.6-11-9.3-6.7h11.5zM64.9 94.5l3.5 10.9h11.5l-9.3 6.7 3.5 11-9.2-6.8-9.3 6.7 3.5-10.9-9.2-6.7h11.4zm64.8 0 3.6 10.9h11.4l-9.3 6.7 3.6 11-9.3-6.8-9.3 6.7 3.6-10.9-9.3-6.7h11.5zm64.9 0 3.5 10.9h11.5l-9.3 6.7 3.5 11-9.2-6.8-9.3 6.7 3.5-10.9-9.2-6.7H191zm64.8 0 3.6 10.9h11.4l-9.2 6.7 3.5 11-9.3-6.8-9.2 6.7 3.5-10.9-9.3-6.7H256zm64.9 0 3.5 10.9h11.5l-9.3 6.7 3.5 11-9.2-6.8-9.3 6.7 3.5-10.9-9.2-6.7h11.4zM32.4 122.1 36 133h11.4l-9.2 6.7 3.5 11-9.3-6.8-9.2 6.7 3.5-10.9-9.3-6.7H29zm64.9 0 3.5 10.9h11.5l-9.3 6.7 3.5 10.9-9.2-6.7-9.3 6.7 3.5-10.9-9.2-6.7h11.4zm64.8 0 3.6 10.9H177l-9.2 6.7 3.5 11-9.3-6.8-9.2 6.7 3.5-10.9-9.3-6.7h11.5zm64.9 0 3.5 10.9H242l-9.3 6.7 3.6 11-9.3-6.8-9.3 6.7 3.6-10.9-9.3-6.7h11.4zm64.8 0 3.6 10.9h11.4l-9.2 6.7 3.5 11-9.3-6.8-9.2 6.7 3.5-10.9-9.2-6.7h11.4zm64.9 0 3.5 10.9h11.5l-9.3 6.7 3.6 11-9.3-6.8-9.3 6.7 3.6-10.9-9.3-6.7h11.5zM64.9 149.7l3.5 10.9h11.5l-9.3 6.7 3.5 10.9-9.2-6.8-9.3 6.8 3.5-11-9.2-6.7h11.4zm64.8 0 3.6 10.9h11.4l-9.3 6.7 3.6 10.9-9.3-6.8-9.3 6.8 3.6-11-9.3-6.7h11.5zm64.9 0 3.5 10.9h11.5l-9.3 6.7 3.5 10.9-9.2-6.8-9.3 6.8 3.5-11-9.2-6.7H191zm64.8 0 3.6 10.9h11.4l-9.2 6.7 3.5 10.9-9.3-6.8-9.2 6.8 3.5-11-9.3-6.7H256zm64.9 0 3.5 10.9h11.5l-9.3 6.7 3.5 10.9-9.2-6.8-9.3 6.8 3.5-11-9.2-6.7h11.4zM32.4 177.2l3.6 11h11.4l-9.2 6.7 3.5 10.8-9.3-6.7-9.2 6.7 3.5-10.9-9.3-6.7H29zm64.9 0 3.5 11h11.5l-9.3 6.7 3.6 10.8-9.3-6.7-9.3 6.7 3.6-10.9-9.3-6.7h11.4zm64.8 0 3.6 11H177l-9.2 6.7 3.5 10.8-9.3-6.7-9.2 6.7 3.5-10.9-9.3-6.7h11.5zm64.9 0 3.5 11H242l-9.3 6.7 3.6 10.8-9.3-6.7-9.3 6.7 3.6-10.9-9.3-6.7h11.4zm64.8 0 3.6 11h11.4l-9.2 6.7 3.5 10.8-9.3-6.7-9.2 6.7 3.5-10.9-9.2-6.7h11.4zm64.9 0 3.5 11h11.5l-9.3 6.7 3.6 10.8-9.3-6.7-9.3 6.7 3.6-10.9-9.3-6.7h11.5zM64.9 204.8l3.5 10.9h11.5l-9.3 6.7 3.5 11-9.2-6.8-9.3 6.7 3.5-10.9-9.2-6.7h11.4zm64.8 0 3.6 10.9h11.4l-9.3 6.7 3.6 11-9.3-6.8-9.3 6.7 3.6-10.9-9.3-6.7h11.5zm64.9 0 3.5 10.9h11.5l-9.3 6.7 3.5 11-9.2-6.8-9.3 6.7 3.5-10.9-9.2-6.7H191zm64.8 0 3.6 10.9h11.4l-9.2 6.7 3.5 11-9.3-6.8-9.2 6.7 3.5-10.9-9.3-6.7H256zm64.9 0 3.5 10.9h11.5l-9.3 6.7 3.5 11-9.2-6.8-9.3 6.7 3.5-10.9-9.2-6.7h11.4zM32.4 232.4l3.6 10.9h11.4l-9.2 6.7 3.5 10.9-9.3-6.7-9.2 6.7 3.5-11-9.3-6.7H29zm64.9 0 3.5 10.9h11.5L103 250l3.6 10.9-9.3-6.7-9.3 6.7 3.6-11-9.3-6.7h11.4zm64.8 0 3.6 10.9H177l-9 6.7 3.5 10.9-9.3-6.7-9.2 6.7 3.5-11-9.3-6.7h11.5zm64.9 0 3.5 10.9H242l-9.3 6.7 3.6 10.9-9.3-6.7-9.3 6.7 3.6-11-9.3-6.7h11.4zm64.8 0 3.6 10.9h11.4l-9.2 6.7 3.5 10.9-9.3-6.7-9.2 6.7 3.5-11-9.2-6.7h11.4zm64.9 0 3.5 10.9h11.5l-9.3 6.7 3.6 10.9-9.3-6.7-9.3 6.7 3.6-11-9.3-6.7h11.5z" transform="scale(.9375)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB