mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
feat: show recent invocations in functions view (#43030)
## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? By far the most important feature in the functions view is the Invocations tab. This just adds recent invocations to the dashboard for a function. So it's easy for you to digest what's happening. ## Demo <img width="1818" height="1312" alt="image" src="https://github.com/user-attachments/assets/b3f59255-f780-4edc-bc9c-cc711b0d22f9" />
This commit is contained in:
1 parent
03660838ef
commit
65dac5429e
4 files changed
+274
-3
No files matched your search
@@ -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 (
|
||||
<div className="flex flex-col gap-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm">Recent Invocations</p>
|
||||
<p className="text-xs text-foreground-light">
|
||||
Latest invocation requests for this function
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="default"
|
||||
loading={isLoading}
|
||||
disabled={isLoading}
|
||||
icon={<RefreshCw size={14} />}
|
||||
onClick={() => refresh()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading && !isSuccess ? (
|
||||
<GenericSkeletonLoader />
|
||||
) : logData.length === 0 ? (
|
||||
<Admonition
|
||||
type="note"
|
||||
title="No recent invocations"
|
||||
description="Invocation logs will appear here when requests are made to this function"
|
||||
/>
|
||||
) : (
|
||||
<div className="border rounded-md divide-y overflow-hidden">
|
||||
{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 (
|
||||
<div
|
||||
key={log.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => 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"
|
||||
>
|
||||
<span className="text-xs text-foreground-light whitespace-nowrap">
|
||||
<TimestampInfo utcTimestamp={log.timestamp!} format="DD MMM YY, HH:mm:ss" />
|
||||
</span>
|
||||
<div className="flex items-center">
|
||||
{statusCode ? (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center border px-1.5 py-0.5 rounded text-xs font-mono',
|
||||
is2xx && 'text-brand border-brand bg-brand-300',
|
||||
is4xx && 'text-warning border-warning bg-warning-300',
|
||||
is5xx && 'text-destructive border-destructive bg-destructive-300',
|
||||
!is2xx &&
|
||||
!is4xx &&
|
||||
!is5xx &&
|
||||
'text-foreground-light border-default bg-surface-200'
|
||||
)}
|
||||
>
|
||||
{statusCode}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-foreground-lighter">-</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-foreground-light">{method || '-'}</span>
|
||||
{executionTime !== undefined && (
|
||||
<span className="flex items-center gap-1 text-xs text-foreground-light">
|
||||
<Clock size={12} className="text-foreground-muted" />
|
||||
{Number(executionTime).toFixed(0)}ms
|
||||
</span>
|
||||
)}
|
||||
<span className="flex-1 text-xs text-foreground-light truncate">
|
||||
{parseEdgeFunctionEventMessage(
|
||||
String(log.event_message ?? ''),
|
||||
method,
|
||||
statusCode
|
||||
)}
|
||||
</span>
|
||||
<ExternalLink
|
||||
size={14}
|
||||
className="shrink-0 text-foreground-muted opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<Link
|
||||
href={`/project/${ref}/functions/${functionSlug}/invocations`}
|
||||
className="flex items-center justify-center py-2 text-xs text-foreground-light hover:text-foreground transition-colors"
|
||||
>
|
||||
View all invocations
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+87
@@ -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('')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 = () => {
|
||||
<PageContainer size="full">
|
||||
<PageSection>
|
||||
<PageSectionContent>
|
||||
{IS_PLATFORM && id && (
|
||||
<div className="mb-8">
|
||||
<EdgeFunctionRecentInvocations
|
||||
functionId={id}
|
||||
functionSlug={functionSlug as string}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-row items-center gap-2 mb-4">
|
||||
<div className="flex items-center">
|
||||
{CHART_INTERVALS.map((item, i) => {
|
||||
|
||||
Reference in new issue
Block a user