mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
Edge function overview (#44009)
<img width="1575" height="1134" alt="image" src="https://github.com/user-attachments/assets/994b1113-717f-44a2-89a4-13bc0182db20" /> Attempts to improve our edge function overview pages to provide stronger insights into the health of a function, including reliability (error rates), performance (execution times) and usage (cpu and memory). As part of this work it refactors existing charts to use our new chart components. main consideration is the collective performance of error queries https://github.com/supabase/supabase/pull/44009/changes#diff-2a79cf61c5397a8ef363c333229fa7729a2efc90a4d8e0806e49c212d5aa97e7 ## To test: 1. Create an edge function that errors out randomly across requests. You can use cron to poll this function every second. 2. View the edge function and on the overview page confirm that errors are showing and grouped correctly in recent failed invocations sections. --------- Co-authored-by: Ali Waseem <waseema393@gmail.com>
This commit is contained in:
1 parent
36269df347
commit
e21088144d
17 files changed
+2288
-17
No files matched your search
+20
@@ -0,0 +1,20 @@
|
||||
import { BarChart2 } from 'lucide-react'
|
||||
import { ChartEmptyState } from 'ui-patterns/Chart'
|
||||
|
||||
interface EdgeFunctionChartEmptyStateProps {
|
||||
title: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export const EdgeFunctionChartEmptyState = ({
|
||||
title,
|
||||
description,
|
||||
}: EdgeFunctionChartEmptyStateProps) => {
|
||||
return (
|
||||
<ChartEmptyState
|
||||
icon={<BarChart2 size={16} />}
|
||||
title={title}
|
||||
description={description ?? 'It may take up to 24 hours for data to refresh'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
import { Rocket } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
Bar,
|
||||
CartesianGrid,
|
||||
BarChart as RechartBarChart,
|
||||
ReferenceLine,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts'
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from 'ui'
|
||||
|
||||
import {
|
||||
formatChartTimestamp,
|
||||
getChartTimeRangeLabels,
|
||||
INVOCATION_CHART_CONFIG,
|
||||
} from './EdgeFunctionOverview.utils'
|
||||
import type { InvocationChartDatum, InvocationUpdateAnnotation } from './EdgeFunctionOverview.utils'
|
||||
|
||||
interface EdgeFunctionInvocationsChartProps {
|
||||
chartData: InvocationChartDatum[]
|
||||
dateTimeFormat: string
|
||||
onChartClick: () => void
|
||||
updateAnnotation?: InvocationUpdateAnnotation
|
||||
}
|
||||
|
||||
export const EdgeFunctionInvocationsChart = ({
|
||||
chartData,
|
||||
dateTimeFormat,
|
||||
onChartClick,
|
||||
updateAnnotation,
|
||||
}: EdgeFunctionInvocationsChartProps) => {
|
||||
const timeRangeLabels = useMemo(
|
||||
() => getChartTimeRangeLabels(chartData, dateTimeFormat),
|
||||
[chartData, dateTimeFormat]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="relative h-40 w-full overflow-visible">
|
||||
<ChartContainer config={INVOCATION_CHART_CONFIG} className="!aspect-auto !h-full !w-full">
|
||||
<RechartBarChart
|
||||
data={chartData}
|
||||
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
|
||||
onClick={onChartClick}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<YAxis hide width={0} />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tick={false}
|
||||
minTickGap={32}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
className="text-foreground-light"
|
||||
labelFormatter={(value) =>
|
||||
formatChartTimestamp(value as string | number | undefined, dateTimeFormat)
|
||||
}
|
||||
indicator="dot"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="error_count"
|
||||
stackId="invocations"
|
||||
fill="var(--color-error_count)"
|
||||
maxBarSize={24}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="warning_count"
|
||||
stackId="invocations"
|
||||
fill="var(--color-warning_count)"
|
||||
maxBarSize={24}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="ok_count"
|
||||
stackId="invocations"
|
||||
fill="var(--color-ok_count)"
|
||||
maxBarSize={24}
|
||||
/>
|
||||
{updateAnnotation && (
|
||||
<ReferenceLine
|
||||
x={updateAnnotation.timestamp}
|
||||
stroke="hsl(var(--foreground-default))"
|
||||
strokeDasharray="4 4"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
)}
|
||||
</RechartBarChart>
|
||||
</ChartContainer>
|
||||
{updateAnnotation && (
|
||||
<span
|
||||
className="pointer-events-none absolute bottom-0 z-10 flex h-6 w-6 -translate-x-1/2 translate-y-1/2 items-center justify-center rounded-full border border-foreground/20 bg-background text-foreground shadow-sm"
|
||||
style={{ left: `${updateAnnotation.position}%` }}
|
||||
title={`Updated ${formatChartTimestamp(updateAnnotation.updatedAt, dateTimeFormat)}`}
|
||||
>
|
||||
<Rocket size={12} strokeWidth={1.75} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{timeRangeLabels && (
|
||||
<div className="-mt-6 flex items-center justify-between text-[10px] font-mono text-foreground-lighter">
|
||||
<span>{timeRangeLabels.start}</span>
|
||||
<span>{timeRangeLabels.end}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import type { ComponentProps } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import type { ChartIntervals } from 'types'
|
||||
import { Button } from 'ui'
|
||||
import { Chart, ChartActions, ChartLoadingState, ChartMetric } from 'ui-patterns/Chart'
|
||||
import { PageContainer } from 'ui-patterns/PageContainer'
|
||||
import {
|
||||
PageSection,
|
||||
PageSectionAside,
|
||||
PageSectionContent,
|
||||
PageSectionMeta,
|
||||
PageSectionSummary,
|
||||
} from 'ui-patterns/PageSection'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { EdgeFunctionChartEmptyState } from './EdgeFunctionChartEmptyState'
|
||||
import { EdgeFunctionInvocationsChart } from './EdgeFunctionInvocationsChart'
|
||||
import {
|
||||
EDGE_FUNCTION_CHART_INTERVALS,
|
||||
formatRate,
|
||||
getChartEmptyStateCopy,
|
||||
getSegmentedButtonClassName,
|
||||
} from './EdgeFunctionOverview.utils'
|
||||
import { toAlertError } from './EdgeFunctionRecentErrors.utils'
|
||||
import type { InvocationChartDatum, InvocationUpdateAnnotation } from './EdgeFunctionOverview.utils'
|
||||
|
||||
interface EdgeFunctionInvocationsSectionProps {
|
||||
interval: string
|
||||
onIntervalChange: (interval: string) => void
|
||||
selectedInterval: ChartIntervals
|
||||
actions?: ComponentProps<typeof ChartActions>['actions']
|
||||
totalInvocationCount: number
|
||||
totalErrorCount: number
|
||||
totalWarningCount: number
|
||||
isLoadingFunction: boolean
|
||||
isErrorFunction: boolean
|
||||
functionError?: unknown
|
||||
isLoadingChart: boolean
|
||||
isErrorChart: boolean
|
||||
chartErrorMessage?: string
|
||||
chartData: InvocationChartDatum[]
|
||||
onChartClick: () => void
|
||||
updateAnnotation?: InvocationUpdateAnnotation
|
||||
}
|
||||
|
||||
export const EdgeFunctionInvocationsSection = ({
|
||||
interval,
|
||||
onIntervalChange,
|
||||
selectedInterval,
|
||||
actions,
|
||||
totalInvocationCount,
|
||||
totalErrorCount,
|
||||
totalWarningCount,
|
||||
isLoadingFunction,
|
||||
isErrorFunction,
|
||||
functionError,
|
||||
isLoadingChart,
|
||||
isErrorChart,
|
||||
chartErrorMessage,
|
||||
chartData,
|
||||
onChartClick,
|
||||
updateAnnotation,
|
||||
}: EdgeFunctionInvocationsSectionProps) => {
|
||||
const dateTimeFormat = selectedInterval.format ?? 'MMM D, h:mma'
|
||||
const emptyStateCopy = useMemo(
|
||||
() => getChartEmptyStateCopy('invocations', isErrorChart, chartErrorMessage),
|
||||
[chartErrorMessage, isErrorChart]
|
||||
)
|
||||
|
||||
return (
|
||||
<PageSection className="bg-surface-100/50 border-b pb-10 pt-8">
|
||||
<PageSectionContent>
|
||||
<PageContainer size="full">
|
||||
<div className="flex flex-col gap-5">
|
||||
<PageSectionMeta className="!items-center">
|
||||
<PageSectionSummary>
|
||||
<div className="flex flex-wrap items-start gap-x-8 gap-y-4">
|
||||
<ChartMetric
|
||||
label="Total Invocations"
|
||||
value={totalInvocationCount}
|
||||
status="default"
|
||||
tooltip="Total number of invocations"
|
||||
/>
|
||||
<ChartMetric
|
||||
label="5xx Rate"
|
||||
value={formatRate(totalErrorCount, totalInvocationCount)}
|
||||
status="negative"
|
||||
tooltip="Share of invocations that returned a 5xx status code"
|
||||
/>
|
||||
<ChartMetric
|
||||
label="4xx Rate"
|
||||
value={formatRate(totalWarningCount, totalInvocationCount)}
|
||||
status="warning"
|
||||
tooltip="Share of invocations that returned a 4xx status code"
|
||||
/>
|
||||
</div>
|
||||
</PageSectionSummary>
|
||||
<PageSectionAside className="flex-wrap @xl:self-center">
|
||||
<div className="flex items-center">
|
||||
{EDGE_FUNCTION_CHART_INTERVALS.map((item, index) => {
|
||||
return (
|
||||
<Button
|
||||
key={`function-filter-${item.key}`}
|
||||
type={interval === item.key ? 'secondary' : 'default'}
|
||||
onClick={() => onIntervalChange(item.key)}
|
||||
className={getSegmentedButtonClassName(
|
||||
index,
|
||||
EDGE_FUNCTION_CHART_INTERVALS.length
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<ChartActions actions={actions} />
|
||||
</PageSectionAside>
|
||||
</PageSectionMeta>
|
||||
|
||||
<div>
|
||||
{isLoadingFunction && <GenericSkeletonLoader />}
|
||||
{isErrorFunction && (
|
||||
<AlertError
|
||||
error={toAlertError(functionError)}
|
||||
subject="Failed to retrieve edge function details"
|
||||
layout="vertical"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Chart isLoading={isLoadingChart}>
|
||||
{isLoadingChart ? (
|
||||
<ChartLoadingState />
|
||||
) : isErrorChart || chartData.length === 0 ? (
|
||||
<EdgeFunctionChartEmptyState
|
||||
title={emptyStateCopy.title}
|
||||
description={emptyStateCopy.description}
|
||||
/>
|
||||
) : (
|
||||
<EdgeFunctionInvocationsChart
|
||||
chartData={chartData}
|
||||
dateTimeFormat={dateTimeFormat}
|
||||
onChartClick={onChartClick}
|
||||
updateAnnotation={updateAnnotation}
|
||||
/>
|
||||
)}
|
||||
</Chart>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
import type { ChartLineProps } from 'ui-patterns/Chart'
|
||||
|
||||
import type { EdgeFunctionChartDatum } from './EdgeFunctionOverview.utils'
|
||||
import { formatReferenceDelta, getMemoryTooltipDetail } from './EdgeFunctionOverview.utils'
|
||||
|
||||
export const ExecutionTooltipDetail = ({
|
||||
averageExecutionTime,
|
||||
metricKey,
|
||||
value,
|
||||
}: {
|
||||
averageExecutionTime: number
|
||||
metricKey: string
|
||||
value: unknown
|
||||
}) => {
|
||||
if (metricKey !== 'max_execution_time') return null
|
||||
|
||||
return (
|
||||
<span className="text-foreground">
|
||||
{formatReferenceDelta(Number(value ?? 0), averageExecutionTime)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export const CpuTooltipDetail = ({
|
||||
averageCpuTime,
|
||||
value,
|
||||
}: {
|
||||
averageCpuTime: number
|
||||
value: unknown
|
||||
}) => {
|
||||
return (
|
||||
<span className="text-foreground">
|
||||
{formatReferenceDelta(Number(value ?? 0), averageCpuTime)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export const MemoryTooltipDetail = ({
|
||||
averageMemoryUsage,
|
||||
datum,
|
||||
value,
|
||||
}: {
|
||||
averageMemoryUsage: number
|
||||
datum: EdgeFunctionChartDatum
|
||||
value: unknown
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<span className="text-foreground">
|
||||
{formatReferenceDelta(Number(value ?? 0), averageMemoryUsage)}
|
||||
</span>
|
||||
<span className="text-foreground-light">
|
||||
{getMemoryTooltipDetail(datum.avg_heap_memory_used, datum.avg_external_memory_used)}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const getExecutionTooltipDetails = (
|
||||
averageExecutionTime: number
|
||||
): NonNullable<ChartLineProps['tooltipDetails']> => {
|
||||
return function renderExecutionTooltipDetails(_, key, value) {
|
||||
return (
|
||||
<ExecutionTooltipDetail
|
||||
averageExecutionTime={averageExecutionTime}
|
||||
metricKey={key}
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const getCpuTooltipDetails = (
|
||||
averageCpuTime: number
|
||||
): NonNullable<ChartLineProps['tooltipDetails']> => {
|
||||
return function renderCpuTooltipDetails(_, __, value) {
|
||||
return <CpuTooltipDetail averageCpuTime={averageCpuTime} value={value} />
|
||||
}
|
||||
}
|
||||
|
||||
export const getMemoryTooltipDetails = (
|
||||
averageMemoryUsage: number
|
||||
): NonNullable<ChartLineProps['tooltipDetails']> => {
|
||||
return function renderMemoryTooltipDetails(datum, _, value) {
|
||||
return (
|
||||
<MemoryTooltipDetail
|
||||
averageMemoryUsage={averageMemoryUsage}
|
||||
datum={datum as EdgeFunctionChartDatum}
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { IS_PLATFORM, useParams } from 'common'
|
||||
import { useUnifiedLogsPreview } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
import {
|
||||
EDGE_FUNCTION_CHART_INTERVALS,
|
||||
getBucketedTimeRange,
|
||||
getExecutionMetrics,
|
||||
getInvocationChartData,
|
||||
getInvocationTotals,
|
||||
getInvocationUpdateAnnotation,
|
||||
getRollingTimeRange,
|
||||
getUsageMetrics,
|
||||
toEdgeFunctionChartData,
|
||||
} from './EdgeFunctionOverview.utils'
|
||||
import type { EdgeFunctionChartRawDatum } from './EdgeFunctionOverview.utils'
|
||||
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 { useFillTimeseriesSorted } from 'hooks/analytics/useFillTimeseriesSorted'
|
||||
import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { EdgeFunctionInvocationsSection } from './EdgeFunctionInvocationsSection'
|
||||
import { EdgeFunctionPerformanceSection } from './EdgeFunctionPerformanceSection'
|
||||
import { EdgeFunctionRecentErrors } from './EdgeFunctionRecentErrors'
|
||||
import { EdgeFunctionUsageSection } from './EdgeFunctionUsageSection'
|
||||
|
||||
export const EdgeFunctionOverview = () => {
|
||||
const router = useRouter()
|
||||
const { ref: projectRef, functionSlug } = useParams()
|
||||
const { isEnabled: isUnifiedLogsEnabled } = useUnifiedLogsPreview()
|
||||
|
||||
const [interval, setInterval] = useState<string>('15min')
|
||||
const selectedInterval =
|
||||
EDGE_FUNCTION_CHART_INTERVALS.find((item) => item.key === interval) ||
|
||||
EDGE_FUNCTION_CHART_INTERVALS[1]
|
||||
const {
|
||||
data: selectedFunction,
|
||||
error: functionError,
|
||||
isPending: isLoadingFunction,
|
||||
isError: isErrorFunction,
|
||||
} = useEdgeFunctionQuery({
|
||||
projectRef,
|
||||
slug: functionSlug,
|
||||
})
|
||||
const id = selectedFunction?.id
|
||||
const combinedStatsResults = useFunctionsCombinedStatsQuery(
|
||||
{
|
||||
projectRef,
|
||||
functionId: id,
|
||||
interval: selectedInterval.key as FunctionsCombinedStatsVariables['interval'],
|
||||
},
|
||||
{
|
||||
enabled: IS_PLATFORM,
|
||||
}
|
||||
)
|
||||
|
||||
const combinedStatsData = useMemo(
|
||||
() => (combinedStatsResults.data?.result as EdgeFunctionChartRawDatum[] | undefined) || [],
|
||||
[combinedStatsResults.data]
|
||||
)
|
||||
|
||||
const [startDate, endDate] = useMemo(
|
||||
() => getBucketedTimeRange(selectedInterval),
|
||||
[selectedInterval]
|
||||
)
|
||||
const [selectedWindowStart, selectedWindowEnd] = useMemo(
|
||||
() => getRollingTimeRange(selectedInterval),
|
||||
[selectedInterval]
|
||||
)
|
||||
const dateTimeFormat = selectedInterval.format ?? 'MMM D, h:mma'
|
||||
|
||||
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 chartData = useMemo(
|
||||
() => toEdgeFunctionChartData(combinedStatsChartData),
|
||||
[combinedStatsChartData]
|
||||
)
|
||||
const invocationChartData = useMemo(() => getInvocationChartData(chartData), [chartData])
|
||||
const { totalInvocationCount, totalWarningCount, totalErrorCount } = useMemo(
|
||||
() => getInvocationTotals(invocationChartData),
|
||||
[invocationChartData]
|
||||
)
|
||||
const { averageExecutionTime, maxExecutionTime } = useMemo(
|
||||
() => getExecutionMetrics(chartData),
|
||||
[chartData]
|
||||
)
|
||||
const {
|
||||
averageCpuTime,
|
||||
maxCpuTime,
|
||||
averageMemoryUsage,
|
||||
totalHeapMemory,
|
||||
totalExternalMemory,
|
||||
totalMemoryByType,
|
||||
} = useMemo(() => getUsageMetrics(chartData), [chartData])
|
||||
const invocationUpdateAnnotation = useMemo(
|
||||
() =>
|
||||
getInvocationUpdateAnnotation({
|
||||
updatedAt:
|
||||
selectedFunction?.updated_at === undefined
|
||||
? undefined
|
||||
: String(selectedFunction.updated_at),
|
||||
invocationChartData,
|
||||
windowStart: selectedWindowStart,
|
||||
windowEnd: selectedWindowEnd,
|
||||
}),
|
||||
[invocationChartData, selectedFunction?.updated_at, selectedWindowEnd, selectedWindowStart]
|
||||
)
|
||||
|
||||
const invocationActions = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: isUnifiedLogsEnabled ? 'Open logs' : 'Open invocations',
|
||||
href: `/project/${projectRef}/functions/${functionSlug}/${
|
||||
isUnifiedLogsEnabled ? 'logs' : 'invocations'
|
||||
}`,
|
||||
icon: <ExternalLink size={12} />,
|
||||
},
|
||||
],
|
||||
[functionSlug, isUnifiedLogsEnabled, projectRef]
|
||||
)
|
||||
|
||||
const { isLoading: permissionsLoading, can: canReadFunction } = useAsyncCheckPermissions(
|
||||
PermissionAction.FUNCTIONS_READ,
|
||||
functionSlug as string
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_PLATFORM && projectRef && functionSlug) {
|
||||
router.replace(`/project/${projectRef}/functions/${functionSlug}/details`)
|
||||
}
|
||||
}, [functionSlug, projectRef, router])
|
||||
|
||||
if (!canReadFunction && !permissionsLoading) {
|
||||
return <NoPermission isFullPage resourceText="access this edge function" />
|
||||
}
|
||||
|
||||
if (!IS_PLATFORM) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<EdgeFunctionInvocationsSection
|
||||
interval={interval}
|
||||
onIntervalChange={setInterval}
|
||||
selectedInterval={selectedInterval}
|
||||
actions={invocationActions}
|
||||
totalInvocationCount={totalInvocationCount}
|
||||
totalErrorCount={totalErrorCount}
|
||||
totalWarningCount={totalWarningCount}
|
||||
isLoadingFunction={isLoadingFunction}
|
||||
isErrorFunction={isErrorFunction}
|
||||
functionError={functionError}
|
||||
isLoadingChart={combinedStatsResults.isLoading}
|
||||
isErrorChart={isErrorCombinedStats}
|
||||
chartErrorMessage={combinedStatsError?.message ?? 'Unknown error'}
|
||||
chartData={invocationChartData}
|
||||
onChartClick={() => {
|
||||
router.push(
|
||||
`/project/${projectRef}/functions/${functionSlug}/${
|
||||
isUnifiedLogsEnabled ? 'logs' : 'invocations'
|
||||
}${isUnifiedLogsEnabled ? '' : `?its=${startDate.toISOString()}`}`
|
||||
)
|
||||
}}
|
||||
updateAnnotation={invocationUpdateAnnotation}
|
||||
/>
|
||||
|
||||
<EdgeFunctionRecentErrors
|
||||
functionId={id}
|
||||
functionSlug={functionSlug as string}
|
||||
projectRef={projectRef as string}
|
||||
isoTimestampStart={selectedWindowStart.toISOString()}
|
||||
isoTimestampEnd={selectedWindowEnd.toISOString()}
|
||||
/>
|
||||
|
||||
<EdgeFunctionPerformanceSection
|
||||
data={chartData}
|
||||
dateTimeFormat={dateTimeFormat}
|
||||
isLoading={combinedStatsResults.isLoading}
|
||||
isError={isErrorCombinedStats}
|
||||
errorMessage={combinedStatsError?.message ?? 'Unknown error'}
|
||||
averageExecutionTime={averageExecutionTime}
|
||||
maxExecutionTime={maxExecutionTime}
|
||||
/>
|
||||
|
||||
<EdgeFunctionUsageSection
|
||||
data={chartData}
|
||||
dateTimeFormat={dateTimeFormat}
|
||||
isLoading={combinedStatsResults.isLoading}
|
||||
isError={isErrorCombinedStats}
|
||||
errorMessage={combinedStatsError?.message ?? 'Unknown error'}
|
||||
averageCpuTime={averageCpuTime}
|
||||
maxCpuTime={maxCpuTime}
|
||||
averageMemoryUsage={averageMemoryUsage}
|
||||
totalHeapMemory={totalHeapMemory}
|
||||
totalExternalMemory={totalExternalMemory}
|
||||
totalMemoryByType={totalMemoryByType}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default EdgeFunctionOverview
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
EDGE_FUNCTION_CHART_INTERVALS,
|
||||
type EdgeFunctionChartRawDatum,
|
||||
formatChartTimestamp,
|
||||
formatMetric,
|
||||
formatRate,
|
||||
formatReferenceDelta,
|
||||
getBucketedTimeRange,
|
||||
getChartEmptyStateCopy,
|
||||
getChartTimeRangeLabels,
|
||||
getExecutionMetrics,
|
||||
getInvocationChartData,
|
||||
getInvocationTotals,
|
||||
getInvocationUpdateAnnotation,
|
||||
getMemoryTooltipDetail,
|
||||
getRollingTimeRange,
|
||||
getSegmentedButtonClassName,
|
||||
toEdgeFunctionChartData,
|
||||
getUsageMetrics,
|
||||
} from './EdgeFunctionOverview.utils'
|
||||
|
||||
describe('EdgeFunctionOverview.utils', () => {
|
||||
it('uses a full day interval for the 1 day option', () => {
|
||||
expect(
|
||||
EDGE_FUNCTION_CHART_INTERVALS.find((interval) => interval.key === '1day')?.startUnit
|
||||
).toBe('day')
|
||||
})
|
||||
|
||||
it('builds invocation chart data and totals from combined stats', () => {
|
||||
const chartData = toEdgeFunctionChartData([
|
||||
{
|
||||
timestamp: '2026-03-20T10:00:00.000Z',
|
||||
success_count: 10,
|
||||
redirect_count: 2,
|
||||
client_err_count: 1,
|
||||
server_err_count: 3,
|
||||
},
|
||||
{
|
||||
timestamp: '2026-03-20T10:15:00.000Z',
|
||||
success_count: '4',
|
||||
redirect_count: '1',
|
||||
client_err_count: '0',
|
||||
server_err_count: '2',
|
||||
},
|
||||
] satisfies EdgeFunctionChartRawDatum[])
|
||||
const data = getInvocationChartData(chartData)
|
||||
|
||||
expect(data).toEqual([
|
||||
{
|
||||
timestamp: '2026-03-20T10:00:00.000Z',
|
||||
ok_count: 10,
|
||||
warning_count: 3,
|
||||
error_count: 3,
|
||||
},
|
||||
{
|
||||
timestamp: '2026-03-20T10:15:00.000Z',
|
||||
ok_count: 4,
|
||||
warning_count: 1,
|
||||
error_count: 2,
|
||||
},
|
||||
])
|
||||
|
||||
expect(chartData[1]).toEqual({
|
||||
timestamp: '2026-03-20T10:15:00.000Z',
|
||||
success_count: 4,
|
||||
redirect_count: 1,
|
||||
client_err_count: 0,
|
||||
server_err_count: 2,
|
||||
avg_execution_time: 0,
|
||||
max_execution_time: 0,
|
||||
avg_cpu_time_used: 0,
|
||||
max_cpu_time_used: 0,
|
||||
avg_memory_used: 0,
|
||||
avg_heap_memory_used: 0,
|
||||
avg_external_memory_used: 0,
|
||||
})
|
||||
|
||||
expect(getInvocationTotals(data)).toEqual({
|
||||
totalInvocationCount: 23,
|
||||
totalWarningCount: 4,
|
||||
totalErrorCount: 5,
|
||||
})
|
||||
})
|
||||
|
||||
it('builds segmented button classes and chart range labels', () => {
|
||||
expect(getSegmentedButtonClassName(0, 4)).toBe('rounded-tr-none rounded-br-none')
|
||||
expect(getSegmentedButtonClassName(1, 4)).toBe('rounded-none')
|
||||
expect(getSegmentedButtonClassName(3, 4)).toBe('rounded-tl-none rounded-bl-none')
|
||||
|
||||
expect(
|
||||
getChartTimeRangeLabels(
|
||||
[{ timestamp: '2026-03-20T10:00:00.000Z' }, { timestamp: '2026-03-20T11:00:00.000Z' }],
|
||||
'MMM D, h:mma'
|
||||
)
|
||||
).toEqual({
|
||||
start: dayjs('2026-03-20T10:00:00.000Z').format('MMM D, h:mma'),
|
||||
end: dayjs('2026-03-20T11:00:00.000Z').format('MMM D, h:mma'),
|
||||
})
|
||||
expect(getChartTimeRangeLabels([], 'MMM D')).toBeUndefined()
|
||||
expect(formatChartTimestamp('2026-03-20T10:00:00.000Z', 'MMM D, h:mma')).toBe(
|
||||
dayjs('2026-03-20T10:00:00.000Z').format('MMM D, h:mma')
|
||||
)
|
||||
})
|
||||
|
||||
it('computes execution and usage metrics', () => {
|
||||
const stats = [
|
||||
{
|
||||
timestamp: '2026-03-20T10:00:00.000Z',
|
||||
success_count: 0,
|
||||
redirect_count: 0,
|
||||
client_err_count: 0,
|
||||
server_err_count: 0,
|
||||
avg_execution_time: 10,
|
||||
max_execution_time: 18,
|
||||
avg_cpu_time_used: 5,
|
||||
max_cpu_time_used: 8,
|
||||
avg_memory_used: 100,
|
||||
avg_heap_memory_used: 75,
|
||||
avg_external_memory_used: 25,
|
||||
},
|
||||
{
|
||||
timestamp: '2026-03-20T10:15:00.000Z',
|
||||
success_count: 0,
|
||||
redirect_count: 0,
|
||||
client_err_count: 0,
|
||||
server_err_count: 0,
|
||||
avg_execution_time: 30,
|
||||
max_execution_time: 45,
|
||||
avg_cpu_time_used: 15,
|
||||
max_cpu_time_used: 20,
|
||||
avg_memory_used: 200,
|
||||
avg_heap_memory_used: 150,
|
||||
avg_external_memory_used: 50,
|
||||
},
|
||||
]
|
||||
|
||||
expect(getExecutionMetrics(stats)).toEqual({
|
||||
averageExecutionTime: 20,
|
||||
maxExecutionTime: 45,
|
||||
})
|
||||
|
||||
expect(getUsageMetrics(stats)).toEqual({
|
||||
averageCpuTime: 10,
|
||||
maxCpuTime: 20,
|
||||
averageMemoryUsage: 150,
|
||||
totalHeapMemory: 225,
|
||||
totalExternalMemory: 75,
|
||||
totalMemoryByType: 300,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns a snapped deploy annotation when updated_at falls within the selected window', () => {
|
||||
const annotation = getInvocationUpdateAnnotation({
|
||||
updatedAt: '2026-03-20T10:16:30.000Z',
|
||||
invocationChartData: [
|
||||
{ timestamp: '2026-03-20T10:00:00.000Z', ok_count: 3, warning_count: 0, error_count: 0 },
|
||||
{ timestamp: '2026-03-20T10:15:00.000Z', ok_count: 5, warning_count: 1, error_count: 1 },
|
||||
{ timestamp: '2026-03-20T10:30:00.000Z', ok_count: 2, warning_count: 0, error_count: 0 },
|
||||
],
|
||||
windowStart: new Date('2026-03-20T09:45:00.000Z'),
|
||||
windowEnd: new Date('2026-03-20T10:45:00.000Z'),
|
||||
})
|
||||
|
||||
expect(annotation?.timestamp).toBe('2026-03-20T10:15:00.000Z')
|
||||
expect(annotation?.position).toBeCloseTo(50)
|
||||
expect(annotation?.updatedAt.toISOString()).toBe('2026-03-20T10:16:30.000Z')
|
||||
})
|
||||
|
||||
it('hides the deploy annotation when updated_at is outside the selected window', () => {
|
||||
const annotation = getInvocationUpdateAnnotation({
|
||||
updatedAt: '2026-03-20T11:05:00.000Z',
|
||||
invocationChartData: [
|
||||
{ timestamp: '2026-03-20T10:00:00.000Z', ok_count: 3, warning_count: 0, error_count: 0 },
|
||||
],
|
||||
windowStart: new Date('2026-03-20T09:45:00.000Z'),
|
||||
windowEnd: new Date('2026-03-20T10:45:00.000Z'),
|
||||
})
|
||||
|
||||
expect(annotation).toBeUndefined()
|
||||
})
|
||||
|
||||
it('builds bucketed and rolling time windows from the selected interval', () => {
|
||||
const interval = EDGE_FUNCTION_CHART_INTERVALS.find((item) => item.key === '1hr')
|
||||
expect(interval).toBeDefined()
|
||||
|
||||
const now = new Date('2026-03-20T10:37:00.000Z')
|
||||
const [bucketedStart, bucketedEnd] = getBucketedTimeRange(interval!, now)
|
||||
const [rollingStart, rollingEnd] = getRollingTimeRange(interval!, now)
|
||||
|
||||
expect(bucketedStart.toISOString()).toBe('2026-03-20T09:00:00.000Z')
|
||||
expect(bucketedEnd.toISOString()).toBe('2026-03-20T10:00:00.000Z')
|
||||
expect(rollingStart.toISOString()).toBe('2026-03-20T09:37:00.000Z')
|
||||
expect(rollingEnd.toISOString()).toBe('2026-03-20T10:37:00.000Z')
|
||||
})
|
||||
|
||||
it('formats metric, rate, and reference deltas consistently', () => {
|
||||
expect(formatMetric(12.34, 'MB')).toBe('12.3MB')
|
||||
expect(formatMetric(1234, 'ms')).toBe('1,234ms')
|
||||
expect(formatRate(1, 4)).toBe('25%')
|
||||
expect(formatReferenceDelta(110, 100)).toBe('10% above average')
|
||||
expect(formatReferenceDelta(90, 100)).toBe('10% below average')
|
||||
expect(formatReferenceDelta(100, 100)).toBe('At average')
|
||||
})
|
||||
|
||||
it('builds empty-state copy and tooltip detail strings', () => {
|
||||
expect(getChartEmptyStateCopy('invocations', false, 'boom')).toEqual({
|
||||
title: 'No data to show',
|
||||
description: undefined,
|
||||
})
|
||||
expect(getChartEmptyStateCopy('CPU time', true, 'Request failed')).toEqual({
|
||||
title: 'Unable to load CPU time',
|
||||
description: 'Request failed',
|
||||
})
|
||||
expect(getMemoryTooltipDetail(12.34, 5.67)).toBe('Heap 12.3MB • External 5.7MB')
|
||||
})
|
||||
})
|
||||
+297
@@ -0,0 +1,297 @@
|
||||
import dayjs from 'dayjs'
|
||||
import maxBy from 'lodash/maxBy'
|
||||
import meanBy from 'lodash/meanBy'
|
||||
import sumBy from 'lodash/sumBy'
|
||||
import type { ChartIntervals } from 'types'
|
||||
import type { ChartConfig } from 'ui'
|
||||
|
||||
export type EdgeFunctionChartRawDatum = {
|
||||
timestamp: string | number
|
||||
success_count?: string | number
|
||||
redirect_count?: string | number
|
||||
client_err_count?: string | number
|
||||
server_err_count?: string | number
|
||||
avg_execution_time?: string | number
|
||||
max_execution_time?: string | number
|
||||
avg_cpu_time_used?: string | number
|
||||
max_cpu_time_used?: string | number
|
||||
avg_memory_used?: string | number
|
||||
avg_heap_memory_used?: string | number
|
||||
avg_external_memory_used?: string | number
|
||||
}
|
||||
|
||||
export type EdgeFunctionChartDatum = {
|
||||
timestamp: string
|
||||
success_count: number
|
||||
redirect_count: number
|
||||
client_err_count: number
|
||||
server_err_count: number
|
||||
avg_execution_time: number
|
||||
max_execution_time: number
|
||||
avg_cpu_time_used: number
|
||||
max_cpu_time_used: number
|
||||
avg_memory_used: number
|
||||
avg_heap_memory_used: number
|
||||
avg_external_memory_used: number
|
||||
}
|
||||
|
||||
export type InvocationChartDatum = {
|
||||
timestamp: string
|
||||
ok_count: number
|
||||
warning_count: number
|
||||
error_count: number
|
||||
}
|
||||
|
||||
export type InvocationUpdateAnnotation = {
|
||||
timestamp: string
|
||||
position: number
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
export const EDGE_FUNCTION_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: 'day',
|
||||
format: 'MMM D, h:mma',
|
||||
},
|
||||
]
|
||||
|
||||
export const INVOCATION_CHART_CONFIG = {
|
||||
ok_count: {
|
||||
label: 'Ok',
|
||||
color: 'hsl(var(--brand-default))',
|
||||
},
|
||||
warning_count: {
|
||||
label: 'Warnings',
|
||||
color: 'hsl(var(--warning-default))',
|
||||
},
|
||||
error_count: {
|
||||
label: 'Errors',
|
||||
color: 'hsl(var(--destructive-default))',
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
export const CPU_TIME_CHART_CONFIG = {
|
||||
max_cpu_time_used: {
|
||||
label: 'Max CPU Time',
|
||||
color: 'hsl(var(--brand-default))',
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
export const EXECUTION_TIME_CHART_CONFIG = {
|
||||
avg_execution_time: {
|
||||
label: 'Average Execution Time',
|
||||
color: 'hsl(var(--foreground-default))',
|
||||
},
|
||||
max_execution_time: {
|
||||
label: 'Max Execution Time',
|
||||
color: 'hsl(var(--brand-default))',
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
export const MEMORY_CHART_CONFIG = {
|
||||
avg_memory_used: {
|
||||
label: 'Memory Usage',
|
||||
color: 'hsl(var(--brand-default))',
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
const toManipulateUnit = (unit: ChartIntervals['startUnit']) => unit as dayjs.ManipulateType
|
||||
const toNumber = (value: string | number | undefined) => Number(value ?? 0)
|
||||
|
||||
export const getBucketedTimeRange = (
|
||||
interval: ChartIntervals,
|
||||
now: Date = new Date()
|
||||
): [Date, Date] => {
|
||||
const currentTime = dayjs(now)
|
||||
const unit = toManipulateUnit(interval.startUnit)
|
||||
const start = currentTime.subtract(interval.startValue, unit).startOf(unit)
|
||||
const end = currentTime.startOf(unit)
|
||||
|
||||
return [start.toDate(), end.toDate()]
|
||||
}
|
||||
|
||||
export const getRollingTimeRange = (
|
||||
interval: ChartIntervals,
|
||||
now: Date = new Date()
|
||||
): [Date, Date] => {
|
||||
const currentTime = dayjs(now)
|
||||
const start = currentTime.subtract(interval.startValue, toManipulateUnit(interval.startUnit))
|
||||
|
||||
return [start.toDate(), currentTime.toDate()]
|
||||
}
|
||||
|
||||
export const formatChartTimestamp = (value: Date | string | number | undefined, format: string) => {
|
||||
return dayjs(value === undefined ? '' : value).format(format)
|
||||
}
|
||||
|
||||
export const getChartTimeRangeLabels = (
|
||||
data: Array<{ timestamp: string }>,
|
||||
format: string
|
||||
): { start: string; end: string } | undefined => {
|
||||
if (data.length === 0) return undefined
|
||||
|
||||
return {
|
||||
start: formatChartTimestamp(data[0]?.timestamp, format),
|
||||
end: formatChartTimestamp(data[data.length - 1]?.timestamp, format),
|
||||
}
|
||||
}
|
||||
|
||||
export const toEdgeFunctionChartData = (
|
||||
rows: EdgeFunctionChartRawDatum[] = []
|
||||
): EdgeFunctionChartDatum[] =>
|
||||
rows.map((row) => ({
|
||||
timestamp: String(row.timestamp ?? ''),
|
||||
success_count: toNumber(row.success_count),
|
||||
redirect_count: toNumber(row.redirect_count),
|
||||
client_err_count: toNumber(row.client_err_count),
|
||||
server_err_count: toNumber(row.server_err_count),
|
||||
avg_execution_time: toNumber(row.avg_execution_time),
|
||||
max_execution_time: toNumber(row.max_execution_time),
|
||||
avg_cpu_time_used: toNumber(row.avg_cpu_time_used),
|
||||
max_cpu_time_used: toNumber(row.max_cpu_time_used),
|
||||
avg_memory_used: toNumber(row.avg_memory_used),
|
||||
avg_heap_memory_used: toNumber(row.avg_heap_memory_used),
|
||||
avg_external_memory_used: toNumber(row.avg_external_memory_used),
|
||||
}))
|
||||
|
||||
export const getInvocationChartData = (data: EdgeFunctionChartDatum[]): InvocationChartDatum[] =>
|
||||
data.map((datum) => ({
|
||||
timestamp: datum.timestamp,
|
||||
ok_count: datum.success_count,
|
||||
warning_count: datum.redirect_count + datum.client_err_count,
|
||||
error_count: datum.server_err_count,
|
||||
}))
|
||||
|
||||
export const getInvocationTotals = (data: InvocationChartDatum[]) => {
|
||||
const totalInvocationCount = sumBy(data, (datum) => {
|
||||
return datum.ok_count + datum.warning_count + datum.error_count
|
||||
})
|
||||
|
||||
return {
|
||||
totalInvocationCount,
|
||||
totalWarningCount: sumBy(data, 'warning_count'),
|
||||
totalErrorCount: sumBy(data, 'error_count'),
|
||||
}
|
||||
}
|
||||
|
||||
export const getExecutionMetrics = (data: EdgeFunctionChartDatum[]) => ({
|
||||
averageExecutionTime: meanBy(data, 'avg_execution_time') ?? 0,
|
||||
maxExecutionTime: maxBy(data, 'max_execution_time')?.max_execution_time ?? 0,
|
||||
})
|
||||
|
||||
export const getUsageMetrics = (data: EdgeFunctionChartDatum[]) => {
|
||||
const totalHeapMemory = sumBy(data, 'avg_heap_memory_used')
|
||||
const totalExternalMemory = sumBy(data, 'avg_external_memory_used')
|
||||
|
||||
return {
|
||||
averageCpuTime: meanBy(data, 'avg_cpu_time_used') ?? 0,
|
||||
maxCpuTime: maxBy(data, 'max_cpu_time_used')?.max_cpu_time_used ?? 0,
|
||||
averageMemoryUsage: meanBy(data, 'avg_memory_used') ?? 0,
|
||||
totalHeapMemory,
|
||||
totalExternalMemory,
|
||||
totalMemoryByType: totalHeapMemory + totalExternalMemory,
|
||||
}
|
||||
}
|
||||
|
||||
export const getInvocationUpdateAnnotation = ({
|
||||
updatedAt,
|
||||
invocationChartData,
|
||||
windowStart,
|
||||
windowEnd,
|
||||
}: {
|
||||
updatedAt?: string
|
||||
invocationChartData: InvocationChartDatum[]
|
||||
windowStart: Date
|
||||
windowEnd: Date
|
||||
}): InvocationUpdateAnnotation | undefined => {
|
||||
if (!updatedAt || invocationChartData.length === 0) return undefined
|
||||
|
||||
const updatedAtDate = new Date(updatedAt)
|
||||
const updatedAtValue = updatedAtDate.valueOf()
|
||||
|
||||
if (Number.isNaN(updatedAtValue)) return undefined
|
||||
if (updatedAtValue < windowStart.valueOf() || updatedAtValue > windowEnd.valueOf()) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const closestTimestamp = invocationChartData.reduce((closest, datum) => {
|
||||
const datumDistance = Math.abs(new Date(datum.timestamp).valueOf() - updatedAtValue)
|
||||
const closestDistance = Math.abs(new Date(closest.timestamp).valueOf() - updatedAtValue)
|
||||
|
||||
return datumDistance < closestDistance ? datum : closest
|
||||
}).timestamp
|
||||
|
||||
const markerIndex = invocationChartData.findIndex((datum) => datum.timestamp === closestTimestamp)
|
||||
if (markerIndex < 0) return undefined
|
||||
|
||||
return {
|
||||
timestamp: closestTimestamp,
|
||||
position: ((markerIndex + 0.5) / invocationChartData.length) * 100,
|
||||
updatedAt: updatedAtDate,
|
||||
}
|
||||
}
|
||||
|
||||
export const getSegmentedButtonClassName = (index: number, total: number) => {
|
||||
if (index === 0) return 'rounded-tr-none rounded-br-none'
|
||||
if (index === total - 1) return 'rounded-tl-none rounded-bl-none'
|
||||
return 'rounded-none'
|
||||
}
|
||||
|
||||
export const getChartEmptyStateCopy = (
|
||||
subject: string,
|
||||
isError: boolean,
|
||||
errorMessage?: string
|
||||
) => ({
|
||||
title: isError ? `Unable to load ${subject}` : 'No data to show',
|
||||
description: isError ? errorMessage : undefined,
|
||||
})
|
||||
|
||||
export const formatMetric = (value?: number, unit?: string) => {
|
||||
if (value === undefined || Number.isNaN(value)) return unit ? `0${unit}` : '0'
|
||||
|
||||
const formatted = unit === 'MB' ? value.toFixed(1) : Math.round(value).toLocaleString('en-US')
|
||||
return unit ? `${formatted}${unit}` : formatted
|
||||
}
|
||||
|
||||
export const formatRate = (count: number, total: number) =>
|
||||
new Intl.NumberFormat('en-US', {
|
||||
style: 'percent',
|
||||
maximumFractionDigits: 1,
|
||||
}).format(total === 0 ? 0 : count / total)
|
||||
|
||||
export const formatReferenceDelta = (value: number, reference: number, label = 'average') => {
|
||||
const difference = value - reference
|
||||
if (Math.abs(difference) < Number.EPSILON) return `At ${label}`
|
||||
if (reference === 0) return `${difference > 0 ? 'Above' : 'Below'} ${label}`
|
||||
|
||||
const percentDifference = Math.round(Math.abs((difference / reference) * 100))
|
||||
return `${percentDifference}% ${difference > 0 ? 'above' : 'below'} ${label}`
|
||||
}
|
||||
|
||||
export const getMemoryTooltipDetail = (heapMemory: number, externalMemory: number) => {
|
||||
return `Heap ${formatMetric(heapMemory, 'MB')} • External ${formatMetric(externalMemory, 'MB')}`
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
import { useMemo } from 'react'
|
||||
import { ChartMetric } from 'ui-patterns/Chart'
|
||||
import { PageContainer } from 'ui-patterns/PageContainer'
|
||||
import {
|
||||
PageSection,
|
||||
PageSectionContent,
|
||||
PageSectionMeta,
|
||||
PageSectionSummary,
|
||||
PageSectionTitle,
|
||||
} from 'ui-patterns/PageSection'
|
||||
|
||||
import { getExecutionTooltipDetails } from './EdgeFunctionMetricTooltipDetails'
|
||||
import {
|
||||
EXECUTION_TIME_CHART_CONFIG,
|
||||
formatMetric,
|
||||
getChartEmptyStateCopy,
|
||||
} from './EdgeFunctionOverview.utils'
|
||||
import { EdgeFunctionTimeSeriesChartCard } from './EdgeFunctionTimeSeriesChartCard'
|
||||
import type { EdgeFunctionChartDatum } from './EdgeFunctionOverview.utils'
|
||||
|
||||
interface EdgeFunctionPerformanceSectionProps {
|
||||
data: EdgeFunctionChartDatum[]
|
||||
dateTimeFormat: string
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
errorMessage?: string
|
||||
averageExecutionTime: number
|
||||
maxExecutionTime: number
|
||||
}
|
||||
|
||||
export const EdgeFunctionPerformanceSection = ({
|
||||
data,
|
||||
dateTimeFormat,
|
||||
isLoading,
|
||||
isError,
|
||||
errorMessage,
|
||||
averageExecutionTime,
|
||||
maxExecutionTime,
|
||||
}: EdgeFunctionPerformanceSectionProps) => {
|
||||
const emptyStateCopy = getChartEmptyStateCopy('execution time', isError, errorMessage)
|
||||
const tooltipDetails = useMemo(
|
||||
() => getExecutionTooltipDetails(averageExecutionTime),
|
||||
[averageExecutionTime]
|
||||
)
|
||||
const metrics = (
|
||||
<div className="flex flex-wrap gap-x-8 gap-y-4">
|
||||
<ChartMetric
|
||||
label="Average Execution Time"
|
||||
value={formatMetric(averageExecutionTime, 'ms')}
|
||||
tooltip="Average execution time of function invocations"
|
||||
/>
|
||||
<ChartMetric
|
||||
label="Max Execution Time"
|
||||
value={formatMetric(maxExecutionTime, 'ms')}
|
||||
tooltip="Maximum execution time of function invocations"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<PageSection>
|
||||
<PageSectionContent>
|
||||
<PageContainer size="full">
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>Performance</PageSectionTitle>
|
||||
</PageSectionSummary>
|
||||
</PageSectionMeta>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<EdgeFunctionTimeSeriesChartCard
|
||||
className="lg:col-span-2"
|
||||
data={data}
|
||||
dateTimeFormat={dateTimeFormat}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
emptyTitle={emptyStateCopy.title}
|
||||
emptyDescription={emptyStateCopy.description}
|
||||
metrics={metrics}
|
||||
dataKey="max_execution_time"
|
||||
dataKeys={['avg_execution_time', 'max_execution_time']}
|
||||
config={EXECUTION_TIME_CHART_CONFIG}
|
||||
tooltipDetails={tooltipDetails}
|
||||
referenceLines={[
|
||||
{
|
||||
y: averageExecutionTime,
|
||||
label: 'average',
|
||||
stroke: 'hsl(var(--foreground-default))',
|
||||
strokeWidth: 1.5,
|
||||
},
|
||||
]}
|
||||
yAxisProps={{
|
||||
width: 64,
|
||||
tickFormatter: (value: number) => `${Math.round(value)}ms`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
|
||||
import { AiAssistantDropdown } from 'components/ui/AiAssistantDropdown'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import useLogsQuery from 'hooks/analytics/useLogsQuery'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { Fragment, useMemo } from 'react'
|
||||
import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
|
||||
import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
cn,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from 'ui'
|
||||
import { PageContainer } from 'ui-patterns/PageContainer'
|
||||
import {
|
||||
PageSection,
|
||||
PageSectionAside,
|
||||
PageSectionContent,
|
||||
PageSectionMeta,
|
||||
PageSectionSummary,
|
||||
PageSectionTitle,
|
||||
} from 'ui-patterns/PageSection'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import {
|
||||
buildGroupAssistantPrompt,
|
||||
formatLogTimestamp,
|
||||
getStatusBadgeVariant,
|
||||
formatSingleLineMessage,
|
||||
getFunctionRuntimeLogsSql,
|
||||
getRecentErrorGroups,
|
||||
getRecentErrorGroupsBase,
|
||||
getRecentErrorInvocationsSql,
|
||||
getRelatedExecutionIds,
|
||||
toAlertError,
|
||||
type RecentErrorGroup,
|
||||
} from './EdgeFunctionRecentErrors.utils'
|
||||
|
||||
interface EdgeFunctionRecentErrorsProps {
|
||||
functionId?: string
|
||||
functionSlug?: string
|
||||
projectRef?: string
|
||||
isoTimestampStart?: string
|
||||
isoTimestampEnd?: string
|
||||
}
|
||||
|
||||
export const EdgeFunctionRecentErrors = ({
|
||||
functionId,
|
||||
functionSlug,
|
||||
projectRef,
|
||||
isoTimestampStart,
|
||||
isoTimestampEnd,
|
||||
}: EdgeFunctionRecentErrorsProps) => {
|
||||
const router = useRouter()
|
||||
const { openSidebar } = useSidebarManagerSnapshot()
|
||||
const aiAssistant = useAiAssistantStateSnapshot()
|
||||
|
||||
const isQueryEnabled = Boolean(projectRef && functionId)
|
||||
const recentErrorInvocationsSql = useMemo(
|
||||
() => getRecentErrorInvocationsSql(functionId),
|
||||
[functionId]
|
||||
)
|
||||
|
||||
const {
|
||||
logData: recentErrorInvocations,
|
||||
isLoading: isLoadingRecentErrorInvocations,
|
||||
error: recentErrorInvocationsError,
|
||||
} = useLogsQuery(
|
||||
projectRef as string,
|
||||
{
|
||||
sql: recentErrorInvocationsSql,
|
||||
iso_timestamp_start: isoTimestampStart,
|
||||
iso_timestamp_end: isoTimestampEnd,
|
||||
},
|
||||
isQueryEnabled
|
||||
)
|
||||
|
||||
const recentErrorGroupsBase = useMemo(
|
||||
() => getRecentErrorGroupsBase(recentErrorInvocations),
|
||||
[recentErrorInvocations]
|
||||
)
|
||||
|
||||
const relatedExecutionIds = useMemo(
|
||||
() => getRelatedExecutionIds(recentErrorGroupsBase),
|
||||
[recentErrorGroupsBase]
|
||||
)
|
||||
|
||||
const functionRuntimeLogsSql = useMemo(
|
||||
() => getFunctionRuntimeLogsSql({ functionId, executionIds: relatedExecutionIds }),
|
||||
[functionId, relatedExecutionIds]
|
||||
)
|
||||
|
||||
const {
|
||||
logData: functionRuntimeLogs,
|
||||
isLoading: isLoadingFunctionRuntimeLogs,
|
||||
error: functionRuntimeLogsError,
|
||||
} = useLogsQuery(
|
||||
projectRef as string,
|
||||
{
|
||||
sql: functionRuntimeLogsSql,
|
||||
iso_timestamp_start: isoTimestampStart,
|
||||
iso_timestamp_end: isoTimestampEnd,
|
||||
},
|
||||
Boolean(projectRef && functionRuntimeLogsSql)
|
||||
)
|
||||
const queryError =
|
||||
toAlertError(recentErrorInvocationsError) ?? toAlertError(functionRuntimeLogsError)
|
||||
|
||||
const recentErrorGroups = useMemo(
|
||||
() => getRecentErrorGroups({ recentErrorGroupsBase, functionRuntimeLogs }),
|
||||
[functionRuntimeLogs, recentErrorGroupsBase]
|
||||
)
|
||||
|
||||
const handleOpenAssistant = (group: RecentErrorGroup) => {
|
||||
openSidebar(SIDEBAR_KEYS.AI_ASSISTANT)
|
||||
aiAssistant.newChat({
|
||||
name: `Investigate ${functionSlug ?? 'error'}`,
|
||||
initialMessage: buildGroupAssistantPrompt(group, functionSlug),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<PageSection>
|
||||
<PageSectionContent>
|
||||
<PageContainer size="full">
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>Recent Failed Invocations</PageSectionTitle>
|
||||
</PageSectionSummary>
|
||||
<PageSectionAside>
|
||||
<Button
|
||||
type="default"
|
||||
size="tiny"
|
||||
icon={<ExternalLink size={14} />}
|
||||
onClick={() =>
|
||||
router.push(`/project/${projectRef}/functions/${functionSlug}/logs`)
|
||||
}
|
||||
>
|
||||
View logs
|
||||
</Button>
|
||||
</PageSectionAside>
|
||||
</PageSectionMeta>
|
||||
|
||||
{recentErrorInvocationsError || functionRuntimeLogsError ? (
|
||||
<AlertError
|
||||
error={queryError}
|
||||
subject="Failed to retrieve recent edge function errors"
|
||||
/>
|
||||
) : isLoadingRecentErrorInvocations || isLoadingFunctionRuntimeLogs ? (
|
||||
<GenericSkeletonLoader />
|
||||
) : recentErrorGroups.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed px-4 py-6 text-sm text-foreground-light">
|
||||
Recent runtime errors will appear here when this function returns a 5xx response.
|
||||
</div>
|
||||
) : (
|
||||
<Card className="p-0 overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Error</TableHead>
|
||||
<TableHead>Count</TableHead>
|
||||
<TableHead>Last Seen</TableHead>
|
||||
<TableHead>Method</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Duration</TableHead>
|
||||
<TableHead className="text-right">Assistant</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{recentErrorGroups.map((group) => (
|
||||
<Fragment key={group.message}>
|
||||
<TableRow key={`${group.message}-summary`}>
|
||||
<TableCell className="max-w-[420px]">
|
||||
<span
|
||||
className="block truncate whitespace-nowrap text-foreground"
|
||||
title={formatSingleLineMessage(group.message)}
|
||||
>
|
||||
{formatSingleLineMessage(group.message)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-foreground-light">{group.count}</TableCell>
|
||||
<TableCell className="text-foreground-light">
|
||||
{formatLogTimestamp(group.lastSeen, 'relative')}
|
||||
</TableCell>
|
||||
<TableCell className="text-foreground-light">
|
||||
{group.lastMethod ?? '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{group.lastStatusCode ? (
|
||||
<Badge
|
||||
variant={getStatusBadgeVariant(group.lastStatusCode)}
|
||||
className="font-mono"
|
||||
>
|
||||
{group.lastStatusCode}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive" className="font-mono">
|
||||
Error
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-foreground-light">
|
||||
{group.executionTime ?? '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end">
|
||||
<AiAssistantDropdown
|
||||
label="Ask Assistant"
|
||||
size="tiny"
|
||||
buildPrompt={() => buildGroupAssistantPrompt(group, functionSlug)}
|
||||
onOpenAssistant={() => handleOpenAssistant(group)}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow key={`${group.message}-logs`} className="bg-surface-100/30">
|
||||
<TableCell colSpan={7} className="p-0">
|
||||
<div className="max-h-64 overflow-auto bg-surface-75 py-3 text-foreground-light">
|
||||
{group.logs.length === 0 ? (
|
||||
<div className="px-4">
|
||||
No related runtime logs found for this error group.
|
||||
</div>
|
||||
) : (
|
||||
group.logs.map((log) => (
|
||||
<div
|
||||
key={log.key}
|
||||
className={cn(
|
||||
'break-words px-4',
|
||||
log.level === 'error'
|
||||
? 'bg-destructive-300 font-mono text-destructive'
|
||||
: 'text-foreground-light'
|
||||
)}
|
||||
>
|
||||
[{formatLogTimestamp(log.lastSeen, 'time')}] [
|
||||
{log.level.toUpperCase()}] [{log.count}x]{' '}
|
||||
{formatSingleLineMessage(log.message)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</Fragment>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</PageContainer>
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
buildGroupAssistantPrompt,
|
||||
formatLogTimestamp,
|
||||
formatSingleLineMessage,
|
||||
getFunctionRuntimeLogsSql,
|
||||
getRecentErrorGroups,
|
||||
getRecentErrorGroupsBase,
|
||||
getRelatedExecutionIds,
|
||||
getStatusBadgeVariant,
|
||||
toAlertError,
|
||||
} from './EdgeFunctionRecentErrors.utils'
|
||||
|
||||
describe('EdgeFunctionRecentErrors.utils', () => {
|
||||
it('normalizes alert errors and single-line messages', () => {
|
||||
expect(toAlertError('boom')).toEqual({ message: 'boom' })
|
||||
expect(toAlertError({ message: 'broken' })).toEqual({ message: 'broken' })
|
||||
expect(toAlertError({ message: 123 })).toBeUndefined()
|
||||
expect(toAlertError(null)).toBeUndefined()
|
||||
|
||||
expect(formatSingleLineMessage(' first line\n second\t\tline ')).toBe(
|
||||
'first line second line'
|
||||
)
|
||||
})
|
||||
|
||||
it('builds runtime log SQL and escapes interpolated values', () => {
|
||||
expect(getFunctionRuntimeLogsSql({ functionId: undefined, executionIds: ['abc'] })).toBe('')
|
||||
expect(getFunctionRuntimeLogsSql({ functionId: 'fn_123', executionIds: [] })).toBe('')
|
||||
|
||||
expect(
|
||||
getFunctionRuntimeLogsSql({
|
||||
functionId: "fn_'123",
|
||||
executionIds: ['exec_1', "exec_'2"],
|
||||
limit: 25,
|
||||
})
|
||||
)
|
||||
.toBe(`select id, function_logs.timestamp, event_message, metadata.event_type, metadata.function_id, metadata.execution_id, metadata.level from function_logs
|
||||
cross join unnest(metadata) as metadata
|
||||
where metadata.function_id = 'fn_''123' and metadata.execution_id in ('exec_1', 'exec_''2')
|
||||
order by timestamp desc
|
||||
limit 25`)
|
||||
})
|
||||
|
||||
it('groups recent failed invocations by parsed error message', () => {
|
||||
const groups = getRecentErrorGroupsBase([
|
||||
{
|
||||
id: 'invocation-1',
|
||||
event_message: 'POST | 500 | database exploded',
|
||||
method: 'POST',
|
||||
status_code: 500,
|
||||
execution_id: 'exec-1',
|
||||
execution_time_ms: 123.7,
|
||||
timestamp: 100,
|
||||
},
|
||||
{
|
||||
id: 'invocation-2',
|
||||
event_message: 'POST | 500 | database exploded',
|
||||
method: 'POST',
|
||||
status_code: 500,
|
||||
execution_id: 'exec-2',
|
||||
execution_time_ms: 85.1,
|
||||
timestamp: 120,
|
||||
},
|
||||
{
|
||||
id: 'invocation-3',
|
||||
event_message: '',
|
||||
method: 'GET',
|
||||
status_code: 503,
|
||||
execution_id: '',
|
||||
timestamp: 110,
|
||||
},
|
||||
])
|
||||
|
||||
expect(groups).toEqual([
|
||||
{
|
||||
message: 'database exploded',
|
||||
count: 2,
|
||||
lastSeen: 120,
|
||||
lastExecutionId: 'exec-2',
|
||||
lastStatusCode: '500',
|
||||
lastMethod: 'POST',
|
||||
executionTime: '85ms',
|
||||
executionIds: ['exec-1', 'exec-2'],
|
||||
},
|
||||
{
|
||||
message: 'Unknown error',
|
||||
count: 1,
|
||||
lastSeen: 110,
|
||||
lastExecutionId: undefined,
|
||||
lastStatusCode: '503',
|
||||
lastMethod: 'GET',
|
||||
executionTime: undefined,
|
||||
executionIds: [],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('deduplicates execution ids and attaches grouped runtime logs', () => {
|
||||
const recentErrorGroupsBase = [
|
||||
{
|
||||
message: 'database exploded',
|
||||
count: 2,
|
||||
lastSeen: 120,
|
||||
lastExecutionId: 'exec-2',
|
||||
lastStatusCode: '500',
|
||||
lastMethod: 'POST',
|
||||
executionTime: '85ms',
|
||||
executionIds: ['exec-1', 'exec-2', 'exec-1'],
|
||||
},
|
||||
]
|
||||
|
||||
expect(getRelatedExecutionIds(recentErrorGroupsBase)).toEqual(['exec-1', 'exec-2'])
|
||||
|
||||
expect(
|
||||
getRecentErrorGroups({
|
||||
recentErrorGroupsBase,
|
||||
functionRuntimeLogs: [
|
||||
{
|
||||
id: 'runtime-log-1',
|
||||
execution_id: 'exec-1',
|
||||
level: 'error',
|
||||
event_message: 'stack trace',
|
||||
timestamp: 101,
|
||||
},
|
||||
{
|
||||
id: 'runtime-log-2',
|
||||
execution_id: 'exec-2',
|
||||
level: 'error',
|
||||
event_message: 'stack trace',
|
||||
timestamp: 121,
|
||||
},
|
||||
{
|
||||
id: 'runtime-log-3',
|
||||
execution_id: 'exec-2',
|
||||
event_type: 'warn',
|
||||
event_message: 'retrying upstream',
|
||||
timestamp: 119,
|
||||
},
|
||||
{
|
||||
id: 'runtime-log-4',
|
||||
execution_id: '',
|
||||
level: 'info',
|
||||
event_message: 'ignored',
|
||||
timestamp: 999,
|
||||
},
|
||||
],
|
||||
})
|
||||
).toEqual([
|
||||
{
|
||||
...recentErrorGroupsBase[0],
|
||||
logs: [
|
||||
{
|
||||
key: 'error:stack trace',
|
||||
message: 'stack trace',
|
||||
level: 'error',
|
||||
count: 2,
|
||||
lastSeen: 121,
|
||||
},
|
||||
{
|
||||
key: 'warn:retrying upstream',
|
||||
message: 'retrying upstream',
|
||||
level: 'warn',
|
||||
count: 1,
|
||||
lastSeen: 119,
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('formats timestamps, prompts, and status variants', () => {
|
||||
expect(formatLogTimestamp(undefined, 'time')).toBe('-')
|
||||
expect(formatLogTimestamp('2026-03-20T10:15:00.000Z', 'time')).toBe('10:15:00')
|
||||
|
||||
expect(
|
||||
buildGroupAssistantPrompt(
|
||||
{
|
||||
message: 'database exploded',
|
||||
count: 2,
|
||||
lastSeen: 1742465700000000,
|
||||
lastExecutionId: 'exec-2',
|
||||
lastStatusCode: '500',
|
||||
lastMethod: 'POST',
|
||||
executionTime: '85ms',
|
||||
executionIds: ['exec-1', 'exec-2'],
|
||||
logs: [
|
||||
{
|
||||
key: 'error:stack trace',
|
||||
message: 'stack trace',
|
||||
level: 'error',
|
||||
count: 2,
|
||||
lastSeen: 1742465700000000,
|
||||
},
|
||||
],
|
||||
},
|
||||
'my-function'
|
||||
)
|
||||
).toContain('Analyze this recurring edge function error for `my-function`.')
|
||||
|
||||
expect(getStatusBadgeVariant()).toBe('destructive')
|
||||
expect(getStatusBadgeVariant('500')).toBe('destructive')
|
||||
expect(getStatusBadgeVariant('404')).toBe('default')
|
||||
})
|
||||
})
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
import { LOGS_TABLES } from 'components/interfaces/Settings/Logs/Logs.constants'
|
||||
import type { LogData } from 'components/interfaces/Settings/Logs/Logs.types'
|
||||
import {
|
||||
genDefaultQuery,
|
||||
isUnixMicro,
|
||||
unixMicroToIsoTimestamp,
|
||||
} from 'components/interfaces/Settings/Logs/Logs.utils'
|
||||
import type { AlertErrorProps } from 'components/ui/AlertError'
|
||||
import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
|
||||
import { parseEdgeFunctionEventMessage } from '../EdgeFunctionRecentInvocations.utils'
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
export const MAX_RECENT_ERROR_GROUPS = 5
|
||||
export const RECENT_ERROR_INVOCATIONS_LIMIT = 50
|
||||
export const RELATED_RUNTIME_LOGS_LIMIT = 100
|
||||
|
||||
export type GroupedRuntimeLog = {
|
||||
key: string
|
||||
message: string
|
||||
level: string
|
||||
count: number
|
||||
lastSeen: number
|
||||
}
|
||||
|
||||
export type RecentErrorGroup = {
|
||||
message: string
|
||||
count: number
|
||||
lastSeen: number
|
||||
lastExecutionId?: string
|
||||
lastStatusCode?: string
|
||||
lastMethod?: string
|
||||
executionTime?: string
|
||||
executionIds: string[]
|
||||
logs: GroupedRuntimeLog[]
|
||||
}
|
||||
|
||||
export type RecentErrorGroupBase = Omit<RecentErrorGroup, 'logs'>
|
||||
|
||||
export const escapeSqlString = (value: string) => value.replace(/'/g, "''")
|
||||
|
||||
export const formatSingleLineMessage = (message: string) => message.replace(/\s+/g, ' ').trim()
|
||||
|
||||
export const toAlertError = (error: unknown): AlertErrorProps['error'] | undefined => {
|
||||
if (typeof error === 'string') return { message: error }
|
||||
|
||||
if (error && typeof error === 'object') {
|
||||
const message = (error as { message?: unknown }).message
|
||||
if (typeof message === 'string') return { message }
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const formatLogTimestamp = (
|
||||
value: string | number | undefined,
|
||||
format: 'relative' | 'time'
|
||||
) => {
|
||||
if (value === undefined) return '-'
|
||||
|
||||
const timestamp = isUnixMicro(value) ? unixMicroToIsoTimestamp(value) : String(value)
|
||||
return format === 'relative'
|
||||
? dayjs.utc(timestamp).fromNow()
|
||||
: dayjs.utc(timestamp).format('HH:mm:ss')
|
||||
}
|
||||
|
||||
export const buildGroupMarkdown = (group: RecentErrorGroup, functionSlug?: string) => {
|
||||
const lines = [
|
||||
`## Recent error for \`${functionSlug ?? 'edge function'}\``,
|
||||
'',
|
||||
`### ${group.message}`,
|
||||
`- Occurrences: ${group.count}`,
|
||||
`- Last seen: ${formatLogTimestamp(group.lastSeen, 'relative')}`,
|
||||
]
|
||||
|
||||
if (group.lastMethod) lines.push(`- Last method: ${group.lastMethod}`)
|
||||
if (group.lastStatusCode) lines.push(`- Last status: ${group.lastStatusCode}`)
|
||||
if (group.executionTime) lines.push(`- Last execution time: ${group.executionTime}`)
|
||||
|
||||
lines.push('', '#### Related runtime logs')
|
||||
|
||||
if (group.logs.length === 0) {
|
||||
lines.push('- No related runtime logs found for this error group.')
|
||||
} else {
|
||||
for (const log of group.logs) {
|
||||
lines.push(
|
||||
`- [${log.level}] ${log.count} occurrence${
|
||||
log.count === 1 ? '' : 's'
|
||||
}, last seen ${formatLogTimestamp(log.lastSeen, 'relative')}: ${log.message}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export const buildGroupAssistantPrompt = (group: RecentErrorGroup, functionSlug?: string) => {
|
||||
return [
|
||||
`Analyze this recurring edge function error for \`${functionSlug ?? 'edge function'}\`.`,
|
||||
'Summarize the likely root cause, what the runtime logs suggest, and the next debugging steps.',
|
||||
'',
|
||||
buildGroupMarkdown(group, functionSlug),
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export const getStatusBadgeVariant = (statusCode?: string) => {
|
||||
if (!statusCode) return 'destructive' as const
|
||||
|
||||
const status = Number(statusCode)
|
||||
if (Number.isNaN(status)) return 'destructive' as const
|
||||
if (status >= 500) return 'destructive' as const
|
||||
|
||||
return 'default' as const
|
||||
}
|
||||
|
||||
export const getRecentErrorInvocationsSql = (
|
||||
functionId?: string,
|
||||
limit = RECENT_ERROR_INVOCATIONS_LIMIT
|
||||
) =>
|
||||
genDefaultQuery(
|
||||
LOGS_TABLES.fn_edge,
|
||||
{
|
||||
function_id: functionId ?? '__pending__',
|
||||
'status_code.error': true,
|
||||
},
|
||||
limit
|
||||
)
|
||||
|
||||
export const getFunctionRuntimeLogsSql = ({
|
||||
functionId,
|
||||
executionIds,
|
||||
limit = RELATED_RUNTIME_LOGS_LIMIT,
|
||||
}: {
|
||||
functionId?: string
|
||||
executionIds: string[]
|
||||
limit?: number
|
||||
}) => {
|
||||
if (!functionId || executionIds.length === 0) return ''
|
||||
|
||||
const escapedExecutionIds = executionIds.map((id) => `'${escapeSqlString(id)}'`).join(', ')
|
||||
|
||||
return `select id, function_logs.timestamp, event_message, metadata.event_type, metadata.function_id, metadata.execution_id, metadata.level from function_logs
|
||||
cross join unnest(metadata) as metadata
|
||||
where metadata.function_id = '${escapeSqlString(functionId)}' and metadata.execution_id in (${escapedExecutionIds})
|
||||
order by timestamp desc
|
||||
limit ${limit}`
|
||||
}
|
||||
|
||||
export const getRecentErrorGroupsBase = (
|
||||
recentErrorInvocations: LogData[]
|
||||
): RecentErrorGroupBase[] => {
|
||||
const grouped: Record<string, RecentErrorGroupBase> = {}
|
||||
|
||||
for (const item of recentErrorInvocations) {
|
||||
const statusCode = String(item.status_code ?? '')
|
||||
const method = String(item.method ?? '')
|
||||
const message =
|
||||
parseEdgeFunctionEventMessage(
|
||||
String(item.event_message ?? ''),
|
||||
method || undefined,
|
||||
statusCode
|
||||
) || 'Unknown error'
|
||||
const executionId = String(item.execution_id ?? '')
|
||||
const timestamp = Number(item.timestamp ?? 0)
|
||||
const executionTime =
|
||||
item.execution_time_ms !== undefined
|
||||
? `${Math.round(Number(item.execution_time_ms))}ms`
|
||||
: undefined
|
||||
const current = grouped[message]
|
||||
|
||||
if (!current) {
|
||||
grouped[message] = {
|
||||
message,
|
||||
count: 1,
|
||||
lastSeen: timestamp,
|
||||
lastExecutionId: executionId || undefined,
|
||||
lastStatusCode: statusCode || undefined,
|
||||
lastMethod: method || undefined,
|
||||
executionTime,
|
||||
executionIds: executionId ? [executionId] : [],
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
current.count += 1
|
||||
|
||||
if (executionId && !current.executionIds.includes(executionId)) {
|
||||
current.executionIds.push(executionId)
|
||||
}
|
||||
|
||||
if (timestamp > current.lastSeen) {
|
||||
current.lastSeen = timestamp
|
||||
current.lastExecutionId = executionId || undefined
|
||||
current.lastStatusCode = statusCode || undefined
|
||||
current.lastMethod = method || undefined
|
||||
current.executionTime = executionTime
|
||||
}
|
||||
}
|
||||
|
||||
return Object.values(grouped)
|
||||
.sort((a, b) => b.lastSeen - a.lastSeen)
|
||||
.slice(0, MAX_RECENT_ERROR_GROUPS)
|
||||
}
|
||||
|
||||
export const getRelatedExecutionIds = (recentErrorGroupsBase: RecentErrorGroupBase[]) =>
|
||||
Array.from(new Set(recentErrorGroupsBase.flatMap((group) => group.executionIds).filter(Boolean)))
|
||||
|
||||
export const getRecentErrorGroups = ({
|
||||
recentErrorGroupsBase,
|
||||
functionRuntimeLogs,
|
||||
}: {
|
||||
recentErrorGroupsBase: RecentErrorGroupBase[]
|
||||
functionRuntimeLogs: LogData[]
|
||||
}): RecentErrorGroup[] => {
|
||||
const runtimeLogsByExecutionId = functionRuntimeLogs.reduce<Record<string, LogData[]>>(
|
||||
(acc, log) => {
|
||||
const executionId = String(log.execution_id ?? '')
|
||||
if (!executionId) return acc
|
||||
|
||||
acc[executionId] = [...(acc[executionId] ?? []), log]
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
||||
return recentErrorGroupsBase.map((group) => ({
|
||||
...group,
|
||||
logs: Array.from(new Set(group.executionIds))
|
||||
.flatMap((executionId) => runtimeLogsByExecutionId[executionId] ?? [])
|
||||
.reduce<GroupedRuntimeLog[]>((acc, log) => {
|
||||
const level = String(log.level ?? log.event_type ?? 'log')
|
||||
const message = String(log.event_message ?? '')
|
||||
const key = `${level}:${message}`
|
||||
const timestamp = Number(log.timestamp ?? 0)
|
||||
const existing = acc.find((entry) => entry.key === key)
|
||||
|
||||
if (existing) {
|
||||
existing.count += 1
|
||||
existing.lastSeen = Math.max(existing.lastSeen, timestamp)
|
||||
return acc
|
||||
}
|
||||
|
||||
acc.push({ key, message, level, count: 1, lastSeen: timestamp })
|
||||
return acc
|
||||
}, [])
|
||||
.sort((a, b) => b.count - a.count || b.lastSeen - a.lastSeen)
|
||||
.slice(0, MAX_RECENT_ERROR_GROUPS),
|
||||
}))
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ChartConfig } from 'ui'
|
||||
import {
|
||||
Chart,
|
||||
ChartCard,
|
||||
ChartContent,
|
||||
ChartHeader,
|
||||
ChartLine,
|
||||
ChartLoadingState,
|
||||
type ChartLineProps,
|
||||
} from 'ui-patterns/Chart'
|
||||
|
||||
import { EdgeFunctionChartEmptyState } from './EdgeFunctionChartEmptyState'
|
||||
import type { EdgeFunctionChartDatum } from './EdgeFunctionOverview.utils'
|
||||
|
||||
interface EdgeFunctionTimeSeriesChartCardProps {
|
||||
data: EdgeFunctionChartDatum[]
|
||||
dateTimeFormat: string
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
emptyTitle: string
|
||||
emptyDescription?: string
|
||||
metrics: ReactNode
|
||||
dataKey: string
|
||||
dataKeys?: string[]
|
||||
config: ChartConfig
|
||||
tooltipDetails?: ChartLineProps['tooltipDetails']
|
||||
referenceLines?: ChartLineProps['referenceLines']
|
||||
yAxisProps?: ChartLineProps['YAxisProps']
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const EdgeFunctionTimeSeriesChartCard = ({
|
||||
data,
|
||||
dateTimeFormat,
|
||||
isLoading,
|
||||
isError,
|
||||
emptyTitle,
|
||||
emptyDescription,
|
||||
metrics,
|
||||
dataKey,
|
||||
dataKeys,
|
||||
config,
|
||||
tooltipDetails,
|
||||
referenceLines,
|
||||
yAxisProps,
|
||||
className,
|
||||
}: EdgeFunctionTimeSeriesChartCardProps) => {
|
||||
return (
|
||||
<Chart isLoading={isLoading} className={className}>
|
||||
<ChartCard>
|
||||
<ChartHeader align="start">{metrics}</ChartHeader>
|
||||
<ChartContent
|
||||
isEmpty={isError || data.length === 0}
|
||||
emptyState={
|
||||
<EdgeFunctionChartEmptyState title={emptyTitle} description={emptyDescription} />
|
||||
}
|
||||
loadingState={<ChartLoadingState />}
|
||||
>
|
||||
<div className="h-40">
|
||||
<ChartLine
|
||||
data={data}
|
||||
dataKey={dataKey}
|
||||
dataKeys={dataKeys}
|
||||
DateTimeFormat={dateTimeFormat}
|
||||
isFullHeight
|
||||
showYAxis
|
||||
config={config}
|
||||
tooltipDetails={tooltipDetails}
|
||||
referenceLines={referenceLines}
|
||||
YAxisProps={yAxisProps}
|
||||
/>
|
||||
</div>
|
||||
</ChartContent>
|
||||
</ChartCard>
|
||||
</Chart>
|
||||
)
|
||||
}
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
import { useMemo } from 'react'
|
||||
import { ChartMetric } from 'ui-patterns/Chart'
|
||||
import { PageContainer } from 'ui-patterns/PageContainer'
|
||||
import {
|
||||
PageSection,
|
||||
PageSectionContent,
|
||||
PageSectionMeta,
|
||||
PageSectionSummary,
|
||||
PageSectionTitle,
|
||||
} from 'ui-patterns/PageSection'
|
||||
|
||||
import { getCpuTooltipDetails, getMemoryTooltipDetails } from './EdgeFunctionMetricTooltipDetails'
|
||||
import {
|
||||
CPU_TIME_CHART_CONFIG,
|
||||
formatMetric,
|
||||
formatRate,
|
||||
getChartEmptyStateCopy,
|
||||
MEMORY_CHART_CONFIG,
|
||||
} from './EdgeFunctionOverview.utils'
|
||||
import { EdgeFunctionTimeSeriesChartCard } from './EdgeFunctionTimeSeriesChartCard'
|
||||
import type { EdgeFunctionChartDatum } from './EdgeFunctionOverview.utils'
|
||||
|
||||
interface EdgeFunctionUsageSectionProps {
|
||||
data: EdgeFunctionChartDatum[]
|
||||
dateTimeFormat: string
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
errorMessage?: string
|
||||
averageCpuTime: number
|
||||
maxCpuTime: number
|
||||
averageMemoryUsage: number
|
||||
totalHeapMemory: number
|
||||
totalExternalMemory: number
|
||||
totalMemoryByType: number
|
||||
}
|
||||
|
||||
export const EdgeFunctionUsageSection = ({
|
||||
data,
|
||||
dateTimeFormat,
|
||||
isLoading,
|
||||
isError,
|
||||
errorMessage,
|
||||
averageCpuTime,
|
||||
maxCpuTime,
|
||||
averageMemoryUsage,
|
||||
totalHeapMemory,
|
||||
totalExternalMemory,
|
||||
totalMemoryByType,
|
||||
}: EdgeFunctionUsageSectionProps) => {
|
||||
const cpuEmptyStateCopy = getChartEmptyStateCopy('CPU time', isError, errorMessage)
|
||||
const memoryEmptyStateCopy = getChartEmptyStateCopy('memory usage', isError, errorMessage)
|
||||
const cpuTooltipDetails = useMemo(() => getCpuTooltipDetails(averageCpuTime), [averageCpuTime])
|
||||
const memoryTooltipDetails = useMemo(
|
||||
() => getMemoryTooltipDetails(averageMemoryUsage),
|
||||
[averageMemoryUsage]
|
||||
)
|
||||
const cpuMetrics = (
|
||||
<div className="flex flex-wrap gap-x-8 gap-y-4">
|
||||
<ChartMetric
|
||||
label="Average CPU Time"
|
||||
value={formatMetric(averageCpuTime, 'ms')}
|
||||
tooltip="Average CPU time usage for the function"
|
||||
/>
|
||||
<ChartMetric
|
||||
label="Max CPU Time"
|
||||
value={formatMetric(maxCpuTime, 'ms')}
|
||||
tooltip="Maximum CPU time usage for the function"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
const memoryMetrics = (
|
||||
<div className="flex flex-wrap gap-x-8 gap-y-4">
|
||||
<ChartMetric
|
||||
label="Average Memory Usage"
|
||||
value={formatMetric(averageMemoryUsage, 'MB')}
|
||||
tooltip="Average memory usage for the function"
|
||||
/>
|
||||
<ChartMetric
|
||||
label="Heap"
|
||||
value={formatRate(totalHeapMemory, totalMemoryByType)}
|
||||
tooltip="Share of memory attributed to heap usage over the selected interval"
|
||||
/>
|
||||
<ChartMetric
|
||||
label="External"
|
||||
value={formatRate(totalExternalMemory, totalMemoryByType)}
|
||||
tooltip="Share of memory attributed to external usage over the selected interval"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<PageSection>
|
||||
<PageSectionContent>
|
||||
<PageContainer size="full">
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>Usage</PageSectionTitle>
|
||||
</PageSectionSummary>
|
||||
</PageSectionMeta>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<EdgeFunctionTimeSeriesChartCard
|
||||
data={data}
|
||||
dateTimeFormat={dateTimeFormat}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
emptyTitle={cpuEmptyStateCopy.title}
|
||||
emptyDescription={cpuEmptyStateCopy.description}
|
||||
metrics={cpuMetrics}
|
||||
dataKey="max_cpu_time_used"
|
||||
config={CPU_TIME_CHART_CONFIG}
|
||||
tooltipDetails={cpuTooltipDetails}
|
||||
referenceLines={[
|
||||
{
|
||||
y: averageCpuTime,
|
||||
label: 'average',
|
||||
stroke: 'hsl(var(--foreground-default))',
|
||||
strokeWidth: 1.5,
|
||||
},
|
||||
]}
|
||||
yAxisProps={{
|
||||
width: 64,
|
||||
tickFormatter: (value: number) => `${Math.round(value)}ms`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<EdgeFunctionTimeSeriesChartCard
|
||||
data={data}
|
||||
dateTimeFormat={dateTimeFormat}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
emptyTitle={memoryEmptyStateCopy.title}
|
||||
emptyDescription={memoryEmptyStateCopy.description}
|
||||
metrics={memoryMetrics}
|
||||
dataKey="avg_memory_used"
|
||||
config={MEMORY_CHART_CONFIG}
|
||||
tooltipDetails={memoryTooltipDetails}
|
||||
referenceLines={[
|
||||
{
|
||||
y: averageMemoryUsage,
|
||||
label: 'average',
|
||||
stroke: 'hsl(var(--foreground-default))',
|
||||
strokeWidth: 1.5,
|
||||
},
|
||||
]}
|
||||
yAxisProps={{
|
||||
width: 64,
|
||||
tickFormatter: (value: number) => `${Number(value).toFixed(1)}MB`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useMonaco } from '@monaco-editor/react'
|
||||
import { IS_PLATFORM } from 'common'
|
||||
import BackwardIterator from 'components/ui/CodeEditor/Providers/BackwardIterator'
|
||||
import type { PlanId } from 'data/subscriptions/types'
|
||||
import dayjs, { Dayjs } from 'dayjs'
|
||||
import { get } from 'lodash'
|
||||
import uniqBy from 'lodash/uniqBy'
|
||||
@@ -9,7 +8,7 @@ import { useEffect } from 'react'
|
||||
import logConstants from 'shared-data/log-constants'
|
||||
|
||||
import { LogsTableName, SQL_FILTER_TEMPLATES } from './Logs.constants'
|
||||
import type { Filters, LogData, LogsEndpointParams } from './Logs.types'
|
||||
import type { Filters, LogData, LogsEndpointParams, QueryType } from './Logs.types'
|
||||
|
||||
/**
|
||||
* Convert a micro timestamp from number/string to iso timestamp
|
||||
@@ -181,7 +180,7 @@ limit ${limit}
|
||||
`
|
||||
|
||||
case 'function_logs':
|
||||
return `select id, ${table}.timestamp, event_message, metadata.event_type, metadata.function_id, metadata.level from ${table}
|
||||
return `select id, ${table}.timestamp, event_message, metadata.event_type, metadata.function_id, metadata.execution_id, metadata.level from ${table}
|
||||
${joins}
|
||||
${where}
|
||||
${orderBy}
|
||||
@@ -205,7 +204,7 @@ ${orderBy}
|
||||
limit ${limit}
|
||||
`
|
||||
}
|
||||
return `select id, ${table}.timestamp, event_message, response.status_code, request.method, request.pathname, m.function_id, m.execution_time_ms, m.deployment_id, m.version from ${table}
|
||||
return `select id, ${table}.timestamp, event_message, response.status_code, request.method, request.pathname, m.function_id, m.execution_id, m.execution_time_ms, m.deployment_id, m.version from ${table}
|
||||
${joins}
|
||||
${where}
|
||||
${orderBy}
|
||||
@@ -303,12 +302,14 @@ export const genCountQuery = (table: LogsTableName, filters: Filters): string =>
|
||||
}
|
||||
|
||||
/** calculates how much the chart start datetime should be offset given the current datetime filter params */
|
||||
const calcChartStart = (params: Partial<LogsEndpointParams>): [Dayjs, string] => {
|
||||
const calcChartStart = (
|
||||
params: Partial<LogsEndpointParams>
|
||||
): [Dayjs, 'minute' | 'hour' | 'day'] => {
|
||||
const ite = params.iso_timestamp_end ? dayjs(params.iso_timestamp_end) : dayjs()
|
||||
// todo @TzeYiing needs typing
|
||||
const its: any = params.iso_timestamp_start ? dayjs(params.iso_timestamp_start) : dayjs()
|
||||
|
||||
let trunc = 'minute'
|
||||
let trunc: 'minute' | 'hour' | 'day' = 'minute'
|
||||
let extendValue = 60 * 6
|
||||
const minuteDiff = ite.diff(its, 'minute')
|
||||
const hourDiff = ite.diff(its, 'hour')
|
||||
@@ -319,8 +320,6 @@ const calcChartStart = (params: Partial<LogsEndpointParams>): [Dayjs, string] =>
|
||||
trunc = 'day'
|
||||
extendValue = 7
|
||||
}
|
||||
//
|
||||
// @ts-ignore
|
||||
return [its.add(-extendValue, trunc), trunc]
|
||||
}
|
||||
|
||||
@@ -775,7 +774,7 @@ export function formatLogsAsMarkdown(rows: LogData[]): string {
|
||||
.join('\n\n---\n\n')
|
||||
}
|
||||
|
||||
const QUERY_TYPE_LABELS: Record<string, string> = {
|
||||
const QUERY_TYPE_LABELS: Record<QueryType, string> = {
|
||||
api: 'API Gateway (Edge Network)',
|
||||
database: 'Postgres Database',
|
||||
functions: 'Edge Functions',
|
||||
@@ -791,7 +790,7 @@ const QUERY_TYPE_LABELS: Record<string, string> = {
|
||||
etl: 'ETL',
|
||||
}
|
||||
|
||||
const LOG_TABLE_TO_SERVICE_LABEL: Record<string, string> = {
|
||||
const LOG_TABLE_TO_SERVICE_LABEL: Record<LogsTableName, string> = {
|
||||
edge_logs: 'API Gateway (Edge Network)',
|
||||
postgres_logs: 'Postgres Database',
|
||||
function_logs: 'Edge Functions',
|
||||
@@ -808,6 +807,10 @@ const LOG_TABLE_TO_SERVICE_LABEL: Record<string, string> = {
|
||||
etl_replication_logs: 'ETL',
|
||||
}
|
||||
|
||||
const isLogsTableName = (value: string): value is LogsTableName =>
|
||||
value in LOG_TABLE_TO_SERVICE_LABEL
|
||||
const isQueryType = (value: string): value is QueryType => value in QUERY_TYPE_LABELS
|
||||
|
||||
export function extractEdgeFunctionName(pathname: unknown): string {
|
||||
if (typeof pathname !== 'string' || !pathname) return ''
|
||||
const parts = pathname.split('/').filter(Boolean)
|
||||
@@ -817,12 +820,12 @@ export function extractEdgeFunctionName(pathname: unknown): string {
|
||||
function extractServiceLabelFromSql(sql: string): string | null {
|
||||
const match = sql.match(/\bfrom\s+(\w+)/i)
|
||||
const tableName = match?.[1]
|
||||
return tableName ? (LOG_TABLE_TO_SERVICE_LABEL[tableName] ?? null) : null
|
||||
return tableName && isLogsTableName(tableName) ? LOG_TABLE_TO_SERVICE_LABEL[tableName] : null
|
||||
}
|
||||
|
||||
export function buildLogsPrompt(rows: LogData[], queryType?: string, sqlQuery?: string): string {
|
||||
const serviceLabel =
|
||||
(queryType ? QUERY_TYPE_LABELS[queryType] : null) ??
|
||||
(queryType && isQueryType(queryType) ? QUERY_TYPE_LABELS[queryType] : null) ??
|
||||
(sqlQuery ? extractServiceLabelFromSql(sqlQuery) : null)
|
||||
const serviceContext = serviceLabel ? ` from the **${serviceLabel}** service` : ''
|
||||
const sqlContext = sqlQuery ? `\n\n**Query used:**\n\`\`\`sql\n${sqlQuery.trim()}\n\`\`\`` : ''
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { IS_PLATFORM, useParams } from 'common'
|
||||
import { IS_PLATFORM, useFeatureFlags, useFlag, useParams } from 'common'
|
||||
import { EdgeFunctionOverview } from 'components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionOverview'
|
||||
import { EdgeFunctionRecentInvocations } from 'components/interfaces/Functions/EdgeFunctionRecentInvocations'
|
||||
import ReportWidget from 'components/interfaces/Reports/ReportWidget'
|
||||
import DefaultLayout from 'components/layouts/DefaultLayout'
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
AlertDescription_Shadcn_,
|
||||
AlertTitle_Shadcn_,
|
||||
Button,
|
||||
LogoLoader,
|
||||
WarningIcon,
|
||||
} from 'ui'
|
||||
import { PageContainer } from 'ui-patterns/PageContainer'
|
||||
@@ -62,7 +64,7 @@ const CHART_INTERVALS: ChartIntervals[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const PageLayout: NextPageWithLayout = () => {
|
||||
const LegacyEdgeFunctionOverview = () => {
|
||||
const router = useRouter()
|
||||
const { ref: projectRef, functionSlug } = useParams()
|
||||
|
||||
@@ -429,6 +431,21 @@ const PageLayout: NextPageWithLayout = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const PageLayout: NextPageWithLayout = () => {
|
||||
const { hasLoaded: flagsLoaded } = useFeatureFlags()
|
||||
const showNewOverview = useFlag('edgeFunctionsOverview') === true
|
||||
|
||||
if (IS_PLATFORM && !flagsLoaded) {
|
||||
return <LogoLoader />
|
||||
}
|
||||
|
||||
if (showNewOverview) {
|
||||
return <EdgeFunctionOverview />
|
||||
}
|
||||
|
||||
return <LegacyEdgeFunctionOverview />
|
||||
}
|
||||
|
||||
PageLayout.getLayout = (page) => (
|
||||
<DefaultLayout>
|
||||
<EdgeFunctionDetailsLayout title="Overview">{page}</EdgeFunctionDetailsLayout>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
CartesianGrid,
|
||||
AreaChart as RechartAreaChart,
|
||||
ReferenceArea,
|
||||
ReferenceLine,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts'
|
||||
@@ -42,11 +43,20 @@ export type ChartHighlightAction = {
|
||||
onSelect: (ctx: { start: string; end: string; clear: () => void }) => void
|
||||
}
|
||||
|
||||
export type ChartReferenceLine = {
|
||||
y: number
|
||||
label?: string
|
||||
stroke?: string
|
||||
strokeWidth?: number
|
||||
strokeDasharray?: string
|
||||
}
|
||||
|
||||
export interface ChartLineProps {
|
||||
data: ChartLineTick[]
|
||||
dataKey: string
|
||||
dataKeys?: string[] // Add this line
|
||||
dataKeys?: string[]
|
||||
config?: ChartConfig
|
||||
tooltipDetails?: (datum: ChartLineTick, key: string, value: unknown) => ReactNode
|
||||
onLineClick?: (datum: ChartLineTick, tooltipData?: CategoricalChartState) => void
|
||||
DateTimeFormat?: string
|
||||
isFullHeight?: boolean
|
||||
@@ -68,13 +78,15 @@ export interface ChartLineProps {
|
||||
[key: string]: any
|
||||
}
|
||||
strokeWidth?: number
|
||||
referenceLines?: ChartReferenceLine[]
|
||||
}
|
||||
|
||||
export const ChartLine = ({
|
||||
data,
|
||||
dataKey,
|
||||
dataKeys, // Add this line
|
||||
dataKeys,
|
||||
config,
|
||||
tooltipDetails,
|
||||
onLineClick,
|
||||
DateTimeFormat = 'MMM D, YYYY, hh:mma',
|
||||
isFullHeight = false,
|
||||
@@ -91,6 +103,7 @@ export const ChartLine = ({
|
||||
showYAxis = false,
|
||||
YAxisProps,
|
||||
strokeWidth = 1.5,
|
||||
referenceLines,
|
||||
}: ChartLineProps) => {
|
||||
const [focusDataIndex, setFocusDataIndex] = useState<number | null>(null)
|
||||
const { resolvedTheme } = useTheme()
|
||||
@@ -134,6 +147,14 @@ export const ChartLine = ({
|
||||
bottom: 0,
|
||||
}
|
||||
|
||||
const formatTooltipValue = (value: unknown) => {
|
||||
if (typeof value === 'number') {
|
||||
return YAxisProps?.tickFormatter ? YAxisProps.tickFormatter(value) : value.toLocaleString()
|
||||
}
|
||||
|
||||
return String(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="chart-line"
|
||||
@@ -200,6 +221,32 @@ export const ChartLine = ({
|
||||
<ChartTooltipContent
|
||||
className="text-foreground-light -mt-5"
|
||||
labelFormatter={(v: string) => dayjs(v).format(DateTimeFormat)}
|
||||
formatter={(value, name, item) => {
|
||||
const key = String(item.dataKey || name || dataKey)
|
||||
const itemConfig = chartConfig[key]
|
||||
const indicatorColor = item.payload.fill || item.color || color
|
||||
const detail = tooltipDetails?.(item.payload as ChartLineTick, key, value)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-[2px]"
|
||||
style={{ backgroundColor: indicatorColor }}
|
||||
/>
|
||||
<div className="flex flex-1 justify-between gap-3 leading-none">
|
||||
<div className="grid gap-1">
|
||||
<span className="text-foreground-light">
|
||||
{itemConfig?.label || name || key}
|
||||
</span>
|
||||
{detail}
|
||||
</div>
|
||||
<span className="font-mono font-medium tabular-nums text-foreground">
|
||||
{formatTooltipValue(value)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@@ -214,6 +261,15 @@ export const ChartLine = ({
|
||||
fillOpacity={0.2}
|
||||
/>
|
||||
)}
|
||||
{referenceLines?.map((line, index) => (
|
||||
<ReferenceLine
|
||||
key={`${line.y}-${index}`}
|
||||
y={line.y}
|
||||
stroke={line.stroke ?? CHART_COLORS.TICK}
|
||||
strokeWidth={line.strokeWidth ?? 1.5}
|
||||
strokeDasharray={line.strokeDasharray ?? '4 4'}
|
||||
/>
|
||||
))}
|
||||
{keysToRender.map((key, index) => {
|
||||
const keyConfig = chartConfig[key]
|
||||
const lineColor =
|
||||
|
||||
@@ -50,7 +50,7 @@ const ChartContainer = React.forwardRef<
|
||||
data-chart={chartId}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-foreground-muted [&_.recharts-cartesian-grid_line]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line-line]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-foreground-muted [&_.recharts-cartesian-grid_line]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line-line]:stroke-foreground [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
Reference in new issue
Block a user