fix(studio): open edge function logs from chart clicks FE-3922 (#50364)

## Problem

Clicking an invocation bar on the Edge Function overview did not
preserve the selected chart segment, so the destination could not open a
focused investigation window.

## Fix

Forward the clicked bar timestamp and navigate to Logs or Invocations
with an encoded, focused time range. Share the existing chart range
calculation and add real Recharts interaction coverage.

## How to test

- Open an Edge Function Overview page with invocation data.
- Click a populated bar in the Total Invocations chart.
- Expected result: Logs or Invocations opens with its and ite query
parameters centered on the clicked bar.
- Repeat with unified logs enabled and disabled to verify both
destinations.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Clicking a bar in the Edge Function invocations chart now opens the
relevant logs or invocations view.
- The destination is focused on a time window surrounding the selected
invocation, making investigation faster.
- Chart bars now provide a pointer cursor to indicate they are
interactive.

- **Bug Fixes**
- Chart clicks without valid invocation data no longer trigger incorrect
navigation.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Jordi Enric authored and GitHub committed 2026-09-15 11:06:37 +02:00
1 parent 56820eb048
commit 24f8549e41
9 files changed
+166 -59

No files matched your search

@@ -0,0 +1,67 @@
import { fireEvent, render, waitFor } from '@testing-library/react'
import { mockResizeObserver } from 'jsdom-testing-mocks'
import { expect, it, vi } from 'vitest'
import { EdgeFunctionInvocationsChart } from './EdgeFunctionInvocationsChart'
import type { InvocationChartDatum } from './EdgeFunctionOverview.utils'
const resizeObserver = mockResizeObserver()
const renderChart = async ({
chartData,
onChartClick,
}: {
chartData: InvocationChartDatum[]
onChartClick: (timestamp: string) => void
}) => {
const result = render(
<EdgeFunctionInvocationsChart
chartData={chartData}
dateTimeFormat="MMM D, h:mma"
onChartClick={onChartClick}
/>
)
const [chartContainer] = resizeObserver.getObservedElements()
if (!chartContainer) throw new Error('Expected the chart container to be observed')
resizeObserver.mockElementSize(chartContainer, {
contentBoxSize: { inlineSize: 600, blockSize: 160 },
})
resizeObserver.resize(chartContainer)
await waitFor(() => expect(result.container.querySelector('.recharts-surface')).not.toBeNull())
return result
}
it('passes the clicked bar timestamp to the chart click handler', async () => {
const timestamp = '2026-03-20T10:30:00.000Z'
const onChartClick = vi.fn()
const { container } = await renderChart({
chartData: [{ timestamp, ok_count: 3, warning_count: 1, error_count: 2 }],
onChartClick,
})
await waitFor(() => expect(container.querySelector('.recharts-rectangle')).not.toBeNull())
const bar = container.querySelector('.recharts-rectangle')
if (!bar) throw new Error('Expected a chart bar to render')
fireEvent.mouseEnter(bar)
fireEvent.click(bar)
expect(onChartClick).toHaveBeenCalledOnce()
expect(onChartClick).toHaveBeenCalledWith(timestamp)
})
it('ignores a chart click without an active payload', async () => {
const onChartClick = vi.fn()
const { container } = await renderChart({ chartData: [], onChartClick })
const chart = container.querySelector('.recharts-surface')
if (!chart) throw new Error('Expected the chart surface to render')
fireEvent.click(chart)
expect(onChartClick).not.toHaveBeenCalled()
})
@@ -20,7 +20,7 @@ import type { InvocationChartDatum, InvocationUpdateAnnotation } from './EdgeFun
interface EdgeFunctionInvocationsChartProps {
chartData: InvocationChartDatum[]
dateTimeFormat: string
onChartClick: () => void
onChartClick: (timestamp: string) => void
updateAnnotation?: InvocationUpdateAnnotation
}
@@ -41,8 +41,12 @@ export const EdgeFunctionInvocationsChart = ({
<ChartContainer config={INVOCATION_CHART_CONFIG} className="aspect-auto! h-full! w-full!">
<RechartBarChart
data={chartData}
className="cursor-pointer"
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
onClick={onChartClick}
onClick={(tooltipData) => {
const timestamp = tooltipData?.activePayload?.[0]?.payload?.timestamp
if (typeof timestamp === 'string') onChartClick(timestamp)
}}
>
<CartesianGrid vertical={false} />
<YAxis hide width={0} />
@@ -40,7 +40,7 @@ interface EdgeFunctionInvocationsSectionProps {
isErrorChart: boolean
chartErrorMessage?: string
chartData: InvocationChartDatum[]
onChartClick: () => void
onChartClick: (timestamp: string) => void
updateAnnotation?: InvocationUpdateAnnotation
}
@@ -10,6 +10,7 @@ import {
getBucketedTimeRange,
getExecutionMetrics,
getInvocationChartData,
getInvocationChartNavigationUrl,
getInvocationTotals,
getInvocationUpdateAnnotation,
getRollingTimeRange,
@@ -204,11 +205,18 @@ export const EdgeFunctionOverview = () => {
isErrorChart={isErrorCombinedStats}
chartErrorMessage={combinedStatsError?.message ?? 'Unknown error'}
chartData={invocationChartData}
onChartClick={() => {
onChartClick={(timestamp) => {
if (!projectRef || !functionSlug) return
router.push(
`/project/${projectRef}/functions/${functionSlug}/${
isUnifiedLogsEnabled ? 'logs' : 'invocations'
}${isUnifiedLogsEnabled ? '' : `?its=${startDate.toISOString()}`}`
getInvocationChartNavigationUrl({
projectRef,
functionSlug,
isUnifiedLogsEnabled,
rangeStart: startDate.toISOString(),
rangeEnd: endDate.toISOString(),
clickedTimestamp: timestamp,
})
)
}}
updateAnnotation={invocationUpdateAnnotation}
@@ -12,6 +12,7 @@ import {
getChartTimeRangeLabels,
getExecutionMetrics,
getInvocationChartData,
getInvocationChartNavigationUrl,
getInvocationTotals,
getInvocationUpdateAnnotation,
getMemoryTooltipDetail,
@@ -196,6 +197,27 @@ describe('EdgeFunctionOverview.utils', () => {
expect(rollingEnd.toISOString()).toBe('2026-03-20T10:37:00.000Z')
})
it.each([
{ isUnifiedLogsEnabled: true, destination: 'logs' },
{ isUnifiedLogsEnabled: false, destination: 'invocations' },
])(
'builds a focused $destination URL from the clicked bar',
({ isUnifiedLogsEnabled, destination }) => {
const url = getInvocationChartNavigationUrl({
projectRef: 'project-ref',
functionSlug: 'function-slug',
isUnifiedLogsEnabled,
rangeStart: '2026-03-20T10:00:00.000Z',
rangeEnd: '2026-03-20T11:00:00.000Z',
clickedTimestamp: '2026-03-20T10:30:00.000Z',
})
expect(url).toBe(
`/project/project-ref/functions/function-slug/${destination}?its=2026-03-20T10%3A27%3A30.000Z&ite=2026-03-20T10%3A32%3A30.000Z`
)
}
)
it('formats metric, rate, and reference deltas consistently', () => {
expect(formatMetric(12.34, 'MB')).toBe('12.3MB')
expect(formatMetric(1234, 'ms')).toBe('1,234ms')
@@ -4,6 +4,7 @@ import meanBy from 'lodash/meanBy'
import sumBy from 'lodash/sumBy'
import type { ChartConfig } from 'ui'
import { calculateBarClickTimeRange } from '@/components/interfaces/Settings/Logs/LogsBarChart.utils'
import type { ChartIntervals } from '@/types'
export type EdgeFunctionChartRawDatum = {
@@ -145,6 +146,27 @@ export const getRollingTimeRange = (
return [start.toDate(), currentTime.toDate()]
}
export const getInvocationChartNavigationUrl = ({
projectRef,
functionSlug,
isUnifiedLogsEnabled,
rangeStart,
rangeEnd,
clickedTimestamp,
}: {
projectRef: string
functionSlug: string
isUnifiedLogsEnabled: boolean
rangeStart: string
rangeEnd: string
clickedTimestamp: string
}) => {
const { start, end } = calculateBarClickTimeRange(rangeStart, rangeEnd, clickedTimestamp)
const destination = isUnifiedLogsEnabled ? 'logs' : 'invocations'
return `/project/${projectRef}/functions/${functionSlug}/${destination}?its=${encodeURIComponent(start)}&ite=${encodeURIComponent(end)}`
}
export const formatChartTimestamp = (value: Date | string | number | undefined, format: string) => {
return dayjs(value === undefined ? '' : value).format(format)
}
@@ -0,0 +1,33 @@
import dayjs from 'dayjs'
export const calculateBarClickTimeRange = (
currentRangeStart: string,
currentRangeEnd: string | undefined,
clickedTimestamp: string
) => {
const datumTimestamp = dayjs(clickedTimestamp).toISOString()
const endTime = currentRangeEnd ? dayjs(currentRangeEnd) : dayjs()
const currentRangeDuration = endTime.diff(dayjs(currentRangeStart), 'hour', true)
let rangeOffset: number
let rangeUnit: dayjs.ManipulateType
if (currentRangeDuration >= 12) {
rangeOffset = 0.5
rangeUnit = 'hour'
} else if (currentRangeDuration >= 1) {
rangeOffset = 2.5
rangeUnit = 'minute'
} else if (currentRangeDuration >= 1 / 30) {
rangeOffset = 1
rangeUnit = 'minute'
} else {
rangeOffset = 7.5
rangeUnit = 'second'
}
return {
start: dayjs(datumTimestamp).subtract(rangeOffset, rangeUnit).toISOString(),
end: dayjs(datumTimestamp).add(rangeOffset, rangeUnit).toISOString(),
}
}
@@ -15,6 +15,7 @@ import {
import { DatePickerValue } from './Logs.DatePickers'
import type { Filters, LogSearchCallback, LogTemplate, QueryType } from './Logs.types'
import { maybeShowUpgradePromptIfNotEntitled } from './Logs.utils'
import { calculateBarClickTimeRange } from './LogsBarChart.utils'
import { LogTable } from './LogTable'
import UpgradePrompt from './UpgradePrompt'
import { useLogsPreviewShortcuts } from './useLogsPreviewShortcuts'
@@ -33,54 +34,6 @@ import { useUpgradePrompt } from '@/hooks/misc/useUpgradePrompt'
import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector'
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
/**
* Calculates the appropriate time range for bar click filtering based on the current time range duration.
*
* @param currentRangeStart - The start timestamp of the current time range
* @param currentRangeEnd - The end timestamp of the current time range
* @param clickedTimestamp - The timestamp of the clicked bar
* @returns Object containing the new start and end timestamps for filtering
*/
export const calculateBarClickTimeRange = (
currentRangeStart: string,
currentRangeEnd: string | undefined,
clickedTimestamp: string
) => {
const datumTimestamp = dayjs(clickedTimestamp).toISOString()
// Calculate the current time range duration in hours
// If currentRangeEnd is not provided, use current time as the end
const endTime = currentRangeEnd ? dayjs(currentRangeEnd) : dayjs()
const currentRangeDuration = endTime.diff(dayjs(currentRangeStart), 'hour', true)
let rangeOffset: number
let rangeUnit: dayjs.ManipulateType
if (currentRangeDuration >= 12) {
// For ranges >= 12h, use 1h range
rangeOffset = 0.5
rangeUnit = 'hour'
} else if (currentRangeDuration >= 1) {
// For ranges >= 1h but < 12h, use 5min range
rangeOffset = 2.5
rangeUnit = 'minute'
} else if (currentRangeDuration >= 1 / 30) {
// 2 minutes = 1/30 hour
// For ranges >= 2min but < 1h, use 2min range
rangeOffset = 1
rangeUnit = 'minute'
} else {
// For ranges < 2min, use 15sec range
rangeOffset = 7.5
rangeUnit = 'second'
}
return {
start: dayjs(datumTimestamp).subtract(rangeOffset, rangeUnit).toISOString(),
end: dayjs(datumTimestamp).add(rangeOffset, rangeUnit).toISOString(),
}
}
/**
* Acts as a container component for the entire log display
*
@@ -6,10 +6,8 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'
import { LOGS_API_MOCKS } from './logs.mocks'
import { LogsTableName } from '@/components/interfaces/Settings/Logs/Logs.constants'
import {
calculateBarClickTimeRange,
LogsPreviewer,
} from '@/components/interfaces/Settings/Logs/LogsPreviewer'
import { calculateBarClickTimeRange } from '@/components/interfaces/Settings/Logs/LogsBarChart.utils'
import { LogsPreviewer } from '@/components/interfaces/Settings/Logs/LogsPreviewer'
import useLogsPreview from '@/hooks/analytics/useLogsPreview'
import { customRender, customRenderHook } from '@/tests/lib/custom-render'
import { addAPIMock } from '@/tests/lib/msw'