mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
feat(studio-local): functions management api - list functions (#40690)
## What is the current behaviour? Functions page on self-hosted differs from Platform ## What is the new behaviour? Adds the possibility to see a list of edge-functions in Self-Host version. ## Summary by CodeRabbit * **New Features** * Added self-hosted edge functions support with filesystem-based artifact listing and a new API endpoint to list local functions. * Improved functions UI: unified view, enhanced search and adjacent sort controls, and clearer local-hosting guidance. * **Chores** * Added environment configuration and docker volume for edge functions management. * Updated build/task config to pass through edge functions env and include contentlayer outputs. Co-authored-by: Ali Waseem <waseema393@gmail.com> Co-authored-by: Charis Lam <26616127+charislam@users.noreply.github.com> Co-authored-by: Terry Sutton <saltcod@gmail.com>
This commit is contained in:
12 files changed
+253
-94
No files matched your search
@@ -26,3 +26,5 @@ NEXT_PUBLIC_SUPABASE_URL=https://xguihxuzqibwxjnimxev.supabase.co
|
||||
|
||||
DOCKER_SOCKET_LOCATION=/var/run/docker.sock
|
||||
|
||||
# Required to manage edge-functions via dashboard on Self-Hosting
|
||||
# EDGE_FUNCTIONS_MANAGEMENT_FOLDER=/path-where-edge-functions-are-mounted
|
||||
@@ -3,11 +3,12 @@ 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 { copyToClipboard, TableCell, TableRow } from 'ui'
|
||||
import { cn, copyToClipboard, TableCell, TableRow } from 'ui'
|
||||
import { TimestampInfo } from 'ui-patterns'
|
||||
import { createNavigationHandler } from 'lib/navigation'
|
||||
|
||||
@@ -30,7 +31,9 @@ export const EdgeFunctionsListItem = ({ function: item }: EdgeFunctionsListItemP
|
||||
? `https://${customDomainData.customDomain.hostname}/functions/v1/${item.slug}`
|
||||
: `${protocol}://${endpoint}/functions/v1/${item.slug}`
|
||||
|
||||
const handleNavigation = createNavigationHandler(`/project/${ref}/functions/${item.slug}`, router)
|
||||
const handleNavigation = IS_PLATFORM
|
||||
? createNavigationHandler(`/project/${ref}/functions/${item.slug}`, router)
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
@@ -39,7 +42,7 @@ export const EdgeFunctionsListItem = ({ function: item }: EdgeFunctionsListItemP
|
||||
onAuxClick={handleNavigation}
|
||||
onKeyDown={handleNavigation}
|
||||
tabIndex={0}
|
||||
className="cursor-pointer inset-focus"
|
||||
className={cn({ 'cursor-pointer inset-focus': IS_PLATFORM })}
|
||||
>
|
||||
<TableCell>
|
||||
<p className="text-sm text-foreground whitespace-nowrap py-2">{item.name}</p>
|
||||
|
||||
@@ -189,7 +189,7 @@ export const FunctionsEmptyState = () => {
|
||||
)
|
||||
}
|
||||
|
||||
export const FunctionsEmptyStateLocal = () => {
|
||||
export const FunctionsInstructionsLocal = () => {
|
||||
const showStripeExample = useIsFeatureEnabled('edge_functions:show_stripe_example')
|
||||
const templates = useMemo(() => {
|
||||
if (showStripeExample) {
|
||||
|
||||
@@ -37,6 +37,6 @@ export const useEdgeFunctionsQuery = <TData = EdgeFunctionsData>(
|
||||
useQuery<EdgeFunctionsData, EdgeFunctionsError, TData>({
|
||||
queryKey: edgeFunctionsKeys.list(projectRef),
|
||||
queryFn: ({ signal }) => getEdgeFunctions({ projectRef }, signal),
|
||||
enabled: IS_PLATFORM && enabled && typeof projectRef !== 'undefined',
|
||||
enabled: enabled && typeof projectRef !== 'undefined',
|
||||
...options,
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Dirent } from 'node:fs'
|
||||
import { readdir, stat } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
import { FunctionArtifact } from './types'
|
||||
|
||||
export class FileSystemFunctionsArtifactStore {
|
||||
constructor(private folderPath: string) {}
|
||||
|
||||
async getFunctions(): Promise<FunctionArtifact[]> {
|
||||
const dirEntries = await readdir(this.folderPath, { withFileTypes: true })
|
||||
|
||||
const functionsFolders = dirEntries.filter((dir) => dir.isDirectory() && dir.name !== 'main')
|
||||
const functionsArtifacts = await Promise.all(
|
||||
functionsFolders.map(parseFolderToFunctionArtifact)
|
||||
)
|
||||
|
||||
return functionsArtifacts.filter((f) => f !== undefined)
|
||||
}
|
||||
}
|
||||
|
||||
async function parseFolderToFunctionArtifact(
|
||||
folder: Dirent
|
||||
): Promise<FunctionArtifact | undefined> {
|
||||
const folderPath = path.join(folder.parentPath, folder.name)
|
||||
const files = await readdir(folderPath, { withFileTypes: true })
|
||||
const entrypoint = files.find((file) => file.isFile() && file.name.startsWith('index'))
|
||||
|
||||
if (!entrypoint) return
|
||||
|
||||
const entrypointPath = path.join(folderPath, entrypoint.name)
|
||||
const entrypointStat = await stat(entrypointPath)
|
||||
|
||||
return {
|
||||
slug: folder.name,
|
||||
entrypoint_path: pathToFileURL(entrypointPath).href,
|
||||
created_at: entrypointStat.birthtimeMs,
|
||||
updated_at: entrypointStat.mtimeMs,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import assert from 'node:assert'
|
||||
|
||||
import { FileSystemFunctionsArtifactStore } from './fileSystemStore'
|
||||
import { assertSelfHosted } from '../util'
|
||||
|
||||
export function getFunctionsArtifactStore() {
|
||||
assertSelfHosted()
|
||||
assert(
|
||||
process.env.EDGE_FUNCTIONS_MANAGEMENT_FOLDER,
|
||||
'EDGE_FUNCTIONS_MANAGEMENT_FOLDER is required'
|
||||
)
|
||||
|
||||
return new FileSystemFunctionsArtifactStore(process.env.EDGE_FUNCTIONS_MANAGEMENT_FOLDER)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type FunctionArtifact = {
|
||||
slug: string
|
||||
entrypoint_path: string
|
||||
created_at: number
|
||||
updated_at: number
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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 handleGetAll(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 handleGetAll = async (_req: NextApiRequest, res: NextApiResponse) => {
|
||||
const store = getFunctionsArtifactStore()
|
||||
|
||||
const functionsArtifacts = await store.getFunctions()
|
||||
if (functionsArtifacts.length === 0) return res.status(200).json([])
|
||||
|
||||
const functions = functionsArtifacts.map(
|
||||
(func) =>
|
||||
({
|
||||
id: uuidv4(),
|
||||
slug: func.slug,
|
||||
version: 1,
|
||||
name: func.slug,
|
||||
status: 'ACTIVE',
|
||||
entrypoint_path: func.entrypoint_path,
|
||||
created_at: func.created_at,
|
||||
updated_at: func.updated_at,
|
||||
}) satisfies EdgeFunctionsResponse
|
||||
)
|
||||
|
||||
return res.status(200).json(functions)
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { EdgeFunctionsListItem } from 'components/interfaces/Functions/EdgeFunctionsListItem'
|
||||
import {
|
||||
FunctionsEmptyState,
|
||||
FunctionsEmptyStateLocal,
|
||||
FunctionsInstructionsLocal,
|
||||
} from 'components/interfaces/Functions/FunctionsEmptyState'
|
||||
import { TerminalInstructionsDialog } from 'components/interfaces/Functions/TerminalInstructionsDialog'
|
||||
import DefaultLayout from 'components/layouts/DefaultLayout'
|
||||
@@ -25,6 +25,7 @@ import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query'
|
||||
import { DOCS_URL, IS_PLATFORM } from 'lib/constants'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
import { Button, Card, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
import { PageContainer } from 'ui-patterns/PageContainer'
|
||||
import {
|
||||
@@ -84,84 +85,90 @@ const EdgeFunctionsPage: NextPageWithLayout = () => {
|
||||
<PageContainer size="large">
|
||||
<PageSection>
|
||||
<PageSectionContent>
|
||||
{IS_PLATFORM ? (
|
||||
<>
|
||||
{isLoading && <GenericSkeletonLoader />}
|
||||
{isError && <AlertError error={error} subject="Failed to retrieve edge functions" />}
|
||||
{isSuccess && (
|
||||
<>
|
||||
{hasFunctions ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-6">
|
||||
{isLoading && <GenericSkeletonLoader />}
|
||||
{isError &&
|
||||
(IS_PLATFORM ? (
|
||||
<AlertError error={error} subject="Failed to retrieve edge functions" />
|
||||
) : (
|
||||
<Admonition type="warning" title="Failed to retrieve edge functions">
|
||||
<p className="prose [&>code]:text-xs text-sm">
|
||||
Local functions can be found at <code>supabase/functions</code> folder.
|
||||
</p>
|
||||
</Admonition>
|
||||
))}
|
||||
{isSuccess && (
|
||||
<>
|
||||
{hasFunctions ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder="Search function names"
|
||||
icon={<Search />}
|
||||
size="tiny"
|
||||
className="w-32 md:w-64"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
actions={[
|
||||
search && (
|
||||
<Button
|
||||
size="tiny"
|
||||
type="text"
|
||||
icon={<X />}
|
||||
onClick={() => setSearch('')}
|
||||
className="p-0 h-5 w-5"
|
||||
/>
|
||||
),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<EdgeFunctionsSortDropdown value={sort} onChange={setSortQueryParam} />
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder="Search function names"
|
||||
icon={<Search />}
|
||||
size="tiny"
|
||||
className="w-32 md:w-64"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
actions={[
|
||||
search && (
|
||||
<Button
|
||||
size="tiny"
|
||||
type="text"
|
||||
icon={<X />}
|
||||
onClick={() => setSearch('')}
|
||||
className="p-0 h-5 w-5"
|
||||
/>
|
||||
),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>URL</TableHead>
|
||||
<TableHead className="hidden 2xl:table-cell">Created</TableHead>
|
||||
<TableHead className="lg:table-cell">Updated</TableHead>
|
||||
<TableHead className="lg:table-cell">Deployments</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
<>
|
||||
{filteredFunctions.length > 0 ? (
|
||||
filteredFunctions.map((item) => (
|
||||
<EdgeFunctionsListItem key={item.id} function={item} />
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5}>
|
||||
<p className="text-sm text-foreground">No results found</p>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Your search for "{search}" did not return any results
|
||||
</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
<div className="flex items-center gap-2">
|
||||
<EdgeFunctionsSortDropdown value={sort} onChange={setSortQueryParam} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<FunctionsEmptyState />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<FunctionsEmptyStateLocal />
|
||||
)}
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>URL</TableHead>
|
||||
<TableHead className="hidden 2xl:table-cell">Created</TableHead>
|
||||
<TableHead className="lg:table-cell">Updated</TableHead>
|
||||
<TableHead className="lg:table-cell">Deployments</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
<>
|
||||
{filteredFunctions.length > 0 ? (
|
||||
filteredFunctions.map((item) => (
|
||||
<EdgeFunctionsListItem key={item.id} function={item} />
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5}>
|
||||
<p className="text-sm text-foreground">No results found</p>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Your search for "{search}" did not return any results
|
||||
</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<FunctionsEmptyState />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!IS_PLATFORM && <FunctionsInstructionsLocal />}
|
||||
</div>
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
</PageContainer>
|
||||
|
||||
@@ -60,8 +60,10 @@ services:
|
||||
# Uncomment to use Big Query backend for analytics
|
||||
# NEXT_ANALYTICS_BACKEND_PROVIDER: bigquery
|
||||
SNIPPETS_MANAGEMENT_FOLDER: /app/snippets
|
||||
EDGE_FUNCTIONS_MANAGEMENT_FOLDER: /app/edge-functions
|
||||
volumes:
|
||||
- ./volumes/snippets:/app/snippets:Z
|
||||
- ./volumes/functions:/app/edge-functions:Z
|
||||
|
||||
kong:
|
||||
container_name: supabase-kong
|
||||
|
||||
@@ -31,6 +31,7 @@ const defaultEnv = {
|
||||
NEXT_PUBLIC_HCAPTCHA_SITE_KEY: '10000000-ffff-ffff-ffff-000000000001',
|
||||
NEXT_PUBLIC_NODE_ENV: 'test',
|
||||
SNIPPETS_MANAGEMENT_FOLDER: '../../supabase/snippets',
|
||||
EDGE_FUNCTIONS_MANAGEMENT_FOLDER: '../../supabase/functions' // path relative to studio project
|
||||
}
|
||||
|
||||
const environment = { ...generatedEnv, ...defaultEnv }
|
||||
|
||||
+50
-14
@@ -3,11 +3,18 @@
|
||||
"ui": "stream",
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["dist/**", ".next/**"]
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"outputs": [
|
||||
"dist/**",
|
||||
".next/**"
|
||||
]
|
||||
},
|
||||
"cms#build": {
|
||||
"dependsOn": ["^build"],
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"env": [
|
||||
"BLOG_APP_URL",
|
||||
"CRON_SECRET",
|
||||
@@ -25,10 +32,15 @@
|
||||
"S3_ENDPOINT",
|
||||
"VERCEL_PROJECT_PRODUCTION_URL"
|
||||
],
|
||||
"outputs": [".next/**", "!.next/cache/**"]
|
||||
"outputs": [
|
||||
".next/**",
|
||||
"!.next/cache/**"
|
||||
]
|
||||
},
|
||||
"design-system#build": {
|
||||
"dependsOn": ["^build"],
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"env": [
|
||||
"NEXT_PUBLIC_BASE_PATH",
|
||||
"NEXT_PUBLIC_APP_URL",
|
||||
@@ -41,10 +53,16 @@
|
||||
"NEXT_PUBLIC_GOTRUE_URL",
|
||||
"NEXT_PUBLIC_SUPABASE_ANON_KEY"
|
||||
],
|
||||
"outputs": [".next/**", "!.next/cache/**", ".contentlayer/**"]
|
||||
"outputs": [
|
||||
".next/**",
|
||||
"!.next/cache/**",
|
||||
".contentlayer/**"
|
||||
]
|
||||
},
|
||||
"studio#build": {
|
||||
"dependsOn": ["^build"],
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"env": [
|
||||
"ANALYZE",
|
||||
"NEXT_PUBLIC_SUPPORT_API_URL",
|
||||
@@ -133,12 +151,18 @@
|
||||
"passThroughEnv": [
|
||||
"CURRENT_CLI_VERSION",
|
||||
"VERCEL_GIT_COMMIT_SHA",
|
||||
"SNIPPETS_MANAGEMENT_FOLDER"
|
||||
"SNIPPETS_MANAGEMENT_FOLDER",
|
||||
"EDGE_FUNCTIONS_MANAGEMENT_FOLDER"
|
||||
],
|
||||
"outputs": [".next/**", "!.next/cache/**"]
|
||||
"outputs": [
|
||||
".next/**",
|
||||
"!.next/cache/**"
|
||||
]
|
||||
},
|
||||
"www#build": {
|
||||
"dependsOn": ["^build"],
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"env": [
|
||||
"ANALYZE",
|
||||
"CI",
|
||||
@@ -210,10 +234,16 @@
|
||||
"CUSTOMERIO_API_KEY",
|
||||
"CUSTOMERIO_APP_API_KEY"
|
||||
],
|
||||
"outputs": [".next/**", "!.next/cache/**", ".contentlayer/**"]
|
||||
"outputs": [
|
||||
".next/**",
|
||||
"!.next/cache/**",
|
||||
".contentlayer/**"
|
||||
]
|
||||
},
|
||||
"ui-library#build": {
|
||||
"dependsOn": ["^build"],
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"env": [
|
||||
"NEXT_PUBLIC_BASE_PATH",
|
||||
"NEXT_PUBLIC_APP_URL",
|
||||
@@ -235,7 +265,11 @@
|
||||
"SUPABASE_MANAGEMENT_API_TOKEN",
|
||||
"OPENAI_API_KEY"
|
||||
],
|
||||
"outputs": [".next/**", "!.next/cache/**", ".contentlayer/**"]
|
||||
"outputs": [
|
||||
".next/**",
|
||||
"!.next/cache/**",
|
||||
".contentlayer/**"
|
||||
]
|
||||
},
|
||||
"lint": {
|
||||
"outputs": []
|
||||
@@ -251,7 +285,9 @@
|
||||
"cache": false
|
||||
},
|
||||
"typecheck": {
|
||||
"dependsOn": ["^typecheck"],
|
||||
"dependsOn": [
|
||||
"^typecheck"
|
||||
],
|
||||
"outputs": []
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user