mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
1 parent
56820eb048
commit
24f8549e41
9 files changed
+166
-59
No files matched your search
+67
@@ -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()
|
||||
})
|
||||
+6
-2
@@ -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} />
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ interface EdgeFunctionInvocationsSectionProps {
|
||||
isErrorChart: boolean
|
||||
chartErrorMessage?: string
|
||||
chartData: InvocationChartDatum[]
|
||||
onChartClick: () => void
|
||||
onChartClick: (timestamp: string) => void
|
||||
updateAnnotation?: InvocationUpdateAnnotation
|
||||
}
|
||||
|
||||
|
||||
+12
-4
@@ -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}
|
||||
|
||||
+22
@@ -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')
|
||||
|
||||
+22
@@ -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'
|
||||
|
||||
Reference in new issue
Block a user