mirror of
https://github.com/supabase/supabase.git
synced 2026-10-13 05:09:12 +03:00
edge function
This commit is contained in:
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>
|
||||
)
|
||||
}
|
||||
+436
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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) => (
|
||||
|
||||
Reference in new issue
Block a user