Merge branch 'master' into feat/update-auth-settings
No files matched your search
@@ -5,7 +5,8 @@ import type { HTMLAttributes } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { useBreakpoint } from 'common'
|
||||
import { cn, Popover_Shadcn_, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_ } from 'ui'
|
||||
import { cn, Separator, Sheet, SheetContent, SheetHeader, SheetTrigger } from 'ui'
|
||||
import { Feedback } from '~/components/Feedback'
|
||||
|
||||
interface TocItem extends HTMLAttributes<HTMLElement> {
|
||||
label: string
|
||||
@@ -18,7 +19,7 @@ export function ContributingToc({ className }: { className?: string }) {
|
||||
|
||||
useEffect(() => {
|
||||
const headings = [
|
||||
...document.querySelectorAll('article.prose > h2,h3'),
|
||||
...document.querySelectorAll('article.prose > h2,h3:not(#feedback-title)'),
|
||||
] as Array<HTMLHeadingElement>
|
||||
const tocItems = headings
|
||||
.filter((heading) => !!heading.id)
|
||||
@@ -30,20 +31,16 @@ export function ContributingToc({ className }: { className?: string }) {
|
||||
}, [])
|
||||
|
||||
return mobileToc ? (
|
||||
<MobileToc
|
||||
items={tocItems}
|
||||
className={cn(
|
||||
'[--local-top-spacing:2rem]',
|
||||
'fixed top-[calc(var(--header-height)+var(--local-top-spacing))] right-8'
|
||||
)}
|
||||
/>
|
||||
<MobileToc items={tocItems} />
|
||||
) : (
|
||||
<TocBase
|
||||
items={tocItems}
|
||||
className={cn(
|
||||
'[--local-top-spacing:5rem]',
|
||||
'fixed top-[calc(var(--header-height)+var(--local-top-spacing))] right-8 w-36',
|
||||
'xl:border-l border-foreground-muted'
|
||||
'border-l thin-scrollbar overflow-y-auto px-2 hidden lg:block',
|
||||
'col-span-3 self-start sticky',
|
||||
'top-[calc(var(--header-height)+1px+2rem)] max-h-[calc(100vh-var(--header-height)-3rem)]',
|
||||
className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
@@ -52,31 +49,74 @@ export function ContributingToc({ className }: { className?: string }) {
|
||||
function MobileToc({ items, className }: { items: Array<TocItem>; className?: string }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const onHashChanged = () => setOpen(false)
|
||||
|
||||
window.addEventListener('hashchange', onHashChanged)
|
||||
return () => {
|
||||
window.removeEventListener('hashchange', onHashChanged)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Popover_Shadcn_ open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger_Shadcn_ className={cn('border rounded p-2', className)}>
|
||||
<Menu />
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger
|
||||
className={cn(
|
||||
'fixed z-0 inset-0 top-auto w-full rounded-t-lg border border-b-0 p-4 bg-studio flex items-center gap-2 text-foreground-light text-sm',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Menu size={16} strokeWidth={1.5} className="text-foreground-light" />
|
||||
<span className="sr-only">
|
||||
{open ? 'Close table of contents' : 'Open table of contents'}
|
||||
</span>
|
||||
</PopoverTrigger_Shadcn_>
|
||||
<PopoverContent_Shadcn_ align="end" className="w-48">
|
||||
<TocBase items={items} />
|
||||
</PopoverContent_Shadcn_>
|
||||
</Popover_Shadcn_>
|
||||
<span>On this page</span>
|
||||
</SheetTrigger>
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
size="lg"
|
||||
className={cn(
|
||||
'w-full flex flex-col gap-0 p-0 rounded-t-lg overflow-hidden',
|
||||
!open && 'top-[calc(100vh-100px)]'
|
||||
)}
|
||||
>
|
||||
<SheetHeader className="py-0 px-4">
|
||||
<SheetTrigger
|
||||
className={cn(
|
||||
'w-full py-4 flex items-center gap-2 text-foreground-light text-sm',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Menu size={16} strokeWidth={1.5} className="text-foreground-light" />
|
||||
<span className="sr-only">
|
||||
{open ? 'Close table of contents' : 'Open table of contents'}
|
||||
</span>
|
||||
<span>On this page</span>
|
||||
</SheetTrigger>
|
||||
</SheetHeader>
|
||||
<div className="w-full flex-1 p-4 pb-8 overflow-y-auto thin-scrollbar">
|
||||
<TocBase items={items} />
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
function TocBase({ items, className }: { items: Array<TocItem>; className?: string }) {
|
||||
return (
|
||||
<nav aria-label="Table of contents" className={cn('px-4 text-foreground-light', className)}>
|
||||
<ul className="flex flex-col gap-1">
|
||||
<nav aria-label="Table of contents" className={cn('text-foreground-lighter', className)}>
|
||||
<span className="hidden lg:block font-mono text-xs uppercase text-foreground px-5 mb-6">
|
||||
On this page
|
||||
</span>
|
||||
<ul className="toc-menu list-none lg:pl-5 text-[0.8rem] grid gap-2">
|
||||
{items.map((item) => (
|
||||
<li key={item.anchor} className="overflow-hidden truncate">
|
||||
<a href={`#${item.anchor}`}>{item.label}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Separator className="lg:w-[calc(100%-2rem)] lg:ml-5 my-4 lg:my-8" />
|
||||
<Feedback className="pl-0 lg:pl-5" />
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -5,22 +5,30 @@ import { fileURLToPath } from 'node:url'
|
||||
import { ContributingToc } from '~/app/contributing/ContributingToC'
|
||||
import { MDXProviderGuides } from '~/features/docs/GuidesMdx.client'
|
||||
import { MDXRemoteBase } from '~/features/docs/MdxBase'
|
||||
import { LayoutMainContent } from '~/layouts/DefaultLayout'
|
||||
import { SidebarSkeleton } from '~/layouts/MainSkeleton'
|
||||
import Breadcrumbs from '~/components/Breadcrumbs'
|
||||
|
||||
export default async function ContributingPage() {
|
||||
const contentFile = join(dirname(fileURLToPath(import.meta.url)), 'content.mdx')
|
||||
const content = await readFile(contentFile, 'utf-8')
|
||||
|
||||
return (
|
||||
<SidebarSkeleton hideSideNav>
|
||||
<div className="px-8 py-16">
|
||||
<article className="prose mx-auto">
|
||||
<MDXProviderGuides>
|
||||
<MDXRemoteBase source={content} />
|
||||
</MDXProviderGuides>
|
||||
</article>
|
||||
<SidebarSkeleton>
|
||||
<LayoutMainContent className="pb-0 grid grid-cols-12 relative gap-4">
|
||||
<div className="col-span-12 lg:col-span-9">
|
||||
<Breadcrumbs className="mb-2 col-span-full" />
|
||||
<article
|
||||
id="contributing"
|
||||
className="prose max-w-none relative transition-all ease-out duration-100"
|
||||
>
|
||||
<MDXProviderGuides>
|
||||
<MDXRemoteBase source={content} />
|
||||
</MDXProviderGuides>
|
||||
</article>
|
||||
</div>
|
||||
<ContributingToc />
|
||||
</div>
|
||||
</LayoutMainContent>
|
||||
</SidebarSkeleton>
|
||||
)
|
||||
}
|
||||
@@ -33,9 +33,9 @@ const FeedbackButton = forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'mt-0',
|
||||
'flex items-center gap-2',
|
||||
'flex items-center gap-1',
|
||||
'text-xs text-foreground-lighter',
|
||||
'hover:text-foreground',
|
||||
'hover:text-foreground text-left',
|
||||
!visible && 'opacity-0 invisible',
|
||||
'[transition-property:opacity,color]',
|
||||
'[transition-delay:700ms,0ms]'
|
||||
@@ -141,72 +141,83 @@ function Feedback({ className }: { className?: string }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={cn('@container px-5 mb-6', className)} aria-labelledby="feedback-title">
|
||||
<h3 id="feedback-title" className="block font-mono text-xs uppercase text-foreground mb-4">
|
||||
<section className={cn('@container', className)} aria-labelledby="feedback-title">
|
||||
<h3 id="feedback-title" className="block font-mono text-xs text-foreground-light mb-3">
|
||||
Is this helpful?
|
||||
</h3>
|
||||
<div className="relative flex flex-col gap-y-4 mb-2 @[12rem]:flex-row @[12rem]:items-center">
|
||||
<div className="relative flex flex-col gap-2 @[12rem]:gap-4 @[12rem]:flex-row @[12rem]:items-center">
|
||||
<div
|
||||
style={{ '--container-flex-gap': '0.5rem' } as CSSProperties}
|
||||
className={`relative flex gap-[var(--container-flex-gap)] items-center`}
|
||||
className="relative flex gap-2 items-center"
|
||||
>
|
||||
<Button
|
||||
type="outline"
|
||||
rounded
|
||||
className={cn(
|
||||
'px-1',
|
||||
!isYes && 'hover:text-brand-600 hover:border-brand-500',
|
||||
isYes && 'bg-brand text-brand-200 !border-brand disabled:opacity-100',
|
||||
!showYes && 'opacity-0 invisible',
|
||||
'[transition-property:opacity,transform,color] [transition-duration:150ms,300ms,300ms]',
|
||||
'motion-reduce:[transition-duration:150ms,1ms,300ms]'
|
||||
)}
|
||||
onClick={() => handleVote('yes')}
|
||||
disabled={state.type === StateType.Followup}
|
||||
>
|
||||
<Check size={14} strokeWidth={3} />
|
||||
<span className="sr-only">Yes</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="outline"
|
||||
rounded
|
||||
className={cn(
|
||||
'px-1',
|
||||
'px-1 w-7 h-7',
|
||||
'text-foreground-light',
|
||||
'[transition-property:opacity,transform,color] [transition-duration:150ms,250ms,250ms]',
|
||||
'motion-reduce:[transition-duration:150ms,1ms,300ms]',
|
||||
'[transition-timing-function:cubic-bezier(.76,0,.23,1)]',
|
||||
!isNo && 'hover:text-warning-600 hover:border-warning-500',
|
||||
isNo &&
|
||||
`bg-warning text-warning-200 !border-warning -translate-x-[calc(100%+var(--container-flex-gap,0.5rem))] disabled:opacity-100`,
|
||||
!showNo && 'opacity-0 invisible',
|
||||
'[transition-property:opacity,transform,color] [transition-duration:150ms,300ms,300ms]',
|
||||
'motion-reduce:[transition-duration:150ms,1ms,300ms]'
|
||||
isNo && `bg-warning text-warning-200 !border-warning disabled:opacity-100`,
|
||||
!showNo && 'opacity-0 invisible'
|
||||
)}
|
||||
onClick={() => handleVote('no')}
|
||||
disabled={state.type === StateType.Followup}
|
||||
>
|
||||
<X size={14} strokeWidth={3} />
|
||||
<X size={14} strokeWidth={2} className="text-current" />
|
||||
<span className="sr-only">No</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="outline"
|
||||
rounded
|
||||
className={cn(
|
||||
'px-1 w-7 h-7',
|
||||
'text-foreground-light',
|
||||
'[transition-property:opacity,transform,color] [transition-duration:150ms,250ms,250ms]',
|
||||
'motion-reduce:[transition-duration:150ms,1ms,300ms]',
|
||||
'[transition-timing-function:cubic-bezier(.76,0,.23,1)]',
|
||||
!isYes && 'hover:text-brand-600 hover:border-brand-500',
|
||||
isYes &&
|
||||
'bg-brand text-brand-200 !border-brand disabled:opacity-100 -translate-x-[calc(100%+var(--container-inline-flex-gap,0.5rem))]',
|
||||
!showYes && 'opacity-0 invisible'
|
||||
)}
|
||||
onClick={() => handleVote('yes')}
|
||||
disabled={state.type === StateType.Followup}
|
||||
>
|
||||
<Check size={14} strokeWidth={2} />
|
||||
<span className="sr-only">Yes</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-1',
|
||||
'flex flex-col gap-0.5',
|
||||
'@[12rem]:absolute @[12rem]:left-9',
|
||||
'text-xs',
|
||||
'opacity-0 invisible',
|
||||
'text-left',
|
||||
'-translate-x-[0.25rem] @[12rem]:-translate-x-[1.25rem]',
|
||||
'[transition-property:opacity,transform] [transition-duration:150ms,300ms]',
|
||||
'-translate-x-2',
|
||||
'[transition-property:opacity,transform]',
|
||||
'[transition-duration:450ms,300ms]',
|
||||
'[transition-delay:200ms,0ms]',
|
||||
'[transition-timing-function:cubic-bezier(.76,0,.23,1)]',
|
||||
'motion-reduce:[transition-duration:150ms,1ms]',
|
||||
'delay-300',
|
||||
state.type === StateType.Followup &&
|
||||
'opacity-100 visible -translate-x-0 @[12rem]:-translate-x-[1rem]'
|
||||
'!ease-out',
|
||||
state.type === StateType.Followup && 'opacity-100 visible -translate-x-0'
|
||||
)}
|
||||
>
|
||||
<span className="text-foreground-light">Thanks for your feedback!</span>
|
||||
<FeedbackButton
|
||||
ref={feedbackButtonRef}
|
||||
onClick={() => setModalOpen(true)}
|
||||
isYes={isYes}
|
||||
visible={!unanswered}
|
||||
/>
|
||||
{state.type === StateType.Followup && (
|
||||
<>
|
||||
<span className="text-foreground-light">Thanks for your feedback!</span>
|
||||
<FeedbackButton
|
||||
ref={feedbackButtonRef}
|
||||
onClick={() => setModalOpen(true)}
|
||||
isYes={isYes}
|
||||
visible={true}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<FeedbackModal
|
||||
|
||||
@@ -131,16 +131,25 @@ const GuidesTableOfContents = ({
|
||||
const tocVideoPreview = `https://img.youtube.com/vi/${video}/0.jpg`
|
||||
|
||||
return (
|
||||
<div className={cn('border-l', 'thin-scrollbar overflow-y-auto', 'px-2', className)}>
|
||||
<div
|
||||
className={cn(
|
||||
'border-l flex flex-col gap-6 lg:gap-8',
|
||||
'thin-scrollbar overflow-y-auto',
|
||||
'px-2',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{video && (
|
||||
<div className="relative mb-6 pl-5">
|
||||
<div className="relative pl-5">
|
||||
<ExpandableVideo imgUrl={tocVideoPreview} videoId={video} />
|
||||
</div>
|
||||
)}
|
||||
<Feedback key={pathname} />
|
||||
<div className="pl-5">
|
||||
<Feedback key={pathname} />
|
||||
</div>
|
||||
{displayedList.length > 0 && (
|
||||
<>
|
||||
<span className="block font-mono text-xs uppercase text-foreground px-5 mb-6">
|
||||
<div>
|
||||
<span className="block font-mono text-xs uppercase text-foreground px-5 mb-3">
|
||||
On this page
|
||||
</span>
|
||||
<ul className="toc-menu list-none pl-5 text-[0.8rem] grid gap-2">
|
||||
@@ -163,7 +172,7 @@ const GuidesTableOfContents = ({
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1953,6 +1953,13 @@ export const local_development: NavMenuConstant = {
|
||||
],
|
||||
}
|
||||
|
||||
export const contributing: NavMenuConstant = {
|
||||
icon: 'contributing',
|
||||
title: 'Contributing',
|
||||
url: '/contributing',
|
||||
items: [{ name: 'Overview', url: '/contributing' }],
|
||||
}
|
||||
|
||||
export const MIGRATION_PAGES: Partial<NavMenuSection & ComponentProps<typeof IconPanel>>[] = [
|
||||
{
|
||||
name: 'Auth0',
|
||||
|
||||
@@ -24,6 +24,7 @@ enum MenuId {
|
||||
SelfHosting = 'self_hosting',
|
||||
Integrations = 'integrations',
|
||||
LocalDevelopment = 'local_development',
|
||||
Contributing = 'contributing',
|
||||
RefJavaScriptV1 = 'reference_javascript_v1',
|
||||
RefJavaScriptV2 = 'reference_javascript_v2',
|
||||
RefDartV1 = 'reference_dart_v1',
|
||||
@@ -128,6 +129,10 @@ const menus: Menu[] = [
|
||||
id: MenuId.LocalDevelopment,
|
||||
type: 'guide',
|
||||
},
|
||||
{
|
||||
id: MenuId.Contributing,
|
||||
type: 'guide',
|
||||
},
|
||||
{
|
||||
id: MenuId.Deployment,
|
||||
type: 'guide',
|
||||
|
||||
@@ -139,6 +139,8 @@ export const getMenuId = (pathname: string | null) => {
|
||||
return MenuId.SelfHosting
|
||||
case pathname.startsWith('storage'):
|
||||
return MenuId.Storage
|
||||
case pathname.startsWith('/contributing'):
|
||||
return MenuId.Contributing
|
||||
default:
|
||||
return MenuId.GettingStarted
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ The session mode connection string connects to your Postgres instance via a prox
|
||||
The connection string looks like this:
|
||||
|
||||
```
|
||||
postgres://postgres.apbkobhfnmcqqzqeeqss:[YOUR-PASSWORD]@aws-0-ca-central-1.pooler.supabase.com:5432/postgres
|
||||
postgres://postgres.apbkobhfnmcqqzqeeqss:[YOUR-PASSWORD]@aws-0-[REGION].pooler.supabase.com:5432/postgres
|
||||
```
|
||||
|
||||
Get your project's session mode string from the [Database Settings](/dashboard/project/_/settings/database) page:
|
||||
@@ -112,7 +112,7 @@ Transaction mode does not support [prepared statements](https://postgresql.org/d
|
||||
The connection string looks like this:
|
||||
|
||||
```
|
||||
postgres://postgres.apbkobhfnmcqqzqeeqss:[YOUR-PASSWORD]@aws-0-ca-central-1.pooler.supabase.com:6543/postgres
|
||||
postgres://postgres.apbkobhfnmcqqzqeeqss:[YOUR-PASSWORD]@aws-0-[REGION].pooler.supabase.com:6543/postgres
|
||||
```
|
||||
|
||||
Get your project's transaction mode string from the [Database Settings](/dashboard/project/_/settings/database) page:
|
||||
|
||||
@@ -24,7 +24,7 @@ enable row level security;
|
||||
|
||||
</Admonition>
|
||||
|
||||
RLS is incredibly powerful and flexible, allowing you to write complex SQL rules that fit your unique business needs. RLS can be [combined with Supabase Auth](/docs/guides/database/postgres/row-level-security) for end-to-end user security from the browser to the database.
|
||||
RLS is incredibly powerful and flexible, allowing you to write complex SQL rules that fit your unique business needs. RLS can be combined with [Supabase Auth](/docs/guides/auth) for end-to-end user security from the browser to the database.
|
||||
|
||||
RLS is a Postgres primitive and can provide "[defense in depth](<https://en.wikipedia.org/wiki/Defense_in_depth_(computing)>)" to protect your data from malicious actors even when accessed through third-party tooling.
|
||||
|
||||
|
||||
@@ -12,6 +12,10 @@ sdk = [ "auth-getuser" ]
|
||||
[[errors]]
|
||||
http_status_code = 401
|
||||
message = "invalid claim: missing sub"
|
||||
|
||||
[[errors]]
|
||||
http_status_code = 403
|
||||
message = "invalid claim: missing sub"
|
||||
---
|
||||
|
||||
The missing sub claim error is returned when `supabase.auth.getUser()` is called with an invalid JWT in the session or when the user attempts to register/sign in but hasn't completed the sign in when the `getUser` call is made.
|
||||
|
||||
@@ -115,7 +115,7 @@ const GuideTemplate = ({ meta, content, children, editLink, mdxOptions }: GuideT
|
||||
<GuidesTableOfContents
|
||||
video={meta?.tocVideo}
|
||||
className={cn(
|
||||
'hidden md:block',
|
||||
'hidden md:flex',
|
||||
'col-span-3 self-start',
|
||||
'sticky',
|
||||
/**
|
||||
|
||||
@@ -94,7 +94,7 @@ export default async function TroubleshootingPage({ entry }: { entry: ITroublesh
|
||||
<hr className="my-6" aria-hidden />
|
||||
</>
|
||||
)}
|
||||
<Feedback className="px-0" />
|
||||
<Feedback className="px-0 mb-6 lg:mb-8" />
|
||||
</aside>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -158,7 +158,12 @@ export async function getAllTroubleshootingEntriesInternal() {
|
||||
].includes(child.type)
|
||||
)
|
||||
}
|
||||
|
||||
if (node.type === 'link' || node.type === 'image') {
|
||||
canonicalizeUrl(node)
|
||||
}
|
||||
})
|
||||
|
||||
const contentWithoutJsx = toMarkdown(mdxTree, {
|
||||
extensions: [gfmToMarkdown()],
|
||||
})
|
||||
@@ -174,6 +179,16 @@ export async function getAllTroubleshootingEntriesInternal() {
|
||||
return (await Promise.all(troubleshootingFiles)).filter((x) => x != null)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('mdast').Image | import('mdast').Link} node
|
||||
*/
|
||||
function canonicalizeUrl(node) {
|
||||
if (node.url.startsWith('/')) {
|
||||
node.url === 'https://supabase.com' + node.url
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {TroubleshootingEntry} entry
|
||||
*/
|
||||
|
||||
@@ -82,6 +82,10 @@ const levelsData = {
|
||||
icon: 'platform',
|
||||
name: 'Platform',
|
||||
},
|
||||
contributing: {
|
||||
icon: 'contributing',
|
||||
name: 'Contributing',
|
||||
},
|
||||
resources: {
|
||||
icon: 'resources',
|
||||
name: 'Resources',
|
||||
@@ -377,6 +381,8 @@ function SidebarSkeleton({
|
||||
|
||||
const mobileMenuOpen = useMenuMobileOpen()
|
||||
|
||||
console.log('menuName', menuName, menuId)
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-row h-full relative', className)}>
|
||||
{!hideSideNav && (
|
||||
|
||||
@@ -74,7 +74,7 @@ const nextConfig = {
|
||||
headers: [
|
||||
{
|
||||
key: 'Strict-Transport-Security',
|
||||
value: process.env.VERCEL === '1' ? 'max-age=31536000' : '',
|
||||
value: process.env.VERCEL === '1' ? 'max-age=1200; includeSubDomains' : '',
|
||||
},
|
||||
{
|
||||
key: 'X-Robots-Tag',
|
||||
@@ -97,7 +97,7 @@ const nextConfig = {
|
||||
headers: [
|
||||
{
|
||||
key: 'Strict-Transport-Security',
|
||||
value: process.env.VERCEL === '1' ? 'max-age=31536000' : '',
|
||||
value: process.env.VERCEL === '1' ? 'max-age=1200; includeSubDomains' : '',
|
||||
},
|
||||
{
|
||||
key: 'X-Robots-Tag',
|
||||
|
||||
@@ -26,6 +26,8 @@ export const billingPartnerLabel = (billingPartner?: string) => {
|
||||
return 'Fly.io'
|
||||
case 'aws':
|
||||
return 'AWS'
|
||||
case 'vercel_marketplace':
|
||||
return 'Vercel'
|
||||
default:
|
||||
return billingPartner
|
||||
}
|
||||
|
||||
@@ -342,10 +342,31 @@ let { data: ${resourceId}, error } = await supabase
|
||||
bash: {
|
||||
language: 'bash',
|
||||
code: `
|
||||
curl '${endpoint}/rest/v1/${resourceId}?id=eq.1&select=*' \\
|
||||
curl --get '${endpoint}/rest/v1/${resourceId}' \\
|
||||
-H "apikey: ${apiKey}" \\
|
||||
-H "Authorization: Bearer ${apiKey}" \\
|
||||
-H "Range: 0-9"
|
||||
-H "Range: 0-9" \\
|
||||
-d "select=*" \\
|
||||
\\
|
||||
\`# Filters\` \\
|
||||
-d "column=eq.Equal+to" \\
|
||||
-d "column=gt.Greater+than" \\
|
||||
-d "column=lt.Less+than" \\
|
||||
-d "column=gte.Greater+than+or+equal+to" \\
|
||||
-d "column=lte.Less+than+or+equal+to" \\
|
||||
-d "column=like.*CaseSensitive*" \\
|
||||
-d "column=ilike.*CaseInsensitive*" \\
|
||||
-d "column=is.null" \\
|
||||
-d "column=in.(Array,Values)" \\
|
||||
-d "column=neq.Not+equal+to" \\
|
||||
\\
|
||||
\`# Arrays\` \\
|
||||
-d "array_column=cs.{array,contains}" \\
|
||||
-d "array_column=cd.{contained,by}" \\
|
||||
\\
|
||||
\`# Logical operators\` \\
|
||||
-d "column=not.like.Negate+filter" \\
|
||||
-d "or=(some_column.eq.Some+value,other_column.eq.Other+value)"
|
||||
`,
|
||||
},
|
||||
js: {
|
||||
@@ -354,6 +375,7 @@ curl '${endpoint}/rest/v1/${resourceId}?id=eq.1&select=*' \\
|
||||
let { data: ${resourceId}, error } = await supabase
|
||||
.from('${resourceId}')
|
||||
.select("*")
|
||||
|
||||
// Filters
|
||||
.eq('column', 'Equal to')
|
||||
.gt('column', 'Greater than')
|
||||
@@ -365,9 +387,14 @@ let { data: ${resourceId}, error } = await supabase
|
||||
.is('column', null)
|
||||
.in('column', ['Array', 'Values'])
|
||||
.neq('column', 'Not equal to')
|
||||
|
||||
// Arrays
|
||||
.contains('array_column', ['array', 'contains'])
|
||||
.containedBy('array_column', ['contained', 'by'])
|
||||
|
||||
// Logical operators
|
||||
.not('column', 'like', 'Negate filter')
|
||||
.or('some_column.eq.Some value, other_column.eq.Other value')
|
||||
`,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
interface InvocationTab {
|
||||
id: string
|
||||
label: string
|
||||
language: 'bash' | 'js' | 'ts' | 'dart' | 'python'
|
||||
hideLineNumbers?: boolean
|
||||
code: (functionUrl: string, functionName: string, apiKey: string) => string
|
||||
}
|
||||
|
||||
export const INVOCATION_TABS: InvocationTab[] = [
|
||||
{
|
||||
id: 'curl',
|
||||
label: 'cURL',
|
||||
language: 'bash',
|
||||
code: (functionUrl, _, apiKey) => `curl -L -X POST '${functionUrl}' \\
|
||||
-H 'Authorization: Bearer ${apiKey}' \\
|
||||
-H 'Content-Type: application/json' \\
|
||||
--data '{"name":"Functions"}'`,
|
||||
},
|
||||
{
|
||||
id: 'supabase-js',
|
||||
label: 'JavaScript',
|
||||
language: 'js',
|
||||
hideLineNumbers: true,
|
||||
code: (_, functionName) => `import { createClient } from '@supabase/supabase-js'
|
||||
const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_ANON_KEY)
|
||||
const { data, error } = await supabase.functions.invoke('${functionName}', {
|
||||
body: { name: 'Functions' },
|
||||
})`,
|
||||
},
|
||||
{
|
||||
id: 'swift',
|
||||
label: 'Swift',
|
||||
language: 'ts',
|
||||
hideLineNumbers: true,
|
||||
code: (_, functionName) => `struct Response: Decodable {
|
||||
// Expected response definition
|
||||
}
|
||||
|
||||
let response: Response = try await supabase.functions
|
||||
.invoke(
|
||||
"${functionName}",
|
||||
options: FunctionInvokeOptions(
|
||||
body: ["name": "Functions"]
|
||||
)
|
||||
)`,
|
||||
},
|
||||
{
|
||||
id: 'flutter',
|
||||
label: 'Flutter',
|
||||
language: 'dart',
|
||||
hideLineNumbers: true,
|
||||
code: (
|
||||
_,
|
||||
functionName
|
||||
) => `final res = await supabase.functions.invoke('${functionName}', body: {'name': 'Functions'});
|
||||
final data = res.data;`,
|
||||
},
|
||||
{
|
||||
id: 'python',
|
||||
label: 'Python',
|
||||
language: 'python',
|
||||
hideLineNumbers: true,
|
||||
code: (_, functionName) => `response = supabase.functions.invoke(
|
||||
"${functionName}",
|
||||
invoke_options={"body": {"name": "Functions"}}
|
||||
)`,
|
||||
},
|
||||
]
|
||||
@@ -1,17 +1,16 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import dayjs from 'dayjs'
|
||||
import { ExternalLink, Maximize2, Minimize2, Terminal } from 'lucide-react'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
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 { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import { FormActions } from 'components/ui/Forms/FormActions'
|
||||
import { FormHeader } from 'components/ui/Forms/FormHeader'
|
||||
import { FormPanel } from 'components/ui/Forms/FormPanel'
|
||||
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection'
|
||||
import { ScaffoldSection, ScaffoldSectionTitle } from 'components/layouts/Scaffold'
|
||||
import { getAPIKeys, 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'
|
||||
@@ -19,29 +18,50 @@ import { useEdgeFunctionDeleteMutation } from 'data/edge-functions/edge-function
|
||||
import { useEdgeFunctionUpdateMutation } from 'data/edge-functions/edge-functions-update-mutation'
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import {
|
||||
Alert_Shadcn_,
|
||||
AlertDescription_Shadcn_,
|
||||
AlertTitle_Shadcn_,
|
||||
Alert_Shadcn_,
|
||||
Button,
|
||||
CriticalIcon,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Toggle,
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
cn,
|
||||
CodeBlock,
|
||||
CriticalIcon,
|
||||
Form_Shadcn_,
|
||||
FormControl_Shadcn_,
|
||||
FormField_Shadcn_,
|
||||
Input,
|
||||
Input_Shadcn_,
|
||||
Switch,
|
||||
Tabs_Shadcn_ as Tabs,
|
||||
TabsContent_Shadcn_ as TabsContent,
|
||||
TabsList_Shadcn_ as TabsList,
|
||||
TabsTrigger_Shadcn_ as TabsTrigger,
|
||||
} from 'ui'
|
||||
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
|
||||
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
||||
import CommandRender from '../CommandRender'
|
||||
import { INVOCATION_TABS } from './EdgeFunctionDetails.constants'
|
||||
import { generateCLICommands } from './EdgeFunctionDetails.utils'
|
||||
|
||||
const EdgeFunctionDetails = () => {
|
||||
const FormSchema = z.object({
|
||||
name: z.string().min(0, 'Name is required'),
|
||||
verify_jwt: z.boolean(),
|
||||
})
|
||||
|
||||
export const EdgeFunctionDetails = () => {
|
||||
const router = useRouter()
|
||||
const { ref: projectRef, functionSlug } = useParams()
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||
const [showInstructions, setShowInstructions] = useState(true)
|
||||
const canUpdateEdgeFunction = useCheckPermissions(PermissionAction.FUNCTIONS_WRITE, '*')
|
||||
|
||||
const { data: settings } = useProjectSettingsV2Query({ projectRef })
|
||||
const { data: customDomainData } = useCustomDomainsQuery({ projectRef })
|
||||
const { data: selectedFunction } = useEdgeFunctionQuery({ projectRef, slug: functionSlug })
|
||||
|
||||
const { mutate: updateEdgeFunction, isLoading: isUpdating } = useEdgeFunctionUpdateMutation()
|
||||
const { mutate: deleteEdgeFunction, isLoading: isDeleting } = useEdgeFunctionDeleteMutation({
|
||||
onSuccess: () => {
|
||||
@@ -50,8 +70,10 @@ const EdgeFunctionDetails = () => {
|
||||
},
|
||||
})
|
||||
|
||||
const formId = 'edge-function-update-form'
|
||||
const canUpdateEdgeFunction = useCheckPermissions(PermissionAction.FUNCTIONS_WRITE, '*')
|
||||
const form = useForm({
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: { name: '', verify_jwt: false },
|
||||
})
|
||||
|
||||
const { anonKey } = getAPIKeys(settings)
|
||||
const apiKey = anonKey?.api_key ?? '[YOUR ANON KEY]'
|
||||
@@ -62,14 +84,17 @@ const EdgeFunctionDetails = () => {
|
||||
customDomainData?.customDomain?.status === 'active'
|
||||
? `https://${customDomainData.customDomain.hostname}/functions/v1/${selectedFunction?.slug}`
|
||||
: `${protocol}://${endpoint}/functions/v1/${selectedFunction?.slug}`
|
||||
|
||||
const { managementCommands, secretCommands, invokeCommands } = generateCLICommands({
|
||||
const hasImportMap = useMemo(
|
||||
() => selectedFunction?.import_map || selectedFunction?.import_map_path,
|
||||
[selectedFunction]
|
||||
)
|
||||
const { managementCommands } = generateCLICommands({
|
||||
selectedFunction,
|
||||
functionUrl,
|
||||
anonKey: apiKey,
|
||||
})
|
||||
|
||||
const onUpdateFunction = async (values: any, { resetForm }: any) => {
|
||||
const onUpdateFunction: SubmitHandler<z.infer<typeof FormSchema>> = async (values: any) => {
|
||||
if (!projectRef) return console.error('Project ref is required')
|
||||
if (selectedFunction === undefined) return console.error('No edge function selected')
|
||||
|
||||
@@ -81,7 +106,6 @@ const EdgeFunctionDetails = () => {
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
resetForm({ values, initialValues: values })
|
||||
toast.success(`Successfully updated edge function`)
|
||||
},
|
||||
}
|
||||
@@ -94,153 +118,138 @@ const EdgeFunctionDetails = () => {
|
||||
deleteEdgeFunction({ projectRef, slug: selectedFunction.slug })
|
||||
}
|
||||
|
||||
const hasImportMap = useMemo(
|
||||
() => selectedFunction?.import_map || selectedFunction?.import_map_path,
|
||||
[selectedFunction]
|
||||
)
|
||||
useEffect(() => {
|
||||
if (selectedFunction) {
|
||||
form.reset({
|
||||
name: selectedFunction.name,
|
||||
verify_jwt: selectedFunction.verify_jwt,
|
||||
})
|
||||
}
|
||||
}, [selectedFunction])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4 pb-16 pt-3">
|
||||
<Form id={formId} initialValues={{}} onSubmit={onUpdateFunction}>
|
||||
{({ handleReset, values, initialValues, resetForm }: any) => {
|
||||
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
|
||||
|
||||
// [Alaister] although this "technically" is breaking the rules of React hooks
|
||||
// it won't error because the hooks are always rendered in the same order
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useEffect(() => {
|
||||
if (selectedFunction !== undefined) {
|
||||
const formValues = {
|
||||
name: selectedFunction?.name,
|
||||
verify_jwt: selectedFunction?.verify_jwt,
|
||||
}
|
||||
resetForm({ values: formValues, initialValues: formValues })
|
||||
}
|
||||
}, [selectedFunction])
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormPanel
|
||||
disabled={!canUpdateEdgeFunction}
|
||||
footer={
|
||||
<div className="flex py-4 px-8">
|
||||
<FormActions
|
||||
form={formId}
|
||||
isSubmitting={isUpdating}
|
||||
hasChanges={hasChanges}
|
||||
handleReset={handleReset}
|
||||
helper={
|
||||
!canUpdateEdgeFunction
|
||||
? 'You need additional permissions to update this function'
|
||||
: undefined
|
||||
}
|
||||
<div className="mx-auto flex flex-col-reverse 2xl:flex-row gap-8 pb-8">
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<ScaffoldSection isFullWidth className="!pt-0 2xl:first:!pt-12">
|
||||
<ScaffoldSectionTitle className="mb-4">Function Configuration</ScaffoldSectionTitle>
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form onSubmit={form.handleSubmit(onUpdateFunction)}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
label="Name"
|
||||
layout="flex-row-reverse"
|
||||
description="Your slug and endpoint URL will remain the same"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_
|
||||
{...field}
|
||||
className="w-full"
|
||||
disabled={!canUpdateEdgeFunction}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardContent>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="verify_jwt"
|
||||
render={({ field }) => (
|
||||
<FormItemLayout
|
||||
label="Enforce JWT Verification"
|
||||
layout="flex-row-reverse"
|
||||
description="Require a valid JWT in the authorization header when invoking the function"
|
||||
>
|
||||
<FormControl_Shadcn_>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={!canUpdateEdgeFunction}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
</FormItemLayout>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-end space-x-2">
|
||||
{form.formState.isDirty && (
|
||||
<Button type="default" onClick={() => form.reset()}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={isUpdating}
|
||||
disabled={!canUpdateEdgeFunction || !form.formState.isDirty}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
</ScaffoldSection>
|
||||
<ScaffoldSection isFullWidth>
|
||||
<ScaffoldSectionTitle className="mb-4">Invoke function</ScaffoldSectionTitle>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="curl" className="w-full">
|
||||
<TabsList className="flex flex-wrap gap-4">
|
||||
{INVOCATION_TABS.map((tab) => (
|
||||
<TabsTrigger key={tab.id} value={tab.id}>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{INVOCATION_TABS.map((tab) => (
|
||||
<TabsContent key={tab.id} value={tab.id} className="mt-4">
|
||||
<div className="overflow-x-auto">
|
||||
<CodeBlock
|
||||
language={tab.language}
|
||||
hideLineNumbers={tab.hideLineNumbers}
|
||||
className="p-0 text-xs !mt-0 border-none"
|
||||
value={tab.code(functionUrl, selectedFunction?.name ?? '', apiKey)}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<FormSection header={<FormSectionLabel>Function Details</FormSectionLabel>}>
|
||||
<FormSectionContent loading={selectedFunction === undefined}>
|
||||
<Input id="name" name="name" label="Name" disabled={!canUpdateEdgeFunction} />
|
||||
<Input
|
||||
disabled
|
||||
id="slug"
|
||||
name="slug"
|
||||
label="Slug"
|
||||
value={selectedFunction?.slug}
|
||||
/>
|
||||
<Input disabled copy label="Endpoint URL" value={functionUrl} />
|
||||
<Input disabled label="Region" value="All functions are deployed globally" />
|
||||
<Input
|
||||
disabled
|
||||
label="Created at"
|
||||
value={dayjs(selectedFunction?.created_at ?? 0).format(
|
||||
'dddd, MMMM D, YYYY h:mm A'
|
||||
)}
|
||||
/>
|
||||
<Input
|
||||
disabled
|
||||
label="Last updated at"
|
||||
value={dayjs(selectedFunction?.updated_at ?? 0).format(
|
||||
'dddd, MMMM D, YYYY h:mm A'
|
||||
)}
|
||||
/>
|
||||
<Input disabled label="Deployments" value={selectedFunction?.version ?? 0} />
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
<FormSection header={<FormSectionLabel>Function Configuration</FormSectionLabel>}>
|
||||
<FormSectionContent loading={selectedFunction === undefined}>
|
||||
<Toggle
|
||||
id="verify_jwt"
|
||||
name="verify_jwt"
|
||||
disabled={!canUpdateEdgeFunction}
|
||||
label="Enforce JWT Verification"
|
||||
descriptionText="Require a valid JWT in the authorization header when invoking the function"
|
||||
/>
|
||||
<div className="space-y-1 border rounded border-default bg-surface-200 px-4 py-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="text-sm">
|
||||
Import maps are{' '}
|
||||
<span className={cn(hasImportMap ? 'text-brand' : 'text-amber-900')}>
|
||||
{hasImportMap ? 'used' : 'not used'}
|
||||
</span>{' '}
|
||||
for this function
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Import maps allow the use of bare specifiers in functions instead of
|
||||
explicit import URLs
|
||||
</p>
|
||||
<div className="!mt-4">
|
||||
<Button asChild type="default" icon={<ExternalLink strokeWidth={1.5} />}>
|
||||
<Link
|
||||
href="https://supabase.com/docs/guides/functions/dependencies"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
More about import maps
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</FormSectionContent>
|
||||
</FormSection>
|
||||
</FormPanel>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Form>
|
||||
|
||||
<div
|
||||
className="space-y-6 rounded border bg-surface-100 px-6 py-4 drop-shadow-sm transition-all overflow-hidden"
|
||||
style={{ maxHeight: showInstructions ? 800 : 66 }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded border bg-foreground p-2 text-background">
|
||||
<Terminal size={18} strokeWidth={2} />
|
||||
</div>
|
||||
<h4>Deploy, invoke and manage secrets</h4>
|
||||
</div>
|
||||
<div className="cursor-pointer" onClick={() => setShowInstructions(!showInstructions)}>
|
||||
{showInstructions ? (
|
||||
<Minimize2 size={14} strokeWidth={1.5} />
|
||||
) : (
|
||||
<Maximize2 size={14} strokeWidth={1.5} />
|
||||
)}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ScaffoldSection>
|
||||
<ScaffoldSection isFullWidth>
|
||||
<ScaffoldSectionTitle className="mb-4">Develop locally</ScaffoldSectionTitle>
|
||||
<div className="rounded border bg-surface-100 px-6 py-4 drop-shadow-sm">
|
||||
<div className="space-y-6">
|
||||
<CommandRender
|
||||
commands={[
|
||||
{
|
||||
command: `supabase functions download ${selectedFunction?.slug}`,
|
||||
description: 'Download the function to your local machine',
|
||||
jsx: () => (
|
||||
<>
|
||||
<span className="text-brand-600">supabase</span> functions download{' '}
|
||||
{selectedFunction?.slug}
|
||||
</>
|
||||
),
|
||||
comment: '1. Download the function',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<CommandRender commands={[managementCommands[0]]} />
|
||||
<CommandRender commands={[managementCommands[1]]} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5 className="text-base">Deploy your function</h5>
|
||||
<CommandRender commands={managementCommands} />
|
||||
<h5 className="text-base">Invoke your function</h5>
|
||||
<CommandRender commands={invokeCommands} />
|
||||
<h5 className="text-base">Manage secrets</h5>
|
||||
<CommandRender commands={secretCommands} />
|
||||
</div>
|
||||
|
||||
<div className="!mt-8">
|
||||
<FormHeader title="Delete Edge Function" description="" />
|
||||
</ScaffoldSection>
|
||||
<ScaffoldSection isFullWidth>
|
||||
<ScaffoldSectionTitle className="mb-4">Delete function</ScaffoldSectionTitle>
|
||||
<Alert_Shadcn_ variant="destructive">
|
||||
<CriticalIcon />
|
||||
<AlertTitle_Shadcn_>
|
||||
@@ -250,48 +259,109 @@ const EdgeFunctionDetails = () => {
|
||||
Make sure you have made a backup if you want to restore your edge function
|
||||
</AlertDescription_Shadcn_>
|
||||
<AlertDescription_Shadcn_ className="mt-3">
|
||||
<ButtonTooltip
|
||||
<Button
|
||||
type="danger"
|
||||
disabled={!canUpdateEdgeFunction}
|
||||
loading={selectedFunction?.id === undefined}
|
||||
onClick={() => setShowDeleteModal(true)}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: !canUpdateEdgeFunction
|
||||
? 'You need additional permissions to delete edge functions'
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
Delete edge function
|
||||
</ButtonTooltip>
|
||||
</Button>
|
||||
</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
</div>
|
||||
</ScaffoldSection>
|
||||
|
||||
<ConfirmationModal
|
||||
visible={showDeleteModal}
|
||||
loading={isDeleting}
|
||||
variant="destructive"
|
||||
confirmLabel="Delete"
|
||||
confirmLabelLoading="Deleting"
|
||||
title={`Confirm to delete ${selectedFunction?.name}`}
|
||||
onCancel={() => 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',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
size="small"
|
||||
alignFooter="right"
|
||||
header={<h3>Confirm to delete {selectedFunction?.name}</h3>}
|
||||
visible={showDeleteModal}
|
||||
loading={isDeleting}
|
||||
onCancel={() => setShowDeleteModal(false)}
|
||||
onConfirm={onConfirmDelete}
|
||||
>
|
||||
<Modal.Content>
|
||||
<Alert_Shadcn_ variant="warning">
|
||||
<CriticalIcon />
|
||||
<AlertTitle_Shadcn_>This action cannot be undone</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>
|
||||
Ensure that you have made a backup if you want to restore your edge function
|
||||
</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
</>
|
||||
<div className="w-full 2xl:max-w-[600px] shrink-0">
|
||||
<ScaffoldSection isFullWidth className="!pt-6 2xl:first:!pt-12">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6 space-y-6">
|
||||
<dl className="grid grid-cols-1 xl:grid-cols-[auto_1fr] gap-y-4 xl:gap-y-6 gap-x-10">
|
||||
<dt className="text-sm text-foreground-light">Slug</dt>
|
||||
<dd className="text-sm lg:text-left">{selectedFunction?.slug}</dd>
|
||||
|
||||
<dt className="text-sm text-foreground-light">Endpoint URL</dt>
|
||||
<dd className="text-sm lg:text-left">
|
||||
<Input
|
||||
className="font-mono input-mono"
|
||||
disabled
|
||||
copy
|
||||
size="small"
|
||||
value={functionUrl}
|
||||
/>
|
||||
</dd>
|
||||
|
||||
<dt className="text-sm text-foreground-light">Region</dt>
|
||||
<dd className="text-sm lg:text-left">All functions are deployed globally</dd>
|
||||
|
||||
<dt className="text-sm text-foreground-light">Created at</dt>
|
||||
<dd className="text-sm lg:text-left">
|
||||
{dayjs(selectedFunction?.created_at ?? 0).format('dddd, MMMM D, YYYY h:mm A')}
|
||||
</dd>
|
||||
|
||||
<dt className="text-sm text-foreground-light">Last updated at</dt>
|
||||
<dd className="text-sm lg:text-left">
|
||||
{dayjs(selectedFunction?.updated_at ?? 0).format('dddd, MMMM D, YYYY h:mm A')}
|
||||
</dd>
|
||||
|
||||
<dt className="text-sm text-foreground-light">Deployments</dt>
|
||||
<dd className="text-sm lg:text-left">{selectedFunction?.version ?? 0}</dd>
|
||||
|
||||
<dt className="text-sm text-foreground-light">Import Maps</dt>
|
||||
<dd className="text-sm lg:text-left">
|
||||
<p>
|
||||
Import maps are{' '}
|
||||
<span className={cn(hasImportMap ? 'text-brand' : 'text-amber-900')}>
|
||||
{hasImportMap ? 'used' : 'not used'}
|
||||
</span>{' '}
|
||||
for this function
|
||||
</p>
|
||||
<p className="text-foreground-light mt-1">
|
||||
Import maps allow the use of bare specifiers in functions instead of explicit
|
||||
import URLs
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
asChild
|
||||
type="default"
|
||||
size="tiny"
|
||||
icon={<ExternalLink strokeWidth={1.5} />}
|
||||
>
|
||||
<Link
|
||||
href="https://supabase.com/docs/guides/functions/dependencies"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
More about import maps
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</dd>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</ScaffoldSection>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EdgeFunctionDetails
|
||||
@@ -5,6 +5,7 @@ import Table from 'components/to-be-cleaned/Table'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import type { ProjectSecret } from 'data/secrets/secrets-query'
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { TimestampInfo } from 'ui-patterns'
|
||||
|
||||
interface EdgeFunctionSecretProps {
|
||||
secret: ProjectSecret
|
||||
@@ -23,11 +24,21 @@ const EdgeFunctionSecret = ({ secret, onSelectDelete }: EdgeFunctionSecretProps)
|
||||
<p className="truncate py-2">{secret.name}</p>
|
||||
</Table.td>
|
||||
<Table.td>
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="font-mono text-sm truncate" title={secret.value}>
|
||||
{secret.value}
|
||||
</p>
|
||||
</div>
|
||||
<p className="font-mono text-sm max-w-96 truncate" title={secret.value}>
|
||||
{secret.value}
|
||||
</p>
|
||||
</Table.td>
|
||||
<Table.td>
|
||||
{!!secret.updated_at ? (
|
||||
<TimestampInfo
|
||||
displayAs="utc"
|
||||
utcTimestamp={secret.updated_at}
|
||||
labelFormat="DD MMM YYYY HH:mm:ss (ZZ)"
|
||||
className="!text-sm"
|
||||
/>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</Table.td>
|
||||
<Table.td>
|
||||
<div className="flex items-center justify-end">
|
||||
|
||||
@@ -68,47 +68,51 @@ const EdgeFunctionSecrets = () => {
|
||||
icon={<Search size={14} />}
|
||||
/>
|
||||
</div>
|
||||
<Table
|
||||
head={[
|
||||
<Table.th key="secret-name">Name</Table.th>,
|
||||
<Table.th key="secret-value" className="flex items-center gap-x-2">
|
||||
Digest{' '}
|
||||
<Badge color="scale" className="font-mono">
|
||||
SHA256
|
||||
</Badge>
|
||||
</Table.th>,
|
||||
<Table.th key="actions" />,
|
||||
]}
|
||||
body={
|
||||
secrets.length > 0 ? (
|
||||
secrets.map((secret) => (
|
||||
<EdgeFunctionSecret
|
||||
key={secret.name}
|
||||
secret={secret}
|
||||
onSelectDelete={() => setSelectedSecret(secret)}
|
||||
/>
|
||||
))
|
||||
) : secrets.length === 0 && searchString.length > 0 ? (
|
||||
<Table.tr>
|
||||
<Table.td colSpan={3}>
|
||||
<p className="text-sm text-foreground">No results found</p>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Your search for "{searchString}" did not return any results
|
||||
</p>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
) : (
|
||||
<Table.tr>
|
||||
<Table.td colSpan={3}>
|
||||
<p className="text-sm text-foreground">No secrets created</p>
|
||||
<p className="text-sm text-foreground-light">
|
||||
There are no secrets associated with your project yet
|
||||
</p>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="w-full overflow-hidden overflow-x-auto">
|
||||
<Table
|
||||
head={[
|
||||
<Table.th key="secret-name">Name</Table.th>,
|
||||
<Table.th key="secret-value" className="flex items-center gap-x-2">
|
||||
Digest{' '}
|
||||
<Badge color="scale" className="font-mono">
|
||||
SHA256
|
||||
</Badge>
|
||||
</Table.th>,
|
||||
<Table.th key="secret-updated-at">Updated at</Table.th>,
|
||||
<Table.th key="actions" />,
|
||||
]}
|
||||
body={
|
||||
secrets.length > 0 ? (
|
||||
secrets.map((secret) => (
|
||||
<EdgeFunctionSecret
|
||||
key={secret.name}
|
||||
secret={secret}
|
||||
onSelectDelete={() => setSelectedSecret(secret)}
|
||||
/>
|
||||
))
|
||||
) : secrets.length === 0 && searchString.length > 0 ? (
|
||||
<Table.tr>
|
||||
<Table.td colSpan={3}>
|
||||
<p className="text-sm text-foreground">No results found</p>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Your search for "{searchString}" did not return any results
|
||||
</p>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
) : (
|
||||
<Table.tr>
|
||||
<Table.td colSpan={3}>
|
||||
<p className="text-sm text-foreground">No secrets created</p>
|
||||
<p className="text-sm text-foreground-light">
|
||||
There are no secrets associated with your project yet
|
||||
</p>
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ interface EdgeFunctionsListItemProps {
|
||||
function: EdgeFunctionsResponse
|
||||
}
|
||||
|
||||
const EdgeFunctionsListItem = ({ function: item }: EdgeFunctionsListItemProps) => {
|
||||
export const EdgeFunctionsListItem = ({ function: item }: EdgeFunctionsListItemProps) => {
|
||||
const router = useRouter()
|
||||
const { ref } = useParams()
|
||||
const { project } = useProjectContext()
|
||||
@@ -46,7 +46,9 @@ const EdgeFunctionsListItem = ({ function: item }: EdgeFunctionsListItemProps) =
|
||||
</Table.td>
|
||||
<Table.td>
|
||||
<div className="text-xs text-foreground-light flex gap-2 items-center truncate">
|
||||
<p className="font-mono truncate hidden md:inline">{endpoint}</p>
|
||||
<p title={endpoint} className="font-mono truncate hidden md:inline max-w-[30rem]">
|
||||
{endpoint}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="text-foreground-lighter hover:text-foreground transition"
|
||||
@@ -99,5 +101,3 @@ const EdgeFunctionsListItem = ({ function: item }: EdgeFunctionsListItemProps) =
|
||||
</Table.tr>
|
||||
)
|
||||
}
|
||||
|
||||
export default EdgeFunctionsListItem
|
||||
@@ -0,0 +1,302 @@
|
||||
export const EDGE_FUNCTION_TEMPLATES = [
|
||||
{
|
||||
value: 'hello-world',
|
||||
name: 'Simple Hello World',
|
||||
description: 'Basic function that returns a JSON response',
|
||||
content: `// Setup type definitions for built-in Supabase Runtime APIs
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
interface reqPayload {
|
||||
name: string;
|
||||
}
|
||||
|
||||
console.info('server started');
|
||||
|
||||
Deno.serve(async (req: Request) => {
|
||||
const { name }: reqPayload = await req.json();
|
||||
const data = {
|
||||
message: \`Hello \${name} from foo!\`,
|
||||
};
|
||||
|
||||
return new Response(
|
||||
JSON.stringify(data),
|
||||
{ headers: { 'Content-Type': 'application/json', 'Connection': 'keep-alive' }}
|
||||
);
|
||||
});`,
|
||||
},
|
||||
{
|
||||
value: 'database-access',
|
||||
name: 'Supabase Database Access',
|
||||
description: 'Example using Supabase client to query your database',
|
||||
content: `// Setup type definitions for built-in Supabase Runtime APIs
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
import { createClient } from 'jsr:@supabase/supabase-js@2'
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
try {
|
||||
const supabase = createClient(
|
||||
Deno.env.get('SUPABASE_URL') ?? '',
|
||||
Deno.env.get('SUPABASE_ANON_KEY') ?? '',
|
||||
{ global: { headers: { Authorization: req.headers.get('Authorization')! } } }
|
||||
)
|
||||
|
||||
const { data, error } = await supabase.from('table_name').select('*')
|
||||
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ data }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status: 200,
|
||||
})
|
||||
} catch (err) {
|
||||
return new Response(String(err?.message ?? err), { status: 500 })
|
||||
}
|
||||
})`,
|
||||
},
|
||||
{
|
||||
value: 'storage-upload',
|
||||
name: 'Supabase Storage Upload',
|
||||
description: 'Upload files to Supabase Storage',
|
||||
content: `// Setup type definitions for built-in Supabase Runtime APIs
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
import { createClient } from 'jsr:@supabase/supabase-js@2'
|
||||
|
||||
const supabase = createClient(
|
||||
Deno.env.get('SUPABASE_URL') ?? '',
|
||||
Deno.env.get('SUPABASE_ANON_KEY') ?? ''
|
||||
)
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
const formData = await req.formData()
|
||||
const file = formData.get('file')
|
||||
const { data, error } = await supabase
|
||||
.storage
|
||||
.from('your-bucket')
|
||||
.upload(
|
||||
\`files/\${crypto.randomUUID()}\`,
|
||||
file,
|
||||
{ contentType: file.type }
|
||||
)
|
||||
if (error) throw error
|
||||
return new Response(
|
||||
JSON.stringify({ data }),
|
||||
{ headers: { 'Content-Type': 'application/json' }}
|
||||
)
|
||||
})`,
|
||||
},
|
||||
{
|
||||
value: 'node-api',
|
||||
name: 'Node Built-in API Example',
|
||||
description: 'Example using Node.js built-in crypto and http modules',
|
||||
content: `// Setup type definitions for built-in Supabase Runtime APIs
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
import process from "node:process";
|
||||
|
||||
const generateRandomString = (length) => {
|
||||
const buffer = randomBytes(length);
|
||||
return buffer.toString('hex');
|
||||
};
|
||||
|
||||
const randomString = generateRandomString(10);
|
||||
console.log(randomString);
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const message = \`Hello\`;
|
||||
res.end(message);
|
||||
});
|
||||
|
||||
server.listen(9999);`,
|
||||
},
|
||||
{
|
||||
value: 'express',
|
||||
name: 'Express Server',
|
||||
description: 'Example using Express.js for routing',
|
||||
content: `// Setup type definitions for built-in Supabase Runtime APIs
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
import express from "npm:express@4.18.2";
|
||||
|
||||
const app = express();
|
||||
|
||||
app.get(/(.*)/, (req, res) => {
|
||||
res.send("Welcome to Supabase");
|
||||
});
|
||||
|
||||
app.listen(8000);`,
|
||||
},
|
||||
{
|
||||
value: 'openai-completion',
|
||||
name: 'OpenAI Text Completion',
|
||||
description: 'Generate text completions using OpenAI GPT-3',
|
||||
content: `// Setup type definitions for built-in Supabase Runtime APIs
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
import { Configuration, OpenAIApi } from 'npm:openai@3.3.0'
|
||||
|
||||
const openAi = new OpenAIApi(
|
||||
new Configuration({
|
||||
apiKey: Deno.env.get('OPENAI_API_KEY')
|
||||
})
|
||||
)
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
const { prompt } = await req.json()
|
||||
const completion = await openAi.createCompletion({
|
||||
model: 'text-davinci-003',
|
||||
prompt,
|
||||
max_tokens: 200
|
||||
})
|
||||
return new Response(
|
||||
JSON.stringify({ text: completion.data.choices[0].text }),
|
||||
{ headers: { 'Content-Type': 'application/json' }}
|
||||
)
|
||||
})`,
|
||||
},
|
||||
{
|
||||
value: 'stripe-webhook',
|
||||
name: 'Stripe Webhook Example',
|
||||
description: 'Handle Stripe webhook events securely',
|
||||
content: `// Setup type definitions for built-in Supabase Runtime APIs
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
import Stripe from 'npm:stripe@12.0.0'
|
||||
|
||||
const stripe = new Stripe(Deno.env.get('STRIPE_API_KEY')!)
|
||||
const endpointSecret = Deno.env.get('STRIPE_WEBHOOK_SIGNING_SECRET')!
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
const signature = req.headers.get('stripe-signature')
|
||||
try {
|
||||
const event = stripe.webhooks.constructEvent(
|
||||
await req.text(),
|
||||
signature!,
|
||||
endpointSecret
|
||||
)
|
||||
switch (event.type) {
|
||||
case 'payment_intent.succeeded':
|
||||
// Handle successful payment
|
||||
break
|
||||
}
|
||||
return new Response(JSON.stringify({ received: true }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
} catch (err) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: err.message }),
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
})`,
|
||||
},
|
||||
{
|
||||
value: 'resend-email',
|
||||
name: 'Send Emails',
|
||||
description: 'Send emails using the Resend API',
|
||||
content: `// Setup type definitions for built-in Supabase Runtime APIs
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
|
||||
const RESEND_API_KEY = Deno.env.get('RESEND_API_KEY')!
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
const { to, subject, html } = await req.json()
|
||||
const res = await fetch('https://api.resend.com/emails', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: \`Bearer \${RESEND_API_KEY}\`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from: 'you@example.com',
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
return new Response(JSON.stringify(data), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
})`,
|
||||
},
|
||||
{
|
||||
value: 'image-transform',
|
||||
name: 'Image Transformation',
|
||||
description: 'Transform images using ImageMagick WASM',
|
||||
content: `// Setup type definitions for built-in Supabase Runtime APIs
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
import {
|
||||
ImageMagick,
|
||||
initializeImageMagick,
|
||||
} from "npm:@imagemagick/magick-wasm@0.0.30"
|
||||
|
||||
await initializeImageMagick()
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
const formData = await req.formData()
|
||||
const file = formData.get('file')
|
||||
const content = await file.arrayBuffer()
|
||||
const result = await ImageMagick.read(new Uint8Array(content), (img) => {
|
||||
img.resize(500, 300)
|
||||
img.blur(60, 5)
|
||||
return img.write(data => data)
|
||||
})
|
||||
return new Response(
|
||||
result,
|
||||
{ headers: { 'Content-Type': 'image/png' }}
|
||||
)
|
||||
})`,
|
||||
},
|
||||
{
|
||||
value: 'discord-bot',
|
||||
name: 'Discord Bot Example',
|
||||
description: 'Build a Slash Command Discord Bot',
|
||||
content: `// Setup type definitions for built-in Supabase Runtime APIs
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
import { verifyDiscordRequest } from './_shared/discord.ts'
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
const { valid } = await verifyDiscordRequest(req)
|
||||
if (!valid) {
|
||||
return new Response('Invalid request', { status: 401 })
|
||||
}
|
||||
const message = await req.json()
|
||||
if (message.type === 1) {
|
||||
return new Response(
|
||||
JSON.stringify({ type: 1 }),
|
||||
{ headers: { 'Content-Type': 'application/json' }}
|
||||
)
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
type: 4,
|
||||
data: { content: 'Hello from Supabase Edge Functions!' }
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/json' }}
|
||||
)
|
||||
})`,
|
||||
},
|
||||
{
|
||||
value: 'websocket-server',
|
||||
name: 'Websocket Server Example',
|
||||
description: 'Create a real-time WebSocket server',
|
||||
content: `// Setup type definitions for built-in Supabase Runtime APIs
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
|
||||
Deno.serve((req) => {
|
||||
const upgrade = req.headers.get("upgrade") || ""
|
||||
if (upgrade.toLowerCase() != "websocket") {
|
||||
return new Response("request isn't trying to upgrade to websocket.")
|
||||
}
|
||||
const { socket, response } = Deno.upgradeWebSocket(req)
|
||||
socket.onopen = () => {
|
||||
console.log("client connected!")
|
||||
socket.send('Welcome to Supabase Edge Functions!')
|
||||
}
|
||||
socket.onmessage = (e) => {
|
||||
console.log("client sent message:", e.data)
|
||||
socket.send(new Date().toString())
|
||||
}
|
||||
return response
|
||||
})`,
|
||||
},
|
||||
]
|
||||
@@ -1,41 +1,132 @@
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import { useParams } from 'common'
|
||||
import { ScaffoldSectionTitle } from 'components/layouts/Scaffold'
|
||||
import { ResourceItem } from 'components/ui/Resource/ResourceItem'
|
||||
import { ResourceList } from 'components/ui/Resource/ResourceList'
|
||||
import { useFlag } from 'hooks/ui/useFlag'
|
||||
import { Code, Terminal } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useAppStateSnapshot } from 'state/app-state'
|
||||
import {
|
||||
AiIconAnimation,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogSection,
|
||||
DialogTrigger,
|
||||
} from 'ui'
|
||||
import { EDGE_FUNCTION_TEMPLATES } from './Functions.templates'
|
||||
import { TerminalInstructions } from './TerminalInstructions'
|
||||
|
||||
import { DocsButton } from 'components/ui/DocsButton'
|
||||
import { Button } from 'ui'
|
||||
import TerminalInstructions from './TerminalInstructions'
|
||||
export const FunctionsEmptyState = () => {
|
||||
const { ref } = useParams()
|
||||
const router = useRouter()
|
||||
const { setAiAssistantPanel } = useAppStateSnapshot()
|
||||
const edgeFunctionCreate = useFlag('edgeFunctionCreate')
|
||||
|
||||
const FunctionsEmptyState = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-8 w-full">
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="max-w-lg text-base text-foreground">
|
||||
Scalable functions to run your code with no server management.
|
||||
</p>
|
||||
<p className="max-w-lg text-sm text-foreground-light">
|
||||
Edge Functions are server-side Typescript functions, distributed globally at the edge -
|
||||
close to your users. They can be used for listening to webhooks or integrating your
|
||||
Supabase project with third-parties.
|
||||
</p>
|
||||
<div className="flex gap-x-2">
|
||||
<DocsButton href="https://supabase.com/docs/guides/functions" />
|
||||
<Button asChild type="default" icon={<ExternalLink />}>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions"
|
||||
>
|
||||
Examples
|
||||
</a>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Create your first edge function</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0 grid grid-cols-[repeat(auto-fit,minmax(240px,1fr))] divide-y md:divide-y-0 md:divide-x divide-default items-stretch">
|
||||
{/* CLI Option */}
|
||||
<div className="p-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal strokeWidth={1.5} size={20} />
|
||||
<h4 className="text-base text-foreground">Via CLI</h4>
|
||||
</div>
|
||||
<p className="text-sm text-foreground-light mb-4 mt-1">
|
||||
Create and deploy functions using the Supabase CLI. Ideal for local development and
|
||||
version control.
|
||||
</p>
|
||||
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="default">View CLI Instructions</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent size="large">
|
||||
<DialogSection padding="small">
|
||||
<TerminalInstructions />
|
||||
</DialogSection>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* AI Assistant Option */}
|
||||
<div className="p-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<AiIconAnimation size={20} />
|
||||
<h4 className="text-base text-foreground">AI Assistant</h4>
|
||||
</div>
|
||||
<p className="text-sm text-foreground-light mb-4 mt-1">
|
||||
Let our AI assistant help you create functions. Perfect for kickstarting a function.
|
||||
</p>
|
||||
<Button
|
||||
type="default"
|
||||
onClick={() =>
|
||||
setAiAssistantPanel({
|
||||
open: true,
|
||||
initialInput: 'Create a new edge function that ...',
|
||||
suggestions: {
|
||||
title:
|
||||
'I can help you create a new edge function. Here are a few example prompts to get you started:',
|
||||
prompts: [
|
||||
'Create a new edge function that processes payments with Stripe',
|
||||
'Create a new edge function that sends emails with Resend',
|
||||
'Create a new edge function that generates PDFs from HTML templates',
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
Open Assistant
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-8 bg-surface-100 px-5 py-4 border rounded-md">
|
||||
<TerminalInstructions />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor Option */}
|
||||
{edgeFunctionCreate && (
|
||||
<div className="p-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<Code strokeWidth={1.5} size={20} />
|
||||
<h4 className="text-base text-foreground">Via Editor</h4>
|
||||
</div>
|
||||
<p className="text-sm text-foreground-light mb-4 mt-1">
|
||||
Create and edit functions directly in the browser. Download to local at any time.
|
||||
</p>
|
||||
<Button type="default" onClick={() => router.push(`/project/${ref}/functions/new`)}>
|
||||
Open Editor
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{edgeFunctionCreate && (
|
||||
<>
|
||||
<ScaffoldSectionTitle className="text-xl mb-4 mt-12">
|
||||
Start with a template
|
||||
</ScaffoldSectionTitle>
|
||||
<ResourceList>
|
||||
{EDGE_FUNCTION_TEMPLATES.map((template) => (
|
||||
<ResourceItem
|
||||
key={template.name}
|
||||
media={<Code strokeWidth={1.5} size={16} className="-translate-y-[9px]" />}
|
||||
onClick={() => {}}
|
||||
>
|
||||
<Link href={`/project/${ref}/functions/new?template=${template.value}`}>
|
||||
<p>{template.name}</p>
|
||||
<p className="text-sm text-foreground-lighter">{template.description}</p>
|
||||
</Link>
|
||||
</ResourceItem>
|
||||
))}
|
||||
</ResourceList>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default FunctionsEmptyState
|
||||
@@ -21,7 +21,7 @@ interface TerminalInstructionsProps extends ComponentPropsWithoutRef<typeof Coll
|
||||
removeBorder?: boolean
|
||||
}
|
||||
|
||||
const TerminalInstructions = forwardRef<
|
||||
export const TerminalInstructions = forwardRef<
|
||||
ElementRef<typeof Collapsible_Shadcn_>,
|
||||
TerminalInstructionsProps
|
||||
>(({ closable = false, removeBorder = false, ...props }, ref) => {
|
||||
@@ -158,5 +158,3 @@ const TerminalInstructions = forwardRef<
|
||||
})
|
||||
|
||||
TerminalInstructions.displayName = 'TerminalInstructions'
|
||||
|
||||
export default TerminalInstructions
|
||||
@@ -1,4 +0,0 @@
|
||||
export { default as EdgeFunctionDetails } from './EdgeFunctionDetails/EdgeFunctionDetails'
|
||||
export { default as EdgeFunctionsListItem } from './EdgeFunctionsListItem'
|
||||
export { default as FunctionsEmptyState } from './FunctionsEmptyState'
|
||||
export { default as TerminalInstructions } from './TerminalInstructions'
|
||||
@@ -1,4 +1,15 @@
|
||||
import { Box, Clock, Eye, Lock, Ruler, Table2, TextSearch, Unlock, User } from 'lucide-react'
|
||||
import {
|
||||
Box,
|
||||
Clock,
|
||||
Eye,
|
||||
Lock,
|
||||
LockIcon,
|
||||
Ruler,
|
||||
Table2,
|
||||
TextSearch,
|
||||
Unlock,
|
||||
User,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { LINTER_LEVELS, LintInfo } from 'components/interfaces/Linter/Linter.constants'
|
||||
@@ -204,6 +215,14 @@ export const lintInfoMap: LintInfo[] = [
|
||||
linkText: 'View settings',
|
||||
docsLink: 'https://supabase.com/docs/guides/platform/network-restrictions',
|
||||
},
|
||||
{
|
||||
name: 'password_requirements_min_length',
|
||||
title: 'Minimum password length not set or inadequate',
|
||||
icon: <Ruler className="text-foreground-muted" size={15} strokeWidth={1} />,
|
||||
link: ({ projectRef }) => `/project/${projectRef}/settings/auth`,
|
||||
linkText: 'View settings',
|
||||
docsLink: 'https://supabase.com/docs/guides/platform/going-into-prod#security',
|
||||
},
|
||||
{
|
||||
name: 'pitr_not_enabled',
|
||||
title: 'PITR not enabled',
|
||||
@@ -213,12 +232,45 @@ export const lintInfoMap: LintInfo[] = [
|
||||
docsLink: 'https://supabase.com/docs/guides/platform/backups#point-in-time-recovery',
|
||||
},
|
||||
{
|
||||
name: 'password_requirements_min_length',
|
||||
title: 'Minimum password length not set or inadequate',
|
||||
icon: <Ruler className="text-foreground-muted" size={15} strokeWidth={1} />,
|
||||
name: 'auth_leaked_password_protection',
|
||||
title: 'Leaked Password Protection Disabled',
|
||||
icon: <LockIcon className="text-foreground-muted" size={15} strokeWidth={1} />,
|
||||
link: ({ projectRef }) => `/project/${projectRef}/settings/auth`,
|
||||
linkText: 'View settings',
|
||||
docsLink: 'https://supabase.com/docs/guides/platform/going-into-prod#security',
|
||||
docsLink:
|
||||
'https://supabase.com/docs/guides/auth/password-security#password-strength-and-leaked-password-protection',
|
||||
},
|
||||
{
|
||||
name: 'auth_insufficient_mfa_options',
|
||||
title: 'Insufficient MFA Options',
|
||||
icon: <LockIcon className="text-foreground-muted" size={15} strokeWidth={1} />,
|
||||
link: ({ projectRef }) => `/project/${projectRef}/settings/auth`,
|
||||
linkText: 'View settings',
|
||||
docsLink: 'https://supabase.com/docs/guides/auth/auth-mfa',
|
||||
},
|
||||
{
|
||||
name: 'auth_password_policy_missing',
|
||||
title: 'Password Policy Missing',
|
||||
icon: <LockIcon className="text-foreground-muted" size={15} strokeWidth={1} />,
|
||||
link: ({ projectRef }) => `/project/${projectRef}/settings/auth`,
|
||||
linkText: 'View settings',
|
||||
docsLink: 'https://supabase.com/docs/guides/auth/password-security',
|
||||
},
|
||||
{
|
||||
name: 'leaked_service_key',
|
||||
title: 'Leaked Service Key Detected',
|
||||
icon: <LockIcon className="text-foreground-muted" size={15} strokeWidth={1} />,
|
||||
link: ({ projectRef }) => `/project/${projectRef}/settings/api`,
|
||||
linkText: 'View settings',
|
||||
docsLink: 'https://supabase.com/docs/guides/api/api-keys#the-servicerole-key',
|
||||
},
|
||||
{
|
||||
name: 'no_backup_admin',
|
||||
title: 'No Backup Admin Detected',
|
||||
icon: <LockIcon className="text-foreground-muted" size={15} strokeWidth={1} />,
|
||||
link: ({ projectRef }) => `/project/${projectRef}/settings/auth`,
|
||||
linkText: 'View settings',
|
||||
docsLink: 'https://supabase.com/docs/guides/auth/auth-mfa',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ const PaymentMethodSelection = ({
|
||||
<div className="flex items-center justify-between px-4 py-2 border border-dashed rounded-md bg-alternative">
|
||||
<div className="flex items-center space-x-4 text-foreground-light">
|
||||
<AlertCircle size={16} strokeWidth={1.5} />
|
||||
<p className="text-sm">No saved payment methods</p>
|
||||
<p className="text-sm">No payment methods</p>
|
||||
</div>
|
||||
|
||||
<ButtonTooltip
|
||||
|
||||
@@ -26,7 +26,7 @@ import type { OrgPlan, SubscriptionTier } from 'data/subscriptions/types'
|
||||
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
||||
import { useFlag } from 'hooks/ui/useFlag'
|
||||
import { useFlag, usePHFlag } from 'hooks/ui/useFlag'
|
||||
import { PRICING_TIER_PRODUCT_IDS } from 'lib/constants'
|
||||
import { formatCurrency } from 'lib/helpers'
|
||||
import { pickFeatures, pickFooter, plans as subscriptionsPlans } from 'shared-data/plans'
|
||||
@@ -36,6 +36,7 @@ import DowngradeModal from './DowngradeModal'
|
||||
import { EnterpriseCard } from './EnterpriseCard'
|
||||
import ExitSurveyModal from './ExitSurveyModal'
|
||||
import MembersExceedLimitModal from './MembersExceedLimitModal'
|
||||
import SubscriptionPlanUpdateDialog from './SubscriptionPlanUpdateDialog'
|
||||
import PaymentMethodSelection from './PaymentMethodSelection'
|
||||
import UpgradeSurveyModal from './UpgradeModal'
|
||||
|
||||
@@ -48,6 +49,7 @@ const PlanUpdateSidePanel = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const originalPlanRef = useRef<string>()
|
||||
const allowOrioleDB = useFlag('allowOrioleDb')
|
||||
const newPlanUpdateDialog = usePHFlag('newPlanUpdateDialog')
|
||||
|
||||
const [showExitSurvey, setShowExitSurvey] = useState(false)
|
||||
const [showUpgradeSurvey, setShowUpgradeSurvey] = useState(false)
|
||||
@@ -100,7 +102,6 @@ const PlanUpdateSidePanel = () => {
|
||||
|
||||
const billingViaPartner = subscription?.billing_via_partner === true
|
||||
const billingPartner = subscription?.billing_partner
|
||||
const paymentViaInvoice = subscription?.payment_method_type === 'invoice'
|
||||
|
||||
const {
|
||||
data: subscriptionPreview,
|
||||
@@ -115,14 +116,6 @@ const PlanUpdateSidePanel = () => {
|
||||
orgProjects.filter((it) => it.status !== 'INACTIVE' && it.status !== 'GOING_DOWN').length > 0
|
||||
const subscriptionPlanMeta = subscriptionsPlans.find((tier) => tier.id === selectedTier)
|
||||
|
||||
const expandUsageFee = (fee: string) => {
|
||||
setUsageFeesExpanded([...usageFeesExpanded, fee])
|
||||
}
|
||||
|
||||
const collapseUsageFee = (fee: string) => {
|
||||
setUsageFeesExpanded(usageFeesExpanded.filter((item) => item !== fee))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setSelectedTier(undefined)
|
||||
@@ -159,10 +152,14 @@ const PlanUpdateSidePanel = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const planMeta = selectedTier
|
||||
? availablePlans.find((p) => p.id === selectedTier.split('tier_')[1])
|
||||
: null
|
||||
|
||||
const onUpdateSubscription = async () => {
|
||||
if (!slug) return console.error('org slug is required')
|
||||
if (!selectedTier) return console.error('Selected plan is required')
|
||||
if (!selectedPaymentMethod && !paymentViaInvoice) {
|
||||
if (!selectedPaymentMethod && subscription?.payment_method_type !== 'invoice') {
|
||||
return toast.error('Please select a payment method')
|
||||
}
|
||||
|
||||
@@ -189,9 +186,13 @@ const PlanUpdateSidePanel = () => {
|
||||
updateOrgSubscription({ slug, tier, paymentMethod: selectedPaymentMethod })
|
||||
}
|
||||
|
||||
const planMeta = selectedTier
|
||||
? availablePlans.find((p) => p.id === selectedTier.split('tier_')[1])
|
||||
: null
|
||||
const expandUsageFee = (fee: string) => {
|
||||
setUsageFeesExpanded([...usageFeesExpanded, fee])
|
||||
}
|
||||
|
||||
const collapseUsageFee = (fee: string) => {
|
||||
setUsageFeesExpanded(usageFeesExpanded.filter((item) => item !== fee))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -356,218 +357,242 @@ const PlanUpdateSidePanel = () => {
|
||||
projects={orgProjects}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
loading={isUpdating}
|
||||
alignFooter="right"
|
||||
size="xlarge"
|
||||
visible={selectedTier !== undefined && selectedTier !== 'tier_free'}
|
||||
onCancel={() => setSelectedTier(undefined)}
|
||||
onConfirm={onUpdateSubscription}
|
||||
dialogOverlayProps={{ className: 'pointer-events-none' }}
|
||||
header={`Confirm ${planMeta?.change_type === 'downgrade' ? 'downgrade' : 'upgrade'} to ${subscriptionPlanMeta?.name}`}
|
||||
>
|
||||
<Modal.Content>
|
||||
{subscriptionPreviewError && (
|
||||
<AlertError
|
||||
error={subscriptionPreviewError}
|
||||
subject="Failed to preview subscription."
|
||||
/>
|
||||
)}
|
||||
{subscriptionPreviewIsLoading && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-sm">Estimating monthly costs...</span>
|
||||
<ShimmeringLoader />
|
||||
<ShimmeringLoader className="w-3/4" />
|
||||
<ShimmeringLoader className="w-1/2" />
|
||||
</div>
|
||||
)}
|
||||
{subscriptionPreviewInitialized && (
|
||||
<div>
|
||||
<Table
|
||||
className="mt-2"
|
||||
borderless={true}
|
||||
head={[
|
||||
<Table.th key="header-item">Item</Table.th>,
|
||||
<Table.th key="header-count" className="text-right pr-4">
|
||||
Usage
|
||||
</Table.th>,
|
||||
<Table.th key="header-unit-price">Unit Price</Table.th>,
|
||||
<Table.th key="header-price" className="text-right">
|
||||
Cost
|
||||
</Table.th>,
|
||||
]}
|
||||
body={
|
||||
<>
|
||||
{subscriptionPreview.breakdown.map((item) => (
|
||||
<>
|
||||
<Table.tr key={item.description}>
|
||||
<Table.td>
|
||||
{item.breakdown && item.breakdown.length > 0 && (
|
||||
<Button
|
||||
type="text"
|
||||
className="!pl-0 !pr-1"
|
||||
icon={
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'transition',
|
||||
usageFeesExpanded.includes(item.description) && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
}
|
||||
onClick={() =>
|
||||
usageFeesExpanded.includes(item.description)
|
||||
? collapseUsageFee(item.description)
|
||||
: expandUsageFee(item.description)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{item.description ?? 'Unknown'}{' '}
|
||||
{item.description.endsWith('Compute') && ` (Hours)`}
|
||||
</Table.td>
|
||||
<Table.td className="text-right pr-4 tabular-nums">
|
||||
{item.quantity?.toLocaleString()}
|
||||
</Table.td>
|
||||
<Table.td>
|
||||
{item.unit_price_desc
|
||||
? item.unit_price_desc
|
||||
: item.unit_price === 0
|
||||
? 'FREE'
|
||||
: item.unit_price
|
||||
? `${formatCurrency(item.unit_price)}`
|
||||
: ''}
|
||||
</Table.td>
|
||||
<Table.td className="text-right">
|
||||
{formatCurrency(item.total_price)}
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
{newPlanUpdateDialog === 'variant-a' ? (
|
||||
<SubscriptionPlanUpdateDialog
|
||||
selectedTier={selectedTier}
|
||||
selectedOrganization={selectedOrganization}
|
||||
onClose={() => setSelectedTier(undefined)}
|
||||
subscriptionPlanMeta={subscriptionPlanMeta}
|
||||
planMeta={planMeta}
|
||||
subscriptionPreviewError={subscriptionPreviewError}
|
||||
subscriptionPreviewIsLoading={subscriptionPreviewIsLoading}
|
||||
subscriptionPreviewInitialized={subscriptionPreviewInitialized}
|
||||
subscriptionPreview={subscriptionPreview}
|
||||
billingViaPartner={billingViaPartner}
|
||||
billingPartner={billingPartner}
|
||||
subscription={subscription}
|
||||
slug={slug}
|
||||
currentPlanMeta={{
|
||||
...availablePlans.find((p) => p.id === subscription?.plan?.id),
|
||||
features:
|
||||
subscriptionsPlans.find((plan) => plan.id === `tier_${subscription?.plan?.id}`)
|
||||
?.features || [],
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Modal
|
||||
loading={isUpdating}
|
||||
alignFooter="right"
|
||||
size="xlarge"
|
||||
visible={selectedTier !== undefined && selectedTier !== 'tier_free'}
|
||||
onCancel={() => setSelectedTier(undefined)}
|
||||
onConfirm={onUpdateSubscription}
|
||||
dialogOverlayProps={{ className: 'pointer-events-none' }}
|
||||
header={`Confirm ${planMeta?.change_type === 'downgrade' ? 'downgrade' : 'upgrade'} to ${subscriptionPlanMeta?.name}`}
|
||||
>
|
||||
<Modal.Content>
|
||||
{subscriptionPreviewError && (
|
||||
<AlertError
|
||||
error={subscriptionPreviewError}
|
||||
subject="Failed to preview subscription."
|
||||
/>
|
||||
)}
|
||||
{subscriptionPreviewIsLoading && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-sm">Estimating monthly costs...</span>
|
||||
<ShimmeringLoader />
|
||||
<ShimmeringLoader className="w-3/4" />
|
||||
<ShimmeringLoader className="w-1/2" />
|
||||
</div>
|
||||
)}
|
||||
{subscriptionPreviewInitialized && (
|
||||
<div>
|
||||
<Table
|
||||
className="mt-2"
|
||||
borderless={true}
|
||||
head={[
|
||||
<Table.th key="header-item">Item</Table.th>,
|
||||
<Table.th key="header-count" className="text-right pr-4">
|
||||
Usage
|
||||
</Table.th>,
|
||||
<Table.th key="header-unit-price">Unit Price</Table.th>,
|
||||
<Table.th key="header-price" className="text-right">
|
||||
Cost
|
||||
</Table.th>,
|
||||
]}
|
||||
body={
|
||||
<>
|
||||
{subscriptionPreview.breakdown.map((item) => (
|
||||
<>
|
||||
<Table.tr key={item.description}>
|
||||
<Table.td>
|
||||
{item.breakdown && item.breakdown.length > 0 && (
|
||||
<Button
|
||||
type="text"
|
||||
className="!pl-0 !pr-1"
|
||||
icon={
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'transition',
|
||||
usageFeesExpanded.includes(item.description) && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
}
|
||||
onClick={() =>
|
||||
usageFeesExpanded.includes(item.description)
|
||||
? collapseUsageFee(item.description)
|
||||
: expandUsageFee(item.description)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{item.description ?? 'Unknown'}{' '}
|
||||
{item.description.endsWith('Compute') && ` (Hours)`}
|
||||
</Table.td>
|
||||
<Table.td className="text-right pr-4 tabular-nums">
|
||||
{item.quantity?.toLocaleString()}
|
||||
</Table.td>
|
||||
<Table.td>
|
||||
{item.unit_price_desc
|
||||
? item.unit_price_desc
|
||||
: item.unit_price === 0
|
||||
? 'FREE'
|
||||
: item.unit_price
|
||||
? `${formatCurrency(item.unit_price)}`
|
||||
: ''}
|
||||
</Table.td>
|
||||
<Table.td className="text-right">
|
||||
{formatCurrency(item.total_price)}
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
|
||||
{usageFeesExpanded.includes(item.description) &&
|
||||
item.breakdown &&
|
||||
item.breakdown.length > 0 &&
|
||||
item.breakdown.map((project) => (
|
||||
<Table.tr key={project.project_ref}>
|
||||
<Table.td className="!pl-12">{project.project_name}</Table.td>
|
||||
<Table.td className="text-right pr-4 tabular-nums">
|
||||
{project.usage}
|
||||
</Table.td>
|
||||
<Table.td />
|
||||
<Table.td />
|
||||
</Table.tr>
|
||||
))}
|
||||
</>
|
||||
))}
|
||||
{usageFeesExpanded.includes(item.description) &&
|
||||
item.breakdown &&
|
||||
item.breakdown.length > 0 &&
|
||||
item.breakdown.map((project) => (
|
||||
<Table.tr key={project.project_ref}>
|
||||
<Table.td className="!pl-12">{project.project_name}</Table.td>
|
||||
<Table.td className="text-right pr-4 tabular-nums">
|
||||
{project.usage}
|
||||
</Table.td>
|
||||
<Table.td />
|
||||
<Table.td />
|
||||
</Table.tr>
|
||||
))}
|
||||
</>
|
||||
))}
|
||||
|
||||
<Table.tr>
|
||||
<Table.td className="font-medium">
|
||||
Monthly Costs (excluding over-usage and credits)
|
||||
</Table.td>
|
||||
<Table.td />
|
||||
<Table.td />
|
||||
<Table.td className="text-right font-medium">
|
||||
{formatCurrency(
|
||||
Math.round(
|
||||
subscriptionPreview.breakdown.reduce(
|
||||
(prev, cur) => prev + cur.total_price,
|
||||
0
|
||||
)
|
||||
) ?? 0
|
||||
)}
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
</>
|
||||
}
|
||||
></Table>
|
||||
<Table.tr>
|
||||
<Table.td className="font-medium">
|
||||
Monthly Costs (excluding over-usage and credits)
|
||||
</Table.td>
|
||||
<Table.td />
|
||||
<Table.td />
|
||||
<Table.td className="text-right font-medium">
|
||||
{formatCurrency(
|
||||
Math.round(
|
||||
subscriptionPreview.breakdown.reduce(
|
||||
(prev, cur) => prev + cur.total_price,
|
||||
0
|
||||
)
|
||||
) ?? 0
|
||||
)}
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
</>
|
||||
}
|
||||
></Table>
|
||||
|
||||
<InformationBox
|
||||
className="mt-4"
|
||||
title="Usage-billing for Compute"
|
||||
icon={<Info />}
|
||||
defaultVisibility={true}
|
||||
hideCollapse={true}
|
||||
description={
|
||||
<div>
|
||||
<p className="text-sm mt-2">
|
||||
Each project is a dedicated server and database. Paid plans come with $10 of
|
||||
Compute Credits to cover one project on the default Micro Compute size or
|
||||
parts of any compute addon. Additional unpaused projects on paid plans will
|
||||
incur compute usage costs starting at $10 per month, billed hourly.
|
||||
</p>
|
||||
|
||||
{subscription?.plan?.id === 'free' && (
|
||||
<InformationBox
|
||||
className="mt-4"
|
||||
title="Usage-billing for Compute"
|
||||
icon={<Info />}
|
||||
defaultVisibility={true}
|
||||
hideCollapse={true}
|
||||
description={
|
||||
<div>
|
||||
<p className="text-sm mt-2">
|
||||
Mixing paid and non-paid projects in a single organization is not possible.
|
||||
If you want projects to be on the Free Plan, use self-serve project
|
||||
transfers.
|
||||
Each project is a dedicated server and database. Paid plans come with $10 of
|
||||
Compute Credits to cover one project on the default Micro Compute size or
|
||||
parts of any compute addon. Additional unpaused projects on paid plans will
|
||||
incur compute usage costs starting at $10 per month, billed hourly.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-x-3 mt-2">
|
||||
<Button asChild type="default" icon={<ExternalLink strokeWidth={1.5} />}>
|
||||
<Link
|
||||
href="https://supabase.com/docs/guides/platform/manage-your-usage/compute"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
How billing for Compute works
|
||||
</Link>
|
||||
</Button>
|
||||
{subscription?.plan?.id === 'free' && (
|
||||
<p className="text-sm mt-2">
|
||||
Mixing paid and non-paid projects in a single organization is not
|
||||
possible. If you want projects to be on the Free Plan, use self-serve
|
||||
project transfers.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-x-3 mt-2">
|
||||
<Button asChild type="default" icon={<ExternalLink strokeWidth={1.5} />}>
|
||||
<Link
|
||||
href="https://supabase.com/docs/guides/platform/project-transfer"
|
||||
href="https://supabase.com/docs/guides/platform/manage-your-usage/compute"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Project transfers
|
||||
How billing for Compute works
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{subscription?.plan?.id === 'free' && (
|
||||
<Button asChild type="default" icon={<ExternalLink strokeWidth={1.5} />}>
|
||||
<Link
|
||||
href="https://supabase.com/docs/guides/platform/project-transfer"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Project transfers
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Modal.Content>
|
||||
|
||||
<Modal.Content>
|
||||
{!billingViaPartner ? (
|
||||
<div className="py-4 space-y-2">
|
||||
<p className="text-sm">
|
||||
Upon clicking confirm, your monthly invoice will be adjusted and your credit card
|
||||
will be charged immediately. Changing the plan resets your billing cycle and may
|
||||
result in a prorated charge for previous usage.
|
||||
</p>
|
||||
|
||||
<div className="!mt-4">
|
||||
<PaymentMethodSelection
|
||||
selectedPaymentMethod={selectedPaymentMethod}
|
||||
onSelectPaymentMethod={setSelectedPaymentMethod}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-4 space-y-2">
|
||||
<p className="text-sm">
|
||||
This organization is billed through our partner{' '}
|
||||
{billingPartnerLabel(billingPartner)}.{' '}
|
||||
{billingPartner === 'aws' ? (
|
||||
<>The organization's credit balance will be decreased accordingly.</>
|
||||
) : (
|
||||
<>You will be charged by them directly.</>
|
||||
)}
|
||||
</p>
|
||||
{billingViaPartner &&
|
||||
billingPartner === 'fly' &&
|
||||
subscriptionPreview?.plan_change_type === 'downgrade' && (
|
||||
<p className="text-sm">
|
||||
Your organization will be downgraded at the end of your current billing cycle.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
)}
|
||||
</Modal.Content>
|
||||
|
||||
<Modal.Content>
|
||||
{!billingViaPartner ? (
|
||||
<div className="py-4 space-y-2">
|
||||
<p className="text-sm">
|
||||
Upon clicking confirm, your monthly invoice will be adjusted and your credit card
|
||||
will be charged immediately. Changing the plan resets your billing cycle and may
|
||||
result in a prorated charge for previous usage.
|
||||
</p>
|
||||
|
||||
<div className="!mt-4">
|
||||
<PaymentMethodSelection
|
||||
selectedPaymentMethod={selectedPaymentMethod}
|
||||
onSelectPaymentMethod={setSelectedPaymentMethod}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-4 space-y-2">
|
||||
<p className="text-sm">
|
||||
This organization is billed through our partner{' '}
|
||||
{billingPartnerLabel(billingPartner)}.{' '}
|
||||
{billingPartner === 'aws' ? (
|
||||
<>The organization's credit balance will be decreased accordingly.</>
|
||||
) : (
|
||||
<>You will be charged by them directly.</>
|
||||
)}
|
||||
</p>
|
||||
{billingViaPartner &&
|
||||
billingPartner === 'fly' &&
|
||||
subscriptionPreview?.plan_change_type === 'downgrade' && (
|
||||
<p className="text-sm">
|
||||
Your organization will be downgraded at the end of your current billing cycle.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
<MembersExceedLimitModal
|
||||
visible={showDowngradeError}
|
||||
|
||||
@@ -0,0 +1,652 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { InfoIcon, Check, ExternalLink } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import tweets from 'shared-data/tweets'
|
||||
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
Card,
|
||||
CardContent,
|
||||
Badge,
|
||||
} from 'ui'
|
||||
import { useOrgSubscriptionUpdateMutation } from 'data/subscriptions/org-subscription-update-mutation'
|
||||
import { organizationKeys } from 'data/organizations/keys'
|
||||
import { formatCurrency } from 'lib/helpers'
|
||||
import { PRICING_TIER_PRODUCT_IDS } from 'lib/constants'
|
||||
import { SubscriptionTier } from 'data/subscriptions/types'
|
||||
import { billingPartnerLabel } from 'components/interfaces/Billing/Subscription/Subscription.utils'
|
||||
import PaymentMethodSelection from './PaymentMethodSelection'
|
||||
import { Button, Dialog, DialogContent } from 'ui'
|
||||
import { InfoTooltip } from 'ui-patterns/info-tooltip'
|
||||
import { OrganizationBillingSubscriptionPreviewResponse } from 'data/organizations/organization-billing-subscription-preview'
|
||||
|
||||
const getRandomTweet = () => {
|
||||
const filteredTweets = tweets.filter((it) => it.text.length < 180)
|
||||
const randomIndex = Math.floor(Math.random() * filteredTweets.length)
|
||||
return filteredTweets[randomIndex]
|
||||
}
|
||||
|
||||
const PLAN_HEADINGS = {
|
||||
tier_pro:
|
||||
'the Pro plan and create unlimited projects, daily backups and premium support when you need it',
|
||||
tier_team: 'the Team plan for SOC2, SSO, priority support and greater data and log retention',
|
||||
default: 'to a new plan',
|
||||
} as const
|
||||
|
||||
type PlanHeadingKey = keyof typeof PLAN_HEADINGS
|
||||
|
||||
// Add downgrade headings
|
||||
const DOWNGRADE_PLAN_HEADINGS = {
|
||||
tier_free: 'the Free plan with limited resources and active projects',
|
||||
tier_pro: 'the Pro plan',
|
||||
default: 'to a lower plan',
|
||||
} as const
|
||||
|
||||
type DowngradePlanHeadingKey = keyof typeof DOWNGRADE_PLAN_HEADINGS
|
||||
|
||||
interface Props {
|
||||
selectedTier: 'tier_free' | 'tier_pro' | 'tier_team' | undefined
|
||||
onClose: () => void
|
||||
subscriptionPlanMeta: any
|
||||
planMeta: any
|
||||
subscriptionPreviewError: any
|
||||
subscriptionPreviewIsLoading: boolean
|
||||
subscriptionPreviewInitialized: boolean
|
||||
subscriptionPreview: OrganizationBillingSubscriptionPreviewResponse | undefined
|
||||
billingViaPartner: boolean
|
||||
billingPartner?: string
|
||||
selectedOrganization: any
|
||||
subscription: any
|
||||
slug?: string
|
||||
currentPlanMeta: any
|
||||
}
|
||||
|
||||
const SubscriptionPlanUpdateDialog = ({
|
||||
selectedTier,
|
||||
onClose,
|
||||
subscriptionPlanMeta,
|
||||
planMeta,
|
||||
subscriptionPreviewError,
|
||||
subscriptionPreviewIsLoading,
|
||||
subscriptionPreviewInitialized,
|
||||
subscriptionPreview,
|
||||
billingViaPartner,
|
||||
billingPartner,
|
||||
selectedOrganization,
|
||||
subscription,
|
||||
slug,
|
||||
currentPlanMeta,
|
||||
}: Props) => {
|
||||
const queryClient = useQueryClient()
|
||||
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<string>()
|
||||
const [testimonialTweet, setTestimonialTweet] = useState(getRandomTweet())
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTier !== undefined && selectedTier !== 'tier_free') {
|
||||
setTestimonialTweet(getRandomTweet())
|
||||
}
|
||||
}, [selectedTier])
|
||||
|
||||
const { mutate: updateOrgSubscription, isLoading: isUpdating } = useOrgSubscriptionUpdateMutation(
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
`Successfully ${planMeta?.change_type === 'downgrade' ? 'downgraded' : 'upgraded'} subscription to ${subscriptionPlanMeta?.name}!`
|
||||
)
|
||||
onClose()
|
||||
window.scrollTo({ top: 0, left: 0, behavior: 'smooth' })
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Unable to update subscription: ${error.message}`)
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const onUpdateSubscription = async () => {
|
||||
if (!slug) return console.error('org slug is required')
|
||||
if (!selectedTier) return console.error('Selected plan is required')
|
||||
if (!selectedPaymentMethod && subscription?.payment_method_type !== 'invoice') {
|
||||
return toast.error('Please select a payment method')
|
||||
}
|
||||
|
||||
if (selectedPaymentMethod) {
|
||||
queryClient.setQueriesData(organizationKeys.paymentMethods(slug), (prev: any) => {
|
||||
if (!prev) return prev
|
||||
return {
|
||||
...prev,
|
||||
defaultPaymentMethodId: selectedPaymentMethod,
|
||||
data: prev.data.map((pm: any) => ({
|
||||
...pm,
|
||||
is_default: pm.id === selectedPaymentMethod,
|
||||
})),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// If the user is downgrading from team, should have spend cap disabled by default
|
||||
const tier =
|
||||
subscription?.plan?.id === 'team' && selectedTier === PRICING_TIER_PRODUCT_IDS.PRO
|
||||
? (PRICING_TIER_PRODUCT_IDS.PAYG as SubscriptionTier)
|
||||
: selectedTier
|
||||
|
||||
updateOrgSubscription({ slug, tier, paymentMethod: selectedPaymentMethod })
|
||||
}
|
||||
|
||||
const features = subscriptionPlanMeta?.features?.[0]?.features || []
|
||||
const topFeatures = features
|
||||
|
||||
// Get current plan features for downgrade comparison
|
||||
const currentPlanFeatures = currentPlanMeta?.features?.[0]?.features || []
|
||||
|
||||
// Features that will be lost when downgrading
|
||||
const featuresToLose =
|
||||
planMeta?.change_type === 'downgrade'
|
||||
? currentPlanFeatures.filter((feature: string | [string, ...any[]]) => {
|
||||
const featureStr = typeof feature === 'string' ? feature : feature[0]
|
||||
// Check if this feature exists in the new plan
|
||||
return !topFeatures.some((newFeature: string | [string, ...any[]]) => {
|
||||
const newFeatureStr = typeof newFeature === 'string' ? newFeature : newFeature[0]
|
||||
return newFeatureStr === featureStr
|
||||
})
|
||||
})
|
||||
: []
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={selectedTier !== undefined && selectedTier !== 'tier_free'}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose()
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
size="xlarge"
|
||||
className="p-0"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 h-full items-stretch">
|
||||
{/* Left Column */}
|
||||
<div className="p-8 pb-8 flex flex-col">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-medium mb-4">
|
||||
{planMeta?.change_type === 'downgrade' ? 'Downgrade' : 'Upgrade'}{' '}
|
||||
{selectedOrganization?.name} to{' '}
|
||||
{planMeta?.change_type === 'downgrade'
|
||||
? DOWNGRADE_PLAN_HEADINGS[(selectedTier as DowngradePlanHeadingKey) || 'default']
|
||||
: PLAN_HEADINGS[(selectedTier as PlanHeadingKey) || 'default']}
|
||||
</h3>
|
||||
|
||||
{subscriptionPreviewIsLoading && (
|
||||
<div className="space-y-2 mb-4 mt-2">
|
||||
<ShimmeringLoader />
|
||||
<ShimmeringLoader className="w-3/4" />
|
||||
<ShimmeringLoader className="w-1/2" />
|
||||
</div>
|
||||
)}
|
||||
{subscriptionPreviewInitialized && (
|
||||
<>
|
||||
<Table className="mt-2 mb-4 text-foreground-light">
|
||||
<TableBody>
|
||||
{(() => {
|
||||
// Calculate remaining days in current billing cycle
|
||||
const now = Math.floor(Date.now() / 1000) // current time in seconds
|
||||
const remainingSeconds = subscription?.current_period_end - now
|
||||
const totalSeconds =
|
||||
subscription?.current_period_end - subscription?.current_period_start
|
||||
const remainingRatio = remainingSeconds / totalSeconds
|
||||
|
||||
// Calculate prorated credit for current plan
|
||||
const currentPlanMonthlyPrice = currentPlanMeta?.price ?? 0
|
||||
const proratedCredit = currentPlanMonthlyPrice * remainingRatio
|
||||
|
||||
// Calculate new plan cost
|
||||
const newPlanCost = subscriptionPlanMeta?.priceMonthly ?? 0
|
||||
|
||||
const customerBalance = ((subscription?.customer_balance ?? 0) / 100) * -1
|
||||
|
||||
// Calculate total charge (new plan - prorated credit)
|
||||
const totalCharge = Math.max(
|
||||
0,
|
||||
newPlanCost - proratedCredit - customerBalance
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableRow>
|
||||
<TableCell className="py-2 pl-0 flex items-center gap-1">
|
||||
<span>{subscriptionPlanMeta?.name} Plan</span>
|
||||
<Badge variant={'brand'} size={'small'} className="ml-1">
|
||||
New
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="py-2 pr-0 text-right">
|
||||
{formatCurrency(newPlanCost)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{subscription?.plan?.id !== 'free' && (
|
||||
<TableRow>
|
||||
<TableCell className="py-2 pl-0 flex items-center gap-1">
|
||||
<span>Unused Time on {subscription?.plan?.name} Plan</span>
|
||||
<InfoTooltip className="max-w-sm">
|
||||
Your previous plan was charged upfront, so a plan change will
|
||||
prorate any unused time in credits. If the prorated credits
|
||||
exceed the new plan charge, the excessive credits are added to
|
||||
your organization for future use.
|
||||
</InfoTooltip>
|
||||
</TableCell>
|
||||
<TableCell className="py-2 pr-0 text-right">
|
||||
-{formatCurrency(proratedCredit)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
{/* Ignore rare case with negative balance (debt) */}
|
||||
{customerBalance > 0 && (
|
||||
<TableRow>
|
||||
<TableCell className="py-2 pl-0 flex items-center gap-1">
|
||||
<span>Credits</span>
|
||||
<InfoTooltip>
|
||||
Credits will be used first before charging your card.
|
||||
</InfoTooltip>
|
||||
</TableCell>
|
||||
<TableCell className="py-2 pr-0 text-right">
|
||||
{formatCurrency(customerBalance)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
<TableRow className="text-foreground">
|
||||
<TableCell className="py-2 pl-0 border-t">Charge today</TableCell>
|
||||
<TableCell className="py-2 pr-0 text-right border-t">
|
||||
{formatCurrency(totalCharge)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{subscription?.plan?.id !== 'free' && (
|
||||
<TableRow>
|
||||
<TableCell className="py-2 pl-0 flex items-center gap-1">
|
||||
<span>+ current cycle usage</span>
|
||||
<InfoTooltip className="max-w-sm">
|
||||
Changing your plan resets your billing cycle. If your previous
|
||||
billing cycle still has any outstanding usage charges, they will
|
||||
be added to your total charge today.
|
||||
</InfoTooltip>
|
||||
</TableCell>
|
||||
<TableCell className="py-2 pr-0 text-right">
|
||||
<Link
|
||||
href={`/org/${slug}/billing#breakdown`}
|
||||
className="text-sm text-brand hover:text-brand-600 transition"
|
||||
target="_blank"
|
||||
>
|
||||
View spend
|
||||
</Link>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<Card className="cursor-help text-sm">
|
||||
<CardContent className="flex items-center gap-2 py-2 px-3 text-foreground-light hover:text-foreground">
|
||||
<InfoIcon strokeWidth={1.5} size={16} className="text-foreground-light" />
|
||||
Monthly invoice estimate is{' '}
|
||||
{formatCurrency(
|
||||
Math.round(
|
||||
subscriptionPreview?.breakdown.reduce(
|
||||
(prev: number, cur) => prev + cur.total_price,
|
||||
0
|
||||
) ?? 0
|
||||
)
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-[520px] p-6">
|
||||
<h3 className="text-md font-medium mb-2">Your new monthly invoice</h3>
|
||||
|
||||
{subscriptionPreviewError && (
|
||||
<AlertError
|
||||
error={subscriptionPreviewError}
|
||||
subject="Failed to preview subscription."
|
||||
/>
|
||||
)}
|
||||
|
||||
{subscriptionPreviewIsLoading && (
|
||||
<div className="space-y-2 p-6">
|
||||
<span className="text-sm">Estimating monthly costs...</span>
|
||||
<ShimmeringLoader />
|
||||
<ShimmeringLoader className="w-3/4" />
|
||||
<ShimmeringLoader className="w-1/2" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{subscriptionPreviewInitialized && (
|
||||
<>
|
||||
<Table className="[&_tr:last-child]:border-t font-mono text-xs">
|
||||
<TableBody>
|
||||
{/* Non-compute items and Projects list */}
|
||||
{(() => {
|
||||
// Combine all compute-related projects
|
||||
const computeItems =
|
||||
subscriptionPreview?.breakdown?.filter(
|
||||
(item) =>
|
||||
item.description?.toLowerCase().includes('compute') &&
|
||||
item.breakdown?.length > 0
|
||||
) || []
|
||||
|
||||
const computeCreditsItem =
|
||||
subscriptionPreview?.breakdown?.find((item) =>
|
||||
item.description.startsWith('Compute Credits')
|
||||
) ?? null
|
||||
|
||||
const planItem = subscriptionPreview?.breakdown?.find((item) =>
|
||||
item.description?.toLowerCase().includes('plan')
|
||||
)
|
||||
|
||||
const allProjects = computeItems.flatMap((item) =>
|
||||
item.breakdown.map((project) => ({
|
||||
...project,
|
||||
computeType: item.description,
|
||||
computeCosts: Math.round(
|
||||
item.total_price / item.breakdown.length
|
||||
),
|
||||
}))
|
||||
)
|
||||
|
||||
const otherItems =
|
||||
subscriptionPreview?.breakdown?.filter(
|
||||
(item) =>
|
||||
!item.description?.toLowerCase().includes('compute') &&
|
||||
!item.description?.toLowerCase().includes('plan')
|
||||
) || []
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{/* Combined projects section */}
|
||||
{allProjects.length > 0 && (
|
||||
<>
|
||||
<TableRow className="text-foreground-light">
|
||||
<TableCell className="!py-2 px-0">
|
||||
{planItem?.description}
|
||||
</TableCell>
|
||||
<TableCell className="text-right py-2 px-0">
|
||||
{formatCurrency(planItem?.total_price)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
<TableRow className="text-foreground-light">
|
||||
<TableCell className="!py-2 px-0 flex items-center gap-1">
|
||||
<span>Compute</span>
|
||||
<InfoTooltip className="max-w-sm p-3">
|
||||
<p className="prose text-xs mb-2">
|
||||
Each project on a paid plan is a dedicated server
|
||||
running 24/7 with no pausing. The first project is
|
||||
covered by Compute Credits. Additional projects will
|
||||
incur compute costs starting at $10/month,
|
||||
independent of activity. See{' '}
|
||||
<Link
|
||||
href={
|
||||
'/docs/guides/platform/manage-your-usage/compute'
|
||||
}
|
||||
target="_blank"
|
||||
>
|
||||
docs
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
{subscription?.plan?.id === 'free' && (
|
||||
<>
|
||||
<p className="text-xs text-foreground-light mb-3">
|
||||
Mixing paid and non-paid projects in a single
|
||||
organization is not possible. If you want
|
||||
projects to be on the Free Plan, use self-serve
|
||||
project transfers.
|
||||
</p>
|
||||
<div className="space-x-3">
|
||||
<Button
|
||||
asChild
|
||||
type="default"
|
||||
icon={<ExternalLink strokeWidth={1.5} />}
|
||||
>
|
||||
<Link
|
||||
href="/docs/guides/platform/manage-your-usage/compute"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
How billing for Compute works
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
type="default"
|
||||
icon={<ExternalLink strokeWidth={1.5} />}
|
||||
>
|
||||
<Link
|
||||
href="/docs/guides/platform/project-transfer"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Project transfers
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</InfoTooltip>
|
||||
</TableCell>
|
||||
<TableCell className="text-right py-2 px-0">
|
||||
{formatCurrency(
|
||||
computeItems.reduce(
|
||||
(sum: number, item) => sum + item.total_price,
|
||||
0
|
||||
) + (computeCreditsItem?.total_price ?? 0)
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/* Show first 3 projects */}
|
||||
{allProjects.map((project) => (
|
||||
<TableRow
|
||||
key={project.project_ref}
|
||||
className="text-foreground-light"
|
||||
>
|
||||
<TableCell className="!py-2 px-0 pl-6">
|
||||
{project.project_name} ({project.computeType}) |{' '}
|
||||
{formatCurrency(project.computeCosts)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{computeCreditsItem && (
|
||||
<TableRow className="text-foreground-light">
|
||||
<TableCell className="!py-2 px-0 pl-6">
|
||||
Compute Credits |{' '}
|
||||
{formatCurrency(computeCreditsItem.total_price)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Non-compute items */}
|
||||
{otherItems.map((item) => (
|
||||
<TableRow
|
||||
key={item.description}
|
||||
className="text-foreground-light"
|
||||
>
|
||||
<TableCell className="text-xs py-2 px-0">
|
||||
<div className="flex items-center gap-1 flex items-center gap-1">
|
||||
<span>{item.description ?? 'Unknown'}</span>
|
||||
{item.breakdown.length > 0 && (
|
||||
<InfoTooltip className="max-w-sm">
|
||||
<p>Projects using {item.description}:</p>
|
||||
<ul className="ml-6 list-disc">
|
||||
{item.breakdown.map((breakdown) => (
|
||||
<li
|
||||
key={`${item.description}-breakdown-${breakdown.project_ref}`}
|
||||
>
|
||||
{breakdown.project_name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</InfoTooltip>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-xs py-2 px-0">
|
||||
{formatCurrency(item.total_price)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
return content
|
||||
})()}
|
||||
|
||||
<TableRow>
|
||||
<TableCell className="font-medium py-2 px-0">
|
||||
Total per month (excluding other usage)
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium py-2 px-0">
|
||||
{formatCurrency(
|
||||
Math.round(
|
||||
subscriptionPreview?.breakdown?.reduce(
|
||||
(prev, cur) => prev + cur.total_price,
|
||||
0
|
||||
) ?? 0
|
||||
) ?? 0
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</>
|
||||
)}
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4">
|
||||
{!billingViaPartner ? (
|
||||
<div className="space-y-4">
|
||||
<PaymentMethodSelection
|
||||
selectedPaymentMethod={selectedPaymentMethod}
|
||||
onSelectPaymentMethod={setSelectedPaymentMethod}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-4">
|
||||
<p className="text-sm">
|
||||
This organization is billed through our partner{' '}
|
||||
{billingPartnerLabel(billingPartner)}.{' '}
|
||||
{billingPartner === 'aws' ? (
|
||||
<>The organization's credit balance will be decreased accordingly.</>
|
||||
) : (
|
||||
<>You will be charged by them directly.</>
|
||||
)}
|
||||
</p>
|
||||
{billingViaPartner &&
|
||||
billingPartner === 'fly' &&
|
||||
subscriptionPreview?.plan_change_type === 'downgrade' && (
|
||||
<p className="text-sm">
|
||||
Your organization will be downgraded at the end of your current billing
|
||||
cycle.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex space-x-4">
|
||||
<Button type="default" onClick={onClose} className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
loading={isUpdating}
|
||||
type="primary"
|
||||
onClick={onUpdateSubscription}
|
||||
className="flex-1"
|
||||
>
|
||||
Confirm {planMeta?.change_type === 'downgrade' ? 'Downgrade' : 'Upgrade'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column */}
|
||||
<div className="bg-surface-100 p-8 flex flex-col border-l">
|
||||
{planMeta?.change_type === 'downgrade'
|
||||
? featuresToLose.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm mb-1">Features you'll lose</h3>
|
||||
<p className="text-xs text-foreground-light mb-4">
|
||||
Please review this carefully before downgrading.
|
||||
</p>
|
||||
<div className="space-y-2 mb-4 text-foreground-light">
|
||||
{featuresToLose.map((feature: string | [string, ...any[]]) => (
|
||||
<div
|
||||
key={typeof feature === 'string' ? feature : feature[0]}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<div className="w-4">
|
||||
<InfoIcon className="h-3 w-3 text-amber-900" strokeWidth={3} />
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
{typeof feature === 'string' ? feature : feature[0]}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: topFeatures.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm mb-4">Upgrade features</h3>
|
||||
|
||||
<div className="space-y-2 mb-4 text-foreground-light">
|
||||
{topFeatures.map((feature: string | [string, ...any[]]) => (
|
||||
<div
|
||||
key={typeof feature === 'string' ? feature : feature[0]}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<div className="w-4">
|
||||
<Check className="h-3 w-3 text-brand" strokeWidth={3} />
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<p>{typeof feature === 'string' ? feature : feature[0]}</p>
|
||||
{Array.isArray(feature) && feature.length > 1 && (
|
||||
<p className="text-foreground-lighter text-xs">{feature[1]}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{planMeta?.change_type !== 'downgrade' && (
|
||||
<div className="border-t pt-6">
|
||||
<blockquote className="text-sm text-foreground-light italic">
|
||||
{testimonialTweet.text}
|
||||
<div className="mt-2 text-foreground">— @{testimonialTweet.handle}</div>
|
||||
</blockquote>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default SubscriptionPlanUpdateDialog
|
||||
@@ -722,10 +722,31 @@ let { data: ${resourceId}, error } = await supabase
|
||||
key: 'with-filtering',
|
||||
title: 'With filtering',
|
||||
bash: `
|
||||
curl '${endpoint}/rest/v1/${resourceId}?id=eq.1&select=*' \\
|
||||
curl --get '${endpoint}/rest/v1/${resourceId}' \\
|
||||
-H "apikey: ${apikey}" \\
|
||||
-H "Authorization: Bearer ${apikey}" \\
|
||||
-H "Range: 0-9"
|
||||
-H "Range: 0-9" \\
|
||||
-d "select=*" \\
|
||||
\\
|
||||
\`# Filters\` \\
|
||||
-d "column=eq.Equal+to" \\
|
||||
-d "column=gt.Greater+than" \\
|
||||
-d "column=lt.Less+than" \\
|
||||
-d "column=gte.Greater+than+or+equal+to" \\
|
||||
-d "column=lte.Less+than+or+equal+to" \\
|
||||
-d "column=like.*CaseSensitive*" \\
|
||||
-d "column=ilike.*CaseInsensitive*" \\
|
||||
-d "column=is.null" \\
|
||||
-d "column=in.(Array,Values)" \\
|
||||
-d "column=neq.Not+equal+to" \\
|
||||
\\
|
||||
\`# Arrays\` \\
|
||||
-d "array_column=cs.{array,contains}" \\
|
||||
-d "array_column=cd.{contained,by}" \\
|
||||
\\
|
||||
\`# Logical operators\` \\
|
||||
-d "column=not.like.Negate+filter" \\
|
||||
-d "or=(some_column.eq.Some+value,other_column.eq.Other+value)"
|
||||
`,
|
||||
js: `
|
||||
let { data: ${resourceId}, error } = await supabase
|
||||
@@ -747,6 +768,10 @@ let { data: ${resourceId}, error } = await supabase
|
||||
// Arrays
|
||||
.contains('array_column', ['array', 'contains'])
|
||||
.containedBy('array_column', ['contained', 'by'])
|
||||
|
||||
// Logical operators
|
||||
.not('column', 'like', 'Negate filter')
|
||||
.or('some_column.eq.Some value, other_column.eq.Other value')
|
||||
`,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -2,11 +2,13 @@ import type { CloudProvider, Region } from 'shared-data'
|
||||
import { AWS_REGIONS, FLY_REGIONS } from 'shared-data'
|
||||
|
||||
export function getAvailableRegions(cloudProvider: CloudProvider): Region {
|
||||
if (cloudProvider === 'AWS') {
|
||||
return AWS_REGIONS
|
||||
} else if (cloudProvider === 'FLY') {
|
||||
return FLY_REGIONS
|
||||
switch (cloudProvider) {
|
||||
case 'AWS':
|
||||
case 'AWS_NEW':
|
||||
return AWS_REGIONS
|
||||
case 'FLY':
|
||||
return FLY_REGIONS
|
||||
default:
|
||||
throw new Error('Invalid cloud provider')
|
||||
}
|
||||
|
||||
throw new Error('Invalid cloud provider')
|
||||
}
|
||||
@@ -16,11 +16,15 @@ import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectConte
|
||||
import { ProjectIndexPageLink } from 'data/prefetchers/project.$ref'
|
||||
import { useHideSidebar } from 'hooks/misc/useHideSidebar'
|
||||
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
|
||||
import { useLints } from 'hooks/misc/useLints'
|
||||
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
|
||||
import { useFlag } from 'hooks/ui/useFlag'
|
||||
import { Home } from 'icons'
|
||||
import { IS_PLATFORM, LOCAL_STORAGE_KEYS } from 'lib/constants'
|
||||
import { useAppStateSnapshot } from 'state/app-state'
|
||||
import {
|
||||
Button,
|
||||
cn,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
@@ -39,6 +43,7 @@ import {
|
||||
useSidebar,
|
||||
} from 'ui'
|
||||
import { useSetCommandMenuOpen } from 'ui-patterns'
|
||||
import { useIsAPIDocsSidePanelEnabled } from './App/FeaturePreview/FeaturePreviewContext'
|
||||
import { ThemeDropdown } from './ThemeDropdown'
|
||||
import { UserDropdown } from './UserDropdown'
|
||||
|
||||
@@ -237,10 +242,29 @@ export function SideBarNavLink({
|
||||
)
|
||||
}
|
||||
|
||||
const ActiveDot = (errorArray: any[], warningArray: any[]) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute flex h-2 w-2 left-[18px] group-data-[state=expanded]:left-[20px] top-2 z-10 rounded-full',
|
||||
errorArray.length > 0
|
||||
? 'bg-destructive-600'
|
||||
: warningArray.length > 0
|
||||
? 'bg-warning-600'
|
||||
: 'bg-transparent'
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectLinks() {
|
||||
const router = useRouter()
|
||||
const { ref } = useParams()
|
||||
const { project } = useProjectContext()
|
||||
const snap = useAppStateSnapshot()
|
||||
const isNewAPIDocsEnabled = useIsAPIDocsSidePanelEnabled()
|
||||
const { securityLints, errorLints } = useLints()
|
||||
const showWarehouse = useFlag('warehouse')
|
||||
|
||||
const activeRoute = router.pathname.split('/')[3]
|
||||
|
||||
@@ -302,13 +326,53 @@ function ProjectLinks() {
|
||||
</SidebarGroup>
|
||||
<Separator className="w-[calc(100%-1rem)] mx-auto" />
|
||||
<SidebarGroup className="gap-0.5">
|
||||
{otherRoutes.map((route, i) => (
|
||||
<SideBarNavLink
|
||||
key={`other-routes-${i}`}
|
||||
route={route}
|
||||
active={activeRoute === route.key}
|
||||
/>
|
||||
))}
|
||||
{otherRoutes.map((route, i) => {
|
||||
if (route.key === 'api' && isNewAPIDocsEnabled) {
|
||||
return (
|
||||
<SideBarNavLink
|
||||
key={`other-routes-${i}`}
|
||||
route={{
|
||||
label: route.label,
|
||||
icon: route.icon,
|
||||
key: route.key,
|
||||
}}
|
||||
onClick={() => {
|
||||
snap.setShowProjectApiDocs(true)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
} else if (route.key === 'advisors') {
|
||||
return (
|
||||
<div className="relative" key={route.key}>
|
||||
{ActiveDot(errorLints, securityLints)}
|
||||
<SideBarNavLink
|
||||
key={`other-routes-${i}`}
|
||||
route={route}
|
||||
active={activeRoute === route.key}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
} else if (route.key === 'logs') {
|
||||
// TODO: Undo this when warehouse flag is removed
|
||||
const label = showWarehouse ? 'Logs & Analytics' : route.label
|
||||
const newRoute = { ...route, label }
|
||||
return (
|
||||
<SideBarNavLink
|
||||
key={`other-routes-${i}`}
|
||||
route={newRoute}
|
||||
active={activeRoute === newRoute.key}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<SideBarNavLink
|
||||
key={`other-routes-${i}`}
|
||||
route={route}
|
||||
active={activeRoute === route.key}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})}
|
||||
</SidebarGroup>
|
||||
{/* Settings routes to be added in with project/org nav */}
|
||||
{/* <SidebarGroup className="gap-0.5">
|
||||
|
||||
@@ -12,12 +12,11 @@ import ProjectLayout from '../ProjectLayout/ProjectLayout'
|
||||
import { generateAuthMenu } from './AuthLayout.utils'
|
||||
|
||||
const AuthProductMenu = () => {
|
||||
const router = useRouter()
|
||||
const { ref: projectRef = 'default' } = useParams()
|
||||
const columnLevelPrivileges = useIsColumnLevelPrivilegesEnabled()
|
||||
|
||||
useAuthConfigPrefetch({ projectRef })
|
||||
|
||||
const router = useRouter()
|
||||
const page = router.pathname.split('/')[4]
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useEffect, type PropsWithChildren } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { useIsAPIDocsSidePanelEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
|
||||
import APIDocsButton from 'components/ui/APIDocsButton'
|
||||
import { DocsButton } from 'components/ui/DocsButton'
|
||||
import NoPermission from 'components/ui/NoPermission'
|
||||
import { useEdgeFunctionQuery } from 'data/edge-functions/edge-function-query'
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { withAuth } from 'hooks/misc/withAuth'
|
||||
import { useRouter } from 'next/router'
|
||||
import { toast } from 'sonner'
|
||||
import ProjectLayout from '../ProjectLayout/ProjectLayout'
|
||||
import EdgeFunctionsLayout from './EdgeFunctionsLayout'
|
||||
|
||||
interface EdgeFunctionDetailsLayoutProps {
|
||||
title?: string
|
||||
}
|
||||
|
||||
const EdgeFunctionDetailsLayout = ({
|
||||
title,
|
||||
children,
|
||||
}: PropsWithChildren<EdgeFunctionDetailsLayoutProps>) => {
|
||||
const router = useRouter()
|
||||
const { functionSlug, ref } = useParams()
|
||||
const isNewAPIDocsEnabled = useIsAPIDocsSidePanelEnabled()
|
||||
const canReadFunctions = useCheckPermissions(PermissionAction.FUNCTIONS_READ, '*')
|
||||
|
||||
const {
|
||||
data: selectedFunction,
|
||||
error,
|
||||
isError,
|
||||
} = useEdgeFunctionQuery({ projectRef: ref, slug: functionSlug })
|
||||
|
||||
const name = selectedFunction?.name || ''
|
||||
const breadcrumbItems = [
|
||||
{
|
||||
label: 'Edge Functions',
|
||||
href: `/project/${ref}/functions`,
|
||||
},
|
||||
]
|
||||
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: 'Details',
|
||||
href: `/project/${ref}/functions/${functionSlug}/details`,
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
useEffect(() => {
|
||||
let cancel = false
|
||||
|
||||
if (!!functionSlug && isError && error.code === 404 && !cancel) {
|
||||
toast('Edge function cannot be found in your project')
|
||||
router.push(`/project/${ref}/functions`)
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancel = true
|
||||
}
|
||||
}, [isError])
|
||||
|
||||
if (!canReadFunctions) {
|
||||
return (
|
||||
<ProjectLayout title={title || 'Edge Functions'} product="Edge Functions">
|
||||
<NoPermission isFullPage resourceText="access your project's edge functions" />
|
||||
</ProjectLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<EdgeFunctionsLayout>
|
||||
<PageLayout
|
||||
isCompact
|
||||
size="full"
|
||||
title={functionSlug ? name : 'Edge Functions'}
|
||||
breadcrumbs={breadcrumbItems}
|
||||
navigationItems={navigationItems}
|
||||
primaryActions={
|
||||
<div className="flex items-center space-x-2">
|
||||
{isNewAPIDocsEnabled && (
|
||||
<APIDocsButton
|
||||
section={
|
||||
functionSlug !== undefined ? ['edge-functions', functionSlug] : ['edge-functions']
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<DocsButton href="https://supabase.com/docs/guides/functions" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</PageLayout>
|
||||
</EdgeFunctionsLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default withAuth(EdgeFunctionDetailsLayout)
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useRouter } from 'next/router'
|
||||
import { PropsWithChildren } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { ProductMenu } from 'components/ui/ProductMenu'
|
||||
import { withAuth } from 'hooks/misc/withAuth'
|
||||
import ProjectLayout from '../ProjectLayout/ProjectLayout'
|
||||
|
||||
const EdgeFunctionsProductMenu = () => {
|
||||
const { ref: projectRef = 'default' } = useParams()
|
||||
const router = useRouter()
|
||||
const page = router.pathname.split('/')[4]
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
title: 'Manage',
|
||||
items: [
|
||||
{
|
||||
name: 'Functions',
|
||||
key: 'main',
|
||||
pages: [undefined, '[functionSlug]', 'new'],
|
||||
url: `/project/${projectRef}/functions`,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
name: 'Secrets',
|
||||
key: 'secrets',
|
||||
url: `/project/${projectRef}/functions/secrets`,
|
||||
items: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
return <ProductMenu page={page} menu={menuItems} />
|
||||
}
|
||||
|
||||
const EdgeFunctionsLayout = ({ children }: PropsWithChildren<{}>) => {
|
||||
return (
|
||||
<ProjectLayout
|
||||
title="Edge Functions"
|
||||
product="Edge Functions"
|
||||
productMenu={<EdgeFunctionsProductMenu />}
|
||||
isBlocking={false}
|
||||
>
|
||||
{children}
|
||||
</ProjectLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default withAuth(EdgeFunctionsLayout)
|
||||
@@ -1,168 +0,0 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, type PropsWithChildren } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { useIsAPIDocsSidePanelEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
import APIDocsButton from 'components/ui/APIDocsButton'
|
||||
import { DocsButton } from 'components/ui/DocsButton'
|
||||
import NoPermission from 'components/ui/NoPermission'
|
||||
import { useEdgeFunctionQuery } from 'data/edge-functions/edge-function-query'
|
||||
import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query'
|
||||
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
|
||||
import { withAuth } from 'hooks/misc/withAuth'
|
||||
import { Code } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { toast } from 'sonner'
|
||||
import { Button, cn } from 'ui'
|
||||
import FunctionsNav from '../../interfaces/Functions/FunctionsNav'
|
||||
import ProjectLayout from '../ProjectLayout/ProjectLayout'
|
||||
|
||||
interface FunctionsLayoutProps {
|
||||
title?: string
|
||||
}
|
||||
|
||||
const FunctionsLayout = ({ title, children }: PropsWithChildren<FunctionsLayoutProps>) => {
|
||||
const router = useRouter()
|
||||
const { functionSlug, ref } = useParams()
|
||||
const isNewAPIDocsEnabled = useIsAPIDocsSidePanelEnabled()
|
||||
|
||||
const { data: functions, isLoading } = useEdgeFunctionsQuery({ projectRef: ref })
|
||||
const {
|
||||
data: selectedFunction,
|
||||
error,
|
||||
isError,
|
||||
} = useEdgeFunctionQuery({ projectRef: ref, slug: functionSlug })
|
||||
|
||||
const canReadFunctions = useCheckPermissions(PermissionAction.FUNCTIONS_READ, '*')
|
||||
|
||||
const name = selectedFunction?.name || ''
|
||||
const hasFunctions = (functions ?? []).length > 0
|
||||
const centered = !hasFunctions
|
||||
|
||||
useEffect(() => {
|
||||
let cancel = false
|
||||
|
||||
if (!!functionSlug && isError && error.code === 404 && !cancel) {
|
||||
toast('Edge function cannot be found in your project')
|
||||
router.push(`/project/${ref}/functions`)
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancel = true
|
||||
}
|
||||
}, [isError])
|
||||
|
||||
if (!canReadFunctions) {
|
||||
return (
|
||||
<ProjectLayout title={title || 'Edge Functions'} product="Edge Functions">
|
||||
<NoPermission isFullPage resourceText="access your project's edge functions" />
|
||||
</ProjectLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ProjectLayout isLoading={isLoading} title={title || 'Edge Functions'} product="Edge Functions">
|
||||
{centered ? (
|
||||
<>
|
||||
<div className="mx-auto max-w-full md:max-w-5xl py-12 md:py-20 px-4 md:px-5">
|
||||
<div
|
||||
className="item-center
|
||||
flex
|
||||
flex-col
|
||||
justify-between
|
||||
gap-y-4
|
||||
xl:flex-row"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center space-x-4">
|
||||
<h1 className="text-2xl text-foreground">Edge Functions</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full flex-grow flex-col py-6">
|
||||
<div
|
||||
className={cn(
|
||||
'mx-auto flex w-full flex-col transition-all',
|
||||
'1xl:px-28 gap-4 px-5 lg:px-16 xl:px-24 2xl:px-32'
|
||||
)}
|
||||
>
|
||||
<div className="item-center flex flex-col justify-between gap-y-4 xl:flex-row">
|
||||
<div className="flex items-center gap-3 w-full">
|
||||
<div
|
||||
className={cn(
|
||||
'h-6 w-6 rounded border border-brand-600 bg-brand-300',
|
||||
'flex items-center justify-center text-brand'
|
||||
)}
|
||||
>
|
||||
<Code size={14} strokeWidth={3} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link href={`/project/${ref}/functions`}>
|
||||
<h1 className="cursor-pointer text-2xl text-foreground transition-colors hover:text-foreground-light">
|
||||
Edge Functions
|
||||
</h1>
|
||||
</Link>
|
||||
{name && (
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-foreground-light">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
shapeRendering="geometricPrecision"
|
||||
>
|
||||
<path d="M16 3.549L7.12 20.600"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<h1 className="text-2xl text-foreground">{name}</h1>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button asChild type="default">
|
||||
<Link href={`/project/${ref}/settings/functions`}>Manage secrets</Link>
|
||||
</Button>
|
||||
{isNewAPIDocsEnabled && (
|
||||
<APIDocsButton
|
||||
section={
|
||||
functionSlug !== undefined
|
||||
? ['edge-functions', functionSlug]
|
||||
: ['edge-functions']
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<DocsButton href="https://supabase.com/docs/guides/functions" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{functionSlug !== undefined && <FunctionsNav item={selectedFunction} />}
|
||||
</div>
|
||||
<div
|
||||
className={[
|
||||
'mx-auto flex h-full w-full flex-grow flex-col transition-all',
|
||||
'1xl:px-28 gap-4 px-5 lg:px-16 xl:px-24 2xl:px-32',
|
||||
].join(' ')}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ProjectLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default withAuth(FunctionsLayout)
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useParams } from 'common'
|
||||
import { ChevronLeft } from 'lucide-react'
|
||||
import { Fragment, ReactNode } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import Link from 'next/link'
|
||||
import { cn } from 'ui'
|
||||
import {
|
||||
Breadcrumb,
|
||||
@@ -17,13 +19,15 @@ interface PageHeaderProps {
|
||||
subtitle?: string
|
||||
icon?: ReactNode
|
||||
breadcrumbs?: Array<{
|
||||
label: string
|
||||
label?: string
|
||||
href?: string
|
||||
element?: ReactNode
|
||||
}>
|
||||
primaryActions?: ReactNode
|
||||
secondaryActions?: ReactNode
|
||||
className?: string
|
||||
isCompact?: boolean
|
||||
pageMeta?: ReactNode
|
||||
}
|
||||
|
||||
export const PageHeader = ({
|
||||
@@ -35,6 +39,7 @@ export const PageHeader = ({
|
||||
secondaryActions,
|
||||
className,
|
||||
isCompact = false,
|
||||
pageMeta,
|
||||
}: PageHeaderProps) => {
|
||||
const { ref } = useParams()
|
||||
|
||||
@@ -45,28 +50,50 @@ export const PageHeader = ({
|
||||
{(displayBreadcrumbs.length > 0 ||
|
||||
(isCompact && (title || primaryActions || secondaryActions))) && (
|
||||
<div className={cn('flex items-center gap-4', isCompact ? 'justify-between' : 'mb-4')}>
|
||||
{displayBreadcrumbs.length > 0 ? (
|
||||
<Breadcrumb className={cn('text-foreground-muted', isCompact && 'text-base')}>
|
||||
<BreadcrumbList className={isCompact ? 'text-base' : 'text-xs'}>
|
||||
{displayBreadcrumbs.map((item, index) => (
|
||||
<Fragment key={item.label}>
|
||||
<BreadcrumbItem>
|
||||
{item.href ? (
|
||||
<BreadcrumbLink href={!!ref ? item.href.replace('[ref]', ref) : item.href}>
|
||||
{item.label}
|
||||
</BreadcrumbLink>
|
||||
) : (
|
||||
<BreadcrumbPageItem>{item.label}</BreadcrumbPageItem>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
{index < displayBreadcrumbs.length - 1 && <BreadcrumbSeparator />}
|
||||
</Fragment>
|
||||
))}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
) : isCompact ? (
|
||||
title
|
||||
) : null}
|
||||
<div className="flex items-center gap-4">
|
||||
{breadcrumbs.length > 0 ? (
|
||||
<Breadcrumb className={cn('text-foreground-muted', isCompact && 'text-base')}>
|
||||
<BreadcrumbList className={isCompact ? 'text-base' : 'text-xs'}>
|
||||
{breadcrumbs.map((item, index) => (
|
||||
<Fragment key={item.label || `breadcrumb-${index}`}>
|
||||
<BreadcrumbItem>
|
||||
{item.element ? (
|
||||
item.element
|
||||
) : item.href ? (
|
||||
<BreadcrumbLink asChild className="flex items-center gap-2">
|
||||
<Link href={!!ref ? item.href.replace('[ref]', ref) : item.href}>
|
||||
{breadcrumbs.length === 1 && !isCompact && (
|
||||
<ChevronLeft size={16} strokeWidth={1.5} />
|
||||
)}
|
||||
{item.label}
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
) : (
|
||||
<BreadcrumbPageItem className="flex items-center gap-2">
|
||||
{breadcrumbs.length === 1 && (
|
||||
<ChevronLeft size={16} strokeWidth={1.5} />
|
||||
)}
|
||||
{item.label}
|
||||
</BreadcrumbPageItem>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
{index < breadcrumbs.length - 1 && <BreadcrumbSeparator />}
|
||||
</Fragment>
|
||||
))}
|
||||
{isCompact && title && (
|
||||
<>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPageItem>{title}</BreadcrumbPageItem>
|
||||
</BreadcrumbItem>
|
||||
</>
|
||||
)}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
) : isCompact ? (
|
||||
title
|
||||
) : null}
|
||||
</div>
|
||||
{isCompact && (
|
||||
<div className="flex items-center gap-2">
|
||||
{secondaryActions && (
|
||||
|
||||
@@ -20,14 +20,15 @@ interface PageLayoutProps {
|
||||
subtitle?: string
|
||||
icon?: ReactNode
|
||||
breadcrumbs?: Array<{
|
||||
label: string
|
||||
label?: string
|
||||
href?: string
|
||||
element?: ReactNode
|
||||
}>
|
||||
primaryActions?: ReactNode
|
||||
secondaryActions?: ReactNode
|
||||
navigationItems?: NavigationItem[]
|
||||
className?: string
|
||||
size?: 'default' | 'full'
|
||||
size?: 'default' | 'full' | 'large' | 'small'
|
||||
isCompact?: boolean
|
||||
}
|
||||
|
||||
@@ -46,7 +47,7 @@ interface PageLayoutProps {
|
||||
* @param title - Title rendered in page header
|
||||
* @param subtitle - Subtitle rendered in page header, below title
|
||||
* @param icon - Icon rendered in Page header, to the left of title and subtitle
|
||||
* @param breadcrumbs - Breadcrumbs rendered in page header, above title
|
||||
* @param breadcrumbs - Breadcrumbs rendered in page header, above title. Can be string labels with hrefs or custom elements
|
||||
* @param primaryActions - TBD
|
||||
* @param secondaryActions - TBD
|
||||
* @param navigationItems - Tab navigation rendered below the page header
|
||||
@@ -70,8 +71,9 @@ export const PageLayout = ({
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="w-full min-h-full flex flex-col items-stretch">
|
||||
<ScaffoldContainer
|
||||
size={size}
|
||||
className={cn(
|
||||
'w-full mx-auto',
|
||||
size === 'full' &&
|
||||
@@ -96,7 +98,7 @@ export const PageLayout = ({
|
||||
|
||||
{/* Navigation section */}
|
||||
{navigationItems.length > 0 && (
|
||||
<NavMenu className={cn('mt-4', size === 'full' && 'border-none')}>
|
||||
<NavMenu className={cn(isCompact ? 'mt-2' : 'mt-4', size === 'full' && 'border-none')}>
|
||||
{navigationItems.map((item) => (
|
||||
<NavMenuItem key={item.label} active={router.asPath === item.href}>
|
||||
{item.href ? (
|
||||
|
||||
@@ -129,8 +129,9 @@ export const generateSettingsMenu = (
|
||||
{
|
||||
name: 'Edge Functions',
|
||||
key: 'functions',
|
||||
url: `/project/${ref}/settings/functions`,
|
||||
url: `/project/${ref}/functions/secrets`,
|
||||
items: [],
|
||||
rightIcon: <ArrowUpRight strokeWidth={1} className="h-4 w-4" />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
@@ -19,16 +19,28 @@ export const MAX_WIDTH_CLASSES_COLUMN = 'min-w-[420px]'
|
||||
*/
|
||||
export const ScaffoldContainer = forwardRef<
|
||||
HTMLDivElement,
|
||||
HTMLAttributes<HTMLDivElement> & { bottomPadding?: boolean }
|
||||
>(({ className, bottomPadding, ...props }, ref) => {
|
||||
HTMLAttributes<HTMLDivElement> & {
|
||||
bottomPadding?: boolean
|
||||
size?: 'small' | 'default' | 'large' | 'full'
|
||||
}
|
||||
>(({ className, bottomPadding, size = 'default', ...props }, ref) => {
|
||||
const { aiAssistantPanel } = useAppStateSnapshot()
|
||||
const { open } = aiAssistantPanel
|
||||
|
||||
const maxWidthClass = {
|
||||
small: 'max-w-[768px]',
|
||||
default: 'max-w-[1200px]',
|
||||
large: 'max-w-[1600px]',
|
||||
full: 'max-w-none',
|
||||
}[size]
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={cn(
|
||||
MAX_WIDTH_CLASSES,
|
||||
'mx-auto w-full',
|
||||
maxWidthClass,
|
||||
PADDING_CLASSES,
|
||||
bottomPadding && 'pb-16',
|
||||
open ? 'xl:px-6' : '',
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { useIsLoggedIn } from 'common'
|
||||
import { useApiDocsGotoCommands } from 'components/interfaces/ProjectAPIDocs/ProjectAPIDocs.Commands'
|
||||
import { useAdvisorsGoToCommands } from './AdvisorsLayout/Advisors.Commands'
|
||||
import { useAuthGotoCommands } from './AuthLayout/Auth.Commands'
|
||||
import { useDatabaseGotoCommands } from './DatabaseLayout/Database.Commands'
|
||||
import { useFunctionsGotoCommands } from './FunctionsLayout/Functions.Commands'
|
||||
import { useFunctionsGotoCommands } from './EdgeFunctionsLayout/EdgeFunctions.Commands'
|
||||
import { useLogsGotoCommands } from './LogsLayout/Logs.Commands'
|
||||
import { useProjectSettingsGotoCommands } from './ProjectSettingsLayout/ProjectSettings.Commands'
|
||||
import { useReportsGotoCommands } from './ReportsLayout/Reports.Commands'
|
||||
import { useStorageGotoCommands } from './StorageLayout/Storage.Commands'
|
||||
import { useSqlEditorGotoCommands } from './SQLEditorLayout/SqlEditor.Commands'
|
||||
import { useStorageGotoCommands } from './StorageLayout/Storage.Commands'
|
||||
import { useTableEditorGotoCommands } from './TableEditorLayout/TableEditor.Commands'
|
||||
import { useIsLoggedIn } from 'common'
|
||||
|
||||
export function useLayoutNavCommands() {
|
||||
const isLoggedIn = useIsLoggedIn()
|
||||
|
||||
@@ -313,7 +313,7 @@ const AIEditor = ({
|
||||
) : (
|
||||
<div className="w-full h-full relative">
|
||||
<Editor
|
||||
theme="vs-dark"
|
||||
theme="supabase"
|
||||
language={language}
|
||||
value={currentValue}
|
||||
options={defaultOptions}
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { Edit, File, Plus, Trash } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import AIEditor from 'components/ui/AIEditor'
|
||||
import {
|
||||
Button,
|
||||
ContextMenu_Shadcn_,
|
||||
ContextMenuContent_Shadcn_,
|
||||
ContextMenuItem_Shadcn_,
|
||||
ContextMenuSeparator_Shadcn_,
|
||||
ContextMenuTrigger_Shadcn_,
|
||||
flattenTree,
|
||||
TreeView,
|
||||
TreeViewItem,
|
||||
} from 'ui'
|
||||
|
||||
interface FileData {
|
||||
id: number
|
||||
name: string
|
||||
content: string
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
interface FileExplorerAndEditorProps {
|
||||
files: FileData[]
|
||||
onFilesChange: (files: FileData[]) => void
|
||||
aiEndpoint?: string
|
||||
aiMetadata?: {
|
||||
projectRef?: string
|
||||
connectionString?: string
|
||||
includeSchemaMetadata?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
const getLanguageFromFileName = (fileName: string): string => {
|
||||
const extension = fileName.split('.').pop()?.toLowerCase()
|
||||
switch (extension) {
|
||||
case 'ts':
|
||||
case 'tsx':
|
||||
return 'typescript'
|
||||
case 'js':
|
||||
case 'jsx':
|
||||
return 'javascript'
|
||||
case 'json':
|
||||
return 'json'
|
||||
case 'html':
|
||||
return 'html'
|
||||
case 'css':
|
||||
return 'css'
|
||||
case 'md':
|
||||
return 'markdown'
|
||||
default:
|
||||
return 'typescript' // Default to typescript
|
||||
}
|
||||
}
|
||||
|
||||
const FileExplorerAndEditor = ({
|
||||
files,
|
||||
onFilesChange,
|
||||
aiEndpoint,
|
||||
aiMetadata,
|
||||
}: FileExplorerAndEditorProps) => {
|
||||
const selectedFile = files.find((f) => f.selected) ?? files[0]
|
||||
|
||||
const [treeData, setTreeData] = useState({
|
||||
name: '',
|
||||
children: files.map((file) => ({
|
||||
id: file.id.toString(),
|
||||
name: file.name,
|
||||
metadata: {
|
||||
isEditing: false,
|
||||
originalId: file.id,
|
||||
},
|
||||
})),
|
||||
})
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
const updatedFiles = files.map((file) =>
|
||||
file.id === selectedFile.id ? { ...file, content: value } : file
|
||||
)
|
||||
onFilesChange(updatedFiles)
|
||||
}
|
||||
|
||||
const addNewFile = () => {
|
||||
const newId = Math.max(...files.map((f) => f.id)) + 1
|
||||
const updatedFiles = files.map((f) => ({ ...f, selected: false }))
|
||||
onFilesChange([
|
||||
...updatedFiles,
|
||||
{
|
||||
id: newId,
|
||||
name: `file${newId}.ts`,
|
||||
content: '',
|
||||
selected: true,
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
const handleFileNameChange = (id: number, newName: string) => {
|
||||
if (!newName.trim()) return // Don't allow empty names
|
||||
const updatedFiles = files.map((file) => (file.id === id ? { ...file, name: newName } : file))
|
||||
onFilesChange(updatedFiles)
|
||||
}
|
||||
|
||||
const handleFileDelete = (id: number) => {
|
||||
if (files.length <= 1) {
|
||||
// Don't allow deleting the last file
|
||||
return
|
||||
}
|
||||
|
||||
const fileToDelete = files.find((f) => f.id === id)
|
||||
const isSelected = fileToDelete?.selected
|
||||
|
||||
const updatedFiles = files.filter((file) => file.id !== id)
|
||||
|
||||
// If the deleted file was selected, select another file
|
||||
if (isSelected && updatedFiles.length > 0) {
|
||||
updatedFiles[0].selected = true
|
||||
}
|
||||
|
||||
onFilesChange(updatedFiles)
|
||||
}
|
||||
|
||||
const handleFileSelect = (id: number) => {
|
||||
const updatedFiles = files.map((file) => ({
|
||||
...file,
|
||||
selected: file.id === id,
|
||||
}))
|
||||
onFilesChange(updatedFiles)
|
||||
}
|
||||
|
||||
const handleStartRename = (id: number) => {
|
||||
const updatedTreeData = {
|
||||
name: '',
|
||||
children: files.map((file) => ({
|
||||
id: file.id.toString(),
|
||||
name: file.name,
|
||||
metadata: {
|
||||
isEditing: file.id === id,
|
||||
originalId: file.id,
|
||||
},
|
||||
})),
|
||||
}
|
||||
|
||||
// Force re-render of the TreeView with the updated metadata
|
||||
setTreeData(updatedTreeData)
|
||||
}
|
||||
|
||||
// Update treeData when files change
|
||||
useEffect(() => {
|
||||
setTreeData({
|
||||
name: '',
|
||||
children: files.map((file) => ({
|
||||
id: file.id.toString(),
|
||||
name: file.name,
|
||||
metadata: {
|
||||
isEditing: false,
|
||||
originalId: file.id,
|
||||
},
|
||||
})),
|
||||
})
|
||||
}, [files])
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-hidden flex h-full">
|
||||
<div className="w-64 border-r bg-surface-200 flex flex-col">
|
||||
<div className="py-4 px-6 border-b flex items-center justify-between">
|
||||
<h3 className="text-sm font-normal font-mono uppercase text-lighter tracking-wide">
|
||||
Files
|
||||
</h3>
|
||||
<Button size="tiny" type="default" icon={<Plus size={14} />} onClick={addNewFile}>
|
||||
Add File
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<TreeView
|
||||
data={flattenTree(treeData)}
|
||||
aria-label="files tree"
|
||||
nodeRenderer={({ element, isBranch, isExpanded, getNodeProps, level }) => {
|
||||
const nodeProps = getNodeProps()
|
||||
const originalId =
|
||||
typeof element.metadata?.originalId === 'number'
|
||||
? element.metadata.originalId
|
||||
: null
|
||||
|
||||
return (
|
||||
<ContextMenu_Shadcn_ modal={false}>
|
||||
<ContextMenuTrigger_Shadcn_ asChild>
|
||||
<div>
|
||||
<TreeViewItem
|
||||
{...nodeProps}
|
||||
isExpanded={isExpanded}
|
||||
isBranch={isBranch}
|
||||
isSelected={files.find((f) => f.id === originalId)?.selected}
|
||||
level={level}
|
||||
xPadding={16}
|
||||
name={element.name}
|
||||
icon={<File size={14} className="text-foreground-light" />}
|
||||
isEditing={Boolean(element.metadata?.isEditing)}
|
||||
onEditSubmit={(value) => {
|
||||
if (originalId !== null) handleFileNameChange(originalId, value)
|
||||
}}
|
||||
onClick={() => {
|
||||
if (originalId !== null) handleFileSelect(originalId)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ContextMenuTrigger_Shadcn_>
|
||||
<ContextMenuContent_Shadcn_ onCloseAutoFocus={(e) => e.stopPropagation()}>
|
||||
<ContextMenuItem_Shadcn_
|
||||
className="gap-x-2"
|
||||
onSelect={() => {
|
||||
if (originalId !== null) handleStartRename(originalId)
|
||||
}}
|
||||
onFocusCapture={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Edit size={14} />
|
||||
Rename file
|
||||
</ContextMenuItem_Shadcn_>
|
||||
|
||||
{files.length > 1 && (
|
||||
<>
|
||||
<ContextMenuSeparator_Shadcn_ />
|
||||
<ContextMenuItem_Shadcn_
|
||||
className="gap-x-2"
|
||||
onSelect={() => {
|
||||
if (originalId !== null) handleFileDelete(originalId)
|
||||
}}
|
||||
onFocusCapture={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Trash size={14} />
|
||||
Delete file
|
||||
</ContextMenuItem_Shadcn_>
|
||||
</>
|
||||
)}
|
||||
</ContextMenuContent_Shadcn_>
|
||||
</ContextMenu_Shadcn_>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 relative px-3 bg-surface-200">
|
||||
<AIEditor
|
||||
language={getLanguageFromFileName(selectedFile?.name || 'index.ts')}
|
||||
value={selectedFile?.content}
|
||||
onChange={handleChange}
|
||||
aiEndpoint={aiEndpoint}
|
||||
aiMetadata={aiMetadata}
|
||||
options={{
|
||||
tabSize: 2,
|
||||
fontSize: 12,
|
||||
minimap: { enabled: false },
|
||||
wordWrap: 'on',
|
||||
lineNumbers: 'on',
|
||||
folding: false,
|
||||
padding: { top: 20, bottom: 20 },
|
||||
lineNumbersMinChars: 3,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileExplorerAndEditor
|
||||
@@ -28,9 +28,7 @@ const ProductMenu = ({ page, menu }: ProductMenuProps) => {
|
||||
/>
|
||||
<div>
|
||||
{group.items.map((item) => {
|
||||
const isActive = !!item.pages
|
||||
? item.pages.includes(page ?? '')
|
||||
: page === item.key
|
||||
const isActive = !!item.pages ? item.pages.includes(page) : page === item.key
|
||||
|
||||
return (
|
||||
<ProductMenuItem
|
||||
|
||||
@@ -2,7 +2,8 @@ import { ReactNode } from 'react'
|
||||
|
||||
export interface ProductMenuGroup {
|
||||
title?: string
|
||||
key?: string
|
||||
/** Set to "main" if page is on a '/' route */
|
||||
key?: string | 'main'
|
||||
isPreview?: boolean
|
||||
items: ProductMenuGroupItem[]
|
||||
}
|
||||
@@ -17,5 +18,5 @@ export interface ProductMenuGroupItem {
|
||||
isExternal?: boolean
|
||||
disabled?: boolean
|
||||
label?: string
|
||||
pages?: string[]
|
||||
pages?: (string | undefined)[]
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { components } from 'api-types'
|
||||
import { handleError, post } from 'data/fetchers'
|
||||
import type { ResponseError } from 'types'
|
||||
import { edgeFunctionsKeys } from './keys'
|
||||
import { components } from 'api-types'
|
||||
|
||||
export type EdgeFunctionsDeployVariables = {
|
||||
projectRef: string
|
||||
@@ -61,7 +61,7 @@ export const useEdgeFunctionDeployMutation = ({
|
||||
{
|
||||
async onSuccess(data, variables, context) {
|
||||
const { projectRef } = variables
|
||||
await Promise.all([queryClient.invalidateQueries(edgeFunctionsKeys.list(projectRef))])
|
||||
await queryClient.invalidateQueries(edgeFunctionsKeys.list(projectRef))
|
||||
await onSuccess?.(data, variables, context)
|
||||
},
|
||||
async onError(data, variables, context) {
|
||||
|
||||
@@ -20,7 +20,7 @@ export const instanceSizeSpecs: Record<
|
||||
cpu: '2-core',
|
||||
priceHourly: 0.01344,
|
||||
priceMonthly: 10,
|
||||
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
|
||||
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id, PROVIDERS.FLY.id],
|
||||
},
|
||||
small: {
|
||||
label: 'Small',
|
||||
@@ -28,7 +28,7 @@ export const instanceSizeSpecs: Record<
|
||||
cpu: '2-core',
|
||||
priceHourly: 0.0206,
|
||||
priceMonthly: 15,
|
||||
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
|
||||
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id, PROVIDERS.FLY.id],
|
||||
},
|
||||
medium: {
|
||||
label: 'Medium',
|
||||
@@ -36,7 +36,7 @@ export const instanceSizeSpecs: Record<
|
||||
cpu: '2-core',
|
||||
priceHourly: 0.0822,
|
||||
priceMonthly: 60,
|
||||
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
|
||||
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id, PROVIDERS.FLY.id],
|
||||
},
|
||||
large: {
|
||||
label: 'Large',
|
||||
@@ -44,7 +44,7 @@ export const instanceSizeSpecs: Record<
|
||||
cpu: '2-core',
|
||||
priceHourly: 0.1517,
|
||||
priceMonthly: 110,
|
||||
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
|
||||
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id, PROVIDERS.FLY.id],
|
||||
},
|
||||
xlarge: {
|
||||
label: 'XL',
|
||||
@@ -52,7 +52,7 @@ export const instanceSizeSpecs: Record<
|
||||
cpu: '4-core',
|
||||
priceHourly: 0.2877,
|
||||
priceMonthly: 210,
|
||||
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
|
||||
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id, PROVIDERS.FLY.id],
|
||||
},
|
||||
'2xlarge': {
|
||||
label: '2XL',
|
||||
@@ -60,7 +60,7 @@ export const instanceSizeSpecs: Record<
|
||||
cpu: '8-core',
|
||||
priceHourly: 0.562,
|
||||
priceMonthly: 410,
|
||||
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
|
||||
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id, PROVIDERS.FLY.id],
|
||||
},
|
||||
'4xlarge': {
|
||||
label: '4XL',
|
||||
@@ -68,7 +68,7 @@ export const instanceSizeSpecs: Record<
|
||||
cpu: '16-core',
|
||||
priceHourly: 1.32,
|
||||
priceMonthly: 960,
|
||||
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
|
||||
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id, PROVIDERS.FLY.id],
|
||||
},
|
||||
'8xlarge': {
|
||||
label: '8XL',
|
||||
@@ -76,7 +76,7 @@ export const instanceSizeSpecs: Record<
|
||||
cpu: '32-core',
|
||||
priceHourly: 2.562,
|
||||
priceMonthly: 1870,
|
||||
cloud_providers: [PROVIDERS.AWS.id],
|
||||
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id],
|
||||
},
|
||||
'12xlarge': {
|
||||
label: '12XL',
|
||||
@@ -84,7 +84,7 @@ export const instanceSizeSpecs: Record<
|
||||
cpu: '48-core',
|
||||
priceHourly: 3.836,
|
||||
priceMonthly: 2800,
|
||||
cloud_providers: [PROVIDERS.AWS.id],
|
||||
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id],
|
||||
},
|
||||
'16xlarge': {
|
||||
label: '16XL',
|
||||
@@ -92,6 +92,6 @@ export const instanceSizeSpecs: Record<
|
||||
cpu: '64-core',
|
||||
priceHourly: 5.12,
|
||||
priceMonthly: 3730,
|
||||
cloud_providers: [PROVIDERS.AWS.id],
|
||||
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id],
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useParams } from 'common'
|
||||
import { useProjectLintsQuery } from 'data/lint/lint-query'
|
||||
|
||||
/**
|
||||
* Hook to fetch and filter project lints
|
||||
*
|
||||
* Retrieves all lints for the current project and filters them by:
|
||||
* - Security-related lints
|
||||
* - Error-level security lints
|
||||
*
|
||||
* @returns {Object} Object containing filtered lint arrays
|
||||
* @returns {Array} securityLints - All security-related lints
|
||||
* @returns {Array} errorLints - Security lints with ERROR level
|
||||
*/
|
||||
export const useLints = () => {
|
||||
const { ref } = useParams()
|
||||
const { data } = useProjectLintsQuery({
|
||||
projectRef: ref,
|
||||
})
|
||||
|
||||
const securityLints = (data ?? []).filter((lint) => lint.categories.includes('SECURITY'))
|
||||
const errorLints = securityLints.filter((lint) => lint.level === 'ERROR')
|
||||
|
||||
return { securityLints, errorLints }
|
||||
}
|
||||
@@ -39,6 +39,13 @@ export const PROVIDERS = {
|
||||
default_region: AWS_REGIONS_DEFAULT,
|
||||
regions: { ...AWS_REGIONS },
|
||||
},
|
||||
AWS_NEW: {
|
||||
id: 'AWS_NEW',
|
||||
name: 'AWS (Revamped)',
|
||||
DEFAULT_SSH_KEY: 'supabase-app-instance',
|
||||
default_region: AWS_REGIONS_DEFAULT,
|
||||
regions: { ...AWS_REGIONS },
|
||||
},
|
||||
} as const
|
||||
|
||||
export const PROJECT_STATUS: {
|
||||
|
||||
@@ -10,6 +10,7 @@ const HOSTED_SUPPORTED_API_URLS = [
|
||||
'/ai/sql/suggest',
|
||||
'/ai/sql/generate-v2',
|
||||
'/ai/sql/generate-v3',
|
||||
'/ai/edge-function/complete',
|
||||
'/ai/onboarding/design',
|
||||
'/ai/sql/complete',
|
||||
'/ai/sql/title',
|
||||
|
||||
@@ -460,6 +460,11 @@ const nextConfig = {
|
||||
source: '/project/:ref/settings/warehouse',
|
||||
destination: '/project/:ref/settings/general',
|
||||
},
|
||||
{
|
||||
permanent: true,
|
||||
source: '/project/:ref/settings/functions',
|
||||
destination: '/project/:ref/functions/secrets',
|
||||
},
|
||||
...(process.env.NEXT_PUBLIC_BASE_PATH?.length
|
||||
? [
|
||||
{
|
||||
@@ -489,7 +494,7 @@ const nextConfig = {
|
||||
key: 'Strict-Transport-Security',
|
||||
value:
|
||||
process.env.NEXT_PUBLIC_IS_PLATFORM === 'true' && process.env.VERCEL === '1'
|
||||
? 'max-age=31536000'
|
||||
? 'max-age=1200; includeSubDomains'
|
||||
: '',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
import { openai } from '@ai-sdk/openai'
|
||||
import pgMeta from '@supabase/pg-meta'
|
||||
import { streamText } from 'ai'
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { getTools } from '../sql/tools'
|
||||
|
||||
export const maxDuration = 30
|
||||
const openAiKey = process.env.OPENAI_API_KEY
|
||||
const pgMetaSchemasList = pgMeta.schemas.list()
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!openAiKey) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: 'No OPENAI_API_KEY set. Create this environment variable to use AI features.',
|
||||
}),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
const { method } = req
|
||||
|
||||
switch (method) {
|
||||
case 'POST':
|
||||
return handlePost(req, res)
|
||||
default:
|
||||
return new Response(
|
||||
JSON.stringify({ data: null, error: { message: `Method ${method} Not Allowed` } }),
|
||||
{
|
||||
status: 405,
|
||||
headers: { 'Content-Type': 'application/json', Allow: 'POST' },
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePost(req: NextApiRequest, res: NextApiResponse) {
|
||||
try {
|
||||
const { completionMetadata, projectRef, connectionString, includeSchemaMetadata } = req.body
|
||||
const { textBeforeCursor, textAfterCursor, language, prompt, selection } = completionMetadata
|
||||
|
||||
if (!projectRef) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing project_ref in request body',
|
||||
})
|
||||
}
|
||||
|
||||
const authorization = req.headers.authorization
|
||||
|
||||
const { result: schemas } = includeSchemaMetadata
|
||||
? await executeSql(
|
||||
{
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: pgMetaSchemasList.sql,
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
...(authorization && { Authorization: authorization }),
|
||||
}
|
||||
)
|
||||
: { result: [] }
|
||||
|
||||
const result = await streamText({
|
||||
model: openai('gpt-4o-mini-2024-07-18'),
|
||||
maxSteps: 5,
|
||||
tools: getTools({ projectRef, connectionString, authorization, includeSchemaMetadata }),
|
||||
system: `
|
||||
# Writing Supabase Edge Functions
|
||||
|
||||
You're an expert in writing TypeScript and Deno JavaScript runtime. Generate **high-quality Supabase Edge Functions** that adhere to the following best practices:
|
||||
|
||||
## Guidelines
|
||||
|
||||
1. Try to use Web APIs and Deno's core APIs instead of external dependencies (eg: use fetch instead of Axios, use WebSockets API instead of node-ws)
|
||||
2. Do NOT use bare specifiers when importing dependencies. If you need to use an external dependency, make sure it's prefixed with either \`npm:\` or \`jsr:\`. For example, \`@supabase/supabase-js\` should be written as \`npm:@supabase/supabase-js\`.
|
||||
3. For external imports, always define a version. For example, \`npm:@express\` should be written as \`npm:express@4.18.2\`.
|
||||
4. For external dependencies, importing via \`npm:\` and \`jsr:\` is preferred. Minimize the use of imports from @\`deno.land/x\` , \`esm.sh\` and @\`unpkg.com\` . If you have a package from one of those CDNs, you can replace the CDN hostname with \`npm:\` specifier.
|
||||
5. You can also use Node built-in APIs. You will need to import them using \`node:\` specifier. For example, to import Node process: \`import process from "node:process"\`. Use Node APIs when you find gaps in Deno APIs.
|
||||
6. Do NOT use \`import { serve } from "https://deno.land/std@0.168.0/http/server.ts"\`. Instead use the built-in \`Deno.serve\`.
|
||||
7. Following environment variables (ie. secrets) are pre-populated in both local and hosted Supabase environments. Users don't need to manually set them:
|
||||
* SUPABASE_URL
|
||||
* SUPABASE_ANON_KEY
|
||||
* SUPABASE_SERVICE_ROLE_KEY
|
||||
* SUPABASE_DB_URL
|
||||
8. To set other environment variables the user can go to project settings then edge functions to set them
|
||||
9. A single Edge Function can handle multiple routes. It is recommended to use a library like Express or Hono to handle the routes as it's easier for developer to understand and maintain. Each route must be prefixed with \`/function-name\` so they are routed correctly.
|
||||
10. File write operations are ONLY permitted on \`/tmp\` directory. You can use either Deno or Node File APIs.
|
||||
11. Use \`EdgeRuntime.waitUntil(promise)\` static method to run long-running tasks in the background without blocking response to a request. Do NOT assume it is available in the request / execution context.
|
||||
|
||||
## Example Templates
|
||||
|
||||
### Simple Hello World Function
|
||||
|
||||
\`\`\`edge
|
||||
// Setup type definitions for built-in Supabase Runtime APIs
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
interface reqPayload {
|
||||
name: string;
|
||||
}
|
||||
|
||||
console.info('server started');
|
||||
|
||||
Deno.serve(async (req: Request) => {
|
||||
const { name }: reqPayload = await req.json();
|
||||
const data = {
|
||||
message: \`Hello \${name} from foo!\`,
|
||||
};
|
||||
|
||||
return new Response(
|
||||
JSON.stringify(data),
|
||||
{ headers: { 'Content-Type': 'application/json', 'Connection': 'keep-alive' }}
|
||||
);
|
||||
});
|
||||
\`\`\`
|
||||
|
||||
### Example Function using Node built-in API
|
||||
|
||||
\`\`\`edge
|
||||
// Setup type definitions for built-in Supabase Runtime APIs
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
import process from "node:process";
|
||||
|
||||
const generateRandomString = (length) => {
|
||||
const buffer = randomBytes(length);
|
||||
return buffer.toString('hex');
|
||||
};
|
||||
|
||||
const randomString = generateRandomString(10);
|
||||
console.log(randomString);
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const message = \`Hello\`;
|
||||
res.end(message);
|
||||
});
|
||||
|
||||
server.listen(9999);
|
||||
\`\`\`
|
||||
|
||||
### Using npm packages in Functions
|
||||
|
||||
\`\`\`edge
|
||||
// Setup type definitions for built-in Supabase Runtime APIs
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
import express from "npm:express@4.18.2";
|
||||
|
||||
const app = express();
|
||||
|
||||
app.get(/(.*)/, (req, res) => {
|
||||
res.send("Welcome to Supabase");
|
||||
});
|
||||
|
||||
app.listen(8000);
|
||||
\`\`\`
|
||||
|
||||
### Generate embeddings using built-in @Supabase.ai API
|
||||
|
||||
\`\`\`edge
|
||||
// Setup type definitions for built-in Supabase Runtime APIs
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
const model = new Supabase.ai.Session('gte-small');
|
||||
|
||||
Deno.serve(async (req: Request) => {
|
||||
const params = new URL(req.url).searchParams;
|
||||
const input = params.get('text');
|
||||
const output = await model.run(input, { mean_pool: true, normalize: true });
|
||||
return new Response(
|
||||
JSON.stringify(output),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Connection': 'keep-alive',
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
\`\`\`
|
||||
|
||||
## Integrating with Supabase Auth
|
||||
|
||||
\`\`\`edge
|
||||
// Setup type definitions for built-in Supabase Runtime APIs
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
import { createClient } from \\'jsr:@supabase/supabase-js@2\\'
|
||||
import { corsHeaders } from \\'../_shared/cors.ts\\'
|
||||
|
||||
console.log(\`Function "select-from-table-with-auth-rls" up and running!\`)
|
||||
|
||||
Deno.serve(async (req: Request) => {
|
||||
// This is needed if you\\'re planning to invoke your function from a browser.
|
||||
if (req.method === \\'OPTIONS\\') {
|
||||
return new Response(\\'ok\\', { headers: corsHeaders })
|
||||
}
|
||||
|
||||
try {
|
||||
// Create a Supabase client with the Auth context of the logged in user.
|
||||
const supabaseClient = createClient(
|
||||
// Supabase API URL - env var exported by default.
|
||||
Deno.env.get('SUPABASE_URL')!,
|
||||
// Supabase API ANON KEY - env var exported by default.
|
||||
Deno.env.get('SUPABASE_ANON_KEY')!,
|
||||
// Create client with Auth context of the user that called the function.
|
||||
// This way your row-level-security (RLS) policies are applied.
|
||||
{
|
||||
global: {
|
||||
headers: { Authorization: req.headers.get(\\'Authorization\\')! },
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// First get the token from the Authorization header
|
||||
const token = req.headers.get(\\'Authorization\\').replace(\\'Bearer \\', \\'\\')
|
||||
|
||||
// Now we can get the session or user object
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabaseClient.auth.getUser(token)
|
||||
|
||||
// And we can run queries in the context of our authenticated user
|
||||
const { data, error } = await supabaseClient.from(\\'users\\').select(\\'*\\')
|
||||
if (error) throw error
|
||||
|
||||
return new Response(JSON.stringify({ user, data }), {
|
||||
headers: { ...corsHeaders, \\'Content-Type\\': \\'application/json\\' },
|
||||
status: 200,
|
||||
})
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ error: error.message }), {
|
||||
headers: { ...corsHeaders, \\'Content-Type\\': \\'application/json\\' },
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// To invoke:
|
||||
// curl -i --location --request POST \\'http://localhost:54321/functions/v1/select-from-table-with-auth-rls\\' \\
|
||||
// --header \\'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24ifQ.625_WdcF3KHqz5amU0x2X5WWHP-OEs_4qj0ssLNHzTs\\' \\
|
||||
// --header \\'Content-Type: application/json\\' \\
|
||||
// --data \\'{"name":"Functions"}\\'
|
||||
\`\`\`
|
||||
|
||||
Database Integration:
|
||||
- Use the getSchema tool to understand the database structure when needed
|
||||
- Reference existing tables and schemas to ensure edge functions work with the user's data model
|
||||
- Use proper types that match the database schema
|
||||
- When accessing the database:
|
||||
- Use RLS policies appropriately for security
|
||||
- Handle database errors gracefully
|
||||
- Use efficient queries and proper indexing
|
||||
- Consider rate limiting for resource-intensive operations
|
||||
- Use connection pooling when appropriate
|
||||
- Implement proper error handling for database operations
|
||||
|
||||
# For all your abilities, follow these instructions:
|
||||
- First look at the list of provided schemas and if needed, get more information about a schema to understand the data model you're working with
|
||||
- If the edge function needs to interact with user data, check both the public and auth schemas to understand the authentication setup
|
||||
|
||||
Here are the existing database schema names you can retrieve: ${schemas}
|
||||
`,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: `You are helping me write TypeScript/JavaScript code for an edge function.
|
||||
Here is the context:
|
||||
${textBeforeCursor}<selection>${selection}</selection>${textAfterCursor}
|
||||
|
||||
Instructions:
|
||||
1. Only modify the selected text based on this prompt: ${prompt}
|
||||
2. Your response should be ONLY the modified selection text, nothing else. Remove selected text if needed.
|
||||
3. Do not wrap in code blocks or markdown
|
||||
4. You can respond with one word or multiple words
|
||||
5. Ensure the modified text flows naturally within the current line
|
||||
6. Avoid duplicating variable declarations, imports, or function definitions when considering the full code
|
||||
7. If there is no surrounding context (before or after), make sure your response is a complete valid Deno Edge Function including imports.
|
||||
|
||||
Modify the selected text now:`,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
return result.pipeDataStreamToResponse(res)
|
||||
} catch (error) {
|
||||
console.error('Completion error:', error)
|
||||
return res.status(500).json({
|
||||
error: 'Failed to generate completion',
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { streamText } from 'ai'
|
||||
import { openai } from '@ai-sdk/openai'
|
||||
import pgMeta from '@supabase/pg-meta'
|
||||
import { streamText } from 'ai'
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { getTools } from '../sql/tools'
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import pgMeta from '@supabase/pg-meta'
|
||||
|
||||
export const maxDuration = 30
|
||||
const openAiKey = process.env.OPENAI_API_KEY
|
||||
|
||||
@@ -12,7 +12,7 @@ const pgMetaSchemasList = pgMeta.schemas.list()
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!openAiKey) {
|
||||
return res.status(400).json({
|
||||
return res.status(500).json({
|
||||
error: 'No OPENAI_API_KEY set. Create this environment variable to use AI features.',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { StreamingTextResponse } from 'ai'
|
||||
import { chatRlsPolicy } from 'ai-commands/edge'
|
||||
import { DatabasePoliciesData } from 'data/database-policies/database-policies-query'
|
||||
import { NextRequest } from 'next/server'
|
||||
import OpenAI from 'openai'
|
||||
import { DatabasePoliciesData } from 'data/database-policies/database-policies-query'
|
||||
|
||||
export const config = {
|
||||
runtime: 'edge',
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { useIsInlineEditorEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
import { CreateFunction, DeleteFunction } from 'components/interfaces/Database'
|
||||
import FunctionsList from 'components/interfaces/Database/Functions/FunctionsList/FunctionsList'
|
||||
import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout'
|
||||
import DefaultLayout from 'components/layouts/DefaultLayout'
|
||||
import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
|
||||
import { FormHeader } from 'components/ui/Forms/FormHeader'
|
||||
import NoPermission from 'components/ui/NoPermission'
|
||||
import { DatabaseFunction } from 'data/database-functions/database-functions-query'
|
||||
import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
import DefaultLayout from 'components/layouts/DefaultLayout'
|
||||
import { useIsInlineEditorEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
import { useAppStateSnapshot } from 'state/app-state'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
|
||||
const FunctionsPage: NextPageWithLayout = () => {
|
||||
const DatabaseFunctionsPage: NextPageWithLayout = () => {
|
||||
const [selectedFunction, setSelectedFunction] = useState<DatabaseFunction | undefined>()
|
||||
const [showCreateFunctionForm, setShowCreateFunctionForm] = useState(false)
|
||||
const [showDeleteFunctionForm, setShowDeleteFunctionForm] = useState(false)
|
||||
@@ -101,10 +101,10 @@ $$;`,
|
||||
)
|
||||
}
|
||||
|
||||
FunctionsPage.getLayout = (page) => (
|
||||
DatabaseFunctionsPage.getLayout = (page) => (
|
||||
<DefaultLayout>
|
||||
<DatabaseLayout title="Database">{page}</DatabaseLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
|
||||
export default FunctionsPage
|
||||
export default DatabaseFunctionsPage
|
||||
@@ -1,12 +1,17 @@
|
||||
import { EdgeFunctionDetails } from 'components/interfaces/Functions'
|
||||
import FunctionsLayout from 'components/layouts/FunctionsLayout/FunctionsLayout'
|
||||
import { EdgeFunctionDetails } from 'components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails'
|
||||
import DefaultLayout from 'components/layouts/DefaultLayout'
|
||||
import EdgeFunctionDetailsLayout from 'components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
|
||||
const PageLayout: NextPageWithLayout = () => <EdgeFunctionDetails />
|
||||
const PageLayout: NextPageWithLayout = () => (
|
||||
<div className="px-6">
|
||||
<EdgeFunctionDetails />
|
||||
</div>
|
||||
)
|
||||
|
||||
PageLayout.getLayout = (page) => (
|
||||
<DefaultLayout>
|
||||
<FunctionsLayout>{page}</FunctionsLayout>
|
||||
<EdgeFunctionDetailsLayout>{page}</EdgeFunctionDetailsLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ import { useRouter } from 'next/router'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import ReportWidget from 'components/interfaces/Reports/ReportWidget'
|
||||
import FunctionsLayout from 'components/layouts/FunctionsLayout/FunctionsLayout'
|
||||
import DefaultLayout from 'components/layouts/DefaultLayout'
|
||||
import EdgeFunctionDetailsLayout from 'components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout'
|
||||
import AreaChart from 'components/ui/Charts/AreaChart'
|
||||
import StackedBarChart from 'components/ui/Charts/StackedBarChart'
|
||||
import NoPermission from 'components/ui/NoPermission'
|
||||
@@ -30,7 +31,6 @@ import {
|
||||
Button,
|
||||
WarningIcon,
|
||||
} from 'ui'
|
||||
import DefaultLayout from 'components/layouts/DefaultLayout'
|
||||
|
||||
const CHART_INTERVALS: ChartIntervals[] = [
|
||||
{
|
||||
@@ -80,7 +80,6 @@ const PageLayout: NextPageWithLayout = () => {
|
||||
slug: functionSlug,
|
||||
})
|
||||
const id = selectedFunction?.id
|
||||
|
||||
const reqStatsResult = useFunctionsReqStatsQuery({
|
||||
projectRef,
|
||||
functionId: id,
|
||||
@@ -157,174 +156,183 @@ const PageLayout: NextPageWithLayout = () => {
|
||||
if (!canReadFunction) {
|
||||
return <NoPermission isFullPage resourceText="access this edge function" />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 py-3">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<div className="flex items-center">
|
||||
{CHART_INTERVALS.map((item, i) => {
|
||||
const classes = []
|
||||
<div className="px-6 w-full pt-6">
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<div className="flex items-center">
|
||||
{CHART_INTERVALS.map((item, i) => {
|
||||
const classes = []
|
||||
|
||||
if (i === 0) {
|
||||
classes.push('rounded-tr-none rounded-br-none')
|
||||
} else if (i === CHART_INTERVALS.length - 1) {
|
||||
classes.push('rounded-tl-none rounded-bl-none')
|
||||
} else {
|
||||
classes.push('rounded-none')
|
||||
}
|
||||
if (i === 0) {
|
||||
classes.push('rounded-tr-none rounded-br-none')
|
||||
} else if (i === CHART_INTERVALS.length - 1) {
|
||||
classes.push('rounded-tl-none rounded-bl-none')
|
||||
} else {
|
||||
classes.push('rounded-none')
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={`function-filter-${i}`}
|
||||
type={interval === item.key ? 'secondary' : 'default'}
|
||||
onClick={() => setInterval(item.key)}
|
||||
className={classes.join(' ')}
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<span className="text-xs text-foreground-light">
|
||||
Statistics for past {selectedInterval.label}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 md:gap-4 lg:grid-cols-2 lg:gap-8">
|
||||
<ReportWidget
|
||||
title="Execution time"
|
||||
tooltip="Average execution time of function invocations"
|
||||
data={execTimeChartData}
|
||||
isLoading={reqStatsResult.isLoading}
|
||||
renderer={(props) => {
|
||||
return isErrorExecTime ? (
|
||||
<Alert_Shadcn_ variant="warning">
|
||||
<WarningIcon />
|
||||
<AlertTitle_Shadcn_>Failed to reterieve execution time</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>{execTimeError.message}</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
) : (
|
||||
<AreaChart
|
||||
className="w-full"
|
||||
xAxisKey="timestamp"
|
||||
customDateFormat={selectedInterval.format}
|
||||
yAxisKey="avg_execution_time"
|
||||
data={props.data}
|
||||
format="ms"
|
||||
highlightedValue={meanBy(props.data, 'avg_execution_time')}
|
||||
/>
|
||||
return (
|
||||
<Button
|
||||
key={`function-filter-${i}`}
|
||||
type={interval === item.key ? 'secondary' : 'default'}
|
||||
onClick={() => setInterval(item.key)}
|
||||
className={classes.join(' ')}
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<ReportWidget
|
||||
title="Invocations"
|
||||
data={invocationsChartData}
|
||||
isLoading={reqStatsResult.isLoading}
|
||||
renderer={(props) => {
|
||||
if (isErrorInvocations) {
|
||||
return (
|
||||
})}
|
||||
</div>
|
||||
|
||||
<span className="text-xs text-foreground-light">
|
||||
Statistics for past {selectedInterval.label}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 md:gap-4 lg:grid-cols-2 lg:gap-8">
|
||||
<ReportWidget
|
||||
title="Execution time"
|
||||
tooltip="Average execution time of function invocations"
|
||||
data={execTimeChartData}
|
||||
isLoading={reqStatsResult.isLoading}
|
||||
renderer={(props) => {
|
||||
return isErrorExecTime ? (
|
||||
<Alert_Shadcn_ variant="warning">
|
||||
<WarningIcon />
|
||||
<AlertTitle_Shadcn_>Failed to reterieve invocations</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>{invocationsError.message}</AlertDescription_Shadcn_>
|
||||
<AlertTitle_Shadcn_>Failed to reterieve execution time</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>{execTimeError.message}</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
)
|
||||
} else {
|
||||
const data = props.data
|
||||
.map((d: any) => [
|
||||
{
|
||||
status: '2xx',
|
||||
count: d.success_count,
|
||||
timestamp: d.timestamp,
|
||||
},
|
||||
{
|
||||
status: '3xx',
|
||||
count: d.redirect_count,
|
||||
timestamp: d.timestamp,
|
||||
},
|
||||
{
|
||||
status: '4xx',
|
||||
count: d.client_err_count,
|
||||
timestamp: d.timestamp,
|
||||
},
|
||||
{
|
||||
status: '5xx',
|
||||
count: d.server_err_count,
|
||||
timestamp: d.timestamp,
|
||||
},
|
||||
])
|
||||
.flat()
|
||||
|
||||
return (
|
||||
<StackedBarChart
|
||||
) : (
|
||||
<AreaChart
|
||||
className="w-full"
|
||||
xAxisKey="timestamp"
|
||||
yAxisKey="count"
|
||||
stackKey="status"
|
||||
data={data}
|
||||
highlightedValue={sumBy(data, 'count')}
|
||||
customDateFormat={selectedInterval.format}
|
||||
stackColors={['brand', 'slate', 'yellow', 'red']}
|
||||
onBarClick={() => {
|
||||
router.push(
|
||||
`/project/${projectRef}/functions/${functionSlug}/invocations?its=${startDate.toISOString()}`
|
||||
)
|
||||
}}
|
||||
yAxisKey="avg_execution_time"
|
||||
data={props.data}
|
||||
format="ms"
|
||||
highlightedValue={meanBy(props.data, 'avg_execution_time')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ReportWidget
|
||||
title="CPU time"
|
||||
tooltip="Average CPU time usage for the function"
|
||||
data={resourceUsageChartData}
|
||||
isLoading={resourceUsageResult.isLoading}
|
||||
renderer={(props) => {
|
||||
return isErrorResourceUsage ? (
|
||||
<Alert_Shadcn_ variant="warning">
|
||||
<WarningIcon />
|
||||
<AlertTitle_Shadcn_>Failed to retrieve CPU time</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>{resourceUsageError.message}</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
) : (
|
||||
<AreaChart
|
||||
className="w-full"
|
||||
xAxisKey="timestamp"
|
||||
customDateFormat={selectedInterval.format}
|
||||
yAxisKey="avg_cpu_time_used"
|
||||
data={props.data}
|
||||
format="ms"
|
||||
highlightedValue={meanBy(props.data, 'avg_cpu_time_used')}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<ReportWidget
|
||||
title="Memory"
|
||||
tooltip="Average memory usage for the function"
|
||||
data={resourceUsageChartData}
|
||||
isLoading={resourceUsageResult.isLoading}
|
||||
renderer={(props) => {
|
||||
return isErrorResourceUsage ? (
|
||||
<Alert_Shadcn_ variant="warning">
|
||||
<WarningIcon />
|
||||
<AlertTitle_Shadcn_>Failed to retrieve memory usage</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>{resourceUsageError.message}</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
) : (
|
||||
<AreaChart
|
||||
className="w-full"
|
||||
xAxisKey="timestamp"
|
||||
customDateFormat={selectedInterval.format}
|
||||
yAxisKey="avg_memory_used"
|
||||
data={props.data}
|
||||
format="MB"
|
||||
highlightedValue={meanBy(props.data, 'avg_memory_used')}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
}}
|
||||
/>
|
||||
<ReportWidget
|
||||
title="Invocations"
|
||||
data={invocationsChartData}
|
||||
isLoading={reqStatsResult.isLoading}
|
||||
renderer={(props) => {
|
||||
if (isErrorInvocations) {
|
||||
return (
|
||||
<Alert_Shadcn_ variant="warning">
|
||||
<WarningIcon />
|
||||
<AlertTitle_Shadcn_>Failed to reterieve invocations</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>
|
||||
{invocationsError.message}
|
||||
</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
)
|
||||
} else {
|
||||
const data = props.data
|
||||
.map((d: any) => [
|
||||
{
|
||||
status: '2xx',
|
||||
count: d.success_count,
|
||||
timestamp: d.timestamp,
|
||||
},
|
||||
{
|
||||
status: '3xx',
|
||||
count: d.redirect_count,
|
||||
timestamp: d.timestamp,
|
||||
},
|
||||
{
|
||||
status: '4xx',
|
||||
count: d.client_err_count,
|
||||
timestamp: d.timestamp,
|
||||
},
|
||||
{
|
||||
status: '5xx',
|
||||
count: d.server_err_count,
|
||||
timestamp: d.timestamp,
|
||||
},
|
||||
])
|
||||
.flat()
|
||||
|
||||
return (
|
||||
<StackedBarChart
|
||||
className="w-full"
|
||||
xAxisKey="timestamp"
|
||||
yAxisKey="count"
|
||||
stackKey="status"
|
||||
data={data}
|
||||
highlightedValue={sumBy(data, 'count')}
|
||||
customDateFormat={selectedInterval.format}
|
||||
stackColors={['brand', 'slate', 'yellow', 'red']}
|
||||
onBarClick={() => {
|
||||
router.push(
|
||||
`/project/${projectRef}/functions/${functionSlug}/invocations?its=${startDate.toISOString()}`
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ReportWidget
|
||||
title="CPU time"
|
||||
tooltip="Average CPU time usage for the function"
|
||||
data={resourceUsageChartData}
|
||||
isLoading={resourceUsageResult.isLoading}
|
||||
renderer={(props) => {
|
||||
return isErrorResourceUsage ? (
|
||||
<Alert_Shadcn_ variant="warning">
|
||||
<WarningIcon />
|
||||
<AlertTitle_Shadcn_>Failed to retrieve CPU time</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>
|
||||
{resourceUsageError.message}
|
||||
</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
) : (
|
||||
<AreaChart
|
||||
className="w-full"
|
||||
xAxisKey="timestamp"
|
||||
customDateFormat={selectedInterval.format}
|
||||
yAxisKey="avg_cpu_time_used"
|
||||
data={props.data}
|
||||
format="ms"
|
||||
highlightedValue={meanBy(props.data, 'avg_cpu_time_used')}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<ReportWidget
|
||||
title="Memory"
|
||||
tooltip="Average memory usage for the function"
|
||||
data={resourceUsageChartData}
|
||||
isLoading={resourceUsageResult.isLoading}
|
||||
renderer={(props) => {
|
||||
return isErrorResourceUsage ? (
|
||||
<Alert_Shadcn_ variant="warning">
|
||||
<WarningIcon />
|
||||
<AlertTitle_Shadcn_>Failed to retrieve memory usage</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_>
|
||||
{resourceUsageError.message}
|
||||
</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
) : (
|
||||
<AreaChart
|
||||
className="w-full"
|
||||
xAxisKey="timestamp"
|
||||
customDateFormat={selectedInterval.format}
|
||||
yAxisKey="avg_memory_used"
|
||||
data={props.data}
|
||||
format="MB"
|
||||
highlightedValue={meanBy(props.data, 'avg_memory_used')}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -333,7 +341,7 @@ const PageLayout: NextPageWithLayout = () => {
|
||||
|
||||
PageLayout.getLayout = (page) => (
|
||||
<DefaultLayout>
|
||||
<FunctionsLayout>{page}</FunctionsLayout>
|
||||
<EdgeFunctionDetailsLayout>{page}</EdgeFunctionDetailsLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { useParams } from 'common'
|
||||
|
||||
import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer'
|
||||
import FunctionsLayout from 'components/layouts/FunctionsLayout/FunctionsLayout'
|
||||
import DefaultLayout from 'components/layouts/DefaultLayout'
|
||||
import EdgeFunctionDetailsLayout from 'components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout'
|
||||
import { useEdgeFunctionQuery } from 'data/edge-functions/edge-function-query'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
|
||||
/**
|
||||
* Placeholder page for logs previewers until we figure out where to slot them
|
||||
*/
|
||||
export const LogPage: NextPageWithLayout = () => {
|
||||
const { ref, functionSlug } = useParams()
|
||||
const { data: selectedFunction, isLoading } = useEdgeFunctionQuery({
|
||||
@@ -19,19 +15,20 @@ export const LogPage: NextPageWithLayout = () => {
|
||||
if (selectedFunction === undefined || isLoading) return null
|
||||
|
||||
return (
|
||||
<LogsPreviewer
|
||||
condensedLayout
|
||||
projectRef={ref as string}
|
||||
queryType={'fn_edge'}
|
||||
filterOverride={{ function_id: selectedFunction.id }}
|
||||
filterPanelClassName="px-0"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<LogsPreviewer
|
||||
condensedLayout
|
||||
projectRef={ref as string}
|
||||
queryType="fn_edge"
|
||||
filterOverride={{ function_id: selectedFunction.id }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
LogPage.getLayout = (page) => (
|
||||
<DefaultLayout>
|
||||
<FunctionsLayout>{page}</FunctionsLayout>
|
||||
<EdgeFunctionDetailsLayout>{page}</EdgeFunctionDetailsLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useParams } from 'common/hooks'
|
||||
import { useParams } from 'common'
|
||||
import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer'
|
||||
import FunctionsLayout from 'components/layouts/FunctionsLayout/FunctionsLayout'
|
||||
import DefaultLayout from 'components/layouts/DefaultLayout'
|
||||
import EdgeFunctionDetailsLayout from 'components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout'
|
||||
import { useEdgeFunctionQuery } from 'data/edge-functions/edge-function-query'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
|
||||
@@ -16,19 +16,20 @@ export const LogPage: NextPageWithLayout = () => {
|
||||
if (selectedFunction === undefined || isLoading) return null
|
||||
|
||||
return (
|
||||
<LogsPreviewer
|
||||
condensedLayout
|
||||
projectRef={ref as string}
|
||||
queryType={'functions'}
|
||||
filterOverride={{ 'metadata.function_id': selectedFunction.id }}
|
||||
filterPanelClassName="px-0"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<LogsPreviewer
|
||||
condensedLayout
|
||||
projectRef={ref as string}
|
||||
queryType="functions"
|
||||
filterOverride={{ 'metadata.function_id': selectedFunction.id }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
LogPage.getLayout = (page) => (
|
||||
<DefaultLayout>
|
||||
<FunctionsLayout>{page}</FunctionsLayout>
|
||||
<EdgeFunctionDetailsLayout>{page}</EdgeFunctionDetailsLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
|
||||
|
||||
@@ -1,22 +1,40 @@
|
||||
import { useParams } from 'common'
|
||||
import { Button, Dialog, DialogContent, DialogSection, DialogTrigger } from 'ui'
|
||||
import { ChevronDown, Code, ExternalLink, Terminal } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
import {
|
||||
EdgeFunctionsListItem,
|
||||
FunctionsEmptyState,
|
||||
TerminalInstructions,
|
||||
} from 'components/interfaces/Functions'
|
||||
import FunctionsLayout from 'components/layouts/FunctionsLayout/FunctionsLayout'
|
||||
import { useParams } from 'common'
|
||||
import { EdgeFunctionsListItem } from 'components/interfaces/Functions/EdgeFunctionsListItem'
|
||||
import { FunctionsEmptyState } from 'components/interfaces/Functions/FunctionsEmptyState'
|
||||
import { TerminalInstructions } from 'components/interfaces/Functions/TerminalInstructions'
|
||||
import DefaultLayout from 'components/layouts/DefaultLayout'
|
||||
import EdgeFunctionsLayout from 'components/layouts/EdgeFunctionsLayout/EdgeFunctionsLayout'
|
||||
import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
|
||||
import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
|
||||
import Table from 'components/to-be-cleaned/Table'
|
||||
import AlertError from 'components/ui/AlertError'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import { DocsButton } from 'components/ui/DocsButton'
|
||||
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
|
||||
import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query'
|
||||
import { useFlag } from 'hooks/ui/useFlag'
|
||||
import { useAppStateSnapshot } from 'state/app-state'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
import {
|
||||
AiIconAnimation,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogSection,
|
||||
DialogTrigger,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from 'ui'
|
||||
|
||||
const PageLayout: NextPageWithLayout = () => {
|
||||
const EdgeFunctionsPage: NextPageWithLayout = () => {
|
||||
const { ref } = useParams()
|
||||
|
||||
const { setAiAssistantPanel } = useAppStateSnapshot()
|
||||
const router = useRouter()
|
||||
const {
|
||||
data: functions,
|
||||
error,
|
||||
@@ -24,35 +42,111 @@ const PageLayout: NextPageWithLayout = () => {
|
||||
isError,
|
||||
isSuccess,
|
||||
} = useEdgeFunctionsQuery({ projectRef: ref })
|
||||
const edgeFunctionCreate = useFlag('edgeFunctionCreate')
|
||||
|
||||
const hasFunctions = (functions ?? []).length > 0
|
||||
|
||||
const deployButton = (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="primary" iconRight={<ChevronDown className="w-4 h-4" strokeWidth={1.5} />}>
|
||||
Deploy a new function
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-80">
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<DropdownMenuItem className="gap-4" onSelect={(e) => e.preventDefault()}>
|
||||
<Terminal className="shrink-0" size={16} strokeWidth={1.5} />
|
||||
<div>
|
||||
<span className="text-foreground">Via CLI</span>
|
||||
<p>
|
||||
Create an edge function locally and then deploy your function via the Supabase CLI
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
<DialogContent size="large">
|
||||
<DialogSection padding="small">
|
||||
<TerminalInstructions />
|
||||
</DialogSection>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{edgeFunctionCreate && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => router.push(`/project/${ref}/functions/new`)}
|
||||
className="gap-4"
|
||||
>
|
||||
<Code className="shrink-0" size={16} strokeWidth={1.5} />
|
||||
<div>
|
||||
<span className="text-foreground">Via Editor</span>
|
||||
<p>
|
||||
Create an edge function in the Supabase Studio editor and then deploy your function
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
|
||||
const secondaryActions = [
|
||||
<DocsButton key="docs" href="https://supabase.com/docs/guides/functions" />,
|
||||
<Button asChild key="edge-function-examples" type="default" icon={<ExternalLink />}>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions"
|
||||
>
|
||||
Examples
|
||||
</a>
|
||||
</Button>,
|
||||
<ButtonTooltip
|
||||
key="edge-function-create"
|
||||
type="default"
|
||||
className="px-1 pointer-events-auto"
|
||||
icon={<AiIconAnimation size={16} />}
|
||||
onClick={() =>
|
||||
setAiAssistantPanel({
|
||||
open: true,
|
||||
initialInput: `Create a new edge function that ...`,
|
||||
suggestions: {
|
||||
title:
|
||||
'I can help you create a new edge function. Here are a few example prompts to get you started:',
|
||||
prompts: [
|
||||
'Create a new edge function that processes payments with Stripe',
|
||||
'Create a new edge function that sends emails with Resend',
|
||||
'Create a new edge function that generates PDFs from HTML templates',
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: 'Create with Supabase Assistant',
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="py-6">
|
||||
{isLoading && <GenericSkeletonLoader />}
|
||||
<PageLayout
|
||||
size="large"
|
||||
title="Edge Functions"
|
||||
subtitle="Deploy edge functions to handle complex business logic"
|
||||
primaryActions={deployButton}
|
||||
secondaryActions={secondaryActions}
|
||||
>
|
||||
<ScaffoldContainer size="large">
|
||||
<ScaffoldSection isFullWidth>
|
||||
{isLoading && <GenericSkeletonLoader />}
|
||||
|
||||
{isError && <AlertError error={error} subject="Failed to retrieve edge functions" />}
|
||||
{isError && <AlertError error={error} subject="Failed to retrieve edge functions" />}
|
||||
|
||||
{isSuccess && (
|
||||
<>
|
||||
{hasFunctions ? (
|
||||
<div className="py-6 space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-foreground-lighter">{`${functions.length} function${
|
||||
functions.length > 1 ? 's' : ''
|
||||
} deployed`}</span>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="primary">Deploy a new function</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent size={'large'}>
|
||||
<DialogSection padding="small">
|
||||
<TerminalInstructions />
|
||||
</DialogSection>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
{isSuccess && (
|
||||
<>
|
||||
{hasFunctions ? (
|
||||
<Table
|
||||
head={
|
||||
<>
|
||||
@@ -72,21 +166,23 @@ const PageLayout: NextPageWithLayout = () => {
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<FunctionsEmptyState />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<FunctionsEmptyState />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ScaffoldSection>
|
||||
</ScaffoldContainer>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
PageLayout.getLayout = (page) => (
|
||||
<DefaultLayout>
|
||||
<FunctionsLayout>{page}</FunctionsLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
EdgeFunctionsPage.getLayout = (page) => {
|
||||
return (
|
||||
<DefaultLayout>
|
||||
<EdgeFunctionsLayout>{page}</EdgeFunctionsLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default PageLayout
|
||||
export default EdgeFunctionsPage
|
||||
@@ -0,0 +1,383 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { AlertCircle, Book, Check } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { toast } from 'sonner'
|
||||
import * as z from 'zod'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { EDGE_FUNCTION_TEMPLATES } from 'components/interfaces/Functions/Functions.templates'
|
||||
import DefaultLayout from 'components/layouts/DefaultLayout'
|
||||
import EdgeFunctionsLayout from 'components/layouts/EdgeFunctionsLayout/EdgeFunctionsLayout'
|
||||
import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
|
||||
import FileExplorerAndEditor from 'components/ui/FileExplorerAndEditor/FileExplorerAndEditor'
|
||||
import { useEdgeFunctionDeployMutation } from 'data/edge-functions/edge-functions-deploy-mutation'
|
||||
import { useOrgOptedIntoAi } from 'hooks/misc/useOrgOptedIntoAi'
|
||||
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
|
||||
import { useFlag } from 'hooks/ui/useFlag'
|
||||
import { BASE_PATH, IS_PLATFORM } from 'lib/constants'
|
||||
import { useAppStateSnapshot } from 'state/app-state'
|
||||
import {
|
||||
AiIconAnimation,
|
||||
Button,
|
||||
cn,
|
||||
Command_Shadcn_,
|
||||
CommandEmpty_Shadcn_,
|
||||
CommandGroup_Shadcn_,
|
||||
CommandInput_Shadcn_,
|
||||
CommandItem_Shadcn_,
|
||||
CommandList_Shadcn_,
|
||||
Form_Shadcn_,
|
||||
FormControl_Shadcn_,
|
||||
FormField_Shadcn_,
|
||||
FormItem_Shadcn_,
|
||||
Input_Shadcn_,
|
||||
Label_Shadcn_,
|
||||
Popover_Shadcn_,
|
||||
PopoverContent_Shadcn_,
|
||||
PopoverTrigger_Shadcn_,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from 'ui'
|
||||
|
||||
// Array of adjectives and nouns for random function name generation
|
||||
const ADJECTIVES = [
|
||||
'quick',
|
||||
'clever',
|
||||
'bright',
|
||||
'swift',
|
||||
'rapid',
|
||||
'smart',
|
||||
'smooth',
|
||||
'dynamic',
|
||||
'super',
|
||||
'hyper',
|
||||
]
|
||||
const NOUNS = [
|
||||
'function',
|
||||
'handler',
|
||||
'processor',
|
||||
'responder',
|
||||
'worker',
|
||||
'service',
|
||||
'api',
|
||||
'endpoint',
|
||||
'action',
|
||||
'task',
|
||||
]
|
||||
|
||||
// Function name validation regex - only allows alphanumeric characters, hyphens, and underscores
|
||||
const FUNCTION_NAME_REGEX = /^[A-Za-z0-9_-]+$/
|
||||
|
||||
// Define form schema with yup
|
||||
const FormSchema = z.object({
|
||||
functionName: z
|
||||
.string()
|
||||
.min(1, 'Function name is required')
|
||||
.regex(FUNCTION_NAME_REGEX, 'Only letters, numbers, hyphens, and underscores allowed'),
|
||||
})
|
||||
|
||||
// Generate a random function name
|
||||
const generateRandomFunctionName = () => {
|
||||
const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]
|
||||
const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)]
|
||||
return `${adjective}-${noun}`
|
||||
}
|
||||
|
||||
// Convert invalid function name to valid one
|
||||
const sanitizeFunctionName = (name: string): string => {
|
||||
// Replace invalid characters with hyphens
|
||||
return name.replace(/[^A-Za-z0-9_-]/g, '-')
|
||||
}
|
||||
|
||||
// Type for the form values
|
||||
type FormValues = z.infer<typeof FormSchema>
|
||||
|
||||
const NewFunctionPage = () => {
|
||||
const router = useRouter()
|
||||
const { ref, template } = useParams()
|
||||
const project = useSelectedProject()
|
||||
const isOptedInToAI = useOrgOptedIntoAi()
|
||||
const includeSchemaMetadata = isOptedInToAI || !IS_PLATFORM
|
||||
const { setAiAssistantPanel } = useAppStateSnapshot()
|
||||
const edgeFunctionCreate = useFlag('edgeFunctionCreate')
|
||||
|
||||
const [files, setFiles] = useState<
|
||||
{ id: number; name: string; content: string; selected?: boolean }[]
|
||||
>([
|
||||
{
|
||||
id: 1,
|
||||
name: 'index.ts',
|
||||
selected: true,
|
||||
content: EDGE_FUNCTION_TEMPLATES[0].content,
|
||||
},
|
||||
])
|
||||
const [open, setOpen] = useState(false)
|
||||
const [isPreviewingTemplate, setIsPreviewingTemplate] = useState(false)
|
||||
const [savedCode, setSavedCode] = useState<string>('')
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: {
|
||||
functionName: generateRandomFunctionName(),
|
||||
},
|
||||
})
|
||||
|
||||
const { mutate: deployFunction, isLoading: isDeploying } = useEdgeFunctionDeployMutation({
|
||||
// [Joshen] To investigate: For some reason, the invalidation for list of edge functions isn't triggering
|
||||
onSuccess: () => {
|
||||
toast.success('Successfully deployed edge function')
|
||||
const functionName = form.getValues('functionName')
|
||||
if (ref && functionName) {
|
||||
router.push(`/project/${ref}/functions/${functionName}/details`)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = (values: FormValues) => {
|
||||
if (isDeploying || !ref) return
|
||||
|
||||
deployFunction({
|
||||
projectRef: ref,
|
||||
metadata: {
|
||||
entrypoint_path: 'index.ts',
|
||||
name: values.functionName,
|
||||
verify_jwt: true,
|
||||
},
|
||||
files: files.map(({ name, content }) => ({ name, content })),
|
||||
})
|
||||
}
|
||||
|
||||
const handleChat = () => {
|
||||
const selectedFile = files.find((f) => f.selected) ?? files[0]
|
||||
setAiAssistantPanel({
|
||||
open: true,
|
||||
sqlSnippets: [selectedFile.content],
|
||||
initialInput: 'Help me understand and improve this edge function...',
|
||||
suggestions: {
|
||||
title:
|
||||
'I can help you understand and improve your edge function. Here are a few example prompts to get you started:',
|
||||
prompts: [
|
||||
'Explain what this function does...',
|
||||
'Help me optimize this function...',
|
||||
'Show me how to add more features...',
|
||||
'Help me handle errors better...',
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const onSelectTemplate = (templateValue: string) => {
|
||||
const template = EDGE_FUNCTION_TEMPLATES.find((t) => t.value === templateValue)
|
||||
if (template) {
|
||||
setFiles((prev) =>
|
||||
prev.map((file) => (file.selected ? { ...file, content: template.content } : file))
|
||||
)
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTemplateMouseEnter = (content: string) => {
|
||||
if (!isPreviewingTemplate) {
|
||||
const selectedFile = files.find((f) => f.selected) ?? files[0]
|
||||
setSavedCode(selectedFile.content)
|
||||
}
|
||||
setIsPreviewingTemplate(true)
|
||||
setFiles((prev) => prev.map((file) => (file.selected ? { ...file, content } : file)))
|
||||
}
|
||||
|
||||
const handleTemplateMouseLeave = () => {
|
||||
if (isPreviewingTemplate) {
|
||||
setIsPreviewingTemplate(false)
|
||||
setFiles((prev) =>
|
||||
prev.map((file) => (file.selected ? { ...file, content: savedCode } : file))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Try to sanitize function name when it's invalid
|
||||
const handleDeploy = () => {
|
||||
const currentName = form.getValues('functionName')
|
||||
const isValid = FUNCTION_NAME_REGEX.test(currentName)
|
||||
|
||||
if (!isValid && currentName) {
|
||||
const sanitizedName = sanitizeFunctionName(currentName)
|
||||
form.setValue('functionName', sanitizedName, { shouldValidate: true })
|
||||
}
|
||||
|
||||
form.handleSubmit(onSubmit)()
|
||||
}
|
||||
|
||||
// TODO (Saxon): Remove this once the flag is fully launched
|
||||
useEffect(() => {
|
||||
if (!edgeFunctionCreate) {
|
||||
router.push(`/project/${ref}/functions`)
|
||||
}
|
||||
}, [edgeFunctionCreate, ref, router])
|
||||
|
||||
useEffect(() => {
|
||||
if (template) {
|
||||
const templateMeta = EDGE_FUNCTION_TEMPLATES.find((x) => x.value === template)
|
||||
if (templateMeta) {
|
||||
form.reset({ functionName: template })
|
||||
setFiles([
|
||||
{
|
||||
id: 1,
|
||||
name: 'index.ts',
|
||||
selected: true,
|
||||
content: templateMeta.content,
|
||||
},
|
||||
])
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [template])
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
size="full"
|
||||
isCompact
|
||||
title="Create new edge function"
|
||||
breadcrumbs={[
|
||||
{
|
||||
label: 'Edge Functions',
|
||||
href: `/project/${ref}/functions`,
|
||||
},
|
||||
]}
|
||||
primaryActions={
|
||||
<>
|
||||
<Popover_Shadcn_ open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger_Shadcn_ asChild>
|
||||
<Button
|
||||
size="tiny"
|
||||
type="default"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
icon={<Book size={14} />}
|
||||
>
|
||||
Templates
|
||||
</Button>
|
||||
</PopoverTrigger_Shadcn_>
|
||||
<PopoverContent_Shadcn_ className="w-[300px] p-0" align="end">
|
||||
<Command_Shadcn_>
|
||||
<CommandInput_Shadcn_ placeholder="Search templates..." />
|
||||
<CommandList_Shadcn_>
|
||||
<CommandEmpty_Shadcn_>No templates found.</CommandEmpty_Shadcn_>
|
||||
<CommandGroup_Shadcn_>
|
||||
{EDGE_FUNCTION_TEMPLATES.map((template) => (
|
||||
<CommandItem_Shadcn_
|
||||
key={template.value}
|
||||
value={template.value}
|
||||
onSelect={onSelectTemplate}
|
||||
onMouseEnter={() => handleTemplateMouseEnter(template.content)}
|
||||
onMouseLeave={handleTemplateMouseLeave}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center">
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
files.some((f) => f.selected && f.content === template.content)
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
<span className="text-foreground">{template.name}</span>
|
||||
</div>
|
||||
<span className="text-xs text-foreground-light pl-6">
|
||||
{template.description}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem_Shadcn_>
|
||||
))}
|
||||
</CommandGroup_Shadcn_>
|
||||
</CommandList_Shadcn_>
|
||||
</Command_Shadcn_>
|
||||
</PopoverContent_Shadcn_>
|
||||
</Popover_Shadcn_>
|
||||
<Button
|
||||
size="tiny"
|
||||
type="default"
|
||||
onClick={handleChat}
|
||||
icon={<AiIconAnimation size={16} />}
|
||||
>
|
||||
Chat
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<FileExplorerAndEditor
|
||||
files={files}
|
||||
onFilesChange={setFiles}
|
||||
aiEndpoint={`${BASE_PATH}/api/ai/edge-function/complete`}
|
||||
aiMetadata={{
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
includeSchemaMetadata,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Form_Shadcn_ {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex items-center bg-background-muted justify-end p-4 border-t bg-surface-100 gap-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Label_Shadcn_ htmlFor="functionName">Function name</Label_Shadcn_>
|
||||
<FormField_Shadcn_
|
||||
control={form.control}
|
||||
name="functionName"
|
||||
render={({ field }) => (
|
||||
<FormItem_Shadcn_ className="flex flex-col gap-0 m-0">
|
||||
<div className="flex items-center">
|
||||
<FormControl_Shadcn_>
|
||||
<Input_Shadcn_
|
||||
id="functionName"
|
||||
type="text"
|
||||
size={'large'}
|
||||
placeholder="Give your function a name..."
|
||||
className="w-[250px]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl_Shadcn_>
|
||||
{form.formState.errors.functionName && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<AlertCircle className="w-4 h-4 text-destructive ml-2" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{form.formState.errors.functionName.message}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</FormItem_Shadcn_>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
loading={isDeploying}
|
||||
size="medium"
|
||||
disabled={files.length === 0 || isDeploying}
|
||||
onClick={handleDeploy}
|
||||
>
|
||||
Deploy function
|
||||
</Button>
|
||||
</form>
|
||||
</Form_Shadcn_>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
NewFunctionPage.getLayout = (page: React.ReactNode) => {
|
||||
return (
|
||||
<DefaultLayout>
|
||||
<EdgeFunctionsLayout>{page}</EdgeFunctionsLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default NewFunctionPage
|
||||
@@ -0,0 +1,32 @@
|
||||
import EdgeFunctionSecrets from 'components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecrets'
|
||||
import DefaultLayout from 'components/layouts/DefaultLayout'
|
||||
import EdgeFunctionsLayout from 'components/layouts/EdgeFunctionsLayout/EdgeFunctionsLayout'
|
||||
import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
|
||||
import { ScaffoldContainer } from 'components/layouts/Scaffold'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
|
||||
const SecretsPage: NextPageWithLayout = () => {
|
||||
return (
|
||||
<ScaffoldContainer size="large" className="pt-6">
|
||||
<EdgeFunctionSecrets />
|
||||
</ScaffoldContainer>
|
||||
)
|
||||
}
|
||||
|
||||
SecretsPage.getLayout = (page) => {
|
||||
return (
|
||||
<DefaultLayout>
|
||||
<EdgeFunctionsLayout>
|
||||
<PageLayout
|
||||
size="large"
|
||||
title="Edge Function Secrets"
|
||||
subtitle="Manage the secrets for your project's edge functions"
|
||||
>
|
||||
{page}
|
||||
</PageLayout>
|
||||
</EdgeFunctionsLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default SecretsPage
|
||||
@@ -1,42 +0,0 @@
|
||||
import EdgeFunctionSecrets from 'components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecrets'
|
||||
import DefaultLayout from 'components/layouts/DefaultLayout'
|
||||
import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout'
|
||||
import {
|
||||
ScaffoldContainer,
|
||||
ScaffoldDescription,
|
||||
ScaffoldHeader,
|
||||
ScaffoldTitle,
|
||||
} from 'components/layouts/Scaffold'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
import { DocsButton } from 'components/ui/DocsButton'
|
||||
|
||||
const PageLayout: NextPageWithLayout = () => {
|
||||
return (
|
||||
<>
|
||||
<ScaffoldContainer>
|
||||
<ScaffoldHeader className="flex flex-row justify-between">
|
||||
<div>
|
||||
<ScaffoldTitle>Edge Function Secrets</ScaffoldTitle>
|
||||
<ScaffoldDescription>
|
||||
Manage the secrets (environment variables) for your project's Edge Functions
|
||||
</ScaffoldDescription>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<DocsButton href="https://supabase.com/docs/guides/functions/secrets" />
|
||||
</div>
|
||||
</ScaffoldHeader>
|
||||
</ScaffoldContainer>
|
||||
<ScaffoldContainer bottomPadding>
|
||||
<EdgeFunctionSecrets />
|
||||
</ScaffoldContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
PageLayout.getLayout = (page) => (
|
||||
<DefaultLayout>
|
||||
<SettingsLayout title="Settings">{page}</SettingsLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
|
||||
export default PageLayout
|
||||
@@ -0,0 +1,320 @@
|
||||
---
|
||||
title: 'Postgres as a Graph Database: (Ab)using pgRouting'
|
||||
description:
|
||||
author: paul_copplestone
|
||||
image: pgrouting-graph/pgrouting-og.png
|
||||
thumb: pgrouting-graph/pgrouting-thumb.png
|
||||
categories:
|
||||
- developers
|
||||
tags:
|
||||
- postgres
|
||||
date: '2025-02-25'
|
||||
toc_depth: 3
|
||||
---
|
||||
|
||||
[pgRouting](https://github.com/pgRouting/pgrouting) is a Postgres extension. It's often used for finding the “shortest path” between two locations, however it's a hidden gem in Postgres and can be used for basic graph functionality.
|
||||
|
||||

|
||||
|
||||
pgRouting is typically combined with PostGIS for working with geospatial data, but it can also be [useful beyond that](https://news.ycombinator.com/item?id=26350454) as a lightweight alternative to Graph extensions like [Apache AGE](https://age.apache.org/), or specialized graph databases like [Neo4j](https://neo4j.com/).
|
||||
|
||||
Let's explore some useful applications of pgRouting and graphs.
|
||||
|
||||
## What is pgRouting?
|
||||
|
||||
pgRouting is an extension of PostGIS that provides geospatial routing functionality. You can use it to calculate the shortest path, perform network analysis, and solve complex routing problems on a graph-based structure. Most commonly, this is used in Geographic Information Systems (GIS) for tasks like determining the fastest route between two locations.
|
||||
|
||||

|
||||
|
||||
## Working with Graphs
|
||||
|
||||
The power of pgRouting lies in its ability to work with any data structured as a graph. A graph is essentially a network of interconnected points, where:
|
||||
|
||||
- **Nodes** represent entities.
|
||||
- **Edges** represent relationships or paths between those nodes.
|
||||
|
||||
In maps / [GIS](https://en.wikipedia.org/wiki/Geographic_information_system), nodes and edges represent intersections and roads respectively. However, this structure can also be applied to abstract systems like a social networks, where users are nodes and friendships are edges.
|
||||
|
||||
<Img
|
||||
alt="create API key on Cal.com"
|
||||
src={{
|
||||
dark: '/images/blog/pgrouting-graph/graph-dark.png',
|
||||
light: '/images/blog/pgrouting-graph/graph-light.png',
|
||||
}}
|
||||
/>
|
||||
|
||||
## Non-GIS Use Cases for pgRouting
|
||||
|
||||
Let's explore how pgRouting can be applied to a few non-[GIS](https://en.wikipedia.org/wiki/Geographic_information_system) problems.
|
||||
|
||||
### Task scheduling
|
||||
|
||||
In any project, tasks have dependencies. For example, task B can only start after task A is completed. This creates a [directed acyclic graph](https://en.wikipedia.org/wiki/Directed_acyclic_graph) (DAG), where:
|
||||
|
||||
- nodes represent tasks
|
||||
- edges represent dependencies
|
||||
|
||||
One of the most challenging aspects of managing projects is determining the “critical path” — the project's overall duration, determined by the longest sequence of dependencies.
|
||||
|
||||
<Img
|
||||
alt="create API key on Cal.com"
|
||||
src={{
|
||||
dark: '/images/blog/pgrouting-graph/path-dark.png',
|
||||
light: '/images/blog/pgrouting-graph/path-light.png',
|
||||
}}
|
||||
/>
|
||||
|
||||
Using pgRouting, you can model your task's dependencies, using graph algorithms to find the critical path. Suppose we have a table tasks with task dependencies modeled as a graph:
|
||||
|
||||
```sql
|
||||
-- Create the tasks table with dependencies
|
||||
create table tasks (
|
||||
id serial primary key,
|
||||
name text not null
|
||||
);
|
||||
|
||||
-- insert tasks into the table
|
||||
insert into tasks (name)
|
||||
values
|
||||
('Start Project'),
|
||||
('Task A'),
|
||||
('Task B'),
|
||||
('Task C'),
|
||||
('Task D'),
|
||||
('End Project');
|
||||
|
||||
-- create the dependencies table
|
||||
create table dependencies (
|
||||
id serial primary key,
|
||||
source integer not null, -- task id where the dependency starts
|
||||
target integer not null, -- task id where the dependency ends
|
||||
duration integer not null, -- duration of the task in days
|
||||
constraint fk_source foreign key (source) references tasks (id),
|
||||
constraint fk_target foreign key (target) references tasks (id)
|
||||
);
|
||||
|
||||
-- insert dependencies with durations (directed edges)
|
||||
insert into dependencies (source, target, duration)
|
||||
values
|
||||
(1, 2, 3), -- start project -> task a (3 days)
|
||||
(2, 3, 4), -- task a -> task b (4 days)
|
||||
(3, 4, 5), -- task b -> task c (5 days)
|
||||
(4, 5, 2), -- task c -> task d (2 days)
|
||||
(5, 6, 6);
|
||||
-- task d -> end project (6 days)
|
||||
```
|
||||
|
||||
You can then use the [`pgr_dijkstra()`](https://docs.pgrouting.org/latest/en/pgr_dijkstra.html) function to find the shortest (or longest) path through the tasks, allowing you to map out the project schedule effectively:
|
||||
|
||||
```sql
|
||||
create schema if not exists extensions;
|
||||
create extension pgrouting schema extensions cascade;
|
||||
|
||||
-- find the longest path using pgr_dijkstra()
|
||||
-- (as it calculates shortest path, use negative weights)
|
||||
select * FROM extensions.pgr_dijkstra(
|
||||
'select id, source, target, duration as cost from dependencies',
|
||||
1, -- Start Project (Task ID 1)
|
||||
6 -- End Project (Task ID 6)
|
||||
);
|
||||
```
|
||||
|
||||
Which returns a table showing that this project will take 20 days from start to finish:
|
||||
|
||||
| **seq** | **path_seq** | **node** | **edge** | **cost** | **agg_cost** |
|
||||
| ------- | ------------ | -------- | -------- | -------- | ------------ |
|
||||
| 1 | 1 | 1 | 1 | 3 | 0 |
|
||||
| 2 | 2 | 2 | 2 | 4 | 3 |
|
||||
| 3 | 3 | 3 | 3 | 5 | 7 |
|
||||
| 4 | 4 | 4 | 4 | 2 | 12 |
|
||||
| 5 | 5 | 5 | 5 | 6 | 14 |
|
||||
| 6 | 6 | 6 | -1 | 0 | 20 |
|
||||
|
||||
<details>
|
||||
<summary>Tangent: the Dijkstra algorithm</summary>
|
||||
<div>
|
||||
The `pgr_dijkstra()` function implements [Dijkstra's
|
||||
algorithm](https://en.wikipedia.org/wiki/Dijkstra's_algorithm), which is used to find the
|
||||
shortest path between nodes in a graph. This algorithm guarantees the shortest path from a
|
||||
source node to a target node (or all other nodes), based on the cost of edges connecting the
|
||||
nodes.
|
||||
</div>
|
||||
<div>
|
||||
Fun fact: Dijkstra's algorithm was published in 1959 by Dutch computer scientist Edsger
|
||||
Dijkstra. It's a “greedy” algorithm, meaning it always picks the closest, cheapest node to
|
||||
explore next.
|
||||
</div>
|
||||
</details>
|
||||
|
||||
### Reverse proxy routing based on resource allocation
|
||||
|
||||
Distributed systems usually involve allocating resources efficiently across a network of nodes. Each node might represent a physical location or a computing process, and the edges represent the available pathways to move resources between them. For example, in a cloud infrastructure, pgRouting could help determine how to allocate compute tasks across a set of distributed servers by finding the shortest or least-congested path to route data.
|
||||
|
||||
Suppose you have a network of servers represented by nodes and their data connections as edges in a table servers.
|
||||
|
||||
```sql
|
||||
-- create the servers table representing the nodes
|
||||
create table servers (
|
||||
id serial primary key,
|
||||
name text,
|
||||
x double precision, -- x coordinate for spatial data (latitude)
|
||||
y double precision -- y coordinate for spatial data (longitude)
|
||||
);
|
||||
|
||||
-- insert some sample servers
|
||||
insert into servers (name, x, y)
|
||||
values
|
||||
('server a', 0, 0),
|
||||
('server b', 2, 1),
|
||||
('server c', 4, 3),
|
||||
('server d', 3, 5);
|
||||
|
||||
-- create the server_connections table representing the edges
|
||||
create table server_latency (
|
||||
id serial primary key,
|
||||
source integer,
|
||||
target integer,
|
||||
cost double precision, -- cost could represent latency or bandwidth
|
||||
x1 double precision, -- x coordinate of source
|
||||
y1 double precision, -- y coordinate of source
|
||||
x2 double precision, -- x coordinate of target
|
||||
y2 double precision, -- y coordinate of target,
|
||||
constraint fk_source foreign key (source) references servers (id),
|
||||
constraint fk_target foreign key (target) references servers (id)
|
||||
);
|
||||
|
||||
-- insert connections between servers
|
||||
insert into server_latency (source, target, cost, x1, y1, x2, y2)
|
||||
values
|
||||
(1, 2, 1.5, 0, 0, 2, 1), -- server a -> server b with a cost of 1.5 (could be latency)
|
||||
(2, 3, 2.0, 2, 1, 4, 3), -- server b -> server c with a cost of 2.0
|
||||
(2, 4, 1.8, 2, 1, 3, 5), -- server b -> server d with a cost of 1.8
|
||||
(4, 3, 1.0, 3, 5, 4, 3);
|
||||
-- server d -> server c with a cost of 1.0
|
||||
```
|
||||
|
||||
You can then use [`pgr_astar`](https://docs.pgrouting.org/latest/en/pgr_aStar.html)() to find the most efficient path for data or compute tasks to travel through this network, optimizing for speed or load:
|
||||
|
||||
```sql
|
||||
-- Query to find the most efficient path (using pgr_astar)
|
||||
select *
|
||||
from
|
||||
extensions.pgr_astar(
|
||||
'select id, source, target, cost, x1, y1, x2, y2 from server_latency',
|
||||
1,
|
||||
3 -- Start from Server A (id=1) to Server C (id=3)
|
||||
);
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Tangent: the A\* algorithm</summary>
|
||||
<div>
|
||||
The `pgr_astar()` function is an implementation of the [A\* (A-star)
|
||||
algorithm](https://en.wikipedia.org/wiki/A*_search_algorithm). It's used to find the most
|
||||
efficient (shortest) path between two points in a graph. A\* is commonly used in navigation and
|
||||
routing because it is more efficient than Dijkstra's algorithm in many scenarios, especially
|
||||
when you have spatial data with coordinates (e.g., X, Y positions).
|
||||
</div>
|
||||
<div>
|
||||
Fun fact: A\* was originally designed in the 1960s for artificial intelligence applications and
|
||||
pathfinding in games. Today, it's one of the most widely used algorithms in video game
|
||||
development to help characters navigate complex environments efficiently.
|
||||
</div>
|
||||
</details>
|
||||
|
||||
### Recommendation engines like YouTube
|
||||
|
||||
In recommendation engines or search algorithms that use knowledge graphs, pgRouting can be used to build relationships between entities and events. Take YouTube's recommendation algorithm, we can structure this data as a graph where:
|
||||
|
||||
- **Nodes** represent entities like users, videos, or categories.
|
||||
- **Edges** represent relationships or interactions between those entities, such as a user liking a video or videos being part of the same category.
|
||||
|
||||
Let's create a list of “nodes”:
|
||||
|
||||
```sql
|
||||
create table categories (
|
||||
id serial primary key,
|
||||
name text
|
||||
);
|
||||
|
||||
insert into categories (name)
|
||||
values
|
||||
('Graph Theory'),
|
||||
('AI & Machine Learning'),
|
||||
('Python Programming');
|
||||
|
||||
create table videos (
|
||||
id serial primary key,
|
||||
title text,
|
||||
category_id int references categories (id)
|
||||
);
|
||||
|
||||
insert into videos (title, category_id)
|
||||
values
|
||||
('Intro to Graph Theory', 1),
|
||||
('Advanced Graph Algorithms', 1),
|
||||
('Graph Neural Networks', 2),
|
||||
('Beginner Python Tutorial', 3),
|
||||
('Advanced Python Techniques', 3);
|
||||
```
|
||||
|
||||
And some “edges”:
|
||||
|
||||
```sql
|
||||
create table video_relationships (
|
||||
source_video_id int references videos (id),
|
||||
target_video_id int references videos (id),
|
||||
relationship_type text, -- 'same_category', 'watched_by_same_users', etc.
|
||||
weight int default 1 -- strength of the relationship
|
||||
);
|
||||
|
||||
insert into video_relationships (source_video_id, target_video_id, relationship_type, weight)
|
||||
values
|
||||
(1, 2, 'same_category', 5), -- "Intro to Graph Theory" and "Advanced Graph Algorithms" are in the same category
|
||||
(2, 3, 'watched_by_same_users', 3), -- "Advanced Graph Algorithms" and "Graph Neural Networks" are often watched together
|
||||
(4, 5, 'same_category', 5); -- "Beginner Python Tutorial
|
||||
|
||||
create table interactions (
|
||||
user_id int references auth.users (id),
|
||||
video_id int references videos (id),
|
||||
interaction_type text, -- 'liked', 'viewed', etc.
|
||||
weight int default 1 -- strength of the interaction
|
||||
);
|
||||
|
||||
insert into interactions (user_id, video_id, interaction_type, weight)
|
||||
values
|
||||
('user_01', 1, 'viewed', 5), -- "User 01" watched "Intro to Graph Theory" to the end (weight = 5)
|
||||
('user_01', 2, 'liked', 5), -- "User 01" liked "Advanced Graph Algorithms"
|
||||
('user_02', 3, 'viewed', 2), -- "User 02" watched "Graph Neural Networks" and bounced halfway through (weight = 2)
|
||||
('user_03', 4, 'liked', 5), -- "User 03" liked "Beginner Python Tutorial"
|
||||
('user_03', 5, 'viewed', 2);
|
||||
-- "User 03" watched "Advanced Python Techniques" and bounced halfway through (weight = 2)
|
||||
```
|
||||
|
||||
Now we can use the `pgr_dijkstra()` function to find the shortest or most relevant path between a user and new videos. For example, let's find videos that are most relevant to `user_01` considering their past interactions:
|
||||
|
||||
<details>
|
||||
<summary>Tangent: ranking recommendations</summary>
|
||||
<div>
|
||||
Since it's “just postgres” it's simple enough to rank the results using an `order by` clause. For example, if we stored the `pgr_dijkstra()` results above in a table called “recommendations”, we use this a query like this to sort the paths by the highest ranking:
|
||||
</div>
|
||||
<div>
|
||||
|
||||
```sql
|
||||
select videos.title, sum(weight) as recommendation_score
|
||||
from
|
||||
recommendations
|
||||
join videos on recommendations.target = videos.id
|
||||
group by videos.title
|
||||
order by recommendation_score desc;
|
||||
```
|
||||
|
||||
</div>
|
||||
</details>
|
||||
|
||||
## Get Started
|
||||
|
||||
pgRouting is a powerful extension for Postgres that can be used to solve a wide range of graph-based problems. Check out the [pgRouting docs](https://docs.pgrouting.org/latest/en/index.html) for more information on how to use it. You can also use it on Supabase:
|
||||
|
||||
- Docs: [pgrouting: Geospatial Routing](/docs/guides/database/extensions/pgrouting)
|
||||
- Launch a new Postgres database: [database.new](https://database.new)
|
||||
@@ -84,7 +84,7 @@ const nextConfig = {
|
||||
source: "/(.*)",
|
||||
headers: [{
|
||||
key: 'Strict-Transport-Security',
|
||||
value: process.env.NEXT_PUBLIC_IS_PLATFORM === 'true' && process.env.VERCEL === '1' ? 'max-age=31536000' : '' }],
|
||||
value: process.env.NEXT_PUBLIC_IS_PLATFORM === 'true' && process.env.VERCEL === '1' ? 'max-age=1200; includeSubDomains' : '' }],
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
|
After Width: | Height: | Size: 473 KiB |
|
After Width: | Height: | Size: 502 KiB |
|
After Width: | Height: | Size: 231 KiB |
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 340 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 182 KiB |
@@ -5,9 +5,16 @@
|
||||
<link>https://supabase.com</link>
|
||||
<description>Latest news from Supabase</description>
|
||||
<language>en</language>
|
||||
<lastBuildDate>Fri, 20 Dec 2024 00:00:00 -0700</lastBuildDate>
|
||||
<lastBuildDate>Tue, 25 Feb 2025 00:00:00 -0700</lastBuildDate>
|
||||
<atom:link href="https://supabase.com/rss.xml" rel="self" type="application/rss+xml"/>
|
||||
<item>
|
||||
<guid>https://supabase.com/blog/pgrouting-postgres-graph-database</guid>
|
||||
<title>Postgres as a Graph Database: (Ab)using pgRouting</title>
|
||||
<link>https://supabase.com/blog/pgrouting-postgres-graph-database</link>
|
||||
<description></description>
|
||||
<pubDate>Tue, 25 Feb 2025 00:00:00 -0700</pubDate>
|
||||
</item>
|
||||
<item>
|
||||
<guid>https://supabase.com/blog/ai-hackathon-at-y-combinator</guid>
|
||||
<title>AI Hackathon at Y Combinator</title>
|
||||
<link>https://supabase.com/blog/ai-hackathon-at-y-combinator</link>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type CloudProvider = 'FLY' | 'AWS'
|
||||
export type CloudProvider = 'FLY' | 'AWS' | 'AWS_NEW'
|
||||
export type Region = typeof AWS_REGIONS | typeof FLY_REGIONS
|
||||
|
||||
export const AWS_REGIONS = {
|
||||
|
||||
@@ -90,17 +90,17 @@ const SidebarProvider = React.forwardRef<
|
||||
}, [isMobile, setOpen, setOpenMobile])
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault()
|
||||
toggleSidebar()
|
||||
}
|
||||
}
|
||||
// React.useEffect(() => {
|
||||
// const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
|
||||
// event.preventDefault()
|
||||
// toggleSidebar()
|
||||
// }
|
||||
// }
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [toggleSidebar])
|
||||
// window.addEventListener('keydown', handleKeyDown)
|
||||
// return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
// }, [toggleSidebar])
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
@@ -209,7 +209,8 @@ const Sidebar = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
overflowing ? 'w-12' : '',
|
||||
'relative group peer hidden md:block text-sidebar-foreground'
|
||||
'relative group peer hidden md:block text-sidebar-foreground',
|
||||
'flex-shrink-0'
|
||||
)}
|
||||
data-state={state}
|
||||
data-collapsible={state === 'collapsed' ? collapsible : ''}
|
||||
|
||||
@@ -16,5 +16,5 @@ rules.filler = "include('supa-mdx-lint/Rule004ExcludeWords/filler.toml')"
|
||||
rules.first_person = "include('supa-mdx-lint/Rule004ExcludeWords/first_person.toml')"
|
||||
rules.gender_neutral = "include('supa-mdx-lint/Rule004ExcludeWords/gender_neutral.toml')"
|
||||
rules.inclusive_wording = "include('supa-mdx-lint/Rule004ExcludeWords/inclusive_wording.toml')"
|
||||
rules.preferred_spelling = "include('supa-mdx-lint/Rule004ExcludeWords/preferred_spelling.toml')"
|
||||
rules.preferred_usage = "include('supa-mdx-lint/Rule004ExcludeWords/preferred_usage.toml')"
|
||||
rules.slang = "include('supa-mdx-lint/Rule004ExcludeWords/slang.toml')"
|
||||
@@ -75,6 +75,7 @@ allow_list = [
|
||||
"[Pp]laintext",
|
||||
"[Pp]olyfill(s|ed)?",
|
||||
"[Pp]oolers?",
|
||||
"[Pp]resign(ed|ing)?",
|
||||
"[Pp]sycopg",
|
||||
"[Qq]uickstarts?",
|
||||
"[Rr]ealtime",
|
||||
|
||||
@@ -21,4 +21,8 @@ words = [
|
||||
"PostgreSQL",
|
||||
"Postgres",
|
||||
],
|
||||
[
|
||||
"concurrent clients",
|
||||
"concurrent connections",
|
||||
],
|
||||
]
|
||||