| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449 |
- // @ts-nocheck
- import { PermissionAction } from '@supabase/shared-types/out/constants'
- import { BlobReader, BlobWriter, ZipWriter } from '@zip.js/zip.js'
- import { IS_PLATFORM, useParams } from 'common'
- import dayjs from 'dayjs'
- import relativeTime from 'dayjs/plugin/relativeTime'
- import { Clock, Download, FileArchive, Send } from 'lucide-react'
- import Link from 'next/link'
- import { useRouter } from 'next/router'
- import React, { useEffect, useState, type PropsWithChildren } from 'react'
- import { toast } from 'sonner'
- import {
- BreadcrumbItem,
- BreadcrumbLink,
- BreadcrumbList,
- BreadcrumbSeparator,
- Button,
- copyToClipboard,
- HoverCard,
- HoverCardContent,
- HoverCardTrigger,
- NavMenu,
- NavMenuItem,
- Popover,
- PopoverContent,
- PopoverTrigger,
- Separator,
- } from 'ui'
- import { TimestampInfo } from 'ui-patterns'
- import { Input } from 'ui-patterns/DataInputs/Input'
- import {
- PageHeader,
- PageHeaderAside,
- PageHeaderBreadcrumb,
- PageHeaderDescription,
- PageHeaderMeta,
- PageHeaderNavigationTabs,
- PageHeaderSummary,
- PageHeaderTitle,
- } from 'ui-patterns/PageHeader'
- import { ProjectLayout } from '../ProjectLayout'
- import EdgeFunctionsLayout from './EdgeFunctionsLayout'
- import { EdgeFunctionTesterSheet } from '@/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet'
- import { useFunctionsDetailShortcuts } from '@/components/interfaces/Functions/useFunctionsDetailShortcuts'
- import CopyButton from '@/components/ui/CopyButton'
- import { DocsButton } from '@/components/ui/DocsButton'
- import NoPermission from '@/components/ui/NoPermission'
- import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip'
- import { useProjectApiUrl } from '@/data/config/project-endpoint-query'
- import { useEdgeFunctionBodyQuery } from '@/data/edge-functions/edge-function-body-query'
- import { useEdgeFunctionQuery } from '@/data/edge-functions/edge-function-query'
- import { useSendEventMutation } from '@/data/telemetry/send-event-mutation'
- import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
- import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
- import { withAuth } from '@/hooks/misc/withAuth'
- import { DOCS_URL } from '@/lib/constants'
- import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
- dayjs.extend(relativeTime)
- interface EdgeFunctionDetailsLayoutProps {
- title: string
- }
- const EdgeFunctionDetailsLayout = ({
- title,
- children,
- }: PropsWithChildren<EdgeFunctionDetailsLayoutProps>) => {
- const router = useRouter()
- const { data: org } = useSelectedOrganizationQuery()
- const { functionSlug, ref } = useParams()
- const { mutate: sendEvent } = useSendEventMutation()
- const { isLoading, can: canReadFunctions } = useAsyncCheckPermissions(
- PermissionAction.FUNCTIONS_READ,
- '*'
- )
- const [isOpen, setIsOpen] = useState(false)
- const [isDownloadOpen, setIsDownloadOpen] = useState(false)
- const [isTimestampHoverCardOpen, setIsTimestampHoverCardOpen] = useState(false)
- const {
- data: selectedFunction,
- error,
- isError,
- } = useEdgeFunctionQuery({ projectRef: ref, slug: functionSlug })
- const { data: endpoint } = useProjectApiUrl({ projectRef: ref })
- const { data: functionBody = { version: 0, files: [] }, error: filesError } =
- useEdgeFunctionBodyQuery(
- {
- projectRef: ref,
- slug: functionSlug,
- },
- {
- retry: false,
- retryOnMount: true,
- refetchOnWindowFocus: false,
- staleTime: Infinity,
- refetchOnMount: false,
- refetchOnReconnect: false,
- refetchInterval: false,
- refetchIntervalInBackground: false,
- }
- )
- 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 browserTitle = {
- entity: functionSlug ? name || functionSlug : undefined,
- section: title,
- }
- const breadcrumbItems = [
- {
- label: 'Edge Functions',
- href: `/project/${ref}/functions`,
- },
- {
- label: functionSlug,
- href: `/project/${ref}/functions/${functionSlug}`,
- },
- ]
- const navigationItems = functionSlug
- ? [
- ...(IS_PLATFORM
- ? [
- {
- label: 'Overview',
- href: `/project/${ref}/functions/${functionSlug}`,
- },
- {
- label: 'Invocations',
- href: `/project/${ref}/functions/${functionSlug}/invocations`,
- },
- {
- label: 'Logs',
- href: `/project/${ref}/functions/${functionSlug}/logs`,
- },
- ]
- : []),
- {
- label: 'Code',
- href: `/project/${ref}/functions/${functionSlug}/code`,
- },
- {
- label: 'Settings',
- href: `/project/${ref}/functions/${functionSlug}/details`,
- },
- ]
- : []
- const downloadFunction = async () => {
- if (filesError) return toast.error('Failed to retrieve edge function files')
- const zipFileWriter = new BlobWriter('application/zip')
- const zipWriter = new ZipWriter(zipFileWriter, { bufferedWrite: true })
- // Extract file paths relative to function slug
- const filePaths = functionBody.files.map((file) => {
- const nameSections = file.name.split('/')
- const slugIndex = nameSections.indexOf(functionSlug ?? '')
- return nameSections.slice(slugIndex + 1).join('/')
- })
- // Find the deepest relative path (count leading ../ segments)
- let maxDepth = 0
- filePaths.forEach((path) => {
- const segments = path.split('/')
- let depth = 0
- for (const segment of segments) {
- if (segment === '..') {
- depth++
- } else {
- break
- }
- }
- maxDepth = Math.max(maxDepth, depth)
- })
- // Add files to zip with normalized paths
- functionBody.files.forEach((file) => {
- const nameSections = file.name.split('/')
- const slugIndex = nameSections.indexOf(functionSlug ?? '')
- const fileName = nameSections.slice(slugIndex + 1).join('/')
- // Count and remove leading ../ segments
- const segments = fileName.split('/')
- let parentDirCount = 0
- while (segments.length > 0 && segments[0] === '..') {
- segments.shift()
- parentDirCount++
- }
- // Calculate safe path:
- // - Files without ../ go into the full base path
- // - Files with ../ go into a shallower path based on how many levels up they go
- const depthFromBase = maxDepth - parentDirCount
- const safePath =
- depthFromBase > 0
- ? Array.from({ length: depthFromBase }, (_, i) => (i === 0 ? 'src' : `src${i}`)).join(
- '/'
- ) +
- '/' +
- segments.join('/')
- : segments.join('/')
- const fileBlob = new Blob([file.content])
- zipWriter.add(safePath, new BlobReader(fileBlob))
- })
- const blobURL = URL.createObjectURL(await zipWriter.close())
- const link = document.createElement('a')
- link.href = blobURL
- link.setAttribute('download', `${functionSlug}.zip`)
- document.body.appendChild(link)
- link.click()
- link.parentNode?.removeChild(link)
- }
- useEffect(() => {
- let cancel = false
- if (!!functionSlug && isError && error.code === 404 && !cancel) {
- toast('Edge function cannot be found in your project')
- router.push(`/project/${ref}/functions`)
- }
- return () => {
- cancel = true
- }
- }, [isError])
- const openTestSheet = () => {
- if (!functionSlug) return
- setIsOpen(true)
- if (IS_PLATFORM) {
- sendEvent({
- action: 'edge_function_test_side_panel_opened',
- groups: {
- project: ref ?? 'Unknown',
- organization: org?.slug ?? 'Unknown',
- },
- })
- }
- }
- const copyFunctionUrl = () => {
- if (!functionUrl) return
- copyToClipboard(functionUrl)
- toast.success('Function URL copied to clipboard')
- }
- useFunctionsDetailShortcuts({
- projectRef: ref,
- functionSlug,
- canReadFunctions,
- isPlatform: IS_PLATFORM,
- onOpenTest: openTestSheet,
- onOpenDownload: () => setIsDownloadOpen((prev) => !prev),
- onCopyUrl: copyFunctionUrl,
- })
- if (!isLoading && !canReadFunctions) {
- return (
- <ProjectLayout product="Edge Functions" browserTitle={browserTitle}>
- <NoPermission isFullPage resourceText="access your project's edge functions" />
- </ProjectLayout>
- )
- }
- return (
- <EdgeFunctionsLayout title={title} browserTitle={browserTitle}>
- <div className="w-full min-h-full flex flex-col items-stretch">
- <PageHeader size="full" className="sticky top-0 z-10 bg-surface-75">
- {breadcrumbItems.length > 0 && (
- <PageHeaderBreadcrumb>
- <BreadcrumbList>
- {breadcrumbItems.map((item, index) => (
- <React.Fragment key={item.label || `breadcrumb-${index}`}>
- <BreadcrumbItem>
- {item.href ? (
- <BreadcrumbLink asChild>
- <Link href={item.href}>{item.label}</Link>
- </BreadcrumbLink>
- ) : (
- <span>{item.label}</span>
- )}
- </BreadcrumbItem>
- {index < breadcrumbItems.length - 1 && <BreadcrumbSeparator />}
- </React.Fragment>
- ))}
- </BreadcrumbList>
- </PageHeaderBreadcrumb>
- )}
- <PageHeaderMeta>
- <PageHeaderSummary>
- <PageHeaderTitle>{functionSlug ? name : 'Edge Functions'}</PageHeaderTitle>
- <PageHeaderDescription className="flex flex-row flex-wrap items-center gap-x-4 gap-y-1 text-sm!">
- <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>
- <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">
- {updatedRelative ?? '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>
- <PageHeaderAside>
- <div className="flex items-center space-x-2">
- <DocsButton href={`${DOCS_URL}/guides/functions`} />
- <Popover open={isDownloadOpen} onOpenChange={setIsDownloadOpen}>
- <ShortcutTooltip
- shortcutId={SHORTCUT_IDS.FUNCTION_DETAIL_OPEN_DOWNLOAD}
- side="bottom"
- open={isDownloadOpen ? false : undefined}
- >
- <PopoverTrigger asChild>
- <Button type="default" icon={<Download />}>
- Download
- </Button>
- </PopoverTrigger>
- </ShortcutTooltip>
- <PopoverContent align="end" className="p-0">
- {IS_PLATFORM && (
- <>
- <div className="p-3 flex flex-col gap-y-2">
- <p className="text-xs text-foreground-light">Download via CLI</p>
- <Input
- copy
- showCopyOnHover
- readOnly
- containerClassName=""
- className="text-xs font-mono tracking-tighter"
- value={`briven functions download ${functionSlug}`}
- />
- </div>
- <Separator className="bg-border-overlay!" />
- </>
- )}
- <div className="py-2 px-1">
- <Button
- type="text"
- className="w-min hover:bg-transparent"
- icon={<FileArchive />}
- onClick={downloadFunction}
- >
- Download as ZIP
- </Button>
- </div>
- </PopoverContent>
- </Popover>
- {!!functionSlug && (
- <ShortcutTooltip
- shortcutId={SHORTCUT_IDS.FUNCTION_DETAIL_OPEN_TEST}
- side="bottom"
- >
- <Button type="default" icon={<Send />} onClick={openTestSheet}>
- Test
- </Button>
- </ShortcutTooltip>
- )}
- </div>
- </PageHeaderAside>
- </PageHeaderMeta>
- {navigationItems.length > 0 && (
- <PageHeaderNavigationTabs>
- <NavMenu>
- {navigationItems.map((item) => {
- const isActive = router.asPath.split('?')[0] === item.href
- return (
- <NavMenuItem key={item.label} active={isActive}>
- <Link href={item.href}>{item.label}</Link>
- </NavMenuItem>
- )
- })}
- </NavMenu>
- </PageHeaderNavigationTabs>
- )}
- </PageHeader>
- {children}
- <EdgeFunctionTesterSheet visible={isOpen} onClose={() => setIsOpen(false)} />
- </div>
- </EdgeFunctionsLayout>
- )
- }
- export default withAuth(EdgeFunctionDetailsLayout)
|