mirror of
https://github.com/supabase/supabase.git
synced 2026-10-16 14:49:10 +03:00
poc
This commit is contained in:
1 parent
9917ff517f
commit
fcbe1eae5f
7 files changed
+476
-199
No files matched your search
@@ -0,0 +1,146 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Button } from 'ui'
|
||||
import { FilterBar, type FilterGroup, type FilterProperty } from 'ui-patterns'
|
||||
|
||||
interface FilterBarWithApplyProps {
|
||||
filterProperties: FilterProperty[]
|
||||
filters: FilterGroup
|
||||
onFilterChange: (filters: FilterGroup) => void
|
||||
freeformText: string
|
||||
onFreeformTextChange: (freeformText: string) => void
|
||||
onApply?: () => void
|
||||
aiApiUrl?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function FilterBarWithApply({
|
||||
filterProperties,
|
||||
filters,
|
||||
onFilterChange,
|
||||
freeformText,
|
||||
onFreeformTextChange,
|
||||
onApply,
|
||||
aiApiUrl,
|
||||
className,
|
||||
}: FilterBarWithApplyProps) {
|
||||
const [pendingFilters, setPendingFilters] = useState<FilterGroup>(filters)
|
||||
const [pendingFreeformText, setPendingFreeformText] = useState(freeformText)
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
const filterBarRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const hasActiveFilters = filters.conditions.length > 0 || freeformText.trim() !== ''
|
||||
|
||||
useEffect(() => {
|
||||
const filtersChanged = JSON.stringify(pendingFilters) !== JSON.stringify(filters)
|
||||
const textChanged = pendingFreeformText !== freeformText
|
||||
setHasChanges(filtersChanged || textChanged)
|
||||
}, [pendingFilters, pendingFreeformText, filters, freeformText])
|
||||
|
||||
// Sync pending state when external filters change
|
||||
useEffect(() => {
|
||||
setPendingFilters(filters)
|
||||
}, [filters])
|
||||
|
||||
useEffect(() => {
|
||||
setPendingFreeformText(freeformText)
|
||||
}, [freeformText])
|
||||
|
||||
const handleApply = useCallback(async () => {
|
||||
onFilterChange(pendingFilters)
|
||||
onFreeformTextChange(pendingFreeformText)
|
||||
setHasChanges(false)
|
||||
|
||||
// Use setTimeout to ensure state updates are flushed before calling onApply
|
||||
setTimeout(() => {
|
||||
onApply?.()
|
||||
}, 0)
|
||||
}, [pendingFilters, pendingFreeformText, onFilterChange, onFreeformTextChange, onApply])
|
||||
|
||||
const handleClear = useCallback(async () => {
|
||||
const emptyFilters: FilterGroup = { logicalOperator: 'AND', conditions: [] }
|
||||
setPendingFilters(emptyFilters)
|
||||
setPendingFreeformText('')
|
||||
onFilterChange(emptyFilters)
|
||||
onFreeformTextChange('')
|
||||
setHasChanges(false)
|
||||
|
||||
// Use setTimeout to ensure state updates are flushed before calling onApply
|
||||
setTimeout(() => {
|
||||
onApply?.()
|
||||
}, 0)
|
||||
}, [onFilterChange, onFreeformTextChange, onApply])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (hasChanges) {
|
||||
handleApply()
|
||||
}
|
||||
}
|
||||
},
|
||||
[hasChanges, handleApply]
|
||||
)
|
||||
|
||||
// Add global keyboard listener when FilterBar is focused
|
||||
useEffect(() => {
|
||||
const filterBarElement = filterBarRef.current
|
||||
if (!filterBarElement) return
|
||||
|
||||
const handleFocus = () => {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
|
||||
const handleBlur = (e: FocusEvent) => {
|
||||
// Only remove listener if focus is moving outside the FilterBar
|
||||
if (!filterBarElement.contains(e.relatedTarget as Node)) {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}
|
||||
|
||||
filterBarElement.addEventListener('focusin', handleFocus)
|
||||
filterBarElement.addEventListener('focusout', handleBlur)
|
||||
|
||||
return () => {
|
||||
filterBarElement.removeEventListener('focusin', handleFocus)
|
||||
filterBarElement.removeEventListener('focusout', handleBlur)
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [handleKeyDown])
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<div ref={filterBarRef} className="flex-1">
|
||||
<FilterBar
|
||||
filterProperties={filterProperties}
|
||||
filters={pendingFilters}
|
||||
onFilterChange={setPendingFilters}
|
||||
freeformText={pendingFreeformText}
|
||||
onFreeformTextChange={setPendingFreeformText}
|
||||
aiApiUrl={aiApiUrl}
|
||||
className={className}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Button
|
||||
type="default"
|
||||
size="tiny"
|
||||
onClick={handleClear}
|
||||
disabled={!hasActiveFilters}
|
||||
title="Clear all filters"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
size="tiny"
|
||||
onClick={handleApply}
|
||||
disabled={!hasChanges}
|
||||
title="Apply filters (⌘+Enter)"
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
import { Card, CardContent, cn } from 'ui'
|
||||
import { ReportChartUpsell } from './ReportChartUpsell'
|
||||
import { FilterGroup } from 'ui-patterns'
|
||||
|
||||
export interface ReportChartV2Props {
|
||||
report: ReportConfig
|
||||
@@ -18,8 +19,9 @@ export interface ReportChartV2Props {
|
||||
endDate: string
|
||||
interval: AnalyticsInterval
|
||||
updateDateRange: (from: string, to: string) => void
|
||||
functionIds?: string[]
|
||||
filters?: FilterGroup
|
||||
edgeFnIdToName?: (id: string) => string | undefined
|
||||
freeformText?: string
|
||||
className?: string
|
||||
syncId?: string
|
||||
}
|
||||
@@ -31,8 +33,9 @@ export const ReportChartV2 = ({
|
||||
endDate,
|
||||
interval,
|
||||
updateDateRange,
|
||||
functionIds,
|
||||
filters,
|
||||
edgeFnIdToName,
|
||||
freeformText,
|
||||
className,
|
||||
syncId,
|
||||
}: ReportChartV2Props) => {
|
||||
@@ -50,19 +53,26 @@ export const ReportChartV2 = ({
|
||||
isLoading: isLoadingChart,
|
||||
error,
|
||||
} = useQuery(
|
||||
['projects', projectRef, 'report-v2', report.id, { startDate, endDate, interval, functionIds }],
|
||||
[
|
||||
'projects',
|
||||
projectRef,
|
||||
'report-v2',
|
||||
report.id,
|
||||
{ startDate, endDate, interval, filters, freeformText },
|
||||
],
|
||||
async () => {
|
||||
return await report.dataProvider(
|
||||
projectRef,
|
||||
startDate,
|
||||
endDate,
|
||||
interval,
|
||||
functionIds,
|
||||
edgeFnIdToName
|
||||
filters,
|
||||
edgeFnIdToName,
|
||||
freeformText
|
||||
)
|
||||
},
|
||||
{
|
||||
enabled: Boolean(projectRef && canFetch && isAvailable),
|
||||
enabled: Boolean(projectRef && canFetch && isAvailable && !report.hide),
|
||||
refetchOnWindowFocus: false,
|
||||
}
|
||||
)
|
||||
@@ -91,17 +101,16 @@ export const ReportChartV2 = ({
|
||||
}
|
||||
|
||||
const isErrorState = error && !isLoadingChart
|
||||
const showEmptyState = (!finalChartData || finalChartData.length === 0) && !isLoadingChart
|
||||
|
||||
if (report.hide) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Card id={report.id} className={cn('relative w-full overflow-hidden scroll-mt-16', className)}>
|
||||
<CardContent className="flex flex-col gap-4 min-h-[280px] items-center justify-center">
|
||||
{isLoadingChart ? (
|
||||
<Loader2 className="size-5 animate-spin text-foreground-light" />
|
||||
) : showEmptyState ? (
|
||||
<p className="text-sm text-foreground-light text-center h-full flex items-center justify-center">
|
||||
No data available for the selected time range
|
||||
</p>
|
||||
) : isErrorState ? (
|
||||
<p className="text-sm text-foreground-light text-center h-full flex items-center justify-center">
|
||||
Error loading chart data
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { FilterProperty } from 'ui-patterns/FilterBar'
|
||||
|
||||
export const edgeFunctionFilterProperties = (
|
||||
functions: { id: string; name: string; slug: string }[]
|
||||
): FilterProperty[] => [
|
||||
{
|
||||
label: 'Function',
|
||||
name: 'function_id',
|
||||
type: 'string' as const,
|
||||
options: functions.map((fn) => ({
|
||||
label: fn.slug,
|
||||
value: fn.id,
|
||||
})),
|
||||
operators: ['=', '!='],
|
||||
},
|
||||
{
|
||||
label: 'Status Code',
|
||||
name: 'status_code',
|
||||
type: 'number' as const,
|
||||
options: [
|
||||
{ label: '200 - Success', value: 200 },
|
||||
{ label: '400 - Bad Request', value: 400 },
|
||||
{ label: '401 - Unauthorized', value: 401 },
|
||||
{ label: '403 - Forbidden', value: 403 },
|
||||
{ label: '404 - Not Found', value: 404 },
|
||||
{ label: '500 - Internal Server Error', value: 500 },
|
||||
{ label: '502 - Bad Gateway', value: 502 },
|
||||
{ label: '503 - Service Unavailable', value: 503 },
|
||||
{ label: '504 - Gateway Timeout', value: 504 },
|
||||
],
|
||||
operators: ['=', '!=', '>', '<', '>=', '<='],
|
||||
},
|
||||
{
|
||||
label: 'Region',
|
||||
name: 'region',
|
||||
type: 'string' as const,
|
||||
options: [
|
||||
{ label: 'us-east-1', value: 'us-east-1' },
|
||||
{ label: 'us-west-1', value: 'us-west-1' },
|
||||
{ label: 'eu-west-1', value: 'eu-west-1' },
|
||||
{ label: 'ap-southeast-1', value: 'ap-southeast-1' },
|
||||
{ label: 'ap-northeast-1', value: 'ap-northeast-1' },
|
||||
],
|
||||
operators: ['=', '!='],
|
||||
},
|
||||
{
|
||||
label: 'Execution Time',
|
||||
name: 'execution_time_ms',
|
||||
type: 'number' as const,
|
||||
operators: ['=', '!=', '>', '<', '>=', '<='],
|
||||
},
|
||||
]
|
||||
@@ -11,12 +11,88 @@ import {
|
||||
REPORT_STATUS_CODE_COLORS,
|
||||
} from 'data/reports/report.utils'
|
||||
import { getHttpStatusCodeInfo } from 'lib/http-status-codes'
|
||||
import type { FilterGroup, FilterCondition } from 'ui-patterns/FilterBar'
|
||||
import { ReportConfig } from './reports.types'
|
||||
|
||||
const METRIC_SQL: Record<string, (interval: AnalyticsInterval, functionIds?: string[]) => string> =
|
||||
{
|
||||
TotalInvocations: (interval, functionIds) => {
|
||||
return `
|
||||
function buildFilterWhereClause(filters?: FilterGroup, freeformText?: string): string {
|
||||
if (!filters && !freeformText) return ''
|
||||
|
||||
const conditions: string[] = []
|
||||
|
||||
if (freeformText && freeformText.trim()) {
|
||||
conditions.push(`(
|
||||
function_id ILIKE '%${freeformText.trim()}%' OR
|
||||
CAST(res.status_code AS TEXT) ILIKE '%${freeformText.trim()}%' OR
|
||||
h.x_sb_edge_region ILIKE '%${freeformText.trim()}%'
|
||||
)`)
|
||||
}
|
||||
|
||||
if (filters && filters.conditions.length > 0) {
|
||||
const filterConditions = buildFilterConditions(filters)
|
||||
if (filterConditions) {
|
||||
conditions.push(filterConditions)
|
||||
}
|
||||
}
|
||||
|
||||
if (conditions.length === 0) return ''
|
||||
|
||||
return conditions.length === 1 ? `WHERE ${conditions[0]}` : `WHERE ${conditions.join(' AND ')}`
|
||||
}
|
||||
|
||||
function buildFilterConditions(group: FilterGroup): string {
|
||||
if (group.conditions.length === 0) return ''
|
||||
|
||||
const conditionStrings = group.conditions
|
||||
.map((condition) => {
|
||||
if ('logicalOperator' in condition) {
|
||||
const subConditions = buildFilterConditions(condition as FilterGroup)
|
||||
return subConditions ? `(${subConditions})` : ''
|
||||
} else {
|
||||
return buildSingleCondition(condition as FilterCondition)
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
if (conditionStrings.length === 0) return ''
|
||||
|
||||
return conditionStrings.join(` ${group.logicalOperator} `)
|
||||
}
|
||||
|
||||
function buildSingleCondition(condition: FilterCondition): string {
|
||||
const { propertyName, operator, value } = condition
|
||||
|
||||
if (value === null || value === undefined) return ''
|
||||
|
||||
const escapedValue = typeof value === 'string' ? `'${value.replace(/'/g, "''")}'` : value
|
||||
|
||||
let sqlCondition = ''
|
||||
switch (propertyName) {
|
||||
case 'function_id':
|
||||
sqlCondition = `function_id ${operator} ${escapedValue}`
|
||||
break
|
||||
case 'status_code':
|
||||
sqlCondition = `res.status_code ${operator} ${escapedValue}`
|
||||
break
|
||||
case 'region':
|
||||
sqlCondition = `h.x_sb_edge_region ${operator} ${escapedValue}`
|
||||
break
|
||||
case 'execution_time_ms':
|
||||
sqlCondition = `m.execution_time_ms ${operator} ${escapedValue}`
|
||||
break
|
||||
default:
|
||||
sqlCondition = ''
|
||||
}
|
||||
|
||||
return sqlCondition
|
||||
}
|
||||
|
||||
const METRIC_SQL: Record<
|
||||
string,
|
||||
(interval: AnalyticsInterval, filters?: FilterGroup, freeformText?: string) => string
|
||||
> = {
|
||||
TotalInvocations: (interval, filters, freeformText) => {
|
||||
const whereClause = buildFilterWhereClause(filters, freeformText)
|
||||
return `
|
||||
--edgefn-report-invocations
|
||||
select
|
||||
timestamp_trunc(timestamp, ${analyticsIntervalToGranularity(interval)}) as timestamp,
|
||||
@@ -25,46 +101,52 @@ select
|
||||
from
|
||||
function_edge_logs
|
||||
CROSS JOIN UNNEST(metadata) AS m
|
||||
CROSS JOIN UNNEST(m.request) AS request
|
||||
CROSS JOIN UNNEST(m.response) AS response
|
||||
${
|
||||
functionIds && functionIds.length > 0
|
||||
? `WHERE function_id IN (${functionIds.map((id) => `'${id}'`).join(',')})`
|
||||
: ''
|
||||
}
|
||||
CROSS JOIN UNNEST(m.request) AS req
|
||||
CROSS JOIN UNNEST(m.response) AS res
|
||||
CROSS JOIN UNNEST(res.headers) AS h
|
||||
${whereClause}
|
||||
group by
|
||||
timestamp,
|
||||
function_id
|
||||
order by
|
||||
timestamp desc;
|
||||
`
|
||||
},
|
||||
ExecutionStatusCodes: (interval, functionIds) => {
|
||||
return `
|
||||
},
|
||||
ExecutionStatusCodes: (interval, filters, freeformText) => {
|
||||
const whereClause = buildFilterWhereClause(filters, freeformText)
|
||||
return `
|
||||
--edgefn-report-execution-status-codes
|
||||
select
|
||||
timestamp_trunc(timestamp, ${analyticsIntervalToGranularity(interval)}) as timestamp,
|
||||
response.status_code as status_code,
|
||||
count(response.status_code) as count
|
||||
res.status_code as status_code,
|
||||
count(res.status_code) as count
|
||||
from
|
||||
function_edge_logs
|
||||
cross join unnest(metadata) as m
|
||||
cross join unnest(m.response) as response
|
||||
${
|
||||
functionIds && functionIds.length > 0
|
||||
? `where function_id in (${functionIds.map((id) => `'${id}'`).join(',')})`
|
||||
: ''
|
||||
}
|
||||
cross join unnest(m.response) as res
|
||||
cross join unnest(res.headers) as h
|
||||
${whereClause}
|
||||
group by
|
||||
timestamp,
|
||||
status_code
|
||||
order by
|
||||
timestamp desc
|
||||
`
|
||||
},
|
||||
InvocationsByRegion: (interval, functionIds) => {
|
||||
const granularity = analyticsIntervalToGranularity(interval)
|
||||
return `
|
||||
},
|
||||
InvocationsByRegion: (interval, filters, freeformText) => {
|
||||
const granularity = analyticsIntervalToGranularity(interval)
|
||||
const whereClause = buildFilterWhereClause(filters, freeformText)
|
||||
|
||||
let finalWhereClause = ''
|
||||
if (whereClause) {
|
||||
// Wrap the filter conditions in parentheses and combine with region condition
|
||||
const filterConditions = whereClause.replace('WHERE ', '')
|
||||
finalWhereClause = `WHERE (${filterConditions}) AND h.x_sb_edge_region is not null`
|
||||
} else {
|
||||
finalWhereClause = 'WHERE h.x_sb_edge_region is not null'
|
||||
}
|
||||
|
||||
return `
|
||||
--edgefn-report-invocations-by-region
|
||||
select
|
||||
timestamp_trunc(timestamp, ${granularity}) as timestamp,
|
||||
@@ -73,43 +155,44 @@ select
|
||||
from
|
||||
function_edge_logs
|
||||
cross join unnest(metadata) as m
|
||||
cross join unnest(m.response) as r
|
||||
cross join unnest(r.headers) as h
|
||||
where h.x_sb_edge_region is not null
|
||||
${
|
||||
functionIds && functionIds.length > 0
|
||||
? `and function_id IN (${functionIds.map((id) => `'${id}'`).join(',')})`
|
||||
: ''
|
||||
}
|
||||
cross join unnest(m.response) as res
|
||||
cross join unnest(res.headers) as h
|
||||
${finalWhereClause}
|
||||
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, freeformText) => {
|
||||
const granularity = analyticsIntervalToGranularity(interval)
|
||||
const whereClause = buildFilterWhereClause(filters, freeformText)
|
||||
const hasFilters = filters?.conditions.some(
|
||||
(condition) => 'propertyName' in condition && condition.propertyName === 'function_id'
|
||||
)
|
||||
|
||||
return `
|
||||
--edgefn-report-execution-time
|
||||
select
|
||||
timestamp_trunc(timestamp, ${granularity}) as timestamp,
|
||||
${hasFunctions ? 'function_id,' : ''}
|
||||
${hasFilters ? '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.request) as req
|
||||
cross join unnest(m.response) as res
|
||||
cross join unnest(res.headers) as h
|
||||
${whereClause}
|
||||
group by
|
||||
timestamp
|
||||
${hasFunctions ? ', function_id' : ''}
|
||||
${hasFilters ? ', 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,9 +299,7 @@ export const edgeFunctionReports = ({
|
||||
startDate: string
|
||||
endDate: string
|
||||
interval: AnalyticsInterval
|
||||
filters: {
|
||||
functionIds?: string[]
|
||||
}
|
||||
filters?: FilterGroup
|
||||
}): ReportConfig[] => [
|
||||
{
|
||||
id: 'total-invocations',
|
||||
@@ -232,8 +313,16 @@ export const edgeFunctionReports = ({
|
||||
defaultChartStyle: 'line',
|
||||
titleTooltip: 'The total number of edge function invocations over time.',
|
||||
availableIn: ['free', 'pro', 'team', 'enterprise'],
|
||||
dataProvider: async () => {
|
||||
const sql = METRIC_SQL.TotalInvocations(interval, filters.functionIds)
|
||||
dataProvider: async (
|
||||
projectRef,
|
||||
startDate,
|
||||
endDate,
|
||||
interval,
|
||||
filters,
|
||||
edgeFnIdToName,
|
||||
freeformText
|
||||
) => {
|
||||
const sql = METRIC_SQL.TotalInvocations(interval, filters, freeformText)
|
||||
const response = await runQuery(projectRef, sql, startDate, endDate)
|
||||
|
||||
if (!response?.result) return { data: [] }
|
||||
@@ -265,13 +354,15 @@ export const edgeFunctionReports = ({
|
||||
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[]
|
||||
projectRef,
|
||||
startDate,
|
||||
endDate,
|
||||
interval,
|
||||
filters,
|
||||
edgeFnIdToName,
|
||||
freeformText
|
||||
) => {
|
||||
const sql = METRIC_SQL.ExecutionStatusCodes(interval, functionIds)
|
||||
const sql = METRIC_SQL.ExecutionStatusCodes(interval, filters, freeformText)
|
||||
const rawData = await runQuery(projectRef, sql, startDate, endDate)
|
||||
|
||||
if (!rawData?.result) return { data: [] }
|
||||
@@ -307,8 +398,16 @@ export const edgeFunctionReports = ({
|
||||
tickFormatter: (value: number) => `${value}ms`,
|
||||
},
|
||||
format: (value: unknown) => `${Number(value).toFixed(0)}ms`,
|
||||
dataProvider: async () => {
|
||||
const sql = METRIC_SQL.ExecutionTime(interval, filters.functionIds)
|
||||
dataProvider: async (
|
||||
projectRef,
|
||||
startDate,
|
||||
endDate,
|
||||
interval,
|
||||
filters,
|
||||
edgeFnIdToName,
|
||||
freeformText
|
||||
) => {
|
||||
const sql = METRIC_SQL.ExecutionTime(interval, filters, freeformText)
|
||||
const rawData = await runQuery(projectRef, sql, startDate, endDate)
|
||||
|
||||
if (!rawData?.result) return { data: [] }
|
||||
@@ -364,8 +463,16 @@ export const edgeFunctionReports = ({
|
||||
defaultChartStyle: 'line',
|
||||
titleTooltip: 'The total number of edge function invocations by region.',
|
||||
availableIn: ['pro', 'team', 'enterprise'],
|
||||
dataProvider: async () => {
|
||||
const sql = METRIC_SQL.InvocationsByRegion(interval, filters.functionIds)
|
||||
dataProvider: async (
|
||||
projectRef,
|
||||
startDate,
|
||||
endDate,
|
||||
interval,
|
||||
filters,
|
||||
edgeFnIdToName,
|
||||
freeformText
|
||||
) => {
|
||||
const sql = METRIC_SQL.InvocationsByRegion(interval, filters, freeformText)
|
||||
const rawData = await runQuery(projectRef, sql, startDate, endDate)
|
||||
const data = rawData.result?.map((point: any) => ({
|
||||
...point,
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { AnalyticsInterval } from 'data/analytics/constants'
|
||||
import { YAxisProps } from 'recharts'
|
||||
|
||||
type ReportDataProviderFilter = {
|
||||
functionIds?: string[]
|
||||
}
|
||||
import { FilterGroup } from 'ui-patterns'
|
||||
|
||||
export interface ReportDataProvider {
|
||||
(
|
||||
@@ -11,9 +8,9 @@ export interface ReportDataProvider {
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
interval: AnalyticsInterval,
|
||||
functionIds?: string[],
|
||||
filters?: FilterGroup,
|
||||
edgeFnIdToName?: (id: string) => string | undefined,
|
||||
filters?: ReportDataProviderFilter[]
|
||||
freeformText?: string
|
||||
): Promise<{
|
||||
data: any
|
||||
attributes?: {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useMemo } from 'react'
|
||||
import { parseAsString, useQueryState } from 'nuqs'
|
||||
import type { FilterGroup } from 'ui-patterns/FilterBar'
|
||||
|
||||
const defaultFilters: FilterGroup = {
|
||||
logicalOperator: 'AND',
|
||||
conditions: [],
|
||||
}
|
||||
|
||||
export function useEdgeFunctionFilters() {
|
||||
const [filtersParam, setFiltersParam] = useQueryState('filters', parseAsString.withDefault(''))
|
||||
|
||||
const [freeformText, setFreeformText] = useQueryState('search', parseAsString.withDefault(''))
|
||||
|
||||
const filters = useMemo(() => {
|
||||
if (!filtersParam) return defaultFilters
|
||||
|
||||
try {
|
||||
return JSON.parse(filtersParam) as FilterGroup
|
||||
} catch {
|
||||
return defaultFilters
|
||||
}
|
||||
}, [filtersParam])
|
||||
|
||||
const setFilters = (newFilters: FilterGroup) => {
|
||||
if (newFilters.conditions.length === 0) {
|
||||
setFiltersParam('')
|
||||
} else {
|
||||
setFiltersParam(JSON.stringify(newFilters))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
filters,
|
||||
setFilters,
|
||||
freeformText,
|
||||
setFreeformText,
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
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'
|
||||
@@ -13,18 +12,22 @@ import { LogsDatePicker } from 'components/interfaces/Settings/Logs/Logs.DatePic
|
||||
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 { FilterBarWithApply } from 'components/interfaces/Reports/FilterBarWithApply'
|
||||
|
||||
import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query'
|
||||
import { edgeFunctionReports } from 'data/reports/v2/edge-functions.config'
|
||||
import { edgeFunctionFilterProperties } from 'data/reports/v2/edge-functions-filters'
|
||||
|
||||
import { REPORT_DATERANGE_HELPER_LABELS } from 'components/interfaces/Reports/Reports.constants'
|
||||
import UpgradePrompt from 'components/interfaces/Settings/Logs/UpgradePrompt'
|
||||
import { useReportDateRange } from 'hooks/misc/useReportDateRange'
|
||||
import { useEdgeFunctionFilters } from 'hooks/misc/useEdgeFunctionFilters'
|
||||
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
import { ReportSettings } from 'components/ui/Charts/ReportSettings'
|
||||
import { toast } from 'sonner'
|
||||
import { ReportConfig } from 'data/reports/v2/reports.types'
|
||||
|
||||
const EdgeFunctionsReportV2: NextPageWithLayout = () => {
|
||||
return (
|
||||
@@ -51,15 +54,7 @@ const EdgeFunctionsUsage = () => {
|
||||
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])
|
||||
const { filters, setFilters, freeformText, setFreeformText } = useEdgeFunctionFilters()
|
||||
|
||||
const {
|
||||
selectedDateRange,
|
||||
@@ -73,19 +68,20 @@ const EdgeFunctionsUsage = () => {
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [refreshKey, setRefreshKey] = useState(0)
|
||||
|
||||
const reportConfig = useMemo(() => {
|
||||
return edgeFunctionReports({
|
||||
projectRef: ref!,
|
||||
functions: functions ?? [],
|
||||
startDate: selectedDateRange?.period_start?.date ?? '',
|
||||
endDate: selectedDateRange?.period_end?.date ?? '',
|
||||
interval: selectedDateRange?.interval ?? 'minute',
|
||||
filters: {
|
||||
functionIds,
|
||||
},
|
||||
})
|
||||
}, [ref, functions, selectedDateRange, functionIds])
|
||||
const filterProperties = useMemo(() => {
|
||||
return edgeFunctionFilterProperties(functions ?? [])
|
||||
}, [functions])
|
||||
|
||||
const reportConfig = edgeFunctionReports({
|
||||
projectRef: ref!,
|
||||
functions: functions ?? [],
|
||||
startDate: selectedDateRange?.period_start?.date ?? '',
|
||||
endDate: selectedDateRange?.period_end?.date ?? '',
|
||||
interval: selectedDateRange?.interval ?? 'minute',
|
||||
filters,
|
||||
})
|
||||
|
||||
const onRefreshReport = async () => {
|
||||
if (!selectedDateRange) return
|
||||
@@ -95,6 +91,11 @@ const EdgeFunctionsUsage = () => {
|
||||
setTimeout(() => setIsRefreshing(false), 1000)
|
||||
}
|
||||
|
||||
const handleFiltersApply = async () => {
|
||||
setRefreshKey((prev) => prev + 1)
|
||||
await onRefreshReport()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ReportHeader title="Edge Functions" showDatabaseSelector={false} />
|
||||
@@ -138,108 +139,34 @@ 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="flex flex-col">
|
||||
<span>All Functions</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 className="w-full">
|
||||
<FilterBarWithApply
|
||||
filterProperties={filterProperties}
|
||||
filters={filters}
|
||||
onFilterChange={setFilters}
|
||||
freeformText={freeformText}
|
||||
onFreeformTextChange={setFreeformText}
|
||||
onApply={handleFiltersApply}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="mt-8 flex flex-col gap-4">
|
||||
{selectedDateRange &&
|
||||
reportConfig
|
||||
.filter((report) => !report.hide)
|
||||
.map((report) => (
|
||||
<ReportChartV2
|
||||
key={`${report.id}`}
|
||||
report={report}
|
||||
projectRef={ref!}
|
||||
interval={selectedDateRange.interval}
|
||||
startDate={selectedDateRange?.period_start?.date}
|
||||
endDate={selectedDateRange?.period_end?.date}
|
||||
updateDateRange={updateDateRange}
|
||||
functionIds={functionIds}
|
||||
syncId={chartSyncId}
|
||||
/>
|
||||
))}
|
||||
{reportConfig.map((report) => (
|
||||
<ReportChartV2
|
||||
key={`${report.id}-${refreshKey}`}
|
||||
report={report}
|
||||
projectRef={ref!}
|
||||
interval={selectedDateRange.interval}
|
||||
startDate={selectedDateRange?.period_start?.date}
|
||||
endDate={selectedDateRange?.period_end?.date}
|
||||
updateDateRange={updateDateRange}
|
||||
filters={filters}
|
||||
freeformText={freeformText}
|
||||
syncId={chartSyncId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ReportStickyNav>
|
||||
</>
|
||||
|
||||
Reference in new issue
Block a user