From 8c3655cd714c31c08079d54ce95f084b9f0a525b Mon Sep 17 00:00:00 2001
From: Kalleby Santos <105971119+kallebysantos@users.noreply.github.com>
Date: Fri, 30 Jan 2026 21:14:28 +0000
Subject: [PATCH] feat(studio-local): functions management api - function
details (#42322)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Functions page on self-hosted differs from Platform Adds the possibility to see function details page in Self-Host version.
## Summary by CodeRabbit
* **New Features**
* Edge function detail lookup added for self‑hosted deployments (new
retrieval endpoint & store method).
* Consistent navigation to function pages from the functions list.
* **Improvements**
* UI tabs, download, and test controls adapt to deployment type.
* Region, JWT verification, local development, and delete controls shown
only on the platform.
* Edit/save/delete controls enable/disable correctly based on deployment
and permissions.
* Function details load reliably across deployments.
---
.../EdgeFunctionDetails.tsx | 298 +++++++++---------
.../Functions/EdgeFunctionsListItem.tsx | 25 +-
.../EdgeFunctionDetailsLayout.tsx | 132 ++++----
.../edge-functions/edge-function-query.ts | 3 +-
.../self-hosted/functions/fileSystemStore.ts | 11 +
.../projects/[ref]/functions/[slug]/index.ts | 49 +++
.../[ref]/functions/[functionSlug]/index.tsx | 5 +-
7 files changed, 303 insertions(+), 220 deletions(-)
create mode 100644 apps/studio/pages/api/v1/projects/[ref]/functions/[slug]/index.ts
diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx
index 2bc1979e236..40f8fd83fa0 100644
--- a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx
+++ b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx
@@ -1,5 +1,6 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
+import { IS_PLATFORM, useParams } from 'common'
import dayjs from 'dayjs'
import { ExternalLink } from 'lucide-react'
import Link from 'next/link'
@@ -7,44 +8,30 @@ import { useRouter } from 'next/router'
import { useEffect, useMemo, useState } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
import { toast } from 'sonner'
-import z from 'zod'
-
-import { useParams } from 'common'
-import { AlertError } from 'components/ui/AlertError'
-import { getKeys, useAPIKeysQuery } from 'data/api-keys/api-keys-query'
-import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
-import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query'
-import { useEdgeFunctionQuery } from 'data/edge-functions/edge-function-query'
-import { useEdgeFunctionDeleteMutation } from 'data/edge-functions/edge-functions-delete-mutation'
-import { useEdgeFunctionUpdateMutation } from 'data/edge-functions/edge-functions-update-mutation'
-import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions'
-import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
-import { DOCS_URL } from 'lib/constants'
import {
- Alert_Shadcn_,
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
+ Alert_Shadcn_,
Button,
Card,
CardContent,
CardFooter,
- cn,
CodeBlock,
- copyToClipboard,
CriticalIcon,
- Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
+ Form_Shadcn_,
Switch,
Tabs_Shadcn_ as Tabs,
TabsContent_Shadcn_ as TabsContent,
TabsList_Shadcn_ as TabsList,
TabsTrigger_Shadcn_ as TabsTrigger,
+ cn,
+ copyToClipboard,
} from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns'
import { Input } from 'ui-patterns/DataInputs/Input'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
-import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { PageContainer } from 'ui-patterns/PageContainer'
import {
PageSection,
@@ -53,9 +40,22 @@ import {
PageSectionSummary,
PageSectionTitle,
} from 'ui-patterns/PageSection'
+import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
+import z from 'zod'
+
import CommandRender from '../CommandRender'
import { INVOCATION_TABS } from './EdgeFunctionDetails.constants'
import { generateCLICommands } from './EdgeFunctionDetails.utils'
+import AlertError from '@/components/ui/AlertError'
+import { getKeys, useAPIKeysQuery } from '@/data/api-keys/api-keys-query'
+import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query'
+import { useCustomDomainsQuery } from '@/data/custom-domains/custom-domains-query'
+import { useEdgeFunctionQuery } from '@/data/edge-functions/edge-function-query'
+import { useEdgeFunctionDeleteMutation } from '@/data/edge-functions/edge-functions-delete-mutation'
+import { useEdgeFunctionUpdateMutation } from '@/data/edge-functions/edge-functions-update-mutation'
+import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
+import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
+import { DOCS_URL } from '@/lib/constants'
const FormSchema = z.object({
name: z.string().min(0, 'Name is required'),
@@ -78,11 +78,13 @@ export const EdgeFunctionDetails = () => {
const [selectedTab, setSelectedTab] = useState(invocationTabs[0].id)
const [showDeleteModal, setShowDeleteModal] = useState(false)
- const { can: canUpdateEdgeFunction } = useAsyncCheckPermissions(
+ const { can: canUpdateEdgeFunctionPermission } = useAsyncCheckPermissions(
PermissionAction.FUNCTIONS_WRITE,
'*'
)
+ const canUpdateEdgeFunction = IS_PLATFORM && canUpdateEdgeFunctionPermission
+
const { can: canReadAPIKeys } = useAsyncCheckPermissions(PermissionAction.SECRETS_READ, '*')
const { data: apiKeys } = useAPIKeysQuery(
{
@@ -197,8 +199,12 @@ export const EdgeFunctionDetails = () => {
/>
-
Region
- All functions are deployed globally
+ {IS_PLATFORM && (
+ <>
+ Region
+ All functions are deployed globally
+ >
+ )}
Created at
@@ -278,53 +284,58 @@ export const EdgeFunctionDetails = () => {
)}
/>
-
- (
-
- Requires that a JWT signed{' '}
-
- only by the legacy JWT secret
- {' '}
- is present in the Authorization header. The easy to
- obtain anon key can be used to satisfy this
- requirement. Recommendation: OFF with JWT and additional
- authorization logic implemented inside your function's code.
- >
- }
+ {IS_PLATFORM && (
+ <>
+
+ (
+
+ Requires that a JWT signed{' '}
+
+ only by the legacy JWT secret
+ {' '}
+ is present in the Authorization header. The easy to
+ obtain anon key can be used to satisfy this
+ requirement. Recommendation: OFF with JWT and additional
+ authorization logic implemented inside your function's code.
+ >
+ }
+ >
+
+
+
+
+ )}
+ />
+
+
+
+ {form.formState.isDirty && (
+ form.reset()}>
+ Cancel
+
+ )}
+
-
-
-
-
- )}
- />
-
-
- {form.formState.isDirty && (
- form.reset()}>
- Cancel
-
- )}
-
- Save changes
-
-
+ Save changes
+
+
+ >
+ )}
@@ -401,81 +412,84 @@ export const EdgeFunctionDetails = () => {
-
-
-
- Develop locally
-
-
-
-
-
- (
- <>
- supabase functions download{' '}
- {selectedFunction?.slug}
- >
- ),
- comment: '1. Download the function',
- },
- ]}
- />
-
-
-
-
-
-
-
-
-
- Delete function
-
-
-
-
-
-
- Once your function is deleted, it can no longer be restored
-
-
- Make sure you have made a backup if you want to restore your edge function
-
-
- setShowDeleteModal(true)}
- >
- Delete edge function
-
-
-
-
-
-
- setShowDeleteModal(false)}
- onConfirm={onConfirmDelete}
- alert={{
- base: { variant: 'destructive' },
- title: 'This action cannot be undone',
- description:
- 'Ensure that you have made a backup if you want to restore your edge function',
- }}
- />
+ {IS_PLATFORM && (
+ <>
+
+
+
+ Develop locally
+
+
+
+
+
+ (
+ <>
+ supabase functions download{' '}
+ {selectedFunction?.slug}
+ >
+ ),
+ comment: '1. Download the function',
+ },
+ ]}
+ />
+
+
+
+
+
+
+
+
+
+ Delete function
+
+
+
+
+
+
+ Once your function is deleted, it can no longer be restored
+
+
+ Make sure you have made a backup if you want to restore your edge function
+
+
+ setShowDeleteModal(true)}
+ >
+ Delete edge function
+
+
+
+
+
+ setShowDeleteModal(false)}
+ onConfirm={onConfirmDelete}
+ alert={{
+ base: { variant: 'destructive' },
+ title: 'This action cannot be undone',
+ description:
+ 'Ensure that you have made a backup if you want to restore your edge function',
+ }}
+ />
+ >
+ )}
diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionsListItem.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionsListItem.tsx
index c57fdbda3b2..359948879a7 100644
--- a/apps/studio/components/interfaces/Functions/EdgeFunctionsListItem.tsx
+++ b/apps/studio/components/interfaces/Functions/EdgeFunctionsListItem.tsx
@@ -1,16 +1,16 @@
+import { IS_PLATFORM } from 'common'
+import { useParams } from 'common/hooks'
import dayjs from 'dayjs'
import { Check, Copy } from 'lucide-react'
import { useRouter } from 'next/router'
import { useState } from 'react'
-
-import { IS_PLATFORM } from 'common'
-import { useParams } from 'common/hooks'
-import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
-import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query'
-import type { EdgeFunctionsResponse } from 'data/edge-functions/edge-functions-query'
-import { cn, copyToClipboard, TableCell, TableRow } from 'ui'
+import { TableCell, TableRow, copyToClipboard } from 'ui'
import { TimestampInfo } from 'ui-patterns'
-import { createNavigationHandler } from 'lib/navigation'
+
+import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query'
+import { useCustomDomainsQuery } from '@/data/custom-domains/custom-domains-query'
+import type { EdgeFunctionsResponse } from '@/data/edge-functions/edge-functions-query'
+import { createNavigationHandler } from '@/lib/navigation'
interface EdgeFunctionsListItemProps {
function: EdgeFunctionsResponse
@@ -31,9 +31,10 @@ export const EdgeFunctionsListItem = ({ function: item }: EdgeFunctionsListItemP
? `https://${customDomainData.customDomain.hostname}/functions/v1/${item.slug}`
: `${protocol}://${endpoint}/functions/v1/${item.slug}`
- const handleNavigation = IS_PLATFORM
- ? createNavigationHandler(`/project/${ref}/functions/${item.slug}`, router)
- : undefined
+ const handleNavigation = createNavigationHandler(
+ `/project/${ref}/functions/${item.slug}${IS_PLATFORM ? '' : `/details`}`,
+ router
+ )
return (
{item.name}
diff --git a/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx b/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx
index 8f66afdf938..e78f9cefaad 100644
--- a/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx
+++ b/apps/studio/components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout.tsx
@@ -5,7 +5,7 @@ import React, { useEffect, useState, type PropsWithChildren } from 'react'
import { toast } from 'sonner'
import { BlobReader, BlobWriter, ZipWriter } from '@zip.js/zip.js'
-import { useParams } from 'common'
+import { IS_PLATFORM, useParams } from 'common'
import { useIsAPIDocsSidePanelEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { EdgeFunctionTesterSheet } from 'components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionTesterSheet'
import { APIDocsButton } from 'components/ui/APIDocsButton'
@@ -105,22 +105,26 @@ const EdgeFunctionDetailsLayout = ({
const navigationItems = functionSlug
? [
- {
- 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`,
- },
+ ...(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: 'Details',
href: `/project/${ref}/functions/${functionSlug}/details`,
@@ -260,54 +264,58 @@ const EdgeFunctionDetailsLayout = ({
/>
)}
-
-
- }>
- Download
-
-
-
-
-
-
+ {IS_PLATFORM && (
+ <>
+
+
+ }>
+ Download
+
+
+
+
+
+
+ }
+ onClick={downloadFunction}
+ >
+ Download as ZIP
+
+
+
+
+ {!!functionSlug && (
}
- onClick={downloadFunction}
+ type="default"
+ icon={
}
+ onClick={() => {
+ setIsOpen(true)
+ sendEvent({
+ action: 'edge_function_test_side_panel_opened',
+ groups: {
+ project: ref ?? 'Unknown',
+ organization: org?.slug ?? 'Unknown',
+ },
+ })
+ }}
>
- Download as ZIP
+ Test
-
-
-
- {!!functionSlug && (
- }
- onClick={() => {
- setIsOpen(true)
- sendEvent({
- action: 'edge_function_test_side_panel_opened',
- groups: {
- project: ref ?? 'Unknown',
- organization: org?.slug ?? 'Unknown',
- },
- })
- }}
- >
- Test
-
+ )}
+ >
)}
diff --git a/apps/studio/data/edge-functions/edge-function-query.ts b/apps/studio/data/edge-functions/edge-function-query.ts
index a44cb9d04e5..16814790bc7 100644
--- a/apps/studio/data/edge-functions/edge-function-query.ts
+++ b/apps/studio/data/edge-functions/edge-function-query.ts
@@ -41,7 +41,6 @@ export const useEdgeFunctionQuery = (
useQuery({
queryKey: edgeFunctionsKeys.detail(projectRef, slug),
queryFn: ({ signal }) => getEdgeFunction({ projectRef, slug }, signal),
- enabled:
- IS_PLATFORM && enabled && typeof projectRef !== 'undefined' && typeof slug !== 'undefined',
+ enabled: enabled && typeof projectRef !== 'undefined' && typeof slug !== 'undefined',
...options,
})
diff --git a/apps/studio/lib/api/self-hosted/functions/fileSystemStore.ts b/apps/studio/lib/api/self-hosted/functions/fileSystemStore.ts
index 38885fe6429..3cd98f49239 100644
--- a/apps/studio/lib/api/self-hosted/functions/fileSystemStore.ts
+++ b/apps/studio/lib/api/self-hosted/functions/fileSystemStore.ts
@@ -18,6 +18,17 @@ export class FileSystemFunctionsArtifactStore {
return functionsArtifacts.filter((f) => f !== undefined)
}
+
+ async getFunctionBySlug(slug: string): Promise {
+ const dirEntries = await readdir(this.folderPath, { withFileTypes: true })
+
+ const functionFolder = dirEntries.find(
+ (dir) => dir.isDirectory() && dir.name !== 'main' && dir.name === slug
+ )
+ if (!functionFolder) return
+
+ return parseFolderToFunctionArtifact(functionFolder)
+ }
}
async function parseFolderToFunctionArtifact(
diff --git a/apps/studio/pages/api/v1/projects/[ref]/functions/[slug]/index.ts b/apps/studio/pages/api/v1/projects/[ref]/functions/[slug]/index.ts
new file mode 100644
index 00000000000..05a4a8016b6
--- /dev/null
+++ b/apps/studio/pages/api/v1/projects/[ref]/functions/[slug]/index.ts
@@ -0,0 +1,49 @@
+import { type NextApiRequest, type NextApiResponse } from 'next'
+
+import type { components } from 'api-types'
+import { uuidv4 } from 'lib/helpers'
+import apiWrapper from 'lib/api/apiWrapper'
+import { getFunctionsArtifactStore } from 'lib/api/self-hosted/functions'
+
+export default function handlerWithErrorCatching(req: NextApiRequest, res: NextApiResponse) {
+ return apiWrapper(req, res, handler, { withAuth: true })
+}
+
+async function handler(req: NextApiRequest, res: NextApiResponse) {
+ const { method } = req
+
+ switch (method) {
+ case 'GET':
+ return handleGet(req, res)
+ default:
+ res.setHeader('Allow', ['GET'])
+ res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } })
+ }
+}
+
+type EdgeFunctionsResponse = components['schemas']['FunctionResponse']
+
+const handleGet = async (req: NextApiRequest, res: NextApiResponse) => {
+ const slugParam = req.query.slug
+ const slug = Array.isArray(slugParam) ? slugParam[0] : slugParam
+ if (!slug)
+ return res.status(404).json({ error: { message: `Missing function 'slug' parameter` } })
+
+ const store = getFunctionsArtifactStore()
+
+ const functionsArtifact = await store.getFunctionBySlug(slug)
+ if (!functionsArtifact) return res.status(404).json({ error: { message: `Function not found` } })
+
+ const functionResponse = {
+ id: uuidv4(),
+ slug: functionsArtifact.slug,
+ version: 1,
+ name: functionsArtifact.slug,
+ status: 'ACTIVE',
+ entrypoint_path: functionsArtifact.entrypoint_path,
+ created_at: functionsArtifact.created_at,
+ updated_at: functionsArtifact.updated_at,
+ } satisfies EdgeFunctionsResponse
+
+ return res.status(200).json(functionResponse)
+}
diff --git a/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx b/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx
index 429d27bb27c..7c7b05294bd 100644
--- a/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx
+++ b/apps/studio/pages/project/[ref]/functions/[functionSlug]/index.tsx
@@ -1,5 +1,5 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
-import { useParams } from 'common'
+import { IS_PLATFORM, useParams } from 'common'
import { useFlag } from 'common'
import ReportWidget from 'components/interfaces/Reports/ReportWidget'
import DefaultLayout from 'components/layouts/DefaultLayout'
@@ -19,7 +19,7 @@ import maxBy from 'lodash/maxBy'
import meanBy from 'lodash/meanBy'
import sumBy from 'lodash/sumBy'
import { useRouter } from 'next/router'
-import { useMemo, useState } from 'react'
+import { useEffect, useMemo, useState } from 'react'
import type { ChartIntervals, NextPageWithLayout } from 'types'
import {
AlertDescription_Shadcn_,
@@ -65,6 +65,7 @@ const CHART_INTERVALS: ChartIntervals[] = [
const PageLayout: NextPageWithLayout = () => {
const router = useRouter()
const { ref: projectRef, functionSlug } = useParams()
+
const newChartsEnabled = useFlag('newEdgeFunctionOverviewCharts')
const [interval, setInterval] = useState('15min')
const selectedInterval = CHART_INTERVALS.find((i) => i.key === interval) || CHART_INTERVALS[1]