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 && ( + + )} + - )} - - + 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(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(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 via CLI

- -
- -
+ {IS_PLATFORM && ( + <> + + + + + +
+

Download via CLI

+ +
+ +
+ +
+
+
+ {!!functionSlug && ( -
-
-
- {!!functionSlug && ( - + )} + )} 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]