From e21088144d4fc4d0760198a8ea99a8291eaf472c Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Wed, 1 Apr 2026 14:59:12 +1000 Subject: [PATCH] Edge function overview (#44009) image 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 --- .../EdgeFunctionChartEmptyState.tsx | 20 ++ .../EdgeFunctionInvocationsChart.tsx | 114 +++++++ .../EdgeFunctionInvocationsSection.tsx | 156 +++++++++ .../EdgeFunctionMetricTooltipDetails.tsx | 93 ++++++ .../EdgeFunctionOverview.tsx | 237 ++++++++++++++ .../EdgeFunctionOverview.utils.test.ts | 219 +++++++++++++ .../EdgeFunctionOverview.utils.ts | 297 ++++++++++++++++++ .../EdgeFunctionPerformanceSection.tsx | 104 ++++++ .../EdgeFunctionRecentErrors.tsx | 263 ++++++++++++++++ .../EdgeFunctionRecentErrors.utils.test.ts | 205 ++++++++++++ .../EdgeFunctionRecentErrors.utils.ts | 251 +++++++++++++++ .../EdgeFunctionTimeSeriesChartCard.tsx | 78 +++++ .../EdgeFunctionUsageSection.tsx | 158 ++++++++++ .../interfaces/Settings/Logs/Logs.utils.ts | 27 +- .../[ref]/functions/[functionSlug]/index.tsx | 21 +- .../src/Chart/charts/chart-line.tsx | 60 +++- .../ui/src/components/shadcn/ui/chart.tsx | 2 +- 17 files changed, 2288 insertions(+), 17 deletions(-) create mode 100644 apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionChartEmptyState.tsx create mode 100644 apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionInvocationsChart.tsx create mode 100644 apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionInvocationsSection.tsx create mode 100644 apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionMetricTooltipDetails.tsx create mode 100644 apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionOverview.tsx create mode 100644 apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionOverview.utils.test.ts create mode 100644 apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionOverview.utils.ts create mode 100644 apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionPerformanceSection.tsx create mode 100644 apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionRecentErrors.tsx create mode 100644 apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionRecentErrors.utils.test.ts create mode 100644 apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionRecentErrors.utils.ts create mode 100644 apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionTimeSeriesChartCard.tsx create mode 100644 apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionUsageSection.tsx diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionChartEmptyState.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionChartEmptyState.tsx new file mode 100644 index 00000000000..77171a44291 --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionChartEmptyState.tsx @@ -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 ( + } + title={title} + description={description ?? 'It may take up to 24 hours for data to refresh'} + /> + ) +} diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionInvocationsChart.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionInvocationsChart.tsx new file mode 100644 index 00000000000..2b578bb4f57 --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionInvocationsChart.tsx @@ -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 ( +
+
+ + + + + + + formatChartTimestamp(value as string | number | undefined, dateTimeFormat) + } + indicator="dot" + /> + } + /> + + + + {updateAnnotation && ( + + )} + + + {updateAnnotation && ( + + + + )} +
+ {timeRangeLabels && ( +
+ {timeRangeLabels.start} + {timeRangeLabels.end} +
+ )} +
+ ) +} diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionInvocationsSection.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionInvocationsSection.tsx new file mode 100644 index 00000000000..9d43ec7a877 --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionInvocationsSection.tsx @@ -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['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 ( + + + +
+ + +
+ + + +
+
+ +
+ {EDGE_FUNCTION_CHART_INTERVALS.map((item, index) => { + return ( + + ) + })} +
+ +
+
+ +
+ {isLoadingFunction && } + {isErrorFunction && ( + + )} +
+ +
+ + {isLoadingChart ? ( + + ) : isErrorChart || chartData.length === 0 ? ( + + ) : ( + + )} + +
+
+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionMetricTooltipDetails.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionMetricTooltipDetails.tsx new file mode 100644 index 00000000000..17e10d0a148 --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionMetricTooltipDetails.tsx @@ -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 ( + + {formatReferenceDelta(Number(value ?? 0), averageExecutionTime)} + + ) +} + +export const CpuTooltipDetail = ({ + averageCpuTime, + value, +}: { + averageCpuTime: number + value: unknown +}) => { + return ( + + {formatReferenceDelta(Number(value ?? 0), averageCpuTime)} + + ) +} + +export const MemoryTooltipDetail = ({ + averageMemoryUsage, + datum, + value, +}: { + averageMemoryUsage: number + datum: EdgeFunctionChartDatum + value: unknown +}) => { + return ( + <> + + {formatReferenceDelta(Number(value ?? 0), averageMemoryUsage)} + + + {getMemoryTooltipDetail(datum.avg_heap_memory_used, datum.avg_external_memory_used)} + + + ) +} + +export const getExecutionTooltipDetails = ( + averageExecutionTime: number +): NonNullable => { + return function renderExecutionTooltipDetails(_, key, value) { + return ( + + ) + } +} + +export const getCpuTooltipDetails = ( + averageCpuTime: number +): NonNullable => { + return function renderCpuTooltipDetails(_, __, value) { + return + } +} + +export const getMemoryTooltipDetails = ( + averageMemoryUsage: number +): NonNullable => { + return function renderMemoryTooltipDetails(datum, _, value) { + return ( + + ) + } +} diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionOverview.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionOverview.tsx new file mode 100644 index 00000000000..c7c5e049a2b --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionOverview.tsx @@ -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('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: , + }, + ], + [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 + } + + if (!IS_PLATFORM) { + return null + } + + return ( + <> + { + router.push( + `/project/${projectRef}/functions/${functionSlug}/${ + isUnifiedLogsEnabled ? 'logs' : 'invocations' + }${isUnifiedLogsEnabled ? '' : `?its=${startDate.toISOString()}`}` + ) + }} + updateAnnotation={invocationUpdateAnnotation} + /> + + + + + + + + ) +} + +export default EdgeFunctionOverview diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionOverview.utils.test.ts b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionOverview.utils.test.ts new file mode 100644 index 00000000000..d05fff7a749 --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionOverview.utils.test.ts @@ -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') + }) +}) diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionOverview.utils.ts b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionOverview.utils.ts new file mode 100644 index 00000000000..0bbc99017fa --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionOverview.utils.ts @@ -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')}` +} diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionPerformanceSection.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionPerformanceSection.tsx new file mode 100644 index 00000000000..1a037405d63 --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionPerformanceSection.tsx @@ -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 = ( +
+ + +
+ ) + + return ( + + + +
+ + + Performance + + + +
+ `${Math.round(value)}ms`, + }} + /> +
+
+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionRecentErrors.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionRecentErrors.tsx new file mode 100644 index 00000000000..34a6de6f936 --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionRecentErrors.tsx @@ -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 ( + + + +
+ + + Recent Failed Invocations + + + + + + + {recentErrorInvocationsError || functionRuntimeLogsError ? ( + + ) : isLoadingRecentErrorInvocations || isLoadingFunctionRuntimeLogs ? ( + + ) : recentErrorGroups.length === 0 ? ( +
+ Recent runtime errors will appear here when this function returns a 5xx response. +
+ ) : ( + + + + + Error + Count + Last Seen + Method + Status + Duration + Assistant + + + + {recentErrorGroups.map((group) => ( + + + + + {formatSingleLineMessage(group.message)} + + + {group.count} + + {formatLogTimestamp(group.lastSeen, 'relative')} + + + {group.lastMethod ?? '-'} + + + {group.lastStatusCode ? ( + + {group.lastStatusCode} + + ) : ( + + Error + + )} + + + {group.executionTime ?? '-'} + + +
+ buildGroupAssistantPrompt(group, functionSlug)} + onOpenAssistant={() => handleOpenAssistant(group)} + /> +
+
+
+ + +
+ {group.logs.length === 0 ? ( +
+ No related runtime logs found for this error group. +
+ ) : ( + group.logs.map((log) => ( +
+ [{formatLogTimestamp(log.lastSeen, 'time')}] [ + {log.level.toUpperCase()}] [{log.count}x]{' '} + {formatSingleLineMessage(log.message)} +
+ )) + )} +
+
+
+
+ ))} +
+
+
+ )} +
+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionRecentErrors.utils.test.ts b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionRecentErrors.utils.test.ts new file mode 100644 index 00000000000..a874e18eee4 --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionRecentErrors.utils.test.ts @@ -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') + }) +}) diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionRecentErrors.utils.ts b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionRecentErrors.utils.ts new file mode 100644 index 00000000000..f12c9245736 --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionRecentErrors.utils.ts @@ -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 + +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 = {} + + 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>( + (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((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), + })) +} diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionTimeSeriesChartCard.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionTimeSeriesChartCard.tsx new file mode 100644 index 00000000000..7eb53d345e7 --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionTimeSeriesChartCard.tsx @@ -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 ( + + + {metrics} + + } + loadingState={} + > +
+ +
+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionUsageSection.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionUsageSection.tsx new file mode 100644 index 00000000000..a434abe7781 --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionUsageSection.tsx @@ -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 = ( +
+ + +
+ ) + const memoryMetrics = ( +
+ + + +
+ ) + + return ( + + + +
+ + + Usage + + + +
+ `${Math.round(value)}ms`, + }} + /> + + `${Number(value).toFixed(1)}MB`, + }} + /> +
+
+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts index 130abc22088..383d1e7abb8 100644 --- a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts @@ -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): [Dayjs, string] => { +const calcChartStart = ( + params: Partial +): [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): [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 = { +const QUERY_TYPE_LABELS: Record = { api: 'API Gateway (Edge Network)', database: 'Postgres Database', functions: 'Edge Functions', @@ -791,7 +790,7 @@ const QUERY_TYPE_LABELS: Record = { etl: 'ETL', } -const LOG_TABLE_TO_SERVICE_LABEL: Record = { +const LOG_TABLE_TO_SERVICE_LABEL: Record = { 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 = { 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\`\`\`` : '' diff --git a/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx b/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx index dc2d55a6d0f..29dad4538ea 100644 --- a/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx +++ b/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx @@ -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 + } + + if (showNewOverview) { + return + } + + return +} + PageLayout.getLayout = (page) => ( {page} diff --git a/packages/ui-patterns/src/Chart/charts/chart-line.tsx b/packages/ui-patterns/src/Chart/charts/chart-line.tsx index 2fc044ab7fe..a7a111f5779 100644 --- a/packages/ui-patterns/src/Chart/charts/chart-line.tsx +++ b/packages/ui-patterns/src/Chart/charts/chart-line.tsx @@ -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(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 (
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 ( + <> +
+
+
+ + {itemConfig?.label || name || key} + + {detail} +
+ + {formatTooltipValue(value)} + +
+ + ) + }} /> } /> @@ -214,6 +261,15 @@ export const ChartLine = ({ fillOpacity={0.2} /> )} + {referenceLines?.map((line, index) => ( + + ))} {keysToRender.map((key, index) => { const keyConfig = chartConfig[key] const lineColor = diff --git a/packages/ui/src/components/shadcn/ui/chart.tsx b/packages/ui/src/components/shadcn/ui/chart.tsx index e8ab237e744..335cb47e16c 100644 --- a/packages/ui/src/components/shadcn/ui/chart.tsx +++ b/packages/ui/src/components/shadcn/ui/chart.tsx @@ -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}