EdgeFunctionDetailsLayout.tsx 16 KB

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