Merge branch 'master' of github.com:supabase/supabase
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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -82,7 +82,10 @@ const AddNewSecretForm = () => {
|
||||
}
|
||||
|
||||
if (pairs.length) {
|
||||
form.setValue('secrets', pairs)
|
||||
const currentSecrets = form.getValues('secrets')
|
||||
// Filter out any empty pairs before combining
|
||||
const nonEmptySecrets = currentSecrets.filter((secret) => secret.name || secret.value)
|
||||
form.setValue('secrets', [...nonEmptySecrets, ...pairs])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,29 +7,7 @@ import TerminalInstructions from './TerminalInstructions'
|
||||
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>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full mt-8">
|
||||
<div className="col-span-8 bg-surface-100 px-5 py-4 border rounded-md">
|
||||
<TerminalInstructions />
|
||||
</div>
|
||||
|
||||
@@ -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-[21px] group-data-[state=expanded]:left-[22.25px] 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">
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useParams } from 'common'
|
||||
import { ProductMenu } from 'components/ui/ProductMenu'
|
||||
import { withAuth } from 'hooks/misc/withAuth'
|
||||
import { useRouter } from 'next/router'
|
||||
import { PropsWithChildren } from 'react'
|
||||
import ProjectLayout from '../ProjectLayout/ProjectLayout'
|
||||
|
||||
const EdgeFunctionsProductMenu = () => {
|
||||
const { ref: projectRef = 'default' } = useParams()
|
||||
const router = useRouter()
|
||||
const page = router.pathname.split('/')[4]
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
name: 'Edge Functions',
|
||||
key: 'functions',
|
||||
url: `/project/${projectRef}/functions`,
|
||||
items: [
|
||||
{
|
||||
name: 'Functions',
|
||||
key: 'main',
|
||||
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)
|
||||
@@ -27,7 +27,7 @@ interface PageLayoutProps {
|
||||
secondaryActions?: ReactNode
|
||||
navigationItems?: NavigationItem[]
|
||||
className?: string
|
||||
size?: 'default' | 'full'
|
||||
size?: 'default' | 'full' | 'large' | 'small'
|
||||
isCompact?: boolean
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ export const PageLayout = ({
|
||||
return (
|
||||
<div className="w-full">
|
||||
<ScaffoldContainer
|
||||
size={size}
|
||||
className={cn(
|
||||
'w-full mx-auto',
|
||||
size === 'full' &&
|
||||
|
||||
@@ -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' : '',
|
||||
|
||||
@@ -30,7 +30,7 @@ const ProductMenu = ({ page, menu }: ProductMenuProps) => {
|
||||
{group.items.map((item) => {
|
||||
const isActive = !!item.pages
|
||||
? item.pages.includes(page ?? '')
|
||||
: page === item.key
|
||||
: page === item.key || (page === undefined && item.key === 'main')
|
||||
|
||||
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[]
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
import { useParams } from 'common'
|
||||
import { Button, Dialog, DialogContent, DialogSection, DialogTrigger } from 'ui'
|
||||
import { AiIconAnimation, Button, Dialog, DialogContent, DialogSection, DialogTrigger } from 'ui'
|
||||
|
||||
import {
|
||||
EdgeFunctionsListItem,
|
||||
FunctionsEmptyState,
|
||||
TerminalInstructions,
|
||||
} from 'components/interfaces/Functions'
|
||||
import FunctionsLayout from 'components/layouts/FunctionsLayout/FunctionsLayout'
|
||||
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 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 { useAppStateSnapshot } from 'state/app-state'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
|
||||
const PageLayout: NextPageWithLayout = () => {
|
||||
const FunctionsPage: NextPageWithLayout = () => {
|
||||
const { ref } = useParams()
|
||||
const { setAiAssistantPanel } = useAppStateSnapshot()
|
||||
|
||||
const {
|
||||
data: functions,
|
||||
@@ -27,10 +34,77 @@ const PageLayout: NextPageWithLayout = () => {
|
||||
|
||||
const hasFunctions = (functions ?? []).length > 0
|
||||
|
||||
const deployButton = (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="primary">Deploy a new function</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent size="large">
|
||||
<DialogSection padding="small">
|
||||
<TerminalInstructions />
|
||||
</DialogSection>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
const secondaryActions = [
|
||||
<DocsButton key="docs" href="https://supabase.com/docs/guides/functions" />,
|
||||
<ButtonTooltip
|
||||
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',
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
]
|
||||
|
||||
if (!hasFunctions) {
|
||||
secondaryActions.unshift(
|
||||
<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>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="py-6">
|
||||
{isLoading && <GenericSkeletonLoader />}
|
||||
<PageLayout
|
||||
size="large"
|
||||
title="Edge Functions"
|
||||
subtitle="Server-side TypeScript functions distributed globally at the edge"
|
||||
primaryActions={deployButton}
|
||||
secondaryActions={secondaryActions}
|
||||
>
|
||||
<ScaffoldContainer size="large">
|
||||
{isLoading && (
|
||||
<div className="pt-8">
|
||||
<GenericSkeletonLoader />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && <AlertError error={error} subject="Failed to retrieve edge functions" />}
|
||||
|
||||
@@ -38,21 +112,6 @@ const PageLayout: NextPageWithLayout = () => {
|
||||
<>
|
||||
{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>
|
||||
<Table
|
||||
head={
|
||||
<>
|
||||
@@ -78,15 +137,17 @@ const PageLayout: NextPageWithLayout = () => {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</ScaffoldContainer>
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
PageLayout.getLayout = (page) => (
|
||||
<DefaultLayout>
|
||||
<FunctionsLayout>{page}</FunctionsLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
FunctionsPage.getLayout = (page) => {
|
||||
return (
|
||||
<DefaultLayout>
|
||||
<EdgeFunctionsLayout>{page}</EdgeFunctionsLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default PageLayout
|
||||
export default FunctionsPage
|
||||
@@ -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-og.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)
|
||||
|
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: 23 KiB |
@@ -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 = {
|
||||
|
||||