diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionRecentInvocations.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionRecentInvocations.tsx new file mode 100644 index 00000000000..04456c2dc27 --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionRecentInvocations.tsx @@ -0,0 +1,139 @@ +import { useParams } from 'common' +import { LOGS_TABLES } from 'components/interfaces/Settings/Logs/Logs.constants' +import useLogsPreview from 'hooks/analytics/useLogsPreview' +import { Clock, ExternalLink, RefreshCw } from 'lucide-react' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { Button, cn } from 'ui' +import { Admonition, TimestampInfo } from 'ui-patterns' +import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' + +import { parseEdgeFunctionEventMessage } from './EdgeFunctionRecentInvocations.utils' + +interface EdgeFunctionRecentInvocationsProps { + functionId: string + functionSlug: string +} + +export const EdgeFunctionRecentInvocations = ({ + functionId, + functionSlug, +}: EdgeFunctionRecentInvocationsProps) => { + const { ref } = useParams() + const router = useRouter() + + const { logData, isLoading, isSuccess, refresh } = useLogsPreview({ + projectRef: ref as string, + table: LOGS_TABLES.fn_edge, + filterOverride: { function_id: functionId }, + limit: 10, + }) + + return ( +
+
+
+

Recent Invocations

+

+ Latest invocation requests for this function +

+
+ +
+ + {isLoading && !isSuccess ? ( + + ) : logData.length === 0 ? ( + + ) : ( +
+ {logData.map((log) => { + const statusCode = String(log.status_code ?? '') + const method = String(log.method ?? '') + const executionTime = log.execution_time_ms + const is2xx = statusCode.startsWith('2') + const is4xx = statusCode.startsWith('4') + const is5xx = statusCode.startsWith('5') + const logUrl = `/project/${ref}/functions/${functionSlug}/invocations?log=${log.id}` + + return ( +
router.push(logUrl)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + router.push(logUrl) + } + }} + className="group flex items-center font-mono px-3 py-2 gap-3 bg-surface-100 cursor-pointer hover:bg-surface-200 transition-colors" + > + + + +
+ {statusCode ? ( +
+ {statusCode} +
+ ) : ( + - + )} +
+ {method || '-'} + {executionTime !== undefined && ( + + + {Number(executionTime).toFixed(0)}ms + + )} + + {parseEdgeFunctionEventMessage( + String(log.event_message ?? ''), + method, + statusCode + )} + + +
+ ) + })} + + View all invocations + +
+ )} +
+ ) +} diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionRecentInvocations.utils.test.ts b/apps/studio/components/interfaces/Functions/EdgeFunctionRecentInvocations.utils.test.ts new file mode 100644 index 00000000000..e5809e2517b --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionRecentInvocations.utils.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' + +import { parseEdgeFunctionEventMessage } from './EdgeFunctionRecentInvocations.utils' + +describe('parseEdgeFunctionEventMessage', () => { + it('should strip method and status code when they match the structured fields', () => { + const message = 'POST | 200 | https://example.supabase.red/functions/v1/hello-world' + expect(parseEdgeFunctionEventMessage(message, 'POST', '200')).toBe( + 'https://example.supabase.red/functions/v1/hello-world' + ) + }) + + it('should strip method and status code for different HTTP methods', () => { + const message = 'GET | 404 | https://example.supabase.red/functions/v1/not-found' + expect(parseEdgeFunctionEventMessage(message, 'GET', '404')).toBe( + 'https://example.supabase.red/functions/v1/not-found' + ) + }) + + it('should strip method and status code for 500 errors', () => { + const message = 'POST | 500 | https://example.supabase.red/functions/v1/broken' + expect(parseEdgeFunctionEventMessage(message, 'POST', '500')).toBe( + 'https://example.supabase.red/functions/v1/broken' + ) + }) + + it('should preserve the rest of the message when it contains pipe separators', () => { + const message = 'POST | 200 | some | complex | message' + expect(parseEdgeFunctionEventMessage(message, 'POST', '200')).toBe('some | complex | message') + }) + + it('should return original message when method does not match', () => { + const message = 'POST | 200 | https://example.supabase.red/functions/v1/hello-world' + expect(parseEdgeFunctionEventMessage(message, 'GET', '200')).toBe(message) + }) + + it('should return original message when status code does not match', () => { + const message = 'POST | 200 | https://example.supabase.red/functions/v1/hello-world' + expect(parseEdgeFunctionEventMessage(message, 'POST', '500')).toBe(message) + }) + + it('should return original message when method is undefined', () => { + const message = 'POST | 200 | https://example.supabase.red/functions/v1/hello-world' + expect(parseEdgeFunctionEventMessage(message, undefined, '200')).toBe(message) + }) + + it('should return original message when status code is undefined', () => { + const message = 'POST | 200 | https://example.supabase.red/functions/v1/hello-world' + expect(parseEdgeFunctionEventMessage(message, 'POST', undefined)).toBe(message) + }) + + it('should return original message when both method and status code are undefined', () => { + const message = 'POST | 200 | https://example.supabase.red/functions/v1/hello-world' + expect(parseEdgeFunctionEventMessage(message, undefined, undefined)).toBe(message) + }) + + it('should return original message when format has fewer than 3 parts', () => { + const message = 'some simple message' + expect(parseEdgeFunctionEventMessage(message, 'POST', '200')).toBe(message) + }) + + it('should return original message when format has only 2 pipe-separated parts', () => { + const message = 'POST | 200' + expect(parseEdgeFunctionEventMessage(message, 'POST', '200')).toBe(message) + }) + + it('should return empty string for empty input', () => { + expect(parseEdgeFunctionEventMessage('', 'POST', '200')).toBe('') + }) + + it('should handle whitespace around parts correctly', () => { + const message = 'POST | 200 | https://example.supabase.red/functions/v1/hello-world' + expect(parseEdgeFunctionEventMessage(message, 'POST', '200')).toBe( + 'https://example.supabase.red/functions/v1/hello-world' + ) + }) + + it('should return original message when the message does not follow the expected format', () => { + const message = 'Error: function crashed unexpectedly' + expect(parseEdgeFunctionEventMessage(message, 'POST', '500')).toBe(message) + }) + + it('should handle messages with extra whitespace in the URL portion', () => { + const message = 'DELETE | 204 | ' + expect(parseEdgeFunctionEventMessage(message, 'DELETE', '204')).toBe('') + }) +}) diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionRecentInvocations.utils.ts b/apps/studio/components/interfaces/Functions/EdgeFunctionRecentInvocations.utils.ts new file mode 100644 index 00000000000..37145dc5787 --- /dev/null +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionRecentInvocations.utils.ts @@ -0,0 +1,37 @@ +/** + * Parses an edge function invocation event_message to extract the meaningful + * content, stripping out the method and status code that are already displayed + * as structured fields. + * + * The event_message typically follows the format: + * "{METHOD} | {STATUS_CODE} | {URL_OR_DETAIL}" + * + * e.g. "POST | 200 | https://example.supabase.red/functions/v1/hello-world" + * + * When the structured method and status_code fields match what's in the message, + * we strip them out to avoid duplication. If parsing fails or the message doesn't + * match the expected format, we return the original message as-is. + */ +export function parseEdgeFunctionEventMessage( + eventMessage: string, + method?: string, + statusCode?: string +): string { + if (!eventMessage) return eventMessage + + const parts = eventMessage.split(' | ') + + if (parts.length < 3) return eventMessage + + const messageMethod = parts[0].trim() + const messageStatus = parts[1].trim() + + const methodMatches = method !== undefined && messageMethod === method + const statusMatches = statusCode !== undefined && messageStatus === statusCode + + if (methodMatches && statusMatches) { + return parts.slice(2).join(' | ').trim() + } + + return eventMessage +} diff --git a/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx b/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx index 7c7b05294bd..b2708a94193 100644 --- a/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx +++ b/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx @@ -1,6 +1,6 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { IS_PLATFORM, useParams } from 'common' -import { useFlag } from 'common' +import { IS_PLATFORM, useFlag, useParams } from 'common' +import { EdgeFunctionRecentInvocations } from 'components/interfaces/Functions/EdgeFunctionRecentInvocations' import ReportWidget from 'components/interfaces/Reports/ReportWidget' import DefaultLayout from 'components/layouts/DefaultLayout' import EdgeFunctionDetailsLayout from 'components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout' @@ -22,9 +22,9 @@ import { useRouter } from 'next/router' import { useEffect, useMemo, useState } from 'react' import type { ChartIntervals, NextPageWithLayout } from 'types' import { + Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, - Alert_Shadcn_, Button, WarningIcon, } from 'ui' @@ -138,6 +138,14 @@ const PageLayout: NextPageWithLayout = () => { + {IS_PLATFORM && id && ( +
+ +
+ )}
{CHART_INTERVALS.map((item, i) => {