edge function

This commit is contained in:
Francesco Sansalvadore committed 2026-03-24 15:59:46 +01:00
1 parent 10a26c5f40
commit abc25f5fae
18 files changed
+916 -544

No files matched your search

@@ -0,0 +1,24 @@
'use client'
import { useParams } from 'next/navigation'
import { useV2Params } from '@/app/v2/V2ParamsContext'
import { EdgeFunctionOverviewPageContent } from '@/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionOverviewPageContent'
import EdgeFunctionDetailsLayout from '@/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout'
import { RouteParamsOverrideProvider } from 'common'
export default function V2EdgeFunctionDetailPage() {
const params = useParams()
const { projectRef } = useV2Params()
const slug = params?.functionSlug as string
if (!projectRef || !slug) return null
return (
<RouteParamsOverrideProvider value={{ ref: projectRef, functionSlug: slug }}>
<EdgeFunctionDetailsLayout title="Overview">
<EdgeFunctionOverviewPageContent />
</EdgeFunctionDetailsLayout>
</RouteParamsOverrideProvider>
)
}
@@ -0,0 +1,436 @@
'use client'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { IS_PLATFORM, useParams } from 'common'
import { EdgeFunctionRecentInvocations } from 'components/interfaces/Functions/EdgeFunctionRecentInvocations'
import ReportWidget from 'components/interfaces/Reports/ReportWidget'
import AreaChart from 'components/ui/Charts/AreaChart'
import StackedBarChart from 'components/ui/Charts/StackedBarChart'
import NoPermission from 'components/ui/NoPermission'
import {
FunctionsCombinedStatsVariables,
useFunctionsCombinedStatsQuery,
} from 'data/analytics/functions-combined-stats-query'
import { useEdgeFunctionQuery } from 'data/edge-functions/edge-function-query'
import dayjs, { Dayjs } from 'dayjs'
import { useFillTimeseriesSorted } from 'hooks/analytics/useFillTimeseriesSorted'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import maxBy from 'lodash/maxBy'
import meanBy from 'lodash/meanBy'
import sumBy from 'lodash/sumBy'
import { useRouter } from 'next/compat/router'
import { useMemo, useState } from 'react'
import type { ChartIntervals } from 'types'
import {
Alert_Shadcn_,
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
Button,
WarningIcon,
} from 'ui'
import { PageContainer } from 'ui-patterns/PageContainer'
import { PageSection, PageSectionContent } from 'ui-patterns/PageSection'
const CHART_INTERVALS: ChartIntervals[] = [
{
key: '15min',
label: '15 min',
startValue: 15,
startUnit: 'minute',
format: 'MMM D, h:mm:ssa',
},
{
key: '1hr',
label: '1 hour',
startValue: 1,
startUnit: 'hour',
format: 'MMM D, h:mma',
},
{
key: '3hr',
label: '3 hours',
startValue: 3,
startUnit: 'hour',
format: 'MMM D, h:mma',
},
{
key: '1day',
label: '1 day',
startValue: 1,
startUnit: 'hour',
format: 'MMM D, h:mma',
},
]
/** Shared body of `pages/project/[ref]/functions/[functionSlug]/index.tsx` (Overview). */
export function EdgeFunctionOverviewPageContent() {
const router = useRouter()
const { ref: projectRef, functionSlug } = useParams()
const [interval, setInterval] = useState<string>('15min')
const selectedInterval = CHART_INTERVALS.find((i) => i.key === interval) || CHART_INTERVALS[1]
const { data: selectedFunction } = useEdgeFunctionQuery({
projectRef,
slug: functionSlug,
})
const id = selectedFunction?.id
const combinedStatsResults = useFunctionsCombinedStatsQuery({
projectRef,
functionId: id,
interval: selectedInterval.key as FunctionsCombinedStatsVariables['interval'],
})
const combinedStatsData = useMemo(() => {
const result = combinedStatsResults.data?.result as
| Record<string, string | number>[]
| undefined
return result || []
}, [combinedStatsResults.data])
const [startDate, endDate]: [Dayjs, Dayjs] = useMemo(() => {
const start = dayjs()
.subtract(selectedInterval.startValue, selectedInterval.startUnit as dayjs.ManipulateType)
.startOf(selectedInterval.startUnit as dayjs.ManipulateType)
const end = dayjs().startOf(selectedInterval.startUnit as dayjs.ManipulateType)
return [start, end]
}, [selectedInterval])
const {
data: combinedStatsChartData,
error: combinedStatsError,
isError: isErrorCombinedStats,
} = useFillTimeseriesSorted({
data: combinedStatsData,
timestampKey: 'timestamp',
valueKey: [
'requests_count',
'log_count',
'log_info_count',
'log_warn_count',
'log_error_count',
'success_count',
'redirect_count',
'client_err_count',
'server_err_count',
'avg_cpu_time_used',
'avg_memory_used',
'avg_execution_time',
'max_execution_time',
'avg_heap_memory_used',
'avg_external_memory_used',
'max_cpu_time_used',
],
defaultValue: 0,
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
})
const { isLoading: permissionsLoading, can: canReadFunction } = useAsyncCheckPermissions(
PermissionAction.FUNCTIONS_READ,
functionSlug as string
)
if (!canReadFunction && !permissionsLoading) {
return <NoPermission isFullPage resourceText="access this edge function" />
}
const push = (path: string) => {
if (router) router.push(path)
else if (typeof window !== 'undefined') window.location.assign(path)
}
return (
<PageContainer size="full">
<PageSection>
<PageSectionContent>
{IS_PLATFORM && id && (
<div className="mb-8">
<EdgeFunctionRecentInvocations
functionId={id}
functionSlug={functionSlug as string}
/>
</div>
)}
<div className="flex flex-row items-center gap-2 mb-4">
<div className="flex items-center">
{CHART_INTERVALS.map((item, i) => {
const classes = []
if (i === 0) {
classes.push('rounded-tr-none rounded-br-none')
} else if (i === CHART_INTERVALS.length - 1) {
classes.push('rounded-tl-none rounded-bl-none')
} else {
classes.push('rounded-none')
}
return (
<Button
key={`function-filter-${i}`}
type={interval === item.key ? 'secondary' : 'default'}
onClick={() => setInterval(item.key)}
className={classes.join(' ')}
>
{item.label}
</Button>
)
})}
</div>
<span className="text-xs text-foreground-light">
Statistics for past {selectedInterval.label}
</span>
</div>
<div>
<div className="grid grid-cols-1 md:grid-cols-2 md:gap-4 lg:grid-cols-2 lg:gap-8">
<ReportWidget
title="Execution time"
tooltip="Average execution time of function invocations"
data={combinedStatsChartData}
isLoading={combinedStatsResults.isLoading}
renderer={(props) => {
return isErrorCombinedStats ? (
<Alert_Shadcn_ variant="warning">
<WarningIcon />
<AlertTitle_Shadcn_>Failed to reterieve execution time</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
{combinedStatsError?.message ?? 'Unknown error'}
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
) : (
<div className="space-y-8">
<AreaChart
title="Average execution time"
className="w-full"
xAxisKey="timestamp"
customDateFormat={selectedInterval.format}
yAxisKey="avg_execution_time"
data={props.data}
format="ms"
highlightedValue={meanBy(props.data, 'avg_execution_time')}
/>
<AreaChart
title="Max execution time"
className="w-full"
xAxisKey="timestamp"
customDateFormat={selectedInterval.format}
yAxisKey="max_execution_time"
data={props.data}
format="ms"
highlightedValue={
maxBy(props.data, 'max_execution_time')?.max_execution_time
}
/>
</div>
)
}}
/>
<ReportWidget
title="Invocations"
tooltip="Requests made to a function are considered invocations, and each invocation may have worker logs."
data={combinedStatsChartData}
isLoading={combinedStatsResults.isLoading}
renderer={(props) => {
if (isErrorCombinedStats) {
return (
<Alert_Shadcn_ variant="warning">
<WarningIcon />
<AlertTitle_Shadcn_>Failed to reterieve invocations</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
{combinedStatsError?.message ?? 'Unknown error'}
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)
} else {
const requestData = props.data
.map((d: any) => [
{
status: '2xx',
count: d.success_count,
timestamp: d.timestamp,
},
{
status: '3xx',
count: d.redirect_count,
timestamp: d.timestamp,
},
{
status: '4xx',
count: d.client_err_count,
timestamp: d.timestamp,
},
{
status: '5xx',
count: d.server_err_count,
timestamp: d.timestamp,
},
])
.flat()
const logsData = props.data
.map((d: any) => [
{
status: 'error',
count: d.log_error_count,
timestamp: d.timestamp,
},
{
status: 'info',
count: d.log_info_count,
timestamp: d.timestamp,
},
{
status: 'warn',
count: d.log_warn_count,
timestamp: d.timestamp,
},
])
.flat()
return (
<div className="space-y-8">
<StackedBarChart
title="Invocation Requests"
className="w-full"
xAxisKey="timestamp"
yAxisKey="count"
stackKey="status"
data={requestData}
highlightedValue={sumBy(requestData, 'count')}
customDateFormat={selectedInterval.format}
stackColors={['brand', 'slate', 'yellow', 'red']}
onBarClick={() => {
push(
`/project/${projectRef}/functions/${functionSlug}/invocations?its=${startDate.toISOString()}`
)
}}
/>
<StackedBarChart
title="Worker Logs"
className="w-full"
xAxisKey="timestamp"
yAxisKey="count"
stackKey="status"
data={logsData}
highlightedValue={sumBy(logsData, 'count')}
customDateFormat={selectedInterval.format}
stackColors={['red', 'brand', 'yellow']}
onBarClick={() => {
push(
`/project/${projectRef}/functions/${functionSlug}/logs?its=${startDate.toISOString()}`
)
}}
/>
</div>
)
}
}}
/>
<ReportWidget
title="CPU time"
tooltip="Average CPU time usage for the function"
data={combinedStatsChartData}
isLoading={combinedStatsResults.isLoading}
renderer={(props) => {
return isErrorCombinedStats ? (
<Alert_Shadcn_ variant="warning">
<WarningIcon />
<AlertTitle_Shadcn_>Failed to retrieve CPU time</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
{combinedStatsError?.message ?? 'Unknown error'}
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
) : (
<div className="space-y-8">
<AreaChart
title="Average CPU Time"
className="w-full"
xAxisKey="timestamp"
customDateFormat={selectedInterval.format}
yAxisKey="avg_cpu_time_used"
data={props.data}
format="ms"
highlightedValue={meanBy(props.data, 'avg_cpu_time_used')}
/>
<AreaChart
title="Max CPU Time"
className="w-full"
xAxisKey="timestamp"
customDateFormat={selectedInterval.format}
yAxisKey="max_cpu_time_used"
data={props.data}
format="ms"
highlightedValue={maxBy(props.data, 'max_cpu_time_used')?.max_cpu_time_used}
/>
</div>
)
}}
/>
<ReportWidget
title="Memory"
tooltip="Average memory usage for the function"
data={combinedStatsChartData}
isLoading={combinedStatsResults.isLoading}
renderer={(props) => {
if (isErrorCombinedStats) {
return (
<Alert_Shadcn_ variant="warning">
<WarningIcon />
<AlertTitle_Shadcn_>Failed to retrieve memory usage</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
{combinedStatsError?.message ?? 'Unknown error'}
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)
}
const memoryData = props.data
.map((d: any) => [
{
type: 'heap',
count: d.avg_heap_memory_used,
timestamp: d.timestamp,
},
{
type: 'external',
count: d.avg_external_memory_used,
timestamp: d.timestamp,
},
])
.flat()
return (
<div className="space-y-8">
<AreaChart
title="Average Memory Usage"
className="w-full"
xAxisKey="timestamp"
customDateFormat={selectedInterval.format}
yAxisKey="avg_memory_used"
data={props.data}
format="MB"
highlightedValue={meanBy(props.data, 'avg_memory_used')}
/>
<StackedBarChart
title="Average Memory Usage by Type"
className="w-full"
xAxisKey="timestamp"
yAxisKey="count"
stackKey="type"
format="MB"
data={memoryData}
highlightedValue={sumBy(memoryData, 'count')}
customDateFormat={selectedInterval.format}
stackColors={['blue', 'brand']}
/>
</div>
)
}}
/>
</div>
</div>
</PageSectionContent>
</PageSection>
</PageContainer>
)
}
@@ -18,7 +18,7 @@ import { withAuth } from 'hooks/misc/withAuth'
import { DOCS_URL } from 'lib/constants'
import { Clock, Download, FileArchive, Send } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useRouter } from 'next/compat/router'
import React, { useEffect, useState, type PropsWithChildren } from 'react'
import { toast } from 'sonner'
import {
@@ -64,8 +64,11 @@ const EdgeFunctionDetailsLayout = ({
children,
}: PropsWithChildren<EdgeFunctionDetailsLayoutProps>) => {
const router = useRouter()
const pathWithoutQuery = router?.asPath?.split('?')[0] ?? ''
const { data: org } = useSelectedOrganizationQuery()
const { functionSlug, ref } = useParams()
const v2OverviewPath =
ref && functionSlug ? `/v2/project/${ref}/data/edge-functions/${functionSlug}` : ''
const { mutate: sendEvent } = useSendEventMutation()
const isNewAPIDocsEnabled = useIsAPIDocsSidePanelEnabled()
@@ -229,7 +232,7 @@ const EdgeFunctionDetailsLayout = ({
if (!!functionSlug && isError && error.code === 404 && !cancel) {
toast('Edge function cannot be found in your project')
router.push(`/project/${ref}/functions`)
router?.push(`/project/${ref}/functions`)
}
return () => {
@@ -393,7 +396,11 @@ const EdgeFunctionDetailsLayout = ({
<PageHeaderNavigationTabs>
<NavMenu>
{navigationItems.map((item) => {
const isActive = router.asPath.split('?')[0] === item.href
const isActive =
pathWithoutQuery === item.href ||
(Boolean(v2OverviewPath) &&
item.href === `/project/${ref}/functions/${functionSlug}` &&
pathWithoutQuery === v2OverviewPath)
return (
<NavMenuItem key={item.label} active={isActive}>
<Link href={item.href}>{item.label}</Link>
@@ -1,7 +1,7 @@
import { useParams } from 'common'
import { ProductMenu } from 'components/ui/ProductMenu'
import { withAuth } from 'hooks/misc/withAuth'
import { useRouter } from 'next/router'
import { useRouter } from 'next/compat/router'
import type { ComponentProps, PropsWithChildren } from 'react'
import { ProjectLayout } from '../ProjectLayout'
@@ -9,7 +9,7 @@ import { ProjectLayout } from '../ProjectLayout'
export const EdgeFunctionsProductMenu = () => {
const { ref: projectRef = 'default' } = useParams()
const router = useRouter()
const page = router.pathname.split('/')[4]
const page = router?.pathname?.split('/')[4]
const menuItems = [
{
+13 -1
View File
@@ -42,10 +42,22 @@ export function DataTabBar() {
if (!pathname.startsWith(base)) return
const rest = pathname.slice(base.length)
const [category] = rest.split('/')
const [category, detailKey] = rest.split('/')
if (!category || category === 'tables') return
if (!(category in CATEGORY_LABELS)) return
if (category === 'edge-functions' && detailKey) {
openDataTab({
id: `edge-function:${detailKey}`,
label: detailKey,
type: 'detail',
category,
domain: CATEGORY_DOMAIN[category] ?? 'fn',
path: `${base}${category}/${detailKey}`,
})
return
}
openDataTab({
id: category,
label: CATEGORY_LABELS[category] ?? category,
@@ -2,7 +2,8 @@
import { ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-react'
import { useCallback, useRef } from 'react'
import { cn } from 'ui'
import { cn, Input_Shadcn_ } from 'ui'
import type { DataTableColumn, SortState } from './types'
interface DataTableHeaderProps<T = unknown> {
@@ -80,8 +81,9 @@ export function DataTableHeader<T = unknown>({
<tr className={cn('border-b border-default bg-surface-200 text-left', rowHeight)}>
{selectable && (
<th className="w-10 min-w-10 max-w-10 shrink-0 px-2 align-middle text-center">
<input
<Input_Shadcn_
type="checkbox"
size="small"
className="h-3.5 w-3.5 cursor-pointer rounded border-control accent-brand"
checked={allSelected}
ref={(el) => {
@@ -1,7 +1,20 @@
'use client'
import { Search } from 'lucide-react'
import { cn } from 'ui'
import {
cn,
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
Input_Shadcn_,
Select_Shadcn_,
SelectContent_Shadcn_,
SelectItem_Shadcn_,
SelectTrigger_Shadcn_,
SelectValue_Shadcn_,
} from 'ui'
import type { FilterDefinition, FilterState } from './types'
interface DataTableToolbarProps {
@@ -34,45 +47,95 @@ export function DataTableToolbar({
<div className="flex flex-1 min-w-0 items-center gap-2">
{toolbarLeft}
{filters?.map((filter) => {
const currentValue = filterState[filter.id]
if (filter.render) {
return (
<div key={filter.id}>
{filter.render({
value: currentValue,
onChange: (value) => updateFilter(filter.id, value),
filterState,
})}
</div>
)
}
if (filter.type === 'search') {
return (
<div key={filter.id} className="relative flex items-center">
<Search className="absolute left-2 h-3.5 w-3.5 text-foreground-lighter pointer-events-none" />
<input
type="text"
<Input_Shadcn_
placeholder={filter.placeholder ?? `Filter ${filter.label.toLowerCase()}...`}
value={(filterState[filter.id] as string) ?? ''}
onChange={(e) => updateFilter(filter.id, e.target.value)}
className={cn(
'h-8 rounded-md border border-control bg-surface-100',
'pl-7 pr-3 text-xs text-foreground placeholder:text-foreground-lighter',
'focus:outline-none focus:ring-1 focus:ring-brand',
'min-w-[180px]'
)}
size="small"
className={cn('h-8 bg-surface-100', 'pl-7 pr-3 text-xs', 'min-w-[180px]')}
/>
</div>
)
}
if (filter.type === 'select' || filter.type === 'multi-select') {
if (filter.type === 'select') {
const value = (filterState[filter.id] as string) ?? '__all__'
return (
<select
<Select_Shadcn_
key={filter.id}
value={(filterState[filter.id] as string) ?? ''}
onChange={(e) => updateFilter(filter.id, e.target.value)}
className={cn(
'h-8 rounded-md border border-control bg-surface-100',
'px-2 text-xs text-foreground',
'focus:outline-none focus:ring-1 focus:ring-brand'
)}
value={value}
onValueChange={(nextValue) =>
updateFilter(filter.id, nextValue === '__all__' ? '' : nextValue)
}
>
<option value="">{filter.placeholder ?? `All ${filter.label}`}</option>
{filter.options?.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<SelectTrigger_Shadcn_
className="h-8 min-w-[150px] bg-surface-100 text-xs"
size="small"
>
<SelectValue_Shadcn_ placeholder={filter.placeholder ?? `All ${filter.label}`} />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
<SelectItem_Shadcn_ value="__all__">
{filter.placeholder ?? `All ${filter.label}`}
</SelectItem_Shadcn_>
{filter.options?.map((opt) => (
<SelectItem_Shadcn_ key={opt.value} value={opt.value}>
{opt.label}
</SelectItem_Shadcn_>
))}
</SelectContent_Shadcn_>
</Select_Shadcn_>
)
}
if (filter.type === 'multi-select') {
const selected = Array.isArray(filterState[filter.id])
? (filterState[filter.id] as string[])
: []
return (
<DropdownMenu key={filter.id}>
<DropdownMenuTrigger asChild>
<button
type="button"
className="inline-flex h-8 items-center rounded-md border border-control bg-surface-100 px-2 text-xs text-foreground-light hover:text-foreground"
>
{selected.length > 0 ? `${filter.label} (${selected.length})` : filter.label}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
{filter.options?.map((opt) => (
<DropdownMenuCheckboxItem
key={opt.value}
checked={selected.includes(opt.value)}
onCheckedChange={(checked) => {
const next = checked
? [...selected, opt.value]
: selected.filter((v) => v !== opt.value)
updateFilter(filter.id, next)
}}
>
{opt.label}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -33,11 +33,11 @@ export function AvatarCell({ value }: AvatarCellProps) {
return (
<div className="flex items-center gap-2">
<span
className={`inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold ${colorClass}`}
className={`inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold ${colorClass}`}
>
{initials}
</span>
<span className="truncate text-[13px]">{str}</span>
<span className="truncate text-xs">{str}</span>
</div>
)
}
@@ -1,3 +1,6 @@
import { getColumnType } from 'components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/DateTimeInput/DateTimeInput.utils'
import dayjs from 'dayjs'
import { DATETIME_FORMAT } from 'lib/constants'
import { Tooltip, TooltipContent, TooltipTrigger } from 'ui'
interface DateCellProps {
@@ -5,28 +8,11 @@ interface DateCellProps {
showTime?: boolean
}
function formatRelative(date: Date): string {
const diff = Date.now() - date.getTime()
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (seconds < 60) return 'just now'
if (minutes < 60) return `${minutes}m ago`
if (hours < 24) return `${hours}h ago`
if (days < 30) return `${days}d ago`
return date.toLocaleDateString()
}
function formatFull(date: Date, showTime: boolean): string {
if (showTime) {
return date
.toISOString()
.replace('T', ' ')
.replace(/\.\d{3}Z$/, ' UTC')
}
return date.toDateString()
const valueType = getColumnType(showTime ? 'timestamp' : 'date')
if (valueType === 'date') return dayjs(date).format('YYYY-MM-DD')
if (valueType === 'time') return dayjs(date).format('HH:mm:ss')
return dayjs(date).format('YYYY-MM-DD HH:mm:ss')
}
export function DateCell({ value, showTime = false }: DateCellProps) {
@@ -42,7 +28,7 @@ export function DateCell({ value, showTime = false }: DateCellProps) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="truncate cursor-default">{formatRelative(date)}</span>
<span className="truncate cursor-default">{dayjs(date).format(DATETIME_FORMAT)}</span>
</TooltipTrigger>
<TooltipContent side="top">
<span className="font-mono text-xs">{formatFull(date, showTime)}</span>
@@ -24,6 +24,7 @@ export function DataTableRenderer<T extends Record<string, unknown>>({
onRetry,
sort: controlledSort,
onSortChange: controlledOnSortChange,
renderSortControl,
filters,
filterState: controlledFilterState,
onFilterChange: controlledOnFilterChange,
@@ -172,7 +173,8 @@ export function DataTableRenderer<T extends Record<string, unknown>>({
const hasPagination = !!pagination && !!onPageChange
const hasRowActions = !!rowActions && rowActions.length > 0
const showChromeToolbar =
!hideToolbar && (hasBulkBar || Boolean(filters?.length) || toolbarLeft || toolbarRight)
!hideToolbar &&
(hasBulkBar || Boolean(filters?.length) || toolbarLeft || toolbarRight || renderSortControl)
return (
<div className={cn('sb-grid flex h-full min-h-0 flex-col', className)}>
@@ -190,7 +192,15 @@ export function DataTableRenderer<T extends Record<string, unknown>>({
filterState={activeFilterState}
onFilterChange={handleFilterChange}
toolbarLeft={toolbarLeft}
toolbarRight={toolbarRight}
toolbarRight={
<>
{renderSortControl?.({
sort: activeSort,
onSortChange: handleSortChange,
})}
{toolbarRight}
</>
}
/>
) : null}
@@ -49,6 +49,11 @@ export interface FilterDefinition {
type: 'search' | 'select' | 'multi-select' | 'toggle'
options?: Array<{ value: string; label: string }>
placeholder?: string
render?: (args: {
value: string | string[] | boolean | undefined
onChange: (value: string | string[] | boolean) => void
filterState: FilterState
}) => ReactNode
}
export type FilterState = Record<string, string | string[] | boolean>
@@ -106,6 +111,10 @@ export interface DataTableRendererProps<T extends Record<string, any> = any> {
// If omitted, sorting is handled client-side internally
sort?: SortState | null
onSortChange?: (sort: SortState | null) => void
renderSortControl?: (args: {
sort: SortState | null
onSortChange: (sort: SortState | null) => void
}) => ReactNode
// ── Filtering (controlled) ──
// If omitted, filtering is handled client-side when filters is defined
@@ -0,0 +1,34 @@
'use client'
import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs'
export function useEntityPanelParams() {
const [isCreating, setIsCreating] = useQueryState(
'new',
parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true })
)
const [editingId, setEditingId] = useQueryState(
'edit',
parseAsString.withOptions({ history: 'push', clearOnDefault: true })
)
const [duplicatingId, setDuplicatingId] = useQueryState(
'duplicate',
parseAsString.withOptions({ history: 'push', clearOnDefault: true })
)
const closePanels = () => {
setIsCreating(false)
setEditingId(null)
setDuplicatingId(null)
}
return {
isCreating,
setIsCreating,
editingId,
setEditingId,
duplicatingId,
setDuplicatingId,
closePanels,
}
}
@@ -2,10 +2,12 @@
import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query'
import type { EdgeFunctionsResponse } from 'data/edge-functions/edge-functions-query'
import { useRouter } from 'next/navigation'
import { useV2Params } from '@/app/v2/V2ParamsContext'
import { DataTableRenderer } from '@/components/v2/DataTableRenderer'
import type { DataTableColumn } from '@/components/v2/DataTableRenderer'
import { useV2DashboardStore } from '@/stores/v2-dashboard'
const EDGE_FUNCTIONS_COLUMNS: DataTableColumn<EdgeFunctionsResponse>[] = [
{
@@ -54,7 +56,9 @@ const EDGE_FUNCTIONS_COLUMNS: DataTableColumn<EdgeFunctionsResponse>[] = [
]
export function V2EdgeFunctionsList() {
const router = useRouter()
const { projectRef } = useV2Params()
const openDataTab = useV2DashboardStore((s) => s.openDataTab)
const {
data: functions,
@@ -73,6 +77,19 @@ export function V2EdgeFunctionsList() {
filters={[
{ id: 'search', label: 'Search', type: 'search', placeholder: 'Filter edge functions…' },
]}
onRowClick={(row) => {
if (!projectRef) return
const path = `/v2/project/${projectRef}/data/edge-functions/${row.slug}`
openDataTab({
id: `edge-function:${row.slug}`,
label: row.name || row.slug,
type: 'detail',
category: 'edge-functions',
domain: 'fn',
path,
})
router.push(path)
}}
emptyState={{
title: 'No edge functions yet',
description: 'Deploy your first edge function to get started.',
@@ -5,8 +5,10 @@ import type { DatabaseFunction } from 'data/database-functions/database-function
import { useSchemasQuery } from 'data/database/schemas-query'
import { isValidConnString } from 'data/fetchers'
import { useProjectDetailQuery } from 'data/projects/project-detail-query'
import { CopyPlus, Pencil, Plus } from 'lucide-react'
import { useState } from 'react'
import {
Button,
Select_Shadcn_,
SelectContent_Shadcn_,
SelectItem_Shadcn_,
@@ -15,8 +17,10 @@ import {
} from 'ui'
import { useV2Params } from '@/app/v2/V2ParamsContext'
import { CreateFunction } from '@/components/interfaces/Database/Functions/CreateFunction'
import { DataTableRenderer } from '@/components/v2/DataTableRenderer'
import type { DataTableColumn } from '@/components/v2/DataTableRenderer'
import { useEntityPanelParams } from '@/components/v2/hooks/useEntityPanelParams'
const FUNCTIONS_COLUMNS: DataTableColumn<DatabaseFunction>[] = [
{
@@ -77,6 +81,15 @@ const FUNCTIONS_COLUMNS: DataTableColumn<DatabaseFunction>[] = [
export function V2FunctionsList() {
const { projectRef } = useV2Params()
const [schema, setSchema] = useState('public')
const {
isCreating,
setIsCreating,
editingId,
setEditingId,
duplicatingId,
setDuplicatingId,
closePanels,
} = useEntityPanelParams()
const { data: project, isPending: isProjectPending } = useProjectDetailQuery(
{ ref: projectRef },
@@ -103,6 +116,8 @@ export function V2FunctionsList() {
const schemaFunctions = (functions ?? []).filter(
(f) => schema === '__all__' || f.schema === schema
)
const functionToEdit = schemaFunctions.find((f) => f.id.toString() === editingId)
const functionToDuplicate = schemaFunctions.find((f) => f.id.toString() === duplicatingId)
const schemaSelector = (
<Select_Shadcn_ value={schema} onValueChange={setSchema}>
@@ -121,25 +136,64 @@ export function V2FunctionsList() {
)
return (
<DataTableRenderer<DatabaseFunction>
columns={FUNCTIONS_COLUMNS}
rows={schemaFunctions}
rowKey="id"
isLoading={isProjectPending || (shouldFetch && isFunctionsPending)}
error={isError ? (error as Error) : null}
filters={[
{
id: 'search',
label: 'Search',
type: 'search',
placeholder: 'Filter functions…',
},
]}
toolbarLeft={schemaSelector}
emptyState={{
title: 'No functions found',
description: 'Create a database function to get started.',
}}
/>
<>
<DataTableRenderer<DatabaseFunction>
columns={FUNCTIONS_COLUMNS}
rows={schemaFunctions}
rowKey="id"
isLoading={isProjectPending || (shouldFetch && isFunctionsPending)}
error={isError ? (error as Error) : null}
filters={[
{
id: 'search',
label: 'Search',
type: 'search',
placeholder: 'Filter functions…',
},
]}
toolbarLeft={schemaSelector}
toolbarRight={
<Button
type="primary"
size="tiny"
icon={<Plus size={12} />}
onClick={() => setIsCreating(true)}
>
Create function
</Button>
}
rowActions={[
{
id: 'edit',
label: 'Edit function',
icon: <Pencil size={12} />,
onClick: (row) => {
setDuplicatingId(null)
setEditingId(row.id.toString())
},
},
{
id: 'duplicate',
label: 'Duplicate function',
icon: <CopyPlus size={12} />,
onClick: (row) => {
setEditingId(null)
setDuplicatingId(row.id.toString())
},
},
]}
emptyState={{
title: 'No functions found',
description: 'Create a database function to get started.',
}}
/>
<CreateFunction
func={functionToEdit || functionToDuplicate}
visible={isCreating || !!functionToEdit || !!functionToDuplicate}
isDuplicating={!!functionToDuplicate}
onClose={closePanels}
/>
</>
)
}
+39 -20
View File
@@ -4,10 +4,14 @@ import { useDatabaseRolesQuery } from 'data/database-roles/database-roles-query'
import type { PgRole } from 'data/database-roles/database-roles-query'
import { isValidConnString } from 'data/fetchers'
import { useProjectDetailQuery } from 'data/projects/project-detail-query'
import { Plus } from 'lucide-react'
import { Button } from 'ui'
import { useV2Params } from '@/app/v2/V2ParamsContext'
import { CreateRolePanel } from '@/components/interfaces/Database/Roles/CreateRolePanel'
import { DataTableRenderer } from '@/components/v2/DataTableRenderer'
import type { DataTableColumn } from '@/components/v2/DataTableRenderer'
import { useEntityPanelParams } from '@/components/v2/hooks/useEntityPanelParams'
const ROLES_COLUMNS: DataTableColumn<PgRole>[] = [
{
@@ -93,6 +97,7 @@ const ROLES_COLUMNS: DataTableColumn<PgRole>[] = [
export function V2RolesList() {
const { projectRef } = useV2Params()
const { isCreating, setIsCreating } = useEntityPanelParams()
const { data: project, isPending: isProjectPending } = useProjectDetailQuery(
{ ref: projectRef },
@@ -112,25 +117,39 @@ export function V2RolesList() {
)
return (
<DataTableRenderer<PgRole>
columns={ROLES_COLUMNS}
rows={(roles as PgRole[]) ?? []}
rowKey="name"
isLoading={isProjectPending || (shouldFetch && isRolesPending)}
error={isError ? (error as Error) : null}
compact
filters={[
{
id: 'search',
label: 'Search',
type: 'search',
placeholder: 'Filter roles…',
},
]}
emptyState={{
title: 'No roles found',
description: 'Database roles will appear here.',
}}
/>
<>
<DataTableRenderer<PgRole>
columns={ROLES_COLUMNS}
rows={(roles as PgRole[]) ?? []}
rowKey="name"
isLoading={isProjectPending || (shouldFetch && isRolesPending)}
error={isError ? (error as Error) : null}
compact
filters={[
{
id: 'search',
label: 'Search',
type: 'search',
placeholder: 'Filter roles…',
},
]}
toolbarRight={
<Button
type="primary"
size="tiny"
icon={<Plus size={12} />}
onClick={() => setIsCreating(true)}
>
Add role
</Button>
}
emptyState={{
title: 'No roles found',
description: 'Database roles will appear here.',
}}
/>
<CreateRolePanel visible={isCreating} onClose={() => setIsCreating(false)} />
</>
)
}
+48 -10
View File
@@ -4,10 +4,15 @@ import { useEnumeratedTypesQuery } from 'data/enumerated-types/enumerated-types-
import type { EnumeratedType } from 'data/enumerated-types/enumerated-types-query'
import { isValidConnString } from 'data/fetchers'
import { useProjectDetailQuery } from 'data/projects/project-detail-query'
import { Pencil, Plus } from 'lucide-react'
import { Button } from 'ui'
import { useV2Params } from '@/app/v2/V2ParamsContext'
import CreateEnumeratedTypeSidePanel from '@/components/interfaces/Database/EnumeratedTypes/CreateEnumeratedTypeSidePanel'
import EditEnumeratedTypeSidePanel from '@/components/interfaces/Database/EnumeratedTypes/EditEnumeratedTypeSidePanel'
import { DataTableRenderer } from '@/components/v2/DataTableRenderer'
import type { DataTableColumn } from '@/components/v2/DataTableRenderer'
import { useEntityPanelParams } from '@/components/v2/hooks/useEntityPanelParams'
const TYPES_COLUMNS: DataTableColumn<EnumeratedType>[] = [
{
@@ -47,6 +52,7 @@ const TYPES_COLUMNS: DataTableColumn<EnumeratedType>[] = [
export function V2TypesList() {
const { projectRef } = useV2Params()
const { isCreating, setIsCreating, editingId, setEditingId } = useEntityPanelParams()
const { data: project, isPending: isProjectPending } = useProjectDetailQuery(
{ ref: projectRef },
@@ -64,17 +70,49 @@ export function V2TypesList() {
{ projectRef, connectionString: project?.connectionString },
{ enabled: shouldFetch }
)
const selectedType = (types ?? []).find((type) => type.id.toString() === editingId)
return (
<DataTableRenderer<EnumeratedType>
columns={TYPES_COLUMNS}
rows={(types as EnumeratedType[]) ?? []}
rowKey="id"
isLoading={isProjectPending || (shouldFetch && isTypesPending)}
error={isError ? (error as Error) : null}
compact
filters={[{ id: 'search', label: 'Search', type: 'search', placeholder: 'Filter types…' }]}
emptyState={{ title: 'No enumerated types found' }}
/>
<>
<DataTableRenderer<EnumeratedType>
columns={TYPES_COLUMNS}
rows={(types as EnumeratedType[]) ?? []}
rowKey="id"
isLoading={isProjectPending || (shouldFetch && isTypesPending)}
error={isError ? (error as Error) : null}
compact
filters={[{ id: 'search', label: 'Search', type: 'search', placeholder: 'Filter types…' }]}
toolbarRight={
<Button
type="primary"
size="tiny"
icon={<Plus size={12} />}
onClick={() => setIsCreating(true)}
>
Create type
</Button>
}
rowActions={[
{
id: 'edit',
label: 'Edit type',
icon: <Pencil size={12} />,
onClick: (row) => setEditingId(row.id.toString()),
},
]}
emptyState={{ title: 'No enumerated types found' }}
/>
<CreateEnumeratedTypeSidePanel
visible={isCreating}
onClose={() => setIsCreating(false)}
schema="public"
/>
<EditEnumeratedTypeSidePanel
visible={!!selectedType}
selectedEnumeratedType={selectedType}
onClose={() => setEditingId(null)}
/>
</>
)
}
+93 -10
View File
@@ -1,15 +1,19 @@
'use client'
import { SortDropdown } from 'components/interfaces/Auth/Users/SortDropdown'
import { useUsersCountQuery } from 'data/auth/users-count-query'
import { useUsersInfiniteQuery } from 'data/auth/users-infinite-query'
import type { User } from 'data/auth/users-infinite-query'
import type { User, Filter as UserTypeFilter } from 'data/auth/users-infinite-query'
import { isValidConnString } from 'data/fetchers'
import { useProjectDetailQuery } from 'data/projects/project-detail-query'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useState } from 'react'
import { useV2Params } from '@/app/v2/V2ParamsContext'
import { AddUserDropdown } from '@/components/interfaces/Auth/Users/AddUserDropdown'
import { PROVIDER_FILTER_OPTIONS } from '@/components/interfaces/Auth/Users/Users.constants'
import { DataTableRenderer } from '@/components/v2/DataTableRenderer'
import type { DataTableColumn, SortState } from '@/components/v2/DataTableRenderer'
import type { DataTableColumn, FilterState, SortState } from '@/components/v2/DataTableRenderer'
const PAGE_SIZE = 50
@@ -85,7 +89,22 @@ const USERS_COLUMNS: DataTableColumn<User>[] = [
export function V2UsersList() {
const { projectRef } = useV2Params()
const [search, setSearch] = useState('')
const [filterUserType, setFilterUserType] = useState<'all' | UserTypeFilter>('all')
const [selectedProviders, setSelectedProviders] = useState<string[]>([])
const [sort, setSort] = useState<SortState | null>(null)
const {
authenticationShowSortByEmail: showSortByEmail,
authenticationShowSortByPhone: showSortByPhone,
} = useIsFeatureEnabled([
'authentication:show_sort_by_email',
'authentication:show_sort_by_phone',
])
const sortColumn =
(sort?.columnId as 'created_at' | 'email' | 'id' | 'phone' | 'last_sign_in_at') ?? 'created_at'
const sortOrder = sort?.direction ?? 'asc'
const sortByValue = `${sortColumn}:${sortOrder}`
const [page, setPage] = useState(1)
const { data: project, isLoading: isProjectLoading } = useProjectDetailQuery(
@@ -95,7 +114,16 @@ export function V2UsersList() {
const shouldFetch = Boolean(projectRef) && isValidConnString(project?.connectionString)
const { data: countData } = useUsersCountQuery({ projectRef }, { enabled: shouldFetch })
const { data: countData } = useUsersCountQuery(
{
projectRef,
connectionString: project?.connectionString,
keywords: search || undefined,
filter: filterUserType === 'all' ? undefined : filterUserType,
providers: selectedProviders,
},
{ enabled: shouldFetch }
)
const total = countData?.count ?? 0
const {
@@ -110,10 +138,10 @@ export function V2UsersList() {
projectRef,
connectionString: project?.connectionString,
keywords: search || undefined,
sort:
(sort?.columnId as 'created_at' | 'email' | 'id' | 'phone' | 'last_sign_in_at') ??
'created_at',
order: sort?.direction ?? 'asc',
filter: filterUserType === 'all' ? undefined : filterUserType,
providers: selectedProviders,
sort: sortColumn,
order: sortOrder,
},
{ enabled: shouldFetch }
)
@@ -133,16 +161,38 @@ export function V2UsersList() {
}
const handleSortChange = (s: SortState | null) => {
setSort(s)
setSort(s ?? { columnId: 'created_at', direction: 'asc' })
setPage(1)
}
const handleFilterChange = (state: Record<string, string | string[] | boolean>) => {
const handleSortByValue = (value: string) => {
const [columnId, direction] = value.split(':') as [
SortState['columnId'],
SortState['direction'],
]
setSort({ columnId, direction })
setPage(1)
}
const handleFilterChange = (state: FilterState) => {
const newSearch = (state['search'] as string) ?? ''
const newUserType = (state['userType'] as 'all' | UserTypeFilter) ?? 'all'
const newProviders = Array.isArray(state['providers'])
? (state['providers'] as string[])
: ([] as string[])
if (newSearch !== search) {
setSearch(newSearch)
setPage(1)
}
if (newUserType !== filterUserType) {
setFilterUserType(newUserType)
setPage(1)
}
if (newProviders.join(',') !== selectedProviders.join(',')) {
setSelectedProviders(newProviders)
setPage(1)
}
}
return (
@@ -154,6 +204,18 @@ export function V2UsersList() {
error={isError ? (error as Error) : null}
sort={sort}
onSortChange={handleSortChange}
renderSortControl={() => (
<SortDropdown
specificFilterColumn="freeform"
sortColumn={sortColumn}
sortOrder={sortOrder}
sortByValue={sortByValue}
showSortByEmail={showSortByEmail}
showSortByPhone={showSortByPhone}
setSortByValue={handleSortByValue}
improvedSearchEnabled
/>
)}
filters={[
{
id: 'search',
@@ -161,9 +223,30 @@ export function V2UsersList() {
type: 'search',
placeholder: 'Search by email or phone…',
},
{
id: 'userType',
label: 'User type',
type: 'select',
options: [
{ value: 'all', label: 'All users' },
{ value: 'verified', label: 'Verified users' },
{ value: 'unverified', label: 'Unverified users' },
{ value: 'anonymous', label: 'Anonymous users' },
],
},
{
id: 'providers',
label: 'Provider',
type: 'multi-select',
options: PROVIDER_FILTER_OPTIONS.map((provider) => ({
value: provider.value,
label: provider.name,
})),
},
]}
filterState={{ search }}
filterState={{ search, userType: filterUserType, providers: selectedProviders }}
onFilterChange={handleFilterChange}
toolbarRight={<AddUserDropdown />}
pagination={{
page,
pageSize: PAGE_SIZE,
@@ -1,432 +1,10 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { IS_PLATFORM, useParams } from 'common'
import { EdgeFunctionRecentInvocations } from 'components/interfaces/Functions/EdgeFunctionRecentInvocations'
import ReportWidget from 'components/interfaces/Reports/ReportWidget'
import { EdgeFunctionOverviewPageContent } from 'components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionOverviewPageContent'
import DefaultLayout from 'components/layouts/DefaultLayout'
import EdgeFunctionDetailsLayout from 'components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout'
import AreaChart from 'components/ui/Charts/AreaChart'
import StackedBarChart from 'components/ui/Charts/StackedBarChart'
import NoPermission from 'components/ui/NoPermission'
import {
FunctionsCombinedStatsVariables,
useFunctionsCombinedStatsQuery,
} from 'data/analytics/functions-combined-stats-query'
import { useEdgeFunctionQuery } from 'data/edge-functions/edge-function-query'
import dayjs, { Dayjs } from 'dayjs'
import { useFillTimeseriesSorted } from 'hooks/analytics/useFillTimeseriesSorted'
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
import maxBy from 'lodash/maxBy'
import meanBy from 'lodash/meanBy'
import sumBy from 'lodash/sumBy'
import { useRouter } from 'next/router'
import { useMemo, useState } from 'react'
import type { ChartIntervals, NextPageWithLayout } from 'types'
import {
Alert_Shadcn_,
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
Button,
WarningIcon,
} from 'ui'
import { PageContainer } from 'ui-patterns/PageContainer'
import { PageSection, PageSectionContent } from 'ui-patterns/PageSection'
const CHART_INTERVALS: ChartIntervals[] = [
{
key: '15min',
label: '15 min',
startValue: 15,
startUnit: 'minute',
format: 'MMM D, h:mm:ssa',
},
{
key: '1hr',
label: '1 hour',
startValue: 1,
startUnit: 'hour',
format: 'MMM D, h:mma',
},
{
key: '3hr',
label: '3 hours',
startValue: 3,
startUnit: 'hour',
format: 'MMM D, h:mma',
},
{
key: '1day',
label: '1 day',
startValue: 1,
startUnit: 'hour',
format: 'MMM D, h:mma',
},
]
import type { NextPageWithLayout } from 'types'
const PageLayout: NextPageWithLayout = () => {
const router = useRouter()
const { ref: projectRef, functionSlug } = useParams()
const [interval, setInterval] = useState<string>('15min')
const selectedInterval = CHART_INTERVALS.find((i) => i.key === interval) || CHART_INTERVALS[1]
const { data: selectedFunction } = useEdgeFunctionQuery({
projectRef,
slug: functionSlug,
})
const id = selectedFunction?.id
const combinedStatsResults = useFunctionsCombinedStatsQuery({
projectRef,
functionId: id,
interval: selectedInterval.key as FunctionsCombinedStatsVariables['interval'],
})
const combinedStatsData = useMemo(() => {
const result = combinedStatsResults.data?.result as
| Record<string, string | number>[]
| undefined
return result || []
}, [combinedStatsResults.data])
const [startDate, endDate]: [Dayjs, Dayjs] = useMemo(() => {
const start = dayjs()
.subtract(selectedInterval.startValue, selectedInterval.startUnit as dayjs.ManipulateType)
.startOf(selectedInterval.startUnit as dayjs.ManipulateType)
const end = dayjs().startOf(selectedInterval.startUnit as dayjs.ManipulateType)
return [start, end]
}, [selectedInterval])
const {
data: combinedStatsChartData,
error: combinedStatsError,
isError: isErrorCombinedStats,
} = useFillTimeseriesSorted({
data: combinedStatsData,
timestampKey: 'timestamp',
valueKey: [
'requests_count',
'log_count',
'log_info_count',
'log_warn_count',
'log_error_count',
'success_count',
'redirect_count',
'client_err_count',
'server_err_count',
'avg_cpu_time_used',
'avg_memory_used',
'avg_execution_time',
'max_execution_time',
'avg_heap_memory_used',
'avg_external_memory_used',
'max_cpu_time_used',
],
defaultValue: 0,
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
})
const { isLoading: permissionsLoading, can: canReadFunction } = useAsyncCheckPermissions(
PermissionAction.FUNCTIONS_READ,
functionSlug as string
)
if (!canReadFunction && !permissionsLoading) {
return <NoPermission isFullPage resourceText="access this edge function" />
}
return (
<PageContainer size="full">
<PageSection>
<PageSectionContent>
{IS_PLATFORM && id && (
<div className="mb-8">
<EdgeFunctionRecentInvocations
functionId={id}
functionSlug={functionSlug as string}
/>
</div>
)}
<div className="flex flex-row items-center gap-2 mb-4">
<div className="flex items-center">
{CHART_INTERVALS.map((item, i) => {
const classes = []
if (i === 0) {
classes.push('rounded-tr-none rounded-br-none')
} else if (i === CHART_INTERVALS.length - 1) {
classes.push('rounded-tl-none rounded-bl-none')
} else {
classes.push('rounded-none')
}
return (
<Button
key={`function-filter-${i}`}
type={interval === item.key ? 'secondary' : 'default'}
onClick={() => setInterval(item.key)}
className={classes.join(' ')}
>
{item.label}
</Button>
)
})}
</div>
<span className="text-xs text-foreground-light">
Statistics for past {selectedInterval.label}
</span>
</div>
<div>
<div className="grid grid-cols-1 md:grid-cols-2 md:gap-4 lg:grid-cols-2 lg:gap-8">
<ReportWidget
title="Execution time"
tooltip="Average execution time of function invocations"
data={combinedStatsChartData}
isLoading={combinedStatsResults.isLoading}
renderer={(props) => {
return isErrorCombinedStats ? (
<Alert_Shadcn_ variant="warning">
<WarningIcon />
<AlertTitle_Shadcn_>Failed to reterieve execution time</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
{combinedStatsError?.message ?? 'Unknown error'}
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
) : (
<div className="space-y-8">
<AreaChart
title="Average execution time"
className="w-full"
xAxisKey="timestamp"
customDateFormat={selectedInterval.format}
yAxisKey="avg_execution_time"
data={props.data}
format="ms"
highlightedValue={meanBy(props.data, 'avg_execution_time')}
/>
<AreaChart
title="Max execution time"
className="w-full"
xAxisKey="timestamp"
customDateFormat={selectedInterval.format}
yAxisKey="max_execution_time"
data={props.data}
format="ms"
highlightedValue={
maxBy(props.data, 'max_execution_time')?.max_execution_time
}
/>
</div>
)
}}
/>
<ReportWidget
title="Invocations"
tooltip="Requests made to a function are considered invocations, and each invocation may have worker logs."
data={combinedStatsChartData}
isLoading={combinedStatsResults.isLoading}
renderer={(props) => {
if (isErrorCombinedStats) {
return (
<Alert_Shadcn_ variant="warning">
<WarningIcon />
<AlertTitle_Shadcn_>Failed to reterieve invocations</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
{combinedStatsError?.message ?? 'Unknown error'}
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)
} else {
const requestData = props.data
.map((d: any) => [
{
status: '2xx',
count: d.success_count,
timestamp: d.timestamp,
},
{
status: '3xx',
count: d.redirect_count,
timestamp: d.timestamp,
},
{
status: '4xx',
count: d.client_err_count,
timestamp: d.timestamp,
},
{
status: '5xx',
count: d.server_err_count,
timestamp: d.timestamp,
},
])
.flat()
const logsData = props.data
.map((d: any) => [
{
status: 'error',
count: d.log_error_count,
timestamp: d.timestamp,
},
{
status: 'info',
count: d.log_info_count,
timestamp: d.timestamp,
},
{
status: 'warn',
count: d.log_warn_count,
timestamp: d.timestamp,
},
])
.flat()
return (
<div className="space-y-8">
<StackedBarChart
title="Invocation Requests"
className="w-full"
xAxisKey="timestamp"
yAxisKey="count"
stackKey="status"
data={requestData}
highlightedValue={sumBy(requestData, 'count')}
customDateFormat={selectedInterval.format}
stackColors={['brand', 'slate', 'yellow', 'red']}
onBarClick={() => {
router.push(
`/project/${projectRef}/functions/${functionSlug}/invocations?its=${startDate.toISOString()}`
)
}}
/>
<StackedBarChart
title="Worker Logs"
className="w-full"
xAxisKey="timestamp"
yAxisKey="count"
stackKey="status"
data={logsData}
highlightedValue={sumBy(logsData, 'count')}
customDateFormat={selectedInterval.format}
stackColors={['red', 'brand', 'yellow']}
onBarClick={() => {
router.push(
`/project/${projectRef}/functions/${functionSlug}/logs?its=${startDate.toISOString()}`
)
}}
/>
</div>
)
}
}}
/>
<ReportWidget
title="CPU time"
tooltip="Average CPU time usage for the function"
data={combinedStatsChartData}
isLoading={combinedStatsResults.isLoading}
renderer={(props) => {
return isErrorCombinedStats ? (
<Alert_Shadcn_ variant="warning">
<WarningIcon />
<AlertTitle_Shadcn_>Failed to retrieve CPU time</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
{combinedStatsError?.message ?? 'Unknown error'}
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
) : (
<div className="space-y-8">
<AreaChart
title="Average CPU Time"
className="w-full"
xAxisKey="timestamp"
customDateFormat={selectedInterval.format}
yAxisKey="avg_cpu_time_used"
data={props.data}
format="ms"
highlightedValue={meanBy(props.data, 'avg_cpu_time_used')}
/>
<AreaChart
title="Max CPU Time"
className="w-full"
xAxisKey="timestamp"
customDateFormat={selectedInterval.format}
yAxisKey="max_cpu_time_used"
data={props.data}
format="ms"
highlightedValue={maxBy(props.data, 'max_cpu_time_used')?.max_cpu_time_used}
/>
</div>
)
}}
/>
<ReportWidget
title="Memory"
tooltip="Average memory usage for the function"
data={combinedStatsChartData}
isLoading={combinedStatsResults.isLoading}
renderer={(props) => {
if (isErrorCombinedStats) {
return (
<Alert_Shadcn_ variant="warning">
<WarningIcon />
<AlertTitle_Shadcn_>Failed to retrieve memory usage</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
{combinedStatsError?.message ?? 'Unknown error'}
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)
}
const memoryData = props.data
.map((d: any) => [
{
type: 'heap',
count: d.avg_heap_memory_used,
timestamp: d.timestamp,
},
{
type: 'external',
count: d.avg_external_memory_used,
timestamp: d.timestamp,
},
])
.flat()
return (
<div className="space-y-8">
<AreaChart
title="Average Memory Usage"
className="w-full"
xAxisKey="timestamp"
customDateFormat={selectedInterval.format}
yAxisKey="avg_memory_used"
data={props.data}
format="MB"
highlightedValue={meanBy(props.data, 'avg_memory_used')}
/>
<StackedBarChart
title="Average Memory Usage by Type"
className="w-full"
xAxisKey="timestamp"
yAxisKey="count"
stackKey="type"
format="MB"
data={memoryData}
highlightedValue={sumBy(memoryData, 'count')}
customDateFormat={selectedInterval.format}
stackColors={['blue', 'brand']}
/>
</div>
)
}}
/>
</div>
</div>
</PageSectionContent>
</PageSection>
</PageContainer>
)
return <EdgeFunctionOverviewPageContent />
}
PageLayout.getLayout = (page) => (