mirror of
https://github.com/supabase/supabase.git
synced 2026-10-15 06:09:08 +03:00
update edge function design
This commit is contained in:
1 parent
fc3ae47b63
commit
9c838f7629
15 files changed
+807
-692
No files matched your search
+258
-236
@@ -30,6 +30,13 @@ import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import { PageContainer } from 'ui-patterns/PageContainer'
|
||||
import {
|
||||
PageHeader,
|
||||
PageHeaderDescription,
|
||||
PageHeaderMeta,
|
||||
PageHeaderSummary,
|
||||
PageHeaderTitle,
|
||||
} from 'ui-patterns/PageHeader'
|
||||
import {
|
||||
PageSection,
|
||||
PageSectionContent,
|
||||
@@ -142,247 +149,262 @@ export const EdgeFunctionDetails = () => {
|
||||
}, [selectedFunction])
|
||||
|
||||
return (
|
||||
<PageContainer size="small">
|
||||
<PageSection>
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>Function configuration</PageSectionTitle>
|
||||
</PageSectionSummary>
|
||||
</PageSectionMeta>
|
||||
<PageSectionContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onUpdateFunction)}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
label="Name"
|
||||
layout="flex-row-reverse"
|
||||
description="Your slug and endpoint URL will remain the same"
|
||||
>
|
||||
<FormControl>
|
||||
<Input {...field} className="w-64" disabled={!canUpdateEdgeFunction} />
|
||||
</FormControl>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
{IS_PLATFORM && (
|
||||
<>
|
||||
<CardContent>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="verify_jwt"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
label="Verify JWT with legacy secret"
|
||||
layout="flex-row-reverse"
|
||||
description={
|
||||
<>
|
||||
<p className="mb-2">
|
||||
Requires a JWT signed{' '}
|
||||
<em className="text-foreground not-italic">
|
||||
only by the legacy secret
|
||||
</em>{' '}
|
||||
in the{' '}
|
||||
<code className="text-code-inline break-keep!">
|
||||
Authorization
|
||||
</code>{' '}
|
||||
header. The <code className="text-code-inline">anon</code> key
|
||||
satisfies this.
|
||||
</p>
|
||||
<p>
|
||||
Recommended: OFF with JWT and custom auth logic in your function
|
||||
code.
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={!canUpdateEdgeFunction}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
<>
|
||||
<PageHeader size="small" className="pb-12">
|
||||
<PageHeaderMeta>
|
||||
<PageHeaderSummary>
|
||||
<PageHeaderTitle>Settings</PageHeaderTitle>
|
||||
<PageHeaderDescription>
|
||||
Configure function behavior, security, and deployment options.
|
||||
</PageHeaderDescription>
|
||||
</PageHeaderSummary>
|
||||
</PageHeaderMeta>
|
||||
</PageHeader>
|
||||
|
||||
<CardFooter className="flex justify-end space-x-2">
|
||||
{form.formState.isDirty && (
|
||||
<Button type="default" onClick={() => form.reset()}>
|
||||
Cancel
|
||||
</Button>
|
||||
<PageContainer size="small">
|
||||
<PageSection className="pt-0">
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>Function configuration</PageSectionTitle>
|
||||
</PageSectionSummary>
|
||||
</PageSectionMeta>
|
||||
<PageSectionContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onUpdateFunction)}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
label="Name"
|
||||
layout="flex-row-reverse"
|
||||
description="Your slug and endpoint URL will remain the same"
|
||||
>
|
||||
<FormControl>
|
||||
<Input {...field} className="w-64" disabled={!canUpdateEdgeFunction} />
|
||||
</FormControl>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={isUpdating}
|
||||
disabled={!canUpdateEdgeFunction || !form.formState.isDirty}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</form>
|
||||
</Form>
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
/>
|
||||
</CardContent>
|
||||
{IS_PLATFORM && (
|
||||
<>
|
||||
<CardContent>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="verify_jwt"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
label="Verify JWT with legacy secret"
|
||||
layout="flex-row-reverse"
|
||||
description={
|
||||
<>
|
||||
<p className="mb-2">
|
||||
Requires a JWT signed{' '}
|
||||
<em className="text-foreground not-italic">
|
||||
only by the legacy secret
|
||||
</em>{' '}
|
||||
in the{' '}
|
||||
<code className="text-code-inline break-keep!">
|
||||
Authorization
|
||||
</code>{' '}
|
||||
header. The <code className="text-code-inline">anon</code> key
|
||||
satisfies this.
|
||||
</p>
|
||||
<p>
|
||||
Recommended: OFF with JWT and custom auth logic in your function
|
||||
code.
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={!canUpdateEdgeFunction}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
<PageSection>
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>Invoke function</PageSectionTitle>
|
||||
</PageSectionSummary>
|
||||
</PageSectionMeta>
|
||||
<PageSectionContent>
|
||||
<Card>
|
||||
<CardContent className="px-0">
|
||||
<Tabs
|
||||
className="w-full"
|
||||
defaultValue="curl"
|
||||
value={selectedTab}
|
||||
onValueChange={setSelectedTab}
|
||||
>
|
||||
<TabsList className="flex flex-wrap gap-4 px-6">
|
||||
{invocationTabs.map((tab) => (
|
||||
<TabsTrigger key={tab.id} value={tab.id}>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
{selectedTab === 'curl' && (
|
||||
<Button
|
||||
type="default"
|
||||
className="ml-auto -translate-y-2 translate-x-3"
|
||||
onClick={() => setShowKey(!showKey)}
|
||||
>
|
||||
{showKey ? 'Hide' : 'Show'} anon key
|
||||
</Button>
|
||||
)}
|
||||
</TabsList>
|
||||
{invocationTabs.map((tab) => {
|
||||
const code = tab.code({
|
||||
showKey,
|
||||
functionUrl,
|
||||
functionName: selectedFunction?.name ?? '',
|
||||
apiKey,
|
||||
})
|
||||
|
||||
return (
|
||||
<TabsContent key={tab.id} value={tab.id}>
|
||||
<CodeBlock
|
||||
value={code}
|
||||
wrapperClassName="[&>div]:top-0 [&>div]:right-3 px-6"
|
||||
className={cn(
|
||||
'p-0 text-xs mt-0! border-none ',
|
||||
showKey ? '[&>code]:break-all' : '[&>code]:wrap-break-word'
|
||||
<CardFooter className="flex justify-end space-x-2">
|
||||
{form.formState.isDirty && (
|
||||
<Button type="default" onClick={() => form.reset()}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
language={tab.language}
|
||||
wrapLines={false}
|
||||
hideLineNumbers={tab.hideLineNumbers}
|
||||
handleCopy={() => {
|
||||
copyToClipboard(
|
||||
tab.code({
|
||||
showKey: true,
|
||||
functionUrl,
|
||||
functionName: selectedFunction?.name ?? '',
|
||||
apiKey,
|
||||
})
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
)
|
||||
})}
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={isUpdating}
|
||||
disabled={!canUpdateEdgeFunction || !form.formState.isDirty}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</form>
|
||||
</Form>
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
|
||||
{IS_PLATFORM && (
|
||||
<>
|
||||
<PageSection>
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>Develop locally</PageSectionTitle>
|
||||
</PageSectionSummary>
|
||||
</PageSectionMeta>
|
||||
<PageSectionContent>
|
||||
<div className="rounded-sm border bg-surface-100 px-6 py-4 drop-shadow-xs">
|
||||
<div className="space-y-6">
|
||||
<CommandRender
|
||||
commands={[
|
||||
{
|
||||
command: `supabase functions download ${selectedFunction?.slug}`,
|
||||
description: 'Download the function to your local machine',
|
||||
jsx: () => (
|
||||
<>
|
||||
<span className="text-brand">supabase</span> functions download{' '}
|
||||
{selectedFunction?.slug}
|
||||
</>
|
||||
),
|
||||
comment: '1. Download the function',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<CommandRender commands={[managementCommands[0]]} />
|
||||
<CommandRender commands={[managementCommands[1]]} />
|
||||
<PageSection>
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>Invoke function</PageSectionTitle>
|
||||
</PageSectionSummary>
|
||||
</PageSectionMeta>
|
||||
<PageSectionContent>
|
||||
<Card>
|
||||
<CardContent className="px-0">
|
||||
<Tabs
|
||||
className="w-full"
|
||||
defaultValue="curl"
|
||||
value={selectedTab}
|
||||
onValueChange={setSelectedTab}
|
||||
>
|
||||
<TabsList className="flex flex-wrap gap-4 px-6">
|
||||
{invocationTabs.map((tab) => (
|
||||
<TabsTrigger key={tab.id} value={tab.id}>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
{selectedTab === 'curl' && (
|
||||
<Button
|
||||
type="default"
|
||||
className="ml-auto -translate-y-2 translate-x-3"
|
||||
onClick={() => setShowKey(!showKey)}
|
||||
>
|
||||
{showKey ? 'Hide' : 'Show'} anon key
|
||||
</Button>
|
||||
)}
|
||||
</TabsList>
|
||||
{invocationTabs.map((tab) => {
|
||||
const code = tab.code({
|
||||
showKey,
|
||||
functionUrl,
|
||||
functionName: selectedFunction?.name ?? '',
|
||||
apiKey,
|
||||
})
|
||||
|
||||
return (
|
||||
<TabsContent key={tab.id} value={tab.id}>
|
||||
<CodeBlock
|
||||
value={code}
|
||||
wrapperClassName="[&>div]:top-0 [&>div]:right-3 px-6"
|
||||
className={cn(
|
||||
'p-0 text-xs mt-0! border-none ',
|
||||
showKey ? '[&>code]:break-all' : '[&>code]:wrap-break-word'
|
||||
)}
|
||||
language={tab.language}
|
||||
wrapLines={false}
|
||||
hideLineNumbers={tab.hideLineNumbers}
|
||||
handleCopy={() => {
|
||||
copyToClipboard(
|
||||
tab.code({
|
||||
showKey: true,
|
||||
functionUrl,
|
||||
functionName: selectedFunction?.name ?? '',
|
||||
apiKey,
|
||||
})
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
)
|
||||
})}
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
|
||||
{IS_PLATFORM && (
|
||||
<>
|
||||
<PageSection>
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>Develop locally</PageSectionTitle>
|
||||
</PageSectionSummary>
|
||||
</PageSectionMeta>
|
||||
<PageSectionContent>
|
||||
<div className="rounded-sm border bg-surface-100 px-6 py-4 drop-shadow-xs">
|
||||
<div className="space-y-6">
|
||||
<CommandRender
|
||||
commands={[
|
||||
{
|
||||
command: `supabase functions download ${selectedFunction?.slug}`,
|
||||
description: 'Download the function to your local machine',
|
||||
jsx: () => (
|
||||
<>
|
||||
<span className="text-brand">supabase</span> functions download{' '}
|
||||
{selectedFunction?.slug}
|
||||
</>
|
||||
),
|
||||
comment: '1. Download the function',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<CommandRender commands={[managementCommands[0]]} />
|
||||
<CommandRender commands={[managementCommands[1]]} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
<PageSection>
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>Delete function</PageSectionTitle>
|
||||
</PageSectionSummary>
|
||||
</PageSectionMeta>
|
||||
<PageSectionContent>
|
||||
<Alert variant="destructive">
|
||||
<CriticalIcon />
|
||||
<AlertTitle>Once your function is deleted, it can no longer be restored</AlertTitle>
|
||||
<AlertDescription>
|
||||
Make sure you have made a backup if you want to restore your edge function
|
||||
</AlertDescription>
|
||||
<AlertDescription className="mt-3">
|
||||
<Button
|
||||
type="danger"
|
||||
disabled={!canUpdateEdgeFunction}
|
||||
loading={selectedFunction?.id === undefined}
|
||||
onClick={() => setShowDeleteModal(true)}
|
||||
>
|
||||
Delete edge function
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
<ConfirmationModal
|
||||
visible={showDeleteModal}
|
||||
loading={isDeleting}
|
||||
variant="destructive"
|
||||
confirmLabel="Delete"
|
||||
confirmLabelLoading="Deleting"
|
||||
title={`Confirm to delete ${selectedFunction?.name}`}
|
||||
onCancel={() => setShowDeleteModal(false)}
|
||||
onConfirm={onConfirmDelete}
|
||||
alert={{
|
||||
base: { variant: 'destructive' },
|
||||
title: 'This action cannot be undone',
|
||||
description:
|
||||
'Ensure that you have made a backup if you want to restore your edge function',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</PageContainer>
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
<PageSection>
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>Delete function</PageSectionTitle>
|
||||
</PageSectionSummary>
|
||||
</PageSectionMeta>
|
||||
<PageSectionContent>
|
||||
<Alert variant="destructive">
|
||||
<CriticalIcon />
|
||||
<AlertTitle>
|
||||
Once your function is deleted, it can no longer be restored
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
Make sure you have made a backup if you want to restore your edge function
|
||||
</AlertDescription>
|
||||
<AlertDescription className="mt-3">
|
||||
<Button
|
||||
type="danger"
|
||||
disabled={!canUpdateEdgeFunction}
|
||||
loading={selectedFunction?.id === undefined}
|
||||
onClick={() => setShowDeleteModal(true)}
|
||||
>
|
||||
Delete edge function
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
<ConfirmationModal
|
||||
visible={showDeleteModal}
|
||||
loading={isDeleting}
|
||||
variant="destructive"
|
||||
confirmLabel="Delete"
|
||||
confirmLabelLoading="Deleting"
|
||||
title={`Confirm to delete ${selectedFunction?.name}`}
|
||||
onCancel={() => setShowDeleteModal(false)}
|
||||
onConfirm={onConfirmDelete}
|
||||
alert={{
|
||||
base: { variant: 'destructive' },
|
||||
title: 'This action cannot be undone',
|
||||
description:
|
||||
'Ensure that you have made a backup if you want to restore your edge function',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+73
-91
@@ -1,16 +1,23 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { Button } from 'ui'
|
||||
import { Chart, ChartActions, ChartLoadingState, ChartMetric } from 'ui-patterns/Chart'
|
||||
import { PageContainer } from 'ui-patterns/PageContainer'
|
||||
import {
|
||||
Chart,
|
||||
ChartActions,
|
||||
ChartCard,
|
||||
ChartContent,
|
||||
ChartHeader,
|
||||
ChartLoadingState,
|
||||
ChartMetric,
|
||||
} from 'ui-patterns/Chart'
|
||||
import {
|
||||
PageSection,
|
||||
PageSectionAside,
|
||||
PageSectionContent,
|
||||
PageSectionMeta,
|
||||
PageSectionSummary,
|
||||
PageSectionTitle,
|
||||
} from 'ui-patterns/PageSection'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { EdgeFunctionChartEmptyState } from './EdgeFunctionChartEmptyState'
|
||||
import { EdgeFunctionInvocationsChart } from './EdgeFunctionInvocationsChart'
|
||||
@@ -21,8 +28,6 @@ import {
|
||||
getSegmentedButtonClassName,
|
||||
} from './EdgeFunctionOverview.utils'
|
||||
import type { InvocationChartDatum, InvocationUpdateAnnotation } from './EdgeFunctionOverview.utils'
|
||||
import { toAlertError } from './EdgeFunctionRecentErrors.utils'
|
||||
import AlertError from '@/components/ui/AlertError'
|
||||
import type { ChartIntervals } from '@/types'
|
||||
|
||||
interface EdgeFunctionInvocationsSectionProps {
|
||||
@@ -33,9 +38,6 @@ interface EdgeFunctionInvocationsSectionProps {
|
||||
totalInvocationCount: number
|
||||
totalErrorCount: number
|
||||
totalWarningCount: number
|
||||
isLoadingFunction: boolean
|
||||
isErrorFunction: boolean
|
||||
functionError?: unknown
|
||||
isLoadingChart: boolean
|
||||
isErrorChart: boolean
|
||||
chartErrorMessage?: string
|
||||
@@ -52,9 +54,6 @@ export const EdgeFunctionInvocationsSection = ({
|
||||
totalInvocationCount,
|
||||
totalErrorCount,
|
||||
totalWarningCount,
|
||||
isLoadingFunction,
|
||||
isErrorFunction,
|
||||
functionError,
|
||||
isLoadingChart,
|
||||
isErrorChart,
|
||||
chartErrorMessage,
|
||||
@@ -67,89 +66,72 @@ export const EdgeFunctionInvocationsSection = ({
|
||||
() => getChartEmptyStateCopy('invocations', isErrorChart, chartErrorMessage),
|
||||
[chartErrorMessage, isErrorChart]
|
||||
)
|
||||
const invocationTitle = isLoadingChart
|
||||
? 'Invocations'
|
||||
: `${totalInvocationCount.toLocaleString('en-US')} total invocations`
|
||||
|
||||
return (
|
||||
<PageSection className="bg-surface-100/50 border-b pb-10 pt-8">
|
||||
<PageSectionContent>
|
||||
<PageContainer size="full">
|
||||
<div className="flex flex-col gap-5">
|
||||
<PageSectionMeta className="items-center!">
|
||||
<PageSectionSummary>
|
||||
<div className="flex flex-wrap items-start gap-x-8 gap-y-4">
|
||||
<ChartMetric
|
||||
label="Total Invocations"
|
||||
value={totalInvocationCount}
|
||||
status="default"
|
||||
tooltip="Total number of invocations"
|
||||
/>
|
||||
<ChartMetric
|
||||
label="5xx Rate"
|
||||
value={formatRate(totalErrorCount, totalInvocationCount)}
|
||||
status="negative"
|
||||
tooltip="Share of invocations that returned a 5xx status code"
|
||||
/>
|
||||
<ChartMetric
|
||||
label="4xx Rate"
|
||||
value={formatRate(totalWarningCount, totalInvocationCount)}
|
||||
status="warning"
|
||||
tooltip="Share of invocations that returned a 4xx status code"
|
||||
/>
|
||||
</div>
|
||||
</PageSectionSummary>
|
||||
<PageSectionAside className="flex-wrap @xl:self-center">
|
||||
<div className="flex items-center">
|
||||
{EDGE_FUNCTION_CHART_INTERVALS.map((item, index) => {
|
||||
return (
|
||||
<Button
|
||||
key={`function-filter-${item.key}`}
|
||||
type={interval === item.key ? 'secondary' : 'default'}
|
||||
onClick={() => onIntervalChange(item.key)}
|
||||
className={getSegmentedButtonClassName(
|
||||
index,
|
||||
EDGE_FUNCTION_CHART_INTERVALS.length
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<ChartActions actions={actions} />
|
||||
</PageSectionAside>
|
||||
</PageSectionMeta>
|
||||
|
||||
<div>
|
||||
{isLoadingFunction && <GenericSkeletonLoader />}
|
||||
{isErrorFunction && (
|
||||
<AlertError
|
||||
error={toAlertError(functionError)}
|
||||
subject="Failed to retrieve edge function details"
|
||||
layout="vertical"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Chart isLoading={isLoadingChart}>
|
||||
{isLoadingChart ? (
|
||||
<ChartLoadingState />
|
||||
) : isErrorChart || chartData.length === 0 ? (
|
||||
<EdgeFunctionChartEmptyState
|
||||
title={emptyStateCopy.title}
|
||||
description={emptyStateCopy.description}
|
||||
/>
|
||||
) : (
|
||||
<EdgeFunctionInvocationsChart
|
||||
chartData={chartData}
|
||||
dateTimeFormat={dateTimeFormat}
|
||||
onChartClick={onChartClick}
|
||||
updateAnnotation={updateAnnotation}
|
||||
/>
|
||||
)}
|
||||
</Chart>
|
||||
</div>
|
||||
<PageSection className="pt-0">
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>{invocationTitle}</PageSectionTitle>
|
||||
</PageSectionSummary>
|
||||
<PageSectionAside className="flex-wrap @xl:self-center">
|
||||
<div className="flex items-center">
|
||||
{EDGE_FUNCTION_CHART_INTERVALS.map((item, index) => (
|
||||
<Button
|
||||
key={`function-filter-${item.key}`}
|
||||
type={interval === item.key ? 'secondary' : 'default'}
|
||||
onClick={() => onIntervalChange(item.key)}
|
||||
className={getSegmentedButtonClassName(index, EDGE_FUNCTION_CHART_INTERVALS.length)}
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</PageContainer>
|
||||
{actions && actions.length > 0 ? <ChartActions actions={actions} /> : null}
|
||||
</PageSectionAside>
|
||||
</PageSectionMeta>
|
||||
<PageSectionContent>
|
||||
<Chart isLoading={isLoadingChart}>
|
||||
<ChartCard>
|
||||
<ChartHeader align="start">
|
||||
<div className="flex flex-wrap gap-x-8 gap-y-4">
|
||||
<ChartMetric
|
||||
label="5xx Rate"
|
||||
value={formatRate(totalErrorCount, totalInvocationCount)}
|
||||
status="negative"
|
||||
tooltip="Share of invocations that returned a 5xx status code"
|
||||
/>
|
||||
<ChartMetric
|
||||
label="4xx Rate"
|
||||
value={formatRate(totalWarningCount, totalInvocationCount)}
|
||||
status="warning"
|
||||
tooltip="Share of invocations that returned a 4xx status code"
|
||||
/>
|
||||
</div>
|
||||
</ChartHeader>
|
||||
<ChartContent
|
||||
isEmpty={isErrorChart || chartData.length === 0}
|
||||
emptyState={
|
||||
<EdgeFunctionChartEmptyState
|
||||
title={emptyStateCopy.title}
|
||||
description={emptyStateCopy.description}
|
||||
/>
|
||||
}
|
||||
loadingState={<ChartLoadingState />}
|
||||
>
|
||||
<div className="h-40">
|
||||
<EdgeFunctionInvocationsChart
|
||||
chartData={chartData}
|
||||
dateTimeFormat={dateTimeFormat}
|
||||
onChartClick={onChartClick}
|
||||
updateAnnotation={updateAnnotation}
|
||||
/>
|
||||
</div>
|
||||
</ChartContent>
|
||||
</ChartCard>
|
||||
</Chart>
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
)
|
||||
|
||||
+55
-58
@@ -3,6 +3,7 @@ import { IS_PLATFORM, useParams } from 'common'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { PageContainer } from 'ui-patterns/PageContainer'
|
||||
|
||||
import { EdgeFunctionInvocationsSection } from './EdgeFunctionInvocationsSection'
|
||||
import {
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
toEdgeFunctionChartData,
|
||||
} from './EdgeFunctionOverview.utils'
|
||||
import type { EdgeFunctionChartRawDatum } from './EdgeFunctionOverview.utils'
|
||||
import { EdgeFunctionOverviewHeader } from './EdgeFunctionOverviewHeader'
|
||||
import { EdgeFunctionPerformanceSection } from './EdgeFunctionPerformanceSection'
|
||||
import { EdgeFunctionRecentErrors } from './EdgeFunctionRecentErrors'
|
||||
import { EdgeFunctionUsageSection } from './EdgeFunctionUsageSection'
|
||||
@@ -40,12 +42,7 @@ export const EdgeFunctionOverview = () => {
|
||||
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({
|
||||
const { data: selectedFunction } = useEdgeFunctionQuery({
|
||||
projectRef,
|
||||
slug: functionSlug,
|
||||
})
|
||||
@@ -189,61 +186,61 @@ export const EdgeFunctionOverview = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<EdgeFunctionInvocationsSection
|
||||
interval={interval}
|
||||
onIntervalChange={setInterval}
|
||||
selectedInterval={selectedInterval}
|
||||
actions={invocationActions}
|
||||
totalInvocationCount={totalInvocationCount}
|
||||
totalErrorCount={totalErrorCount}
|
||||
totalWarningCount={totalWarningCount}
|
||||
isLoadingFunction={isLoadingFunction}
|
||||
isErrorFunction={isErrorFunction}
|
||||
functionError={functionError}
|
||||
isLoadingChart={combinedStatsResults.isLoading}
|
||||
isErrorChart={isErrorCombinedStats}
|
||||
chartErrorMessage={combinedStatsError?.message ?? 'Unknown error'}
|
||||
chartData={invocationChartData}
|
||||
onChartClick={() => {
|
||||
router.push(
|
||||
`/project/${projectRef}/functions/${functionSlug}/${
|
||||
isUnifiedLogsEnabled ? 'logs' : 'invocations'
|
||||
}${isUnifiedLogsEnabled ? '' : `?its=${startDate.toISOString()}`}`
|
||||
)
|
||||
}}
|
||||
updateAnnotation={invocationUpdateAnnotation}
|
||||
/>
|
||||
<EdgeFunctionOverviewHeader />
|
||||
<PageContainer size="default">
|
||||
<EdgeFunctionInvocationsSection
|
||||
interval={interval}
|
||||
onIntervalChange={setInterval}
|
||||
selectedInterval={selectedInterval}
|
||||
actions={invocationActions}
|
||||
totalInvocationCount={totalInvocationCount}
|
||||
totalErrorCount={totalErrorCount}
|
||||
totalWarningCount={totalWarningCount}
|
||||
isLoadingChart={combinedStatsResults.isLoading}
|
||||
isErrorChart={isErrorCombinedStats}
|
||||
chartErrorMessage={combinedStatsError?.message ?? 'Unknown error'}
|
||||
chartData={invocationChartData}
|
||||
onChartClick={() => {
|
||||
router.push(
|
||||
`/project/${projectRef}/functions/${functionSlug}/${
|
||||
isUnifiedLogsEnabled ? 'logs' : 'invocations'
|
||||
}${isUnifiedLogsEnabled ? '' : `?its=${startDate.toISOString()}`}`
|
||||
)
|
||||
}}
|
||||
updateAnnotation={invocationUpdateAnnotation}
|
||||
/>
|
||||
|
||||
<EdgeFunctionRecentErrors
|
||||
functionId={id}
|
||||
functionSlug={functionSlug as string}
|
||||
projectRef={projectRef as string}
|
||||
updatedAt={selectedFunction?.updated_at}
|
||||
/>
|
||||
<EdgeFunctionRecentErrors
|
||||
functionId={id}
|
||||
functionSlug={functionSlug as string}
|
||||
projectRef={projectRef as string}
|
||||
updatedAt={selectedFunction?.updated_at}
|
||||
/>
|
||||
|
||||
<EdgeFunctionPerformanceSection
|
||||
data={chartData}
|
||||
dateTimeFormat={dateTimeFormat}
|
||||
isLoading={combinedStatsResults.isLoading}
|
||||
isError={isErrorCombinedStats}
|
||||
errorMessage={combinedStatsError?.message ?? 'Unknown error'}
|
||||
averageExecutionTime={averageExecutionTime}
|
||||
maxExecutionTime={maxExecutionTime}
|
||||
/>
|
||||
<EdgeFunctionPerformanceSection
|
||||
data={chartData}
|
||||
dateTimeFormat={dateTimeFormat}
|
||||
isLoading={combinedStatsResults.isLoading}
|
||||
isError={isErrorCombinedStats}
|
||||
errorMessage={combinedStatsError?.message ?? 'Unknown error'}
|
||||
averageExecutionTime={averageExecutionTime}
|
||||
maxExecutionTime={maxExecutionTime}
|
||||
/>
|
||||
|
||||
<EdgeFunctionUsageSection
|
||||
data={chartData}
|
||||
dateTimeFormat={dateTimeFormat}
|
||||
isLoading={combinedStatsResults.isLoading}
|
||||
isError={isErrorCombinedStats}
|
||||
errorMessage={combinedStatsError?.message ?? 'Unknown error'}
|
||||
averageCpuTime={averageCpuTime}
|
||||
maxCpuTime={maxCpuTime}
|
||||
averageMemoryUsage={averageMemoryUsage}
|
||||
totalHeapMemory={totalHeapMemory}
|
||||
totalExternalMemory={totalExternalMemory}
|
||||
totalMemoryByType={totalMemoryByType}
|
||||
/>
|
||||
<EdgeFunctionUsageSection
|
||||
data={chartData}
|
||||
dateTimeFormat={dateTimeFormat}
|
||||
isLoading={combinedStatsResults.isLoading}
|
||||
isError={isErrorCombinedStats}
|
||||
errorMessage={combinedStatsError?.message ?? 'Unknown error'}
|
||||
averageCpuTime={averageCpuTime}
|
||||
maxCpuTime={maxCpuTime}
|
||||
averageMemoryUsage={averageMemoryUsage}
|
||||
totalHeapMemory={totalHeapMemory}
|
||||
totalExternalMemory={totalExternalMemory}
|
||||
totalMemoryByType={totalMemoryByType}
|
||||
/>
|
||||
</PageContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+13
-1
@@ -1,5 +1,5 @@
|
||||
import dayjs from 'dayjs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
EDGE_FUNCTION_CHART_INTERVALS,
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
formatMetric,
|
||||
formatRate,
|
||||
formatReferenceDelta,
|
||||
formatShortFromNow,
|
||||
getBucketedTimeRange,
|
||||
getChartEmptyStateCopy,
|
||||
getChartTimeRangeLabels,
|
||||
@@ -196,6 +197,17 @@ describe('EdgeFunctionOverview.utils', () => {
|
||||
expect(rollingEnd.toISOString()).toBe('2026-03-20T10:37:00.000Z')
|
||||
})
|
||||
|
||||
it('formats short relative timestamps', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-03-20T10:00:00.000Z'))
|
||||
|
||||
expect(formatShortFromNow('2026-03-20T09:28:00.000Z')).toBe('32m ago')
|
||||
expect(formatShortFromNow('2026-03-20T09:59:30.000Z')).toBe('30s ago')
|
||||
expect(formatShortFromNow('2026-03-19T10:00:00.000Z')).toBe('1d ago')
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('formats metric, rate, and reference deltas consistently', () => {
|
||||
expect(formatMetric(12.34, 'MB')).toBe('12.3MB')
|
||||
expect(formatMetric(1234, 'ms')).toBe('1,234ms')
|
||||
|
||||
+26
@@ -278,6 +278,32 @@ export const formatMetric = (value?: number, unit?: string) => {
|
||||
return unit ? `${formatted}${unit}` : formatted
|
||||
}
|
||||
|
||||
export const formatShortFromNow = (input: string | number | Date): string => {
|
||||
const then = dayjs(input)
|
||||
if (!then.isValid()) return ''
|
||||
|
||||
const seconds = Math.max(0, dayjs().diff(then, 'second'))
|
||||
if (seconds < 60) return `${seconds}s ago`
|
||||
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 7) return `${days}d ago`
|
||||
|
||||
const weeks = Math.floor(days / 7)
|
||||
if (weeks < 5) return `${weeks}w ago`
|
||||
|
||||
const months = Math.floor(days / 30)
|
||||
if (months < 12) return `${months}mo ago`
|
||||
|
||||
const years = Math.floor(days / 365)
|
||||
return `${years}y ago`
|
||||
}
|
||||
|
||||
export const formatRate = (count: number, total: number) =>
|
||||
new Intl.NumberFormat('en-US', {
|
||||
style: 'percent',
|
||||
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
import { useParams } from 'common'
|
||||
import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import { Clock } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from 'ui'
|
||||
import { TimestampInfo } from 'ui-patterns'
|
||||
import {
|
||||
PageHeader,
|
||||
PageHeaderDescription,
|
||||
PageHeaderMeta,
|
||||
PageHeaderSummary,
|
||||
PageHeaderTitle,
|
||||
} from 'ui-patterns/PageHeader'
|
||||
|
||||
import { formatShortFromNow } from './EdgeFunctionOverview.utils'
|
||||
import CopyButton from '@/components/ui/CopyButton'
|
||||
import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip'
|
||||
import { useProjectApiUrl } from '@/data/config/project-endpoint-query'
|
||||
import { useEdgeFunctionQuery } from '@/data/edge-functions/edge-function-query'
|
||||
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
export const EdgeFunctionOverviewHeader = () => {
|
||||
const { ref: projectRef, functionSlug } = useParams()
|
||||
const [isTimestampHoverCardOpen, setIsTimestampHoverCardOpen] = useState(false)
|
||||
|
||||
const { data: selectedFunction } = useEdgeFunctionQuery({
|
||||
projectRef,
|
||||
slug: functionSlug,
|
||||
})
|
||||
const { data: endpoint } = useProjectApiUrl({ projectRef })
|
||||
|
||||
const name = selectedFunction?.name || ''
|
||||
const functionUrl =
|
||||
endpoint && selectedFunction?.slug ? `${endpoint}/functions/v1/${selectedFunction.slug}` : ''
|
||||
const createdRelative = selectedFunction?.created_at
|
||||
? dayjs(selectedFunction.created_at).fromNow()
|
||||
: undefined
|
||||
const updatedRelative = selectedFunction?.updated_at
|
||||
? dayjs(selectedFunction.updated_at).fromNow()
|
||||
: undefined
|
||||
const updatedShortRelative = selectedFunction?.updated_at
|
||||
? formatShortFromNow(selectedFunction.updated_at)
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<PageHeader size="small" className="pb-12">
|
||||
<PageHeaderMeta>
|
||||
<PageHeaderSummary>
|
||||
<PageHeaderTitle>{name || functionSlug}</PageHeaderTitle>
|
||||
<PageHeaderDescription className="flex flex-row flex-wrap items-center gap-x-4 gap-y-1 text-sm!">
|
||||
{functionUrl ? (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span className="flex items-center gap-2">{functionUrl}</span>
|
||||
<ShortcutTooltip shortcutId={SHORTCUT_IDS.FUNCTION_DETAIL_COPY_URL} side="bottom">
|
||||
<CopyButton iconOnly type="text" text={functionUrl} />
|
||||
</ShortcutTooltip>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<HoverCard
|
||||
openDelay={250}
|
||||
closeDelay={100}
|
||||
open={isTimestampHoverCardOpen}
|
||||
onOpenChange={setIsTimestampHoverCardOpen}
|
||||
>
|
||||
<HoverCardTrigger asChild>
|
||||
<button type="button" className="flex items-center gap-2 group">
|
||||
<Clock size={16} strokeWidth={1.5} className="text-foreground-lighter" />
|
||||
<span className="transition text-foreground-light group-hover:text-foreground underline decoration-dotted decoration-foreground-muted underline-offset-4">
|
||||
{updatedShortRelative ?? 'Deploy status unavailable'}
|
||||
</span>
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent side="bottom" align="start" className="w-40 p-0">
|
||||
{createdRelative && (
|
||||
<div className="px-4 py-2 space-y-1">
|
||||
<h3 className="heading-meta text-foreground-light">Created</h3>
|
||||
{!!selectedFunction && (
|
||||
<TimestampInfo
|
||||
className="text-sm"
|
||||
label={createdRelative}
|
||||
utcTimestamp={selectedFunction.created_at}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{updatedRelative && (
|
||||
<div className="px-4 py-2 space-y-1">
|
||||
<h3 className="heading-meta text-foreground-light">Last deployed</h3>
|
||||
{!!selectedFunction && (
|
||||
<TimestampInfo
|
||||
className="text-sm"
|
||||
label={updatedRelative}
|
||||
utcTimestamp={selectedFunction.updated_at}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{selectedFunction?.version !== undefined && (
|
||||
<div className="px-4 py-2 space-y-1">
|
||||
<h3 className="heading-meta text-foreground-light">Deployments</h3>
|
||||
<p className="text-sm text-foreground">{selectedFunction.version}</p>
|
||||
</div>
|
||||
)}
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</PageHeaderDescription>
|
||||
</PageHeaderSummary>
|
||||
</PageHeaderMeta>
|
||||
</PageHeader>
|
||||
)
|
||||
}
|
||||
+30
-39
@@ -1,6 +1,5 @@
|
||||
import { useMemo } from 'react'
|
||||
import { ChartMetric } from 'ui-patterns/Chart'
|
||||
import { PageContainer } from 'ui-patterns/PageContainer'
|
||||
import {
|
||||
PageSection,
|
||||
PageSectionContent,
|
||||
@@ -59,45 +58,37 @@ export const EdgeFunctionPerformanceSection = ({
|
||||
|
||||
return (
|
||||
<PageSection>
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>Performance</PageSectionTitle>
|
||||
</PageSectionSummary>
|
||||
</PageSectionMeta>
|
||||
<PageSectionContent>
|
||||
<PageContainer size="full">
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>Performance</PageSectionTitle>
|
||||
</PageSectionSummary>
|
||||
</PageSectionMeta>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<EdgeFunctionTimeSeriesChartCard
|
||||
className="lg:col-span-2"
|
||||
data={data}
|
||||
dateTimeFormat={dateTimeFormat}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
emptyTitle={emptyStateCopy.title}
|
||||
emptyDescription={emptyStateCopy.description}
|
||||
metrics={metrics}
|
||||
dataKey="max_execution_time"
|
||||
dataKeys={['avg_execution_time', 'max_execution_time']}
|
||||
config={EXECUTION_TIME_CHART_CONFIG}
|
||||
tooltipDetails={tooltipDetails}
|
||||
referenceLines={[
|
||||
{
|
||||
y: averageExecutionTime,
|
||||
label: 'average',
|
||||
stroke: 'hsl(var(--foreground-default))',
|
||||
strokeWidth: 1.5,
|
||||
},
|
||||
]}
|
||||
yAxisProps={{
|
||||
width: 64,
|
||||
tickFormatter: (value: number) => `${Math.round(value)}ms`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
<EdgeFunctionTimeSeriesChartCard
|
||||
data={data}
|
||||
dateTimeFormat={dateTimeFormat}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
emptyTitle={emptyStateCopy.title}
|
||||
emptyDescription={emptyStateCopy.description}
|
||||
metrics={metrics}
|
||||
dataKey="max_execution_time"
|
||||
dataKeys={['avg_execution_time', 'max_execution_time']}
|
||||
config={EXECUTION_TIME_CHART_CONFIG}
|
||||
tooltipDetails={tooltipDetails}
|
||||
referenceLines={[
|
||||
{
|
||||
y: averageExecutionTime,
|
||||
label: 'average',
|
||||
stroke: 'hsl(var(--foreground-default))',
|
||||
strokeWidth: 1.5,
|
||||
},
|
||||
]}
|
||||
yAxisProps={{
|
||||
width: 64,
|
||||
tickFormatter: (value: number) => `${Math.round(value)}ms`,
|
||||
}}
|
||||
/>
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
)
|
||||
|
||||
+161
-168
@@ -13,7 +13,6 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from 'ui'
|
||||
import { PageContainer } from 'ui-patterns/PageContainer'
|
||||
import {
|
||||
PageSection,
|
||||
PageSectionAside,
|
||||
@@ -196,175 +195,169 @@ export const EdgeFunctionRecentErrors = ({
|
||||
|
||||
return (
|
||||
<PageSection>
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>Errors since last deploy</PageSectionTitle>
|
||||
</PageSectionSummary>
|
||||
<PageSectionAside>
|
||||
<Button
|
||||
type="default"
|
||||
size="tiny"
|
||||
icon={<ExternalLink size={14} />}
|
||||
onClick={() => router.push(`/project/${projectRef}/functions/${functionSlug}/logs`)}
|
||||
>
|
||||
View logs
|
||||
</Button>
|
||||
</PageSectionAside>
|
||||
</PageSectionMeta>
|
||||
|
||||
<PageSectionContent>
|
||||
<PageContainer size="full">
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>Errors since last deploy</PageSectionTitle>
|
||||
</PageSectionSummary>
|
||||
<PageSectionAside>
|
||||
<Button
|
||||
type="default"
|
||||
size="tiny"
|
||||
icon={<ExternalLink size={14} />}
|
||||
onClick={() =>
|
||||
router.push(`/project/${projectRef}/functions/${functionSlug}/logs`)
|
||||
}
|
||||
>
|
||||
View logs
|
||||
</Button>
|
||||
</PageSectionAside>
|
||||
</PageSectionMeta>
|
||||
|
||||
{recentErrorInvocationsError || functionRuntimeLogsError ? (
|
||||
<AlertError
|
||||
error={queryError}
|
||||
subject="Failed to retrieve edge function errors since the last deploy"
|
||||
/>
|
||||
) : isLoadingRecentErrorInvocations ||
|
||||
isLoadingFunctionRuntimeLogs ||
|
||||
isLoadingSinceLastDeployInvocationCount ? (
|
||||
<GenericSkeletonLoader />
|
||||
) : recentErrorGroups.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed px-5 py-6 text-sm text-foreground-light">
|
||||
<div className="flex items-start gap-3">
|
||||
{emptyStateIcon}
|
||||
<div>{emptyStateMessage}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Card className="p-0 overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Error</TableHead>
|
||||
<TableHead>Count</TableHead>
|
||||
<TableHead>Last Seen</TableHead>
|
||||
<TableHead>Method</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Duration</TableHead>
|
||||
<TableHead className="text-right">Troubleshoot</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{recentErrorGroups.map((group) => {
|
||||
const displayMessage = getDisplayErrorMessage(group)
|
||||
const docsUrl = buildTroubleshootingDocsUrl({
|
||||
statusCode: group.lastStatusCode,
|
||||
})
|
||||
|
||||
return (
|
||||
<Fragment key={group.message}>
|
||||
<TableRow key={`${group.message}-summary`}>
|
||||
<TableCell className="max-w-[420px]">
|
||||
<span
|
||||
className="block truncate whitespace-nowrap text-foreground"
|
||||
title={displayMessage}
|
||||
>
|
||||
{displayMessage}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-foreground-light">{group.count}</TableCell>
|
||||
<TableCell className="text-foreground-light">
|
||||
{formatLogTimestamp(group.lastSeen, 'relative')}
|
||||
</TableCell>
|
||||
<TableCell className="text-foreground-light">
|
||||
{group.lastMethod ?? '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{group.lastStatusCode ? (
|
||||
<Badge
|
||||
variant={getStatusBadgeVariant(group.lastStatusCode)}
|
||||
className="font-mono"
|
||||
>
|
||||
{group.lastStatusCode}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive" className="font-mono">
|
||||
Error
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-foreground-light">
|
||||
{group.executionTime ?? '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end">
|
||||
<AiAssistantDropdown
|
||||
label="Ask Assistant"
|
||||
size="tiny"
|
||||
buildPrompt={() => buildGroupAssistantPrompt(group, functionSlug)}
|
||||
onOpenAssistant={() => handleOpenAssistant(group)}
|
||||
additionalDropdownItems={[
|
||||
{
|
||||
label: 'View troubleshooting guide',
|
||||
icon: <BookOpen size={14} />,
|
||||
onClick: () =>
|
||||
window.open(docsUrl, '_blank', 'noopener,noreferrer'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow key={`${group.message}-logs`} className="hover:bg-transparent">
|
||||
<TableCell colSpan={7} className="p-0">
|
||||
<div className="max-h-64 overflow-auto bg-surface-75 font-mono text-xs">
|
||||
{group.logs.length === 0 ? (
|
||||
<div className="px-4 py-3 text-foreground-lighter">
|
||||
No related runtime logs found for this error group.
|
||||
</div>
|
||||
) : (
|
||||
group.logs.map((log, index) => {
|
||||
const isError = log.level === 'error'
|
||||
return (
|
||||
<div
|
||||
key={log.key}
|
||||
className={cn(
|
||||
'flex items-start gap-3 px-4 py-2',
|
||||
index !== 0 && 'border-t border-default',
|
||||
isError && 'bg-destructive-200/40'
|
||||
)}
|
||||
>
|
||||
<span className="shrink-0 tabular-nums text-foreground-muted">
|
||||
{formatLogTimestamp(log.lastSeen, 'time')}
|
||||
</span>
|
||||
<Badge
|
||||
variant={isError ? 'destructive' : 'default'}
|
||||
className="shrink-0"
|
||||
>
|
||||
{log.level}
|
||||
</Badge>
|
||||
{log.count > 1 && (
|
||||
<span className="shrink-0 text-foreground-muted tabular-nums">
|
||||
×{log.count}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'flex-1 wrap-break-word whitespace-pre-wrap',
|
||||
isError ? 'text-destructive' : 'text-foreground-light'
|
||||
)}
|
||||
>
|
||||
{formatSingleLineMessage(log.message)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
)}
|
||||
{recentErrorInvocationsError || functionRuntimeLogsError ? (
|
||||
<AlertError
|
||||
error={queryError}
|
||||
subject="Failed to retrieve edge function errors since the last deploy"
|
||||
/>
|
||||
) : isLoadingRecentErrorInvocations ||
|
||||
isLoadingFunctionRuntimeLogs ||
|
||||
isLoadingSinceLastDeployInvocationCount ? (
|
||||
<GenericSkeletonLoader />
|
||||
) : recentErrorGroups.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed px-5 py-6 text-sm text-foreground-light">
|
||||
<div className="flex items-start gap-3">
|
||||
{emptyStateIcon}
|
||||
<div>{emptyStateMessage}</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
) : (
|
||||
<Card className="p-0 overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Error</TableHead>
|
||||
<TableHead>Count</TableHead>
|
||||
<TableHead>Last Seen</TableHead>
|
||||
<TableHead>Method</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Duration</TableHead>
|
||||
<TableHead className="text-right">Troubleshoot</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{recentErrorGroups.map((group) => {
|
||||
const displayMessage = getDisplayErrorMessage(group)
|
||||
const docsUrl = buildTroubleshootingDocsUrl({
|
||||
statusCode: group.lastStatusCode,
|
||||
})
|
||||
|
||||
return (
|
||||
<Fragment key={group.message}>
|
||||
<TableRow key={`${group.message}-summary`}>
|
||||
<TableCell className="max-w-[420px]">
|
||||
<span
|
||||
className="block truncate whitespace-nowrap text-foreground"
|
||||
title={displayMessage}
|
||||
>
|
||||
{displayMessage}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-foreground-light">{group.count}</TableCell>
|
||||
<TableCell className="text-foreground-light">
|
||||
{formatLogTimestamp(group.lastSeen, 'relative')}
|
||||
</TableCell>
|
||||
<TableCell className="text-foreground-light">
|
||||
{group.lastMethod ?? '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{group.lastStatusCode ? (
|
||||
<Badge
|
||||
variant={getStatusBadgeVariant(group.lastStatusCode)}
|
||||
className="font-mono"
|
||||
>
|
||||
{group.lastStatusCode}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive" className="font-mono">
|
||||
Error
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-foreground-light">
|
||||
{group.executionTime ?? '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end">
|
||||
<AiAssistantDropdown
|
||||
label="Ask Assistant"
|
||||
size="tiny"
|
||||
buildPrompt={() => buildGroupAssistantPrompt(group, functionSlug)}
|
||||
onOpenAssistant={() => handleOpenAssistant(group)}
|
||||
additionalDropdownItems={[
|
||||
{
|
||||
label: 'View troubleshooting guide',
|
||||
icon: <BookOpen size={14} />,
|
||||
onClick: () =>
|
||||
window.open(docsUrl, '_blank', 'noopener,noreferrer'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow key={`${group.message}-logs`} className="hover:bg-transparent">
|
||||
<TableCell colSpan={7} className="p-0">
|
||||
<div className="max-h-64 overflow-auto bg-surface-75 font-mono text-xs">
|
||||
{group.logs.length === 0 ? (
|
||||
<div className="px-4 py-3 text-foreground-lighter">
|
||||
No related runtime logs found for this error group.
|
||||
</div>
|
||||
) : (
|
||||
group.logs.map((log, index) => {
|
||||
const isError = log.level === 'error'
|
||||
return (
|
||||
<div
|
||||
key={log.key}
|
||||
className={cn(
|
||||
'flex items-start gap-3 px-4 py-2',
|
||||
index !== 0 && 'border-t border-default',
|
||||
isError && 'bg-destructive-200/40'
|
||||
)}
|
||||
>
|
||||
<span className="shrink-0 tabular-nums text-foreground-muted">
|
||||
{formatLogTimestamp(log.lastSeen, 'time')}
|
||||
</span>
|
||||
<Badge
|
||||
variant={isError ? 'destructive' : 'default'}
|
||||
className="shrink-0"
|
||||
>
|
||||
{log.level}
|
||||
</Badge>
|
||||
{log.count > 1 && (
|
||||
<span className="shrink-0 text-foreground-muted tabular-nums">
|
||||
×{log.count}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'flex-1 wrap-break-word whitespace-pre-wrap',
|
||||
isError ? 'text-destructive' : 'text-foreground-light'
|
||||
)}
|
||||
>
|
||||
{formatSingleLineMessage(log.message)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
)}
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
)
|
||||
|
||||
+55
-61
@@ -1,6 +1,5 @@
|
||||
import { useMemo } from 'react'
|
||||
import { ChartMetric } from 'ui-patterns/Chart'
|
||||
import { PageContainer } from 'ui-patterns/PageContainer'
|
||||
import {
|
||||
PageSection,
|
||||
PageSectionContent,
|
||||
@@ -90,68 +89,63 @@ export const EdgeFunctionUsageSection = ({
|
||||
|
||||
return (
|
||||
<PageSection>
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>Usage</PageSectionTitle>
|
||||
</PageSectionSummary>
|
||||
</PageSectionMeta>
|
||||
<PageSectionContent>
|
||||
<PageContainer size="full">
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>Usage</PageSectionTitle>
|
||||
</PageSectionSummary>
|
||||
</PageSectionMeta>
|
||||
<div className="flex flex-col gap-6">
|
||||
<EdgeFunctionTimeSeriesChartCard
|
||||
data={data}
|
||||
dateTimeFormat={dateTimeFormat}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
emptyTitle={cpuEmptyStateCopy.title}
|
||||
emptyDescription={cpuEmptyStateCopy.description}
|
||||
metrics={cpuMetrics}
|
||||
dataKey="max_cpu_time_used"
|
||||
config={CPU_TIME_CHART_CONFIG}
|
||||
tooltipDetails={cpuTooltipDetails}
|
||||
referenceLines={[
|
||||
{
|
||||
y: averageCpuTime,
|
||||
label: 'average',
|
||||
stroke: 'hsl(var(--foreground-default))',
|
||||
strokeWidth: 1.5,
|
||||
},
|
||||
]}
|
||||
yAxisProps={{
|
||||
width: 64,
|
||||
tickFormatter: (value: number) => `${Math.round(value)}ms`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<EdgeFunctionTimeSeriesChartCard
|
||||
data={data}
|
||||
dateTimeFormat={dateTimeFormat}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
emptyTitle={cpuEmptyStateCopy.title}
|
||||
emptyDescription={cpuEmptyStateCopy.description}
|
||||
metrics={cpuMetrics}
|
||||
dataKey="max_cpu_time_used"
|
||||
config={CPU_TIME_CHART_CONFIG}
|
||||
tooltipDetails={cpuTooltipDetails}
|
||||
referenceLines={[
|
||||
{
|
||||
y: averageCpuTime,
|
||||
label: 'average',
|
||||
stroke: 'hsl(var(--foreground-default))',
|
||||
strokeWidth: 1.5,
|
||||
},
|
||||
]}
|
||||
yAxisProps={{
|
||||
width: 64,
|
||||
tickFormatter: (value: number) => `${Math.round(value)}ms`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<EdgeFunctionTimeSeriesChartCard
|
||||
data={data}
|
||||
dateTimeFormat={dateTimeFormat}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
emptyTitle={memoryEmptyStateCopy.title}
|
||||
emptyDescription={memoryEmptyStateCopy.description}
|
||||
metrics={memoryMetrics}
|
||||
dataKey="avg_memory_used"
|
||||
config={MEMORY_CHART_CONFIG}
|
||||
tooltipDetails={memoryTooltipDetails}
|
||||
referenceLines={[
|
||||
{
|
||||
y: averageMemoryUsage,
|
||||
label: 'average',
|
||||
stroke: 'hsl(var(--foreground-default))',
|
||||
strokeWidth: 1.5,
|
||||
},
|
||||
]}
|
||||
yAxisProps={{
|
||||
width: 64,
|
||||
tickFormatter: (value: number) => `${Number(value).toFixed(1)}MB`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
<EdgeFunctionTimeSeriesChartCard
|
||||
data={data}
|
||||
dateTimeFormat={dateTimeFormat}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
emptyTitle={memoryEmptyStateCopy.title}
|
||||
emptyDescription={memoryEmptyStateCopy.description}
|
||||
metrics={memoryMetrics}
|
||||
dataKey="avg_memory_used"
|
||||
config={MEMORY_CHART_CONFIG}
|
||||
tooltipDetails={memoryTooltipDetails}
|
||||
referenceLines={[
|
||||
{
|
||||
y: averageMemoryUsage,
|
||||
label: 'average',
|
||||
stroke: 'hsl(var(--foreground-default))',
|
||||
strokeWidth: 1.5,
|
||||
},
|
||||
]}
|
||||
yAxisProps={{
|
||||
width: 64,
|
||||
tickFormatter: (value: number) => `${Number(value).toFixed(1)}MB`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
)
|
||||
|
||||
@@ -6,10 +6,9 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { LogoLoader } from 'ui'
|
||||
|
||||
import { getEdgeFunctionDetailsPageLayout } from './layout'
|
||||
import { DeployEdgeFunctionWarningModal } from '@/components/interfaces/EdgeFunctions/DeployEdgeFunctionWarningModal'
|
||||
import { formatFunctionBodyToFiles } from '@/components/interfaces/EdgeFunctions/EdgeFunctions.utils'
|
||||
import { DefaultLayout } from '@/components/layouts/DefaultLayout'
|
||||
import EdgeFunctionDetailsLayout from '@/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout'
|
||||
import { PreventNavigationOnUnsavedChanges } from '@/components/ui-patterns/Dialogs/PreventNavigationOnUnsavedChanges'
|
||||
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
||||
import { FileExplorerAndEditor } from '@/components/ui/FileExplorerAndEditor'
|
||||
@@ -241,12 +240,6 @@ const CodePage = () => {
|
||||
)
|
||||
}
|
||||
|
||||
CodePage.getLayout = (page: React.ReactNode) => {
|
||||
return (
|
||||
<DefaultLayout>
|
||||
<EdgeFunctionDetailsLayout title="Code">{page}</EdgeFunctionDetailsLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
}
|
||||
CodePage.getLayout = getEdgeFunctionDetailsPageLayout
|
||||
|
||||
export default CodePage
|
||||
@@ -1,14 +1,9 @@
|
||||
import { getEdgeFunctionDetailsPageLayout } from './layout'
|
||||
import { EdgeFunctionDetails } from '@/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails'
|
||||
import DefaultLayout from '@/components/layouts/DefaultLayout'
|
||||
import EdgeFunctionDetailsLayout from '@/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout'
|
||||
import type { NextPageWithLayout } from '@/types'
|
||||
|
||||
const PageLayout: NextPageWithLayout = () => <EdgeFunctionDetails />
|
||||
|
||||
PageLayout.getLayout = (page) => (
|
||||
<DefaultLayout>
|
||||
<EdgeFunctionDetailsLayout title="Settings">{page}</EdgeFunctionDetailsLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
PageLayout.getLayout = getEdgeFunctionDetailsPageLayout
|
||||
|
||||
export default PageLayout
|
||||
@@ -10,11 +10,10 @@ import { Alert, AlertDescription, AlertTitle, Button, LogoLoader, WarningIcon }
|
||||
import { PageContainer } from 'ui-patterns/PageContainer'
|
||||
import { PageSection, PageSectionContent } from 'ui-patterns/PageSection'
|
||||
|
||||
import { getEdgeFunctionDetailsPageLayout } from './layout'
|
||||
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'
|
||||
import EdgeFunctionDetailsLayout from '@/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout'
|
||||
import AreaChart from '@/components/ui/Charts/AreaChart'
|
||||
import StackedBarChart from '@/components/ui/Charts/StackedBarChart'
|
||||
import NoPermission from '@/components/ui/NoPermission'
|
||||
@@ -172,7 +171,7 @@ const LegacyEdgeFunctionOverview = () => {
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 md:gap-4 lg:grid-cols-2 lg:gap-8">
|
||||
<div className="flex flex-col gap-8">
|
||||
<ReportWidget
|
||||
title="Execution time"
|
||||
tooltip="Average execution time of function invocations"
|
||||
@@ -440,10 +439,6 @@ const PageLayout: NextPageWithLayout = () => {
|
||||
return <LegacyEdgeFunctionOverview />
|
||||
}
|
||||
|
||||
PageLayout.getLayout = (page) => (
|
||||
<DefaultLayout>
|
||||
<EdgeFunctionDetailsLayout title="Overview">{page}</EdgeFunctionDetailsLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
PageLayout.getLayout = getEdgeFunctionDetailsPageLayout
|
||||
|
||||
export default PageLayout
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useParams } from 'common'
|
||||
|
||||
import { getEdgeFunctionDetailsPageLayout } from './layout'
|
||||
import { LogsPreviewer } from '@/components/interfaces/Settings/Logs/LogsPreviewer'
|
||||
import DefaultLayout from '@/components/layouts/DefaultLayout'
|
||||
import EdgeFunctionDetailsLayout from '@/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout'
|
||||
import { useEdgeFunctionQuery } from '@/data/edge-functions/edge-function-query'
|
||||
import type { NextPageWithLayout } from '@/types'
|
||||
|
||||
@@ -27,10 +26,6 @@ export const LogPage: NextPageWithLayout = () => {
|
||||
)
|
||||
}
|
||||
|
||||
LogPage.getLayout = (page) => (
|
||||
<DefaultLayout>
|
||||
<EdgeFunctionDetailsLayout title="Invocations">{page}</EdgeFunctionDetailsLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
LogPage.getLayout = getEdgeFunctionDetailsPageLayout
|
||||
|
||||
export default LogPage
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import DefaultLayout from '@/components/layouts/DefaultLayout'
|
||||
import EdgeFunctionDetailsLayout from '@/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout'
|
||||
|
||||
export const getEdgeFunctionDetailsPageLayout = (page: ReactNode) => (
|
||||
<DefaultLayout>
|
||||
<EdgeFunctionDetailsLayout>{page}</EdgeFunctionDetailsLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useParams } from 'common'
|
||||
|
||||
import { getEdgeFunctionDetailsPageLayout } from './layout'
|
||||
import { LogsPreviewer } from '@/components/interfaces/Settings/Logs/LogsPreviewer'
|
||||
import DefaultLayout from '@/components/layouts/DefaultLayout'
|
||||
import EdgeFunctionDetailsLayout from '@/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout'
|
||||
import { useEdgeFunctionQuery } from '@/data/edge-functions/edge-function-query'
|
||||
import type { NextPageWithLayout } from '@/types'
|
||||
|
||||
@@ -28,10 +27,6 @@ export const LogPage: NextPageWithLayout = () => {
|
||||
)
|
||||
}
|
||||
|
||||
LogPage.getLayout = (page) => (
|
||||
<DefaultLayout>
|
||||
<EdgeFunctionDetailsLayout title="Logs">{page}</EdgeFunctionDetailsLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
LogPage.getLayout = getEdgeFunctionDetailsPageLayout
|
||||
|
||||
export default LogPage
|
||||
Reference in new issue
Block a user