EdgeFunctionDetailsLayout.tsx449 lines · main
| 1 | // @ts-nocheck |
| 2 | import { PermissionAction } from '@supabase/shared-types/out/constants' |
| 3 | import { BlobReader, BlobWriter, ZipWriter } from '@zip.js/zip.js' |
| 4 | import { IS_PLATFORM, useParams } from 'common' |
| 5 | import dayjs from 'dayjs' |
| 6 | import relativeTime from 'dayjs/plugin/relativeTime' |
| 7 | import { Clock, Download, FileArchive, Send } from 'lucide-react' |
| 8 | import Link from 'next/link' |
| 9 | import { useRouter } from 'next/router' |
| 10 | import React, { useEffect, useState, type PropsWithChildren } from 'react' |
| 11 | import { toast } from 'sonner' |
| 12 | import { |
| 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' |
| 29 | import { TimestampInfo } from 'ui-patterns' |
| 30 | import { Input } from 'ui-patterns/DataInputs/Input' |
| 31 | import { |
| 32 | PageHeader, |
| 33 | PageHeaderAside, |
| 34 | PageHeaderBreadcrumb, |
| 35 | PageHeaderDescription, |
| 36 | PageHeaderMeta, |
| 37 | PageHeaderNavigationTabs, |
| 38 | PageHeaderSummary, |
| 39 | PageHeaderTitle, |
| 40 | } from 'ui-patterns/PageHeader' |
| 41 | |
| 42 | import { ProjectLayout } from '../ProjectLayout' |
| 43 | import EdgeFunctionsLayout from './EdgeFunctionsLayout' |
| 44 | import { EdgeFunctionTesterSheet } from '@/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet' |
| 45 | import { useFunctionsDetailShortcuts } from '@/components/interfaces/Functions/useFunctionsDetailShortcuts' |
| 46 | import CopyButton from '@/components/ui/CopyButton' |
| 47 | import { DocsButton } from '@/components/ui/DocsButton' |
| 48 | import NoPermission from '@/components/ui/NoPermission' |
| 49 | import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip' |
| 50 | import { useProjectApiUrl } from '@/data/config/project-endpoint-query' |
| 51 | import { useEdgeFunctionBodyQuery } from '@/data/edge-functions/edge-function-body-query' |
| 52 | import { useEdgeFunctionQuery } from '@/data/edge-functions/edge-function-query' |
| 53 | import { useSendEventMutation } from '@/data/telemetry/send-event-mutation' |
| 54 | import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' |
| 55 | import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' |
| 56 | import { withAuth } from '@/hooks/misc/withAuth' |
| 57 | import { DOCS_URL } from '@/lib/constants' |
| 58 | import { SHORTCUT_IDS } from '@/state/shortcuts/registry' |
| 59 | |
| 60 | dayjs.extend(relativeTime) |
| 61 | |
| 62 | interface EdgeFunctionDetailsLayoutProps { |
| 63 | title: string |
| 64 | } |
| 65 | |
| 66 | const 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 | |
| 449 | export default withAuth(EdgeFunctionDetailsLayout) |