EdgeFunctionDetailsLayout.tsx449 lines · main
1// @ts-nocheck
2import { PermissionAction } from '@supabase/shared-types/out/constants'
3import { BlobReader, BlobWriter, ZipWriter } from '@zip.js/zip.js'
4import { IS_PLATFORM, useParams } from 'common'
5import dayjs from 'dayjs'
6import relativeTime from 'dayjs/plugin/relativeTime'
7import { Clock, Download, FileArchive, Send } from 'lucide-react'
8import Link from 'next/link'
9import { useRouter } from 'next/router'
10import React, { useEffect, useState, type PropsWithChildren } from 'react'
11import { toast } from 'sonner'
12import {
13 BreadcrumbItem,
14 BreadcrumbLink,
15 BreadcrumbList,
16 BreadcrumbSeparator,
17 Button,
18 copyToClipboard,
19 HoverCard,
20 HoverCardContent,
21 HoverCardTrigger,
22 NavMenu,
23 NavMenuItem,
24 Popover,
25 PopoverContent,
26 PopoverTrigger,
27 Separator,
28} from 'ui'
29import { TimestampInfo } from 'ui-patterns'
30import { Input } from 'ui-patterns/DataInputs/Input'
31import {
32 PageHeader,
33 PageHeaderAside,
34 PageHeaderBreadcrumb,
35 PageHeaderDescription,
36 PageHeaderMeta,
37 PageHeaderNavigationTabs,
38 PageHeaderSummary,
39 PageHeaderTitle,
40} from 'ui-patterns/PageHeader'
41
42import { ProjectLayout } from '../ProjectLayout'
43import EdgeFunctionsLayout from './EdgeFunctionsLayout'
44import { EdgeFunctionTesterSheet } from '@/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet'
45import { useFunctionsDetailShortcuts } from '@/components/interfaces/Functions/useFunctionsDetailShortcuts'
46import CopyButton from '@/components/ui/CopyButton'
47import { DocsButton } from '@/components/ui/DocsButton'
48import NoPermission from '@/components/ui/NoPermission'
49import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip'
50import { useProjectApiUrl } from '@/data/config/project-endpoint-query'
51import { useEdgeFunctionBodyQuery } from '@/data/edge-functions/edge-function-body-query'
52import { useEdgeFunctionQuery } from '@/data/edge-functions/edge-function-query'
53import { useSendEventMutation } from '@/data/telemetry/send-event-mutation'
54import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
55import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
56import { withAuth } from '@/hooks/misc/withAuth'
57import { DOCS_URL } from '@/lib/constants'
58import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
59
60dayjs.extend(relativeTime)
61
62interface EdgeFunctionDetailsLayoutProps {
63 title: string
64}
65
66const EdgeFunctionDetailsLayout = ({
67 title,
68 children,
69}: PropsWithChildren<EdgeFunctionDetailsLayoutProps>) => {
70 const router = useRouter()
71 const { data: org } = useSelectedOrganizationQuery()
72 const { functionSlug, ref } = useParams()
73 const { mutate: sendEvent } = useSendEventMutation()
74
75 const { isLoading, can: canReadFunctions } = useAsyncCheckPermissions(
76 PermissionAction.FUNCTIONS_READ,
77 '*'
78 )
79
80 const [isOpen, setIsOpen] = useState(false)
81 const [isDownloadOpen, setIsDownloadOpen] = useState(false)
82 const [isTimestampHoverCardOpen, setIsTimestampHoverCardOpen] = useState(false)
83
84 const {
85 data: selectedFunction,
86 error,
87 isError,
88 } = useEdgeFunctionQuery({ projectRef: ref, slug: functionSlug })
89 const { data: endpoint } = useProjectApiUrl({ projectRef: ref })
90
91 const { data: functionBody = { version: 0, files: [] }, error: filesError } =
92 useEdgeFunctionBodyQuery(
93 {
94 projectRef: ref,
95 slug: functionSlug,
96 },
97 {
98 retry: false,
99 retryOnMount: true,
100 refetchOnWindowFocus: false,
101 staleTime: Infinity,
102 refetchOnMount: false,
103 refetchOnReconnect: false,
104 refetchInterval: false,
105 refetchIntervalInBackground: false,
106 }
107 )
108
109 const name = selectedFunction?.name || ''
110 const functionUrl =
111 endpoint && selectedFunction?.slug ? `${endpoint}/functions/v1/${selectedFunction.slug}` : ''
112 const createdRelative = selectedFunction?.created_at
113 ? dayjs(selectedFunction.created_at).fromNow()
114 : undefined
115 const updatedRelative = selectedFunction?.updated_at
116 ? dayjs(selectedFunction.updated_at).fromNow()
117 : undefined
118 const browserTitle = {
119 entity: functionSlug ? name || functionSlug : undefined,
120 section: title,
121 }
122
123 const breadcrumbItems = [
124 {
125 label: 'Edge Functions',
126 href: `/project/${ref}/functions`,
127 },
128 {
129 label: functionSlug,
130 href: `/project/${ref}/functions/${functionSlug}`,
131 },
132 ]
133
134 const navigationItems = functionSlug
135 ? [
136 ...(IS_PLATFORM
137 ? [
138 {
139 label: 'Overview',
140 href: `/project/${ref}/functions/${functionSlug}`,
141 },
142 {
143 label: 'Invocations',
144 href: `/project/${ref}/functions/${functionSlug}/invocations`,
145 },
146 {
147 label: 'Logs',
148 href: `/project/${ref}/functions/${functionSlug}/logs`,
149 },
150 ]
151 : []),
152 {
153 label: 'Code',
154 href: `/project/${ref}/functions/${functionSlug}/code`,
155 },
156 {
157 label: 'Settings',
158 href: `/project/${ref}/functions/${functionSlug}/details`,
159 },
160 ]
161 : []
162
163 const downloadFunction = async () => {
164 if (filesError) return toast.error('Failed to retrieve edge function files')
165
166 const zipFileWriter = new BlobWriter('application/zip')
167 const zipWriter = new ZipWriter(zipFileWriter, { bufferedWrite: true })
168
169 // Extract file paths relative to function slug
170 const filePaths = functionBody.files.map((file) => {
171 const nameSections = file.name.split('/')
172 const slugIndex = nameSections.indexOf(functionSlug ?? '')
173 return nameSections.slice(slugIndex + 1).join('/')
174 })
175
176 // Find the deepest relative path (count leading ../ segments)
177 let maxDepth = 0
178 filePaths.forEach((path) => {
179 const segments = path.split('/')
180 let depth = 0
181 for (const segment of segments) {
182 if (segment === '..') {
183 depth++
184 } else {
185 break
186 }
187 }
188 maxDepth = Math.max(maxDepth, depth)
189 })
190
191 // Add files to zip with normalized paths
192 functionBody.files.forEach((file) => {
193 const nameSections = file.name.split('/')
194 const slugIndex = nameSections.indexOf(functionSlug ?? '')
195 const fileName = nameSections.slice(slugIndex + 1).join('/')
196
197 // Count and remove leading ../ segments
198 const segments = fileName.split('/')
199 let parentDirCount = 0
200 while (segments.length > 0 && segments[0] === '..') {
201 segments.shift()
202 parentDirCount++
203 }
204
205 // Calculate safe path:
206 // - Files without ../ go into the full base path
207 // - Files with ../ go into a shallower path based on how many levels up they go
208 const depthFromBase = maxDepth - parentDirCount
209 const safePath =
210 depthFromBase > 0
211 ? Array.from({ length: depthFromBase }, (_, i) => (i === 0 ? 'src' : `src${i}`)).join(
212 '/'
213 ) +
214 '/' +
215 segments.join('/')
216 : segments.join('/')
217
218 const fileBlob = new Blob([file.content])
219 zipWriter.add(safePath, new BlobReader(fileBlob))
220 })
221
222 const blobURL = URL.createObjectURL(await zipWriter.close())
223 const link = document.createElement('a')
224 link.href = blobURL
225 link.setAttribute('download', `${functionSlug}.zip`)
226 document.body.appendChild(link)
227 link.click()
228 link.parentNode?.removeChild(link)
229 }
230
231 useEffect(() => {
232 let cancel = false
233
234 if (!!functionSlug && isError && error.code === 404 && !cancel) {
235 toast('Edge function cannot be found in your project')
236 router.push(`/project/${ref}/functions`)
237 }
238
239 return () => {
240 cancel = true
241 }
242 }, [isError])
243
244 const openTestSheet = () => {
245 if (!functionSlug) return
246 setIsOpen(true)
247 if (IS_PLATFORM) {
248 sendEvent({
249 action: 'edge_function_test_side_panel_opened',
250 groups: {
251 project: ref ?? 'Unknown',
252 organization: org?.slug ?? 'Unknown',
253 },
254 })
255 }
256 }
257
258 const copyFunctionUrl = () => {
259 if (!functionUrl) return
260 copyToClipboard(functionUrl)
261 toast.success('Function URL copied to clipboard')
262 }
263
264 useFunctionsDetailShortcuts({
265 projectRef: ref,
266 functionSlug,
267 canReadFunctions,
268 isPlatform: IS_PLATFORM,
269 onOpenTest: openTestSheet,
270 onOpenDownload: () => setIsDownloadOpen((prev) => !prev),
271 onCopyUrl: copyFunctionUrl,
272 })
273
274 if (!isLoading && !canReadFunctions) {
275 return (
276 <ProjectLayout product="Edge Functions" browserTitle={browserTitle}>
277 <NoPermission isFullPage resourceText="access your project's edge functions" />
278 </ProjectLayout>
279 )
280 }
281
282 return (
283 <EdgeFunctionsLayout title={title} browserTitle={browserTitle}>
284 <div className="w-full min-h-full flex flex-col items-stretch">
285 <PageHeader size="full" className="sticky top-0 z-10 bg-surface-75">
286 {breadcrumbItems.length > 0 && (
287 <PageHeaderBreadcrumb>
288 <BreadcrumbList>
289 {breadcrumbItems.map((item, index) => (
290 <React.Fragment key={item.label || `breadcrumb-${index}`}>
291 <BreadcrumbItem>
292 {item.href ? (
293 <BreadcrumbLink asChild>
294 <Link href={item.href}>{item.label}</Link>
295 </BreadcrumbLink>
296 ) : (
297 <span>{item.label}</span>
298 )}
299 </BreadcrumbItem>
300 {index < breadcrumbItems.length - 1 && <BreadcrumbSeparator />}
301 </React.Fragment>
302 ))}
303 </BreadcrumbList>
304 </PageHeaderBreadcrumb>
305 )}
306
307 <PageHeaderMeta>
308 <PageHeaderSummary>
309 <PageHeaderTitle>{functionSlug ? name : 'Edge Functions'}</PageHeaderTitle>
310 <PageHeaderDescription className="flex flex-row flex-wrap items-center gap-x-4 gap-y-1 text-sm!">
311 <div className="flex items-center gap-x-2">
312 <span className="flex items-center gap-2">{functionUrl}</span>
313 <ShortcutTooltip shortcutId={SHORTCUT_IDS.FUNCTION_DETAIL_COPY_URL} side="bottom">
314 <CopyButton iconOnly type="text" text={functionUrl} />
315 </ShortcutTooltip>
316 </div>
317
318 <HoverCard
319 openDelay={250}
320 closeDelay={100}
321 open={isTimestampHoverCardOpen}
322 onOpenChange={setIsTimestampHoverCardOpen}
323 >
324 <HoverCardTrigger asChild>
325 <button type="button" className="flex items-center gap-2 group">
326 <Clock size={16} strokeWidth={1.5} className="text-foreground-lighter" />
327 <span className="transition text-foreground-light group-hover:text-foreground underline decoration-dotted decoration-foreground-muted underline-offset-4">
328 {updatedRelative ?? 'Deploy status unavailable'}
329 </span>
330 </button>
331 </HoverCardTrigger>
332 <HoverCardContent side="bottom" align="start" className="w-40 p-0">
333 {createdRelative && (
334 <div className="px-4 py-2 space-y-1">
335 <h3 className="heading-meta text-foreground-light">Created</h3>
336 {!!selectedFunction && (
337 <TimestampInfo
338 className="text-sm"
339 label={createdRelative}
340 utcTimestamp={selectedFunction.created_at}
341 />
342 )}
343 </div>
344 )}
345 {updatedRelative && (
346 <div className="px-4 py-2 space-y-1">
347 <h3 className="heading-meta text-foreground-light">Last deployed</h3>
348 {!!selectedFunction && (
349 <TimestampInfo
350 className="text-sm"
351 label={updatedRelative}
352 utcTimestamp={selectedFunction.updated_at}
353 />
354 )}
355 </div>
356 )}
357 {selectedFunction?.version !== undefined && (
358 <div className="px-4 py-2 space-y-1">
359 <h3 className="heading-meta text-foreground-light">Deployments</h3>
360 <p className="text-sm text-foreground">{selectedFunction.version}</p>
361 </div>
362 )}
363 </HoverCardContent>
364 </HoverCard>
365 </PageHeaderDescription>
366 </PageHeaderSummary>
367
368 <PageHeaderAside>
369 <div className="flex items-center space-x-2">
370 <DocsButton href={`${DOCS_URL}/guides/functions`} />
371 <Popover open={isDownloadOpen} onOpenChange={setIsDownloadOpen}>
372 <ShortcutTooltip
373 shortcutId={SHORTCUT_IDS.FUNCTION_DETAIL_OPEN_DOWNLOAD}
374 side="bottom"
375 open={isDownloadOpen ? false : undefined}
376 >
377 <PopoverTrigger asChild>
378 <Button type="default" icon={<Download />}>
379 Download
380 </Button>
381 </PopoverTrigger>
382 </ShortcutTooltip>
383 <PopoverContent align="end" className="p-0">
384 {IS_PLATFORM && (
385 <>
386 <div className="p-3 flex flex-col gap-y-2">
387 <p className="text-xs text-foreground-light">Download via CLI</p>
388 <Input
389 copy
390 showCopyOnHover
391 readOnly
392 containerClassName=""
393 className="text-xs font-mono tracking-tighter"
394 value={`briven functions download ${functionSlug}`}
395 />
396 </div>
397 <Separator className="bg-border-overlay!" />
398 </>
399 )}
400 <div className="py-2 px-1">
401 <Button
402 type="text"
403 className="w-min hover:bg-transparent"
404 icon={<FileArchive />}
405 onClick={downloadFunction}
406 >
407 Download as ZIP
408 </Button>
409 </div>
410 </PopoverContent>
411 </Popover>
412 {!!functionSlug && (
413 <ShortcutTooltip
414 shortcutId={SHORTCUT_IDS.FUNCTION_DETAIL_OPEN_TEST}
415 side="bottom"
416 >
417 <Button type="default" icon={<Send />} onClick={openTestSheet}>
418 Test
419 </Button>
420 </ShortcutTooltip>
421 )}
422 </div>
423 </PageHeaderAside>
424 </PageHeaderMeta>
425
426 {navigationItems.length > 0 && (
427 <PageHeaderNavigationTabs>
428 <NavMenu>
429 {navigationItems.map((item) => {
430 const isActive = router.asPath.split('?')[0] === item.href
431 return (
432 <NavMenuItem key={item.label} active={isActive}>
433 <Link href={item.href}>{item.label}</Link>
434 </NavMenuItem>
435 )
436 })}
437 </NavMenu>
438 </PageHeaderNavigationTabs>
439 )}
440 </PageHeader>
441
442 {children}
443 <EdgeFunctionTesterSheet visible={isOpen} onClose={() => setIsOpen(false)} />
444 </div>
445 </EdgeFunctionsLayout>
446 )
447}
448
449export default withAuth(EdgeFunctionDetailsLayout)