mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
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:
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')"
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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 |
Reference in new issue
Block a user