Merge pull request #12542 from supabase/chore/update-edge-functions-ui

Chore/update edge functions UI
This commit is contained in:
Joshen Lim authored and GitHub committed 2023-02-22 13:37:55 +08:00
commit c3bef7aedc
13 files changed
+675 -386

No files matched your search

@@ -0,0 +1,332 @@
import dayjs from 'dayjs'
import { useRouter } from 'next/router'
import { FC, useState, useEffect } from 'react'
import * as Tooltip from '@radix-ui/react-tooltip'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import {
Alert,
IconGlobe,
IconTerminal,
IconMinimize2,
IconMaximize2,
IconCheck,
IconClipboard,
Button,
Modal,
} from 'ui'
import { useStore, useParams, checkPermissions } from 'hooks'
import Panel from 'components/ui/Panel'
import CommandRender from './CommandRender'
import { useProjectApiQuery } from 'data/config/project-api-query'
import { useEdgeFunctionDeleteMutation } from 'data/edge-functions/edge-functions-delete-mutation'
interface Props {}
// [Joshen] Next - additional configs: Verify jwt + import maps
const EdgeFunctionDetails: FC<Props> = () => {
const router = useRouter()
const { functions, ui } = useStore()
const { ref: projectRef, id } = useParams()
const [isCopied, setIsCopied] = useState(false)
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [showInstructions, setShowInstructions] = useState(false)
const [selectedFunction, setSelectedFunction] = useState<any>(null)
const canDeleteEdgeFunction = checkPermissions(PermissionAction.FUNCTIONS_WRITE, '*')
const { mutateAsync: deleteEdgeFunction, isLoading: isDeleting } = useEdgeFunctionDeleteMutation()
useEffect(() => {
setSelectedFunction(functions.byId(id))
}, [functions.isLoaded, ui.selectedProject])
const { data: settings } = useProjectApiQuery({ projectRef })
// Get the API service
const apiService = settings?.autoApiService
const anonKey = apiService?.service_api_keys.find((x) => x.name === 'anon key')
? apiService.defaultApiKey
: undefined
const endpoint = apiService?.app_config.endpoint ?? ''
const endpointSections = endpoint.split('.')
const functionsEndpoint = [
...endpointSections.slice(0, 1),
'functions',
...endpointSections.slice(1),
].join('.')
const functionUrl = `${apiService?.protocol}://${functionsEndpoint}/${selectedFunction?.slug}`
const onConfirmDelete = async () => {
if (!projectRef) return console.error('Project ref is required')
if (selectedFunction === undefined) return console.error('No edge function selected')
try {
await deleteEdgeFunction({ projectRef, slug: selectedFunction.slug })
ui.setNotification({
category: 'success',
message: `Successfully deleted "${selectedFunction.name}"`,
})
router.push(`/project/${projectRef}/functions`)
} catch (error: any) {
ui.setNotification({
category: 'error',
message: `Failed to delete function: ${error.message}`,
})
}
}
const managementCommands: any = [
{
command: `supabase functions deploy ${selectedFunction?.slug}`,
description: 'This will overwrite the deployed function with your new function',
jsx: () => {
return (
<>
<span className="text-brand-1100">supabase</span> functions deploy{' '}
{selectedFunction?.slug}
</>
)
},
comment: 'Deploy a new version',
},
{
command: `supabase functions delete ${selectedFunction?.slug}`,
description: 'This will remove the function and all the logs associated with it',
jsx: () => {
return (
<>
<span className="text-brand-1100">supabase</span> functions delete{' '}
{selectedFunction?.slug}
</>
)
},
comment: 'Delete the function',
},
]
const secretCommands: any = [
{
command: `supabase secrets list`,
description: 'This will list all the secrets for your project',
jsx: () => {
return (
<>
<span className="text-brand-1100">supabase</span> secrets list
</>
)
},
comment: 'View all secrets',
},
{
command: `supabase secrets set NAME1=VALUE1 NAME2=VALUE2`,
description: 'This will set secrets for your project',
jsx: () => {
return (
<>
<span className="text-brand-1100">supabase</span> secrets set NAME1=VALUE1 NAME2=VALUE2
</>
)
},
comment: 'Set secrets for your project',
},
{
command: `supabase secrets unset NAME1 NAME2 `,
description: 'This will delete secrets for your project',
jsx: () => {
return (
<>
<span className="text-brand-1100">supabase</span> secrets unset NAME1 NAME2
</>
)
},
comment: 'Unset secrets for your project',
},
]
const invokeCommands: any = [
{
command: `curl -L -X POST '${functionUrl}' -H 'Authorization: Bearer ${
anonKey ?? '[YOUR ANON KEY]'
}' --data '{"name":"Functions"}'`,
description: 'Invokes the hello function',
jsx: () => {
return (
<>
<span className="text-brand-1100">curl</span> -L -X POST 'https://{functionsEndpoint}/
{selectedFunction?.slug}' -H 'Authorization: Bearer [YOUR ANON KEY]'{' '}
{`--data '{"name":"Functions"}'`}
</>
)
},
comment: 'Invoke your function',
},
]
return (
<>
<div className="space-y-8 pb-16">
<div className="space-y-6 rounded border bg-scale-100 px-10 py-8 drop-shadow-sm dark:bg-scale-300">
<div className="flex items-center">
<p className="text-sm text-scale-1000 w-[130px]">Function Name</p>
<p className="text-sm text-scale-1200">{selectedFunction?.name}</p>
</div>
<div className="flex items-center">
<p className="text-sm text-scale-1000 w-[130px]">Endpoint URL</p>
<p className="text-sm text-scale-1200">{functionUrl}</p>
<button
type="button"
className="text-scale-900 hover:text-scale-1200 transition ml-2"
onClick={(event: any) => {
function onCopy(value: any) {
setIsCopied(true)
navigator.clipboard.writeText(value).then()
setTimeout(function () {
setIsCopied(false)
}, 3000)
}
event.stopPropagation()
onCopy(functionUrl)
}}
>
{isCopied ? (
<div className="text-brand-900">
<IconCheck size={14} strokeWidth={3} />
</div>
) : (
<div className="relative">
<div className="block">
<IconClipboard size={14} strokeWidth={1.5} />
</div>
</div>
)}
</button>
</div>
<div className="flex items-center">
<p className="text-sm text-scale-1000 w-[130px]">Created At</p>
<p className="text-sm text-scale-1200">
{selectedFunction?.created_at &&
dayjs(selectedFunction.created_at).format('dddd, MMMM D, YYYY h:mm A')}
</p>
</div>
<div className="flex items-center">
<p className="text-sm text-scale-1000 w-[130px]">Last Updated At</p>
<p className="text-sm text-scale-1200">
{selectedFunction?.updated_at &&
dayjs(selectedFunction.updated_at).format('dddd, MMMM D, YYYY h:mm A')}
</p>
</div>
<div className="flex items-center">
<span className="text-sm text-scale-1000 w-[130px]">Deployments</span>
<div className="text-sm text-scale-1200">{selectedFunction?.version}</div>
</div>
<div className="flex items-start">
<span className="text-sm text-scale-1000 w-[130px]">Regions</span>
<div className="col-span-2 flex flex-col gap-1">
<div className="flex items-center gap-2 text-sm text-scale-1200">
<IconGlobe size={16} strokeWidth={1.5} />
<span>Earth</span>
</div>
<span className="text-sm text-scale-1000">All functions are deployed globally</span>
</div>
</div>
</div>
<div
className="space-y-6 rounded border bg-scale-100 px-8 py-6 drop-shadow-sm dark:bg-scale-300 transition-all overflow-hidden"
style={{ maxHeight: showInstructions ? 800 : 80 }}
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="flex h-8 w-8 items-center justify-center rounded border bg-scale-1200 p-2 text-scale-100 dark:bg-scale-100 dark:text-scale-1200">
<IconTerminal strokeWidth={2} />
</div>
<h4>Command line access</h4>
</div>
<div className="cursor-pointer" onClick={() => setShowInstructions(!showInstructions)}>
{showInstructions ? (
<IconMinimize2 size={14} strokeWidth={1.5} />
) : (
<IconMaximize2 size={14} strokeWidth={1.5} />
)}
</div>
</div>
<h5 className="text-base">Deployment management</h5>
<CommandRender commands={managementCommands} />
<h5 className="text-base">Invoke </h5>
<CommandRender commands={invokeCommands} />
<h5 className="text-base">Secrets management</h5>
<CommandRender commands={secretCommands} />
</div>
<Panel title={<p>Delete Edge Function</p>}>
<Panel.Content>
<Alert
withIcon
variant="danger"
title="Once your function is deleted, it can no longer be restored"
>
<p className="mb-3">
Make sure you have made a backup if you want to restore your edge function
</p>
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger>
<Button
type="danger"
disabled={!canDeleteEdgeFunction}
loading={selectedFunction?.id === undefined}
onClick={() => setShowDeleteModal(true)}
>
Delete edge function
</Button>
</Tooltip.Trigger>
{!canDeleteEdgeFunction && (
<Tooltip.Content side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
'border border-scale-200',
].join(' ')}
>
<span className="text-xs text-scale-1200">
You need additional permissions to delete an edge function
</span>
</div>
</Tooltip.Content>
)}
</Tooltip.Root>
</Alert>
</Panel.Content>
</Panel>
</div>
<Modal
size="small"
alignFooter="right"
header={<h3>Confirm to delete {selectedFunction?.name}</h3>}
visible={showDeleteModal}
loading={isDeleting}
onCancel={() => setShowDeleteModal(false)}
onConfirm={onConfirmDelete}
>
<div className="py-6">
<Modal.Content>
<Alert withIcon variant="warning" title="This action cannot be undone">
Ensure that you have made a backup if you want to restore your edge function
</Alert>
</Modal.Content>
</div>
</Modal>
</>
)
}
export default EdgeFunctionDetails
@@ -0,0 +1,103 @@
import dayjs from 'dayjs'
import { FC, useState } from 'react'
import { useRouter } from 'next/router'
import { observer } from 'mobx-react-lite'
import { IconCheck, IconClipboard } from 'ui'
import * as Tooltip from '@radix-ui/react-tooltip'
import { useParams, useStore } from 'hooks'
import Table from 'components/to-be-cleaned/Table'
import { EdgeFunctionsResponse } from 'data/edge-functions/edge-functions-query'
interface Props {
function: EdgeFunctionsResponse
}
const EdgeFunctionsListItem: FC<Props> = ({ function: item }) => {
const router = useRouter()
const { ui } = useStore()
const { ref } = useParams()
const [isCopied, setIsCopied] = useState(false)
// get the .co or .net TLD from the restUrl
const restUrl = ui.selectedProject?.restUrl
const restUrlTld = new URL(restUrl as string).hostname.split('.').pop()
const functionUrl = `https://${ref}.functions.supabase.${restUrlTld}/${item.slug}`
return (
<Table.tr
key={item.id}
onClick={() => {
router.push(`/project/${ref}/functions/${item.id}/details`)
}}
>
<Table.td className="">
<div className="flex items-center gap-2">
<p className="text-sm text-scale-1200">{item.name}</p>
</div>
</Table.td>
<Table.td className="">
<div className="text-xs text-scale-1100 flex gap-2 items-center truncate">
<p className="font-mono truncate hidden md:inline">{functionUrl}</p>
<button
type="button"
className="text-scale-900 hover:text-scale-1200 transition"
onClick={(event: any) => {
function onCopy(value: any) {
setIsCopied(true)
navigator.clipboard.writeText(value).then()
setTimeout(function () {
setIsCopied(false)
}, 3000)
}
event.stopPropagation()
onCopy(functionUrl)
}}
>
{isCopied ? (
<div className="text-brand-900">
<IconCheck size={14} strokeWidth={3} />
</div>
) : (
<div className="relative">
<div className="block">
<IconClipboard size={14} strokeWidth={1.5} />
</div>
</div>
)}
</button>
</div>
</Table.td>
<Table.td className="hidden 2xl:table-cell">
<p className="text-scale-1100">{dayjs(item.created_at).format('DD MMM, YYYY HH:mm')}</p>
</Table.td>
<Table.td className="lg:table-cell">
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger>
<div className="flex items-center space-x-2">
<p className="text-sm text-scale-1000">{dayjs(item.updated_at).fromNow()}</p>
</div>
</Tooltip.Trigger>
<Tooltip.Content side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
'border border-scale-200',
].join(' ')}
>
<span className="text-xs text-scale-1200">
Last updated on {dayjs(item.updated_at).format('DD MMM, YYYY HH:mm')}
</span>
</div>
</Tooltip.Content>
</Tooltip.Root>
</Table.td>
<Table.td className="lg:table-cell">
<p className="text-scale-1100">{item.version}</p>
</Table.td>
</Table.tr>
)
}
export default observer(EdgeFunctionsListItem)
@@ -1,62 +0,0 @@
import dayjs from 'dayjs'
import { FC } from 'react'
import { useRouter } from 'next/router'
import { observer } from 'mobx-react-lite'
import { useStore } from 'hooks'
import Table from 'components/to-be-cleaned/Table'
interface Props {
function: any
}
const FunctionsListItem: FC<Props> = ({ function: item }) => {
const { ui } = useStore()
const is_functionConfirmed = item.email_confirmed_at || item.phone_confirmed_at
const router = useRouter()
const ref = ui?.selectedProject?.ref
// get the .co or .net TLD from the restUrl
const restUrl = ui.selectedProject?.restUrl
const restUrlTld = new URL(restUrl as string).hostname.split('.').pop()
return (
<Table.tr
key={item.id}
onClick={() => {
router.push(`/project/${ref}/functions/${item.id}`)
}}
>
<Table.td className="">
<div className="flex items-center gap-2">
<span className="text-sm text-scale-1200">{item.name}</span>
</div>
</Table.td>
<Table.td className="">
<div className="text-xs text-scale-900 flex gap-2 items-center truncate">
<span className="font-mono truncate hidden md:inline">{`https://${ref}.functions.supabase.${restUrlTld}/${item.slug}`}</span>
<span className="font-mono truncate md:hidden">{`/${item.name}`}</span>
</div>
</Table.td>
<Table.td className="hidden 2xl:table-cell">
<span className="text-scale-1100">
{dayjs(item.created_at).format('DD MMM, YYYY HH:mm')}
</span>
</Table.td>
<Table.td className="hidden lg:table-cell">
<span className="text-scale-1100">
{dayjs(item.updated_at).format('DD MMM, YYYY HH:mm')}
</span>
</Table.td>
<Table.td className="hidden 2xl:table-cell">
<span className="text-scale-1100">v{item.version}</span>
</Table.td>
<Table.td className="2xl:table-cell text-right">
<span className="text-scale-1100">{item.status}</span>
</Table.td>
</Table.tr>
)
}
export default observer(FunctionsListItem)
@@ -1,8 +1,7 @@
import { Tabs } from 'ui'
import { useRouter } from 'next/router'
// @ts-ignore
const FunctionsNav = ({ item }) => {
const FunctionsNav = ({ item }: any) => {
const router = useRouter()
const activeRoute = router.pathname.split('/')[5]
const { ref } = router.query
@@ -17,9 +16,8 @@ const FunctionsNav = ({ item }) => {
router.push(`/project/${ref}/functions/${item.id}/${e === 'metrics' ? '' : e}`)
}
>
<Tabs.Panel id="metrics" label="Metrics" />
<Tabs.Panel id="details" label="Details" />
{/* <Tabs.Panel id="triggers" label="Triggers" /> */}
<Tabs.Panel id="metrics" label="Metrics" />
<Tabs.Panel id="invocations" label="Invocations" />
<Tabs.Panel id="logs" label="Logs" />
</Tabs>
@@ -10,19 +10,16 @@ import { Commands } from './Functions.types'
interface Props {
closable?: boolean
removeBorder?: boolean
}
const TerminalInstructions: FC<Props> = ({ closable = false }) => {
const TerminalInstructions: FC<Props> = ({ closable = false, removeBorder = false }) => {
const router = useRouter()
const { ref: projectRef } = useParams()
const [showInstructions, setShowInstructions] = useState(!closable)
const { data: tokens } = useAccessTokensQuery()
const { data: settings } = useProjectApiQuery({ projectRef })
const { data: settings } = useProjectApiQuery({
projectRef,
})
const apiService = settings?.autoApiService
const anonKey = apiService?.service_api_keys.find((x) => x.name === 'anon key')
? apiService.defaultApiKey
@@ -86,7 +83,9 @@ const TerminalInstructions: FC<Props> = ({ closable = false }) => {
return (
<div
className="col-span-7 overflow-hidden transition-all border rounded shadow bg-scale-100 dark:bg-scale-300"
className={`col-span-7 overflow-hidden transition-all rounded bg-scale-100 dark:bg-scale-300 ${
removeBorder ? '' : 'border shadow'
}`}
style={{ maxHeight: showInstructions ? 500 : 80 }}
>
<div className="px-8 py-6 space-y-6">
@@ -107,7 +106,7 @@ const TerminalInstructions: FC<Props> = ({ closable = false }) => {
</div>
)}
</div>
<div className="space-y-4">
<div>
<CommandRender commands={commands} />
</div>
</div>
@@ -1,5 +1,6 @@
import FunctionsListItem from './FunctionsListItem'
import EdgeFunctionDetails from './EdgeFunctionDetails'
import EdgeFunctionsListItem from './EdgeFunctionsListItem'
import FunctionsEmptyState from './FunctionsEmptyState'
import TerminalInstructions from './TerminalInstructions'
export { FunctionsListItem, FunctionsEmptyState, TerminalInstructions }
export { EdgeFunctionDetails, EdgeFunctionsListItem, FunctionsEmptyState, TerminalInstructions }
+74 -35
View File
@@ -1,14 +1,15 @@
import Link from 'next/link'
import { useRouter } from 'next/router'
import { FC, useEffect, ReactNode } from 'react'
import { FC, useEffect, ReactNode, useState } from 'react'
import { observer } from 'mobx-react-lite'
import { IconCode } from 'ui'
import { Button, IconExternalLink, IconCode, Modal, IconTerminal } from 'ui'
import { checkPermissions, useStore, withAuth } from 'hooks'
import { checkPermissions, useParams, useStore, withAuth } from 'hooks'
import FunctionsNav from '../interfaces/Functions/FunctionsNav'
import BaseLayout from 'components/layouts'
import NoPermission from 'components/ui/NoPermission'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { TerminalInstructions } from 'components/interfaces/Functions'
interface Props {
title?: string
@@ -16,10 +17,10 @@ interface Props {
}
const FunctionsLayout: FC<Props> = ({ title, children }) => {
const { functions, ui } = useStore()
const router = useRouter()
const { id, ref } = router.query
const { id, ref } = useParams()
const { functions, ui } = useStore()
const [showTerminalInstructions, setShowTerminalInstructions] = useState(false)
useEffect(() => {
if (ui.selectedProjectRef) functions.load()
@@ -87,7 +88,7 @@ const FunctionsLayout: FC<Props> = ({ title, children }) => {
].join(' ')}
>
<div className="item-center flex flex-col justify-between gap-y-4 xl:flex-row">
<div className="flex items-center gap-3">
<div className="flex items-center gap-3 w-full">
<div
className={[
'h-6 w-6 rounded border border-brand-600 bg-brand-300',
@@ -97,35 +98,55 @@ const FunctionsLayout: FC<Props> = ({ title, children }) => {
<IconCode size={14} strokeWidth={3} />
</div>
<div className="flex items-center space-x-4">
<Link href={`/project/${ref}/functions`}>
<h1 className="cursor-pointer text-2xl text-scale-1200 transition-colors hover:text-scale-1100">
Edge Functions
</h1>
</Link>
<p className="mt-1 text-scale-1000">Beta</p>
<div className="flex items-center justify-between w-full">
<div className="flex items-center space-x-4">
<Link href={`/project/${ref}/functions`}>
<h1 className="cursor-pointer text-2xl text-scale-1200 transition-colors hover:text-scale-1100">
Edge Functions
</h1>
</Link>
<p className="mt-1 text-scale-1000">Beta</p>
{name && (
<div className="mt-1.5 flex items-center space-x-4">
<span className="text-scale-1000">
<svg
viewBox="0 0 24 24"
width="16"
height="16"
stroke="currentColor"
strokeWidth="1"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
shapeRendering="geometricPrecision"
>
<path d="M16 3.549L7.12 20.600"></path>
</svg>
</span>
<h5 className="text-lg text-scale-1200">{name}</h5>
</div>
)}
</div>
<div className="flex items-center space-x-2">
<Button
type="default"
icon={<IconTerminal size={14} strokeWidth={1.5} />}
onClick={() => setShowTerminalInstructions(true)}
>
Terminal Instructions
</Button>
<Link href="https://supabase.com/docs/guides/functions">
<a target="_link">
<Button
type="default"
icon={<IconExternalLink size={14} strokeWidth={1.5} />}
>
Documentation
</Button>
</a>
</Link>
</div>
</div>
{name && (
<>
<span className="text-scale-800 dark:text-scale-700">
<svg
viewBox="0 0 24 24"
width="16"
height="16"
stroke="currentColor"
strokeWidth="1"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
shapeRendering="geometricPrecision"
>
<path d="M16 3.549L7.12 20.600"></path>
</svg>
</span>
<h5 className="text-2xl text-scale-1200">{name}</h5>
</>
)}
</div>
</div>
{item && <FunctionsNav item={item} />}
@@ -140,6 +161,24 @@ const FunctionsLayout: FC<Props> = ({ title, children }) => {
</div>
</div>
)}
<Modal
size="xlarge"
visible={showTerminalInstructions}
onCancel={() => setShowTerminalInstructions(false)}
header={<h3>Deploying an edge function to your project</h3>}
customFooter={
<div className="w-full flex items-center justify-end">
<Button type="primary" size="tiny" onClick={() => setShowTerminalInstructions(false)}>
Confirm
</Button>
</div>
}
>
<div className="py-4">
<TerminalInstructions removeBorder />
</div>
</Modal>
</BaseLayout>
)
}
@@ -0,0 +1,46 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { delete_ } from 'lib/common/fetch'
import { API_ADMIN_URL } from 'lib/constants'
import { edgeFunctionsKeys } from './keys'
export type EdgeFunctionsDeleteVariables = {
projectRef: string
slug: string
}
export async function deleteEdgeFunction({ projectRef, slug }: EdgeFunctionsDeleteVariables) {
if (!projectRef) throw new Error('projectRef is required')
const response = await delete_(`${API_ADMIN_URL}/projects/${projectRef}/functions/${slug}`, {})
if (response.error) {
throw response.error
}
return response
}
type EdgeFunctionsDeleteData = Awaited<ReturnType<typeof deleteEdgeFunction>>
export const useEdgeFunctionDeleteMutation = ({
onSuccess,
...options
}: Omit<
UseMutationOptions<EdgeFunctionsDeleteData, unknown, EdgeFunctionsDeleteVariables>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<EdgeFunctionsDeleteData, unknown, EdgeFunctionsDeleteVariables>(
(vars) => deleteEdgeFunction(vars),
{
async onSuccess(data, variables, context) {
const { projectRef } = variables
await queryClient.invalidateQueries(edgeFunctionsKeys.list(projectRef), {
refetchType: 'all',
})
await onSuccess?.(data, variables, context)
},
...options,
}
)
}
@@ -0,0 +1,57 @@
import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query'
import { get } from 'lib/common/fetch'
import { API_ADMIN_URL } from 'lib/constants'
import { useCallback } from 'react'
import { edgeFunctionsKeys } from './keys'
export type EdgeFunctionsVariables = { projectRef?: string }
export type EdgeFunctionsResponse = {
id: string
name: string
slug: string
status: string
version: number
created_at: number
updated_at: number
verify_jwt: boolean
import_map: boolean
}
export async function getEdgeFunctions(
{ projectRef }: EdgeFunctionsVariables,
signal?: AbortSignal
) {
if (!projectRef) throw new Error('projectRef is required')
const response = await get(`${API_ADMIN_URL}/projects/${projectRef}/functions`, {
signal,
})
if (response.error) throw response.error
return response as EdgeFunctionsResponse[]
}
export type EdgeFunctionsData = Awaited<ReturnType<typeof getEdgeFunctions>>
export type EdgeFunctionsError = unknown
export const useEdgeFunctionsQuery = <TData = EdgeFunctionsData>(
{ projectRef }: EdgeFunctionsVariables,
{ enabled = true, ...options }: UseQueryOptions<EdgeFunctionsData, EdgeFunctionsError, TData> = {}
) =>
useQuery<EdgeFunctionsData, EdgeFunctionsError, TData>(
edgeFunctionsKeys.list(projectRef),
({ signal }) => getEdgeFunctions({ projectRef }, signal),
{ enabled: enabled && typeof projectRef !== 'undefined', ...options }
)
export const useEdgeFunctionsPrefetch = ({ projectRef }: EdgeFunctionsVariables) => {
const client = useQueryClient()
return useCallback(() => {
if (projectRef) {
client.prefetchQuery(edgeFunctionsKeys.list(projectRef), ({ signal }) =>
getEdgeFunctions({ projectRef }, signal)
)
}
}, [projectRef])
}
+3
View File
@@ -0,0 +1,3 @@
export const edgeFunctionsKeys = {
list: (projectRef: string | undefined) => ['projects', projectRef, 'edge-functions'] as const,
}
+2
View File
@@ -16,6 +16,7 @@ import dayjs from 'dayjs'
import customParseFormat from 'dayjs/plugin/customParseFormat'
import timezone from 'dayjs/plugin/timezone'
import utc from 'dayjs/plugin/utc'
import relativeTime from 'dayjs/plugin/relativeTime'
// @ts-ignore
import Prism from 'prism-react-renderer/prism'
@@ -44,6 +45,7 @@ import useAutoAuthRedirect from 'hooks/misc/useAutoAuthRedirect'
dayjs.extend(customParseFormat)
dayjs.extend(utc)
dayjs.extend(timezone)
dayjs.extend(relativeTime)
dart(Prism)
@@ -1,244 +1,21 @@
import dayjs from 'dayjs'
import { useEffect, useState } from 'react'
import { observer } from 'mobx-react-lite'
import { IconGlobe, IconTerminal } from 'ui'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { NextPageWithLayout } from 'types'
import { checkPermissions, useParams, useStore } from 'hooks'
import { useProjectApiQuery } from 'data/config/project-api-query'
import { checkPermissions, useParams } from 'hooks'
import FunctionsLayout from 'components/layouts/FunctionsLayout'
import CommandRender from 'components/interfaces/Functions/CommandRender'
import NoPermission from 'components/ui/NoPermission'
import { EdgeFunctionDetails } from 'components/interfaces/Functions'
const PageLayout: NextPageWithLayout = () => {
const { ref: projectRef, id } = useParams()
const { functions, ui } = useStore()
const [selectedFunction, setSelectedFunction] = useState<any>(null)
useEffect(() => {
setSelectedFunction(functions.byId(id))
}, [functions.isLoaded, ui.selectedProject])
const { data: settings } = useProjectApiQuery({
projectRef,
})
// get the .co or .net TLD from the restUrl
const restUrl = ui.selectedProject?.restUrl
const restUrlTld = new URL(restUrl as string).hostname.split('.').pop()
const managementCommands: any = [
{
command: `supabase functions deploy ${selectedFunction?.slug}`,
description: 'This will overwrite the deployed function with your new function',
jsx: () => {
return (
<>
<span className="text-brand-1100">supabase</span> functions deploy{' '}
{selectedFunction?.slug}
</>
)
},
comment: 'Deploy a new version',
},
{
command: `supabase functions delete ${selectedFunction?.slug}`,
description: 'This will remove the function and all the logs associated with it',
jsx: () => {
return (
<>
<span className="text-brand-1100">supabase</span> functions delete{' '}
{selectedFunction?.slug}
</>
)
},
comment: 'Delete the function',
},
]
const secretCommands: any = [
{
command: `supabase secrets list`,
description: 'This will list all the secrets for your project',
jsx: () => {
return (
<>
<span className="text-brand-1100">supabase</span> secrets list
</>
)
},
comment: 'View all secrets',
},
{
command: `supabase secrets set NAME1=VALUE1 NAME2=VALUE2`,
description: 'This will set secrets for your project',
jsx: () => {
return (
<>
<span className="text-brand-1100">supabase</span> secrets set NAME1=VALUE1 NAME2=VALUE2
</>
)
},
comment: 'Set secrets for your project',
},
{
command: `supabase secrets unset NAME1 NAME2 `,
description: 'This will delete secrets for your project',
jsx: () => {
return (
<>
<span className="text-brand-1100">supabase</span> secrets unset NAME1 NAME2
</>
)
},
comment: 'Unset secrets for your project',
},
]
// Get the API service
const apiService = settings?.autoApiService
const anonKey = apiService?.service_api_keys.find((x) => x.name === 'anon key')
? apiService.defaultApiKey
: undefined
const endpoint = apiService?.app_config.endpoint ?? ''
const endpointSections = endpoint.split('.')
const functionsEndpoint = [
...endpointSections.slice(0, 1),
'functions',
...endpointSections.slice(1),
].join('.')
const invokeCommands: any = [
{
command: `curl -L -X POST 'https://${projectRef}.functions.supabase.${restUrlTld}/${
selectedFunction?.slug
}' -H 'Authorization: Bearer ${anonKey ?? '[YOUR ANON KEY]'}' --data '{"name":"Functions"}'`,
description: 'Invokes the hello function',
jsx: () => {
return (
<>
<span className="text-brand-1100">curl</span> -L -X POST 'https://{functionsEndpoint}/
{selectedFunction?.slug}' -H 'Authorization: Bearer [YOUR ANON KEY]'{' '}
{`--data '{"name":"Functions"}'`}
</>
)
},
comment: 'Invoke your function',
},
]
const { id } = useParams()
const canReadFunction = checkPermissions(PermissionAction.FUNCTIONS_READ, id as string)
if (!canReadFunction) {
return <NoPermission isFullPage resourceText="access this edge function's details" />
}
return (
<div className="grid gap-y-4 lg:grid-cols-2 lg:gap-x-8">
<div>
<div
className="
space-y-6 rounded border bg-scale-100 px-10 py-8 drop-shadow-sm
dark:bg-scale-300
"
>
<div className="w-full space-y-4">
<div className="grid grid-cols-3">
<span className="mb-1 block text-sm text-scale-1000">Function Name</span>
<div className="text-sm text-scale-1200">{selectedFunction?.name}</div>
</div>
<div className="grid grid-cols-3">
<span className="mb-1 block text-sm text-scale-1000">Status</span>
<div className="col-span-2 flex flex-col gap-2">
<div className="flex">
<div
className="
lowercasefirst-letter flex flex-row items-center gap-3 rounded-full bg-brand-300
px-3 py-0.5 text-base lowercase text-brand-900 dark:bg-brand-100
"
>
{selectedFunction?.status}
<div className="relative h-2 w-2">
<span className="flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-brand-800 opacity-75"></span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-brand-900"></span>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-3">
<span className="cols-span-1 mb-1 block text-sm text-scale-1000">Endpoint URL</span>
<div className="col-span-2">
<span className="w-full break-words text-sm text-scale-1200">{`https://${projectRef}.functions.supabase.co/${selectedFunction?.slug}`}</span>
</div>
</div>
<div className="grid grid-cols-3">
<span className="mb-1 block text-sm text-scale-1000">Created At</span>
<div className="col-span-2 text-sm text-scale-1200">
{selectedFunction?.created_at &&
dayjs(selectedFunction.created_at).format('dddd, MMMM D, YYYY h:mm A')}
</div>
</div>
<div className="grid grid-cols-3">
<span className="mb-1 block text-sm text-scale-1000">Updated At</span>
<div className="col-span-2 text-sm text-scale-1200">
{selectedFunction?.updated_at &&
dayjs(selectedFunction.updated_at).format('dddd, MMMM D, YYYY h:mm A')}
</div>
</div>
<div className="grid grid-cols-3">
<span className="mb-1 block text-sm text-scale-1000">Version</span>
<div className="col-span-2 text-sm text-scale-1200">v{selectedFunction?.version}</div>
</div>
<div className="grid grid-cols-3">
<span className="mb-1 block text-sm text-scale-1000">Regions</span>
<div className="col-span-2 flex flex-col gap-1">
<div className="flex items-center gap-2 text-sm text-scale-1200">
<IconGlobe />
<span>Earth</span>
</div>
<span className="text-sm text-scale-1000">All functions are deployed globally</span>
</div>
</div>
</div>
</div>
<div>
<div
className="space-y-6 rounded
border bg-scale-100 px-10 py-8
drop-shadow-sm
dark:bg-scale-300"
>
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded border bg-scale-1200 p-2 text-scale-100 dark:bg-scale-100 dark:text-scale-1200">
<IconTerminal strokeWidth={2} />
</div>
<h4>Command line access</h4>
</div>
<h5 className="text-base">Deployment management</h5>
<CommandRender commands={managementCommands} />
<h5 className="text-base">Invoke </h5>
<CommandRender commands={invokeCommands} />
<h5 className="text-base">Secrets management</h5>
<CommandRender commands={secretCommands} />
</div>
</div>
</div>
)
return <EdgeFunctionDetails />
}
PageLayout.getLayout = (page) => <FunctionsLayout>{page}</FunctionsLayout>
+42 -48
View File
@@ -1,59 +1,53 @@
import { observer } from 'mobx-react-lite'
import { useParams } from 'hooks'
import { NextPageWithLayout } from 'types'
import { useStore } from 'hooks'
import FunctionsLayout from 'components/layouts/FunctionsLayout'
import Table from 'components/to-be-cleaned/Table'
import { Function } from 'components/interfaces/Functions/Functions.types'
import {
FunctionsListItem,
FunctionsEmptyState,
TerminalInstructions,
} from 'components/interfaces/Functions'
const EdgeFunctionsList = ({ functions }: { functions: Function[] }) => {
return (
<>
<div className="flex flex-col gap-3 py-6">
<div className="flex justify-between items-center">
<span className="text-sm text-scale-900">{`${functions.length} function${
functions.length > 1 ? 's' : ''
} deployed`}</span>
</div>
<div>
<Table
head={
<>
<Table.th>Name</Table.th>
<Table.th>URL</Table.th>
<Table.th className="hidden lg:table-cell">Created</Table.th>
<Table.th className="hidden 2xl:table-cell">Last updated</Table.th>
<Table.th className="hidden 2xl:table-cell">Version</Table.th>
<Table.th className="text-right">Status</Table.th>
</>
}
body={
<>
{functions.length > 0 &&
functions.map((item: any) => <FunctionsListItem key={item.id} function={item} />)}
</>
}
/>
</div>
</div>
</>
)
}
import FunctionsLayout from 'components/layouts/FunctionsLayout'
import { EdgeFunctionsListItem, FunctionsEmptyState } from 'components/interfaces/Functions'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query'
const PageLayout: NextPageWithLayout = () => {
const { functions } = useStore()
const hasFunctions = functions.list().length > 0
const { ref } = useParams()
const { isLoading, data: functions } = useEdgeFunctionsQuery({ projectRef: ref })
const hasFunctions = (functions ?? []).length > 0
if (isLoading || functions === undefined) {
return (
<div className="space-y-2 w-full py-6">
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
<ShimmeringLoader className="w-1/2" />
</div>
)
}
return hasFunctions ? (
<div className="py-6">
<TerminalInstructions closable />
<EdgeFunctionsList functions={functions.list()} />
<div className="py-6 space-y-4">
<div className="flex justify-between items-center">
<span className="text-sm text-scale-900">{`${functions.length} function${
functions.length > 1 ? 's' : ''
} deployed`}</span>
</div>
<Table
head={
<>
<Table.th>Name</Table.th>
<Table.th>URL</Table.th>
<Table.th className="hidden 2xl:table-cell">Created</Table.th>
<Table.th className="lg:table-cell">Last updated</Table.th>
<Table.th className="lg:table-cell">Deployments</Table.th>
</>
}
body={
<>
{functions.length > 0 &&
functions.map((item: any) => <EdgeFunctionsListItem key={item.id} function={item} />)}
</>
}
/>
</div>
) : (
<FunctionsEmptyState />