mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
feat(studio): CLI deploy instructions for workers (FE-4191) (#49194)
## What The surface that shows you how to deploy a worker from the CLI, plus the product rename and the alpha framing. - **Compute → Workers.** `PRODUCT_NAME` and `CLI_NAME` now say `Workers` / `workers`, so the sidebar, page title, command menu, and every generated snippet match the CLI. One name, not two. - **`DeployWorkerDialog`** — scaffold / configure / push, with copyable `supabase workers` and `config.toml` snippets - **`WorkersEmptyState`** — an `EmptyStatePresentational` with a permission-gated deploy action - **`AlphaNotice`** on the list page, and a **New** badge on the sidebar entry (`Route.isNew`) - **`WorkerSnippetTabs`** — CLI, `config.toml`, and curl/JS/Python calls built from one worker shape. Reused by #49195. Snippet URLs resolve from the project's `app_config.endpoint` (`https://<project>/workers/v1/<name>`, the same shape as `/functions/v1/`), and fall back to `[YOUR WORKER URL]` before settings load rather than printing a wrong host. ## How to test Only on the **Mockamaster** project in staging — it is the one project in the alpha allow-list. 1. Staging dashboard → Mockamaster → **Workers** (the sidebar entry carries a **New** badge) 2. **Deploy a worker** → step through the tabs; every snippet should name the real worker URL, not a placeholder 3. Copy the cURL snippet and run it: expect `401`. Reaching a deployed worker needs a second allow-list (`WORKERS_ALLOWED_PROJECTS` in api-gateway `customer-router/wrangler.toml`), separate from the flag that unlocks the dashboard. 4. Open a project with no workers to see the empty state ## Tests `workerSnippets.test.ts` covers the generated output users copy: worker URL in all three call snippets, the `[YOUR WORKER URL]` fallback, anon vs service-role placeholder, empty-name fallback and trimming, runtime default, and every `config.toml` field. ## Unverified copy The dialog steps and the `supabase workers <sub>` subcommands come from the original POC spec, not from the shipped CLI. Same for "Dockerfile, Node.js and Deno supported" in the empty state — only Deno is confirmed end to end. Worth a check by someone who knows the CLI surface. Closes FE-4191 --------- Co-authored-by: Francesco Sansalvadore <f.sansalvadore@gmail.com>
This commit is contained in:
1 parent
b108c4065c
commit
509d80c9eb
15 files changed
+458
-22
No files matched your search
@@ -7,6 +7,7 @@ import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { ComponentProps, ComponentPropsWithoutRef, FC, ReactNode, useEffect } from 'react'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
cn,
|
||||
DropdownMenu,
|
||||
@@ -195,6 +196,14 @@ export function SideBarNavLink({
|
||||
<>
|
||||
{route.icon}
|
||||
<span>{route.label}</span>
|
||||
{route.isNew && (
|
||||
<Badge
|
||||
variant="success"
|
||||
className="ml-auto shrink-0 px-1.5 py-0.5 text-[9px] uppercase tracking-wider"
|
||||
>
|
||||
New
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogSection,
|
||||
DialogSectionSeparator,
|
||||
DialogTitle,
|
||||
} from 'ui'
|
||||
|
||||
import { EXAMPLE_WORKER } from './workerSnippets'
|
||||
import { WorkerSnippetTabs } from './WorkerSnippetTabs'
|
||||
|
||||
interface DeployWorkerDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
title: 'Scaffold the worker',
|
||||
description: 'Creates supabase/workers/<name>/ with an entrypoint for the runtime you pick.',
|
||||
},
|
||||
{
|
||||
title: 'Configure it',
|
||||
description: 'Runtime, size, access, and instance count live in supabase/config.toml.',
|
||||
},
|
||||
{
|
||||
title: 'Push it',
|
||||
description: 'Builds the image and schedules it on a microVM in US West.',
|
||||
},
|
||||
]
|
||||
|
||||
export const DeployWorkerDialog = ({ open, onOpenChange }: DeployWorkerDialogProps) => (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent size="large">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Deploy a worker</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogSection className="space-y-4">
|
||||
<p className="text-sm text-foreground-light">
|
||||
Workers are deployed with the Supabase CLI. This dashboard is read-only during the private
|
||||
alpha.
|
||||
</p>
|
||||
<ol className="space-y-4">
|
||||
{STEPS.map((step, index) => (
|
||||
<li key={step.title} className="flex gap-3">
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-strong text-xs text-foreground-light">
|
||||
{index + 1}
|
||||
</span>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm text-foreground">{step.title}</p>
|
||||
<p className="text-sm text-foreground-lighter">{step.description}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</DialogSection>
|
||||
|
||||
<DialogSectionSeparator />
|
||||
|
||||
<DialogSection>
|
||||
<WorkerSnippetTabs input={EXAMPLE_WORKER} tabs={['cli', 'config']} />
|
||||
</DialogSection>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
import CopyButton from '@/components/ui/CopyButton'
|
||||
|
||||
interface WorkerCommandLineProps {
|
||||
command: string
|
||||
comment?: string
|
||||
}
|
||||
|
||||
export const WorkerCommandLine = ({ command, comment }: WorkerCommandLineProps) => (
|
||||
<div className="space-y-1">
|
||||
{comment && <p className="font-mono text-xs text-foreground-lighter">{`> ${comment}`}</p>}
|
||||
<div className="flex items-center gap-2 font-mono text-sm text-foreground">
|
||||
<span className="text-foreground-lighter">$</span>
|
||||
<span className="flex-1">{command}</span>
|
||||
<CopyButton
|
||||
text={command}
|
||||
iconOnly
|
||||
variant="text"
|
||||
aria-label="Copy command"
|
||||
className="text-foreground-lighter hover:text-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useParams } from 'common'
|
||||
import { useState } from 'react'
|
||||
import { cn } from 'ui'
|
||||
|
||||
import { buildWorkerSnippets, type WorkerSnippetInput } from './workerSnippets'
|
||||
import CopyButton from '@/components/ui/CopyButton'
|
||||
import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query'
|
||||
|
||||
export type WorkerSnippetTab = 'config' | 'cli' | 'curl' | 'js' | 'python'
|
||||
|
||||
const TAB_LABEL: Record<WorkerSnippetTab, string> = {
|
||||
config: 'config.toml',
|
||||
cli: 'CLI',
|
||||
curl: 'cURL',
|
||||
js: 'JavaScript',
|
||||
python: 'Python',
|
||||
}
|
||||
|
||||
interface WorkerSnippetTabsProps {
|
||||
input: Omit<WorkerSnippetInput, 'endpoint' | 'protocol'>
|
||||
tabs?: [WorkerSnippetTab, ...WorkerSnippetTab[]]
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const WorkerSnippetTabs = ({
|
||||
input,
|
||||
tabs = ['cli', 'curl'],
|
||||
className,
|
||||
}: WorkerSnippetTabsProps) => {
|
||||
const { ref } = useParams()
|
||||
const [active, setActive] = useState<WorkerSnippetTab>(tabs[0])
|
||||
|
||||
const { data: settings } = useProjectSettingsV2Query({ projectRef: ref }, { enabled: !!ref })
|
||||
const snippets = buildWorkerSnippets({
|
||||
...input,
|
||||
endpoint: settings?.app_config?.endpoint,
|
||||
protocol: settings?.app_config?.protocol,
|
||||
})
|
||||
const snippetByTab: Record<WorkerSnippetTab, string> = {
|
||||
config: snippets.configToml,
|
||||
cli: snippets.cli,
|
||||
curl: snippets.curl,
|
||||
js: snippets.javascript,
|
||||
python: snippets.python,
|
||||
}
|
||||
const activeTab = tabs.includes(active) ? active : tabs[0]
|
||||
const value = snippetByTab[activeTab]
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-y-2', className)}>
|
||||
<div className="flex items-center gap-x-4 border-b border-default">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setActive(tab)}
|
||||
className={cn(
|
||||
'-mb-px border-b px-0.5 py-1.5 text-sm transition-colors',
|
||||
tab === activeTab
|
||||
? 'border-foreground text-foreground'
|
||||
: 'border-transparent text-foreground-lighter hover:text-foreground-light'
|
||||
)}
|
||||
>
|
||||
{TAB_LABEL[tab]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="relative rounded-md border border-default bg-surface-100">
|
||||
<CopyButton
|
||||
text={value}
|
||||
iconOnly
|
||||
variant="text"
|
||||
aria-label="Copy snippet"
|
||||
className="absolute right-2 top-2 text-foreground-lighter hover:text-foreground"
|
||||
/>
|
||||
<pre className="overflow-x-auto p-3 pr-10 font-mono text-xs leading-relaxed text-foreground-light">
|
||||
{value}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { BoxPlus } from 'icons'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational'
|
||||
|
||||
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
||||
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
||||
|
||||
interface WorkersEmptyStateProps {
|
||||
onDeploy: () => void
|
||||
}
|
||||
|
||||
export const WorkersEmptyState = ({ onDeploy }: WorkersEmptyStateProps) => {
|
||||
const { can: canDeployWorkers } = useAsyncCheckPermissions(PermissionAction.FUNCTIONS_WRITE, '*')
|
||||
|
||||
return (
|
||||
<EmptyStatePresentational
|
||||
icon={BoxPlus}
|
||||
title="Deploy your first worker"
|
||||
description="Spin up and deploy a worker locally, then deploy it to the cloud. Dockerfile, Node.js and Deno supported at Private Alpha."
|
||||
>
|
||||
<ButtonTooltip
|
||||
variant="primary"
|
||||
size="tiny"
|
||||
icon={<Plus size={14} />}
|
||||
disabled={!canDeployWorkers}
|
||||
onClick={onDeploy}
|
||||
tooltip={{
|
||||
content: {
|
||||
side: 'bottom',
|
||||
text: canDeployWorkers
|
||||
? undefined
|
||||
: 'You need additional permissions to deploy workers',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Deploy a worker
|
||||
</ButtonTooltip>
|
||||
</EmptyStatePresentational>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { Worker } from './Workers.types'
|
||||
import { WorkersList } from './WorkersList'
|
||||
@@ -19,7 +19,7 @@ const worker = (name: string, overrides: Partial<Worker> = {}): Worker => ({
|
||||
})
|
||||
|
||||
const renderList = (workers: Worker[]) =>
|
||||
customRender(<WorkersList projectRef="default" workers={workers} />)
|
||||
customRender(<WorkersList projectRef="default" workers={workers} onDeploy={vi.fn()} />)
|
||||
|
||||
const rowNames = () =>
|
||||
screen
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { ChevronLeft, ChevronRight, Terminal } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardFooter,
|
||||
Input,
|
||||
@@ -30,6 +31,7 @@ import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
||||
interface WorkersListProps {
|
||||
projectRef: string
|
||||
workers: Worker[]
|
||||
onDeploy: () => void
|
||||
}
|
||||
|
||||
const STATE_FILTERS: { value: WorkerBuildState | 'all'; label: string }[] = [
|
||||
@@ -53,7 +55,7 @@ const parseStateFilter = (value: string): WorkerBuildState | 'all' =>
|
||||
const parseAccessFilter = (value: string): WorkerAccess | 'all' =>
|
||||
ACCESS_FILTERS.find((option) => option.value === value)?.value ?? 'all'
|
||||
|
||||
export const WorkersList = ({ projectRef, workers }: WorkersListProps) => {
|
||||
export const WorkersList = ({ projectRef, workers, onDeploy }: WorkersListProps) => {
|
||||
const router = useRouter()
|
||||
const [search, setSearch] = useState('')
|
||||
const [stateFilter, setStateFilter] = useState<WorkerBuildState | 'all'>('all')
|
||||
@@ -125,6 +127,9 @@ export const WorkersList = ({ projectRef, workers }: WorkersListProps) => {
|
||||
<span className="text-sm text-foreground-lighter">
|
||||
{filtered.length} worker{filtered.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
<Button variant="primary" icon={<Terminal />} onClick={onDeploy}>
|
||||
Deploy a worker
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { WORKERS_REGION } from './Workers.constants'
|
||||
import { buildWorkerCliCommands, buildWorkerSnippets, EXAMPLE_WORKER } from './workerSnippets'
|
||||
|
||||
const input = (overrides: Partial<Parameters<typeof buildWorkerSnippets>[0]> = {}) => ({
|
||||
...EXAMPLE_WORKER,
|
||||
endpoint: 'abcdefgh.supabase.co',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('buildWorkerSnippets', () => {
|
||||
it('points every call snippet at the worker URL', () => {
|
||||
const { curl, javascript, python } = buildWorkerSnippets(input({ name: 'embed' }))
|
||||
const url = 'https://abcdefgh.supabase.co/workers/v1/embed'
|
||||
|
||||
expect(curl).toContain(`'${url}'`)
|
||||
expect(javascript).toContain(`'${url}'`)
|
||||
expect(python).toContain(`"${url}"`)
|
||||
})
|
||||
|
||||
it('leaves a placeholder URL until the project settings resolve', () => {
|
||||
const { curl } = buildWorkerSnippets(input({ endpoint: undefined }))
|
||||
expect(curl).toContain('[YOUR WORKER URL]')
|
||||
})
|
||||
|
||||
it('asks for the anon key on a public worker and the service role key on a private one', () => {
|
||||
expect(buildWorkerSnippets(input({ access: 'public' })).curl).toContain('[YOUR ANON KEY]')
|
||||
expect(buildWorkerSnippets(input({ access: 'private' })).curl).toContain(
|
||||
'[YOUR SERVICE ROLE KEY]'
|
||||
)
|
||||
expect(buildWorkerSnippets(input({ access: 'private' })).javascript).toContain(
|
||||
'[YOUR SERVICE ROLE KEY]'
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to a placeholder name when the worker has none', () => {
|
||||
expect(buildWorkerSnippets(input({ name: ' ' })).cli).toContain('my-worker')
|
||||
})
|
||||
|
||||
it('trims the name before interpolating it', () => {
|
||||
expect(buildWorkerSnippets(input({ name: ' embed ' })).cli).toContain('new embed --runtime')
|
||||
})
|
||||
|
||||
it('defaults the runtime when the API omits it', () => {
|
||||
expect(buildWorkerSnippets(input({ runtime: undefined })).cli).toContain('--runtime node')
|
||||
})
|
||||
|
||||
it('writes the worker spec into the config.toml block', () => {
|
||||
const { configToml } = buildWorkerSnippets(
|
||||
input({
|
||||
name: 'embed',
|
||||
runtime: 'python',
|
||||
size: '4gb-2vcpu',
|
||||
access: 'private',
|
||||
instances: 3,
|
||||
})
|
||||
)
|
||||
|
||||
expect(configToml).toContain('[workers.embed]')
|
||||
expect(configToml).toContain('runtime = "python"')
|
||||
expect(configToml).toContain('size = "4gb-2vcpu" # 4 GB · 2 vCPU')
|
||||
expect(configToml).toContain('access = "private"')
|
||||
expect(configToml).toContain('instances = 3')
|
||||
expect(configToml).toContain(WORKERS_REGION)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildWorkerCliCommands', () => {
|
||||
it('names the worker in every command', () => {
|
||||
const commands = buildWorkerCliCommands('embed')
|
||||
expect(commands).not.toHaveLength(0)
|
||||
for (const { command } of commands) {
|
||||
expect(command).toContain('embed')
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to a placeholder name when the worker has none', () => {
|
||||
expect(buildWorkerCliCommands(' ')[0].command).toContain('my-worker')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
import { WORKERS_REGION, workerUrl } from './Workers.constants'
|
||||
import type { WorkerAccess } from './Workers.types'
|
||||
import { formatSize } from './Workers.utils'
|
||||
import { CLI_NAME } from '@/lib/constants/workers'
|
||||
|
||||
export interface WorkerSnippetInput {
|
||||
name: string
|
||||
endpoint: string | undefined
|
||||
protocol?: string
|
||||
runtime: string | undefined
|
||||
size: string
|
||||
access: WorkerAccess
|
||||
instances: number
|
||||
}
|
||||
|
||||
export interface WorkerSnippets {
|
||||
configToml: string
|
||||
cli: string
|
||||
curl: string
|
||||
javascript: string
|
||||
python: string
|
||||
}
|
||||
|
||||
export const EXAMPLE_WORKER: Omit<WorkerSnippetInput, 'endpoint' | 'protocol'> = {
|
||||
name: 'my-worker',
|
||||
runtime: 'node',
|
||||
size: '2gb-1vcpu',
|
||||
access: 'public',
|
||||
instances: 1,
|
||||
}
|
||||
|
||||
const safeName = (name: string) => (name.trim().length > 0 ? name.trim() : 'my-worker')
|
||||
|
||||
export function buildWorkerSnippets(input: WorkerSnippetInput): WorkerSnippets {
|
||||
const name = safeName(input.name)
|
||||
const runtime = input.runtime ?? 'node'
|
||||
|
||||
const url =
|
||||
workerUrl({ endpoint: input.endpoint, protocol: input.protocol, name }) ?? '[YOUR WORKER URL]'
|
||||
|
||||
const cli = [
|
||||
`supabase ${CLI_NAME} new ${name} --runtime ${runtime}`,
|
||||
`supabase ${CLI_NAME} push ${name}`,
|
||||
].join('\n')
|
||||
|
||||
const configToml = [
|
||||
`# supabase/config.toml`,
|
||||
``,
|
||||
`[${CLI_NAME}.${name}]`,
|
||||
`runtime = "${runtime}"`,
|
||||
`size = "${input.size}" # ${formatSize(input.size)}`,
|
||||
`access = "${input.access}"`,
|
||||
`instances = ${input.instances}`,
|
||||
`# region is locked to ${WORKERS_REGION} at alpha`,
|
||||
].join('\n')
|
||||
|
||||
const authHeader =
|
||||
input.access === 'public'
|
||||
? ` -H 'Authorization: Bearer [YOUR ANON KEY]' \\`
|
||||
: ` -H 'Authorization: Bearer [YOUR SERVICE ROLE KEY]' \\`
|
||||
|
||||
const curl = [
|
||||
`curl -L -X POST '${url}' \\`,
|
||||
authHeader,
|
||||
` -H 'Content-Type: application/json' \\`,
|
||||
` --data '{"name":"world"}'`,
|
||||
].join('\n')
|
||||
|
||||
const keyPlaceholder = input.access === 'public' ? '[YOUR ANON KEY]' : '[YOUR SERVICE ROLE KEY]'
|
||||
|
||||
const javascript = [
|
||||
`const res = await fetch('${url}', {`,
|
||||
` method: 'POST',`,
|
||||
` headers: {`,
|
||||
` Authorization: 'Bearer ${keyPlaceholder}',`,
|
||||
` 'Content-Type': 'application/json',`,
|
||||
` },`,
|
||||
` body: JSON.stringify({ name: 'world' }),`,
|
||||
`})`,
|
||||
`const data = await res.json()`,
|
||||
].join('\n')
|
||||
|
||||
const python = [
|
||||
`import requests`,
|
||||
``,
|
||||
`res = requests.post(`,
|
||||
` "${url}",`,
|
||||
` headers={"Authorization": "Bearer ${keyPlaceholder}"},`,
|
||||
` json={"name": "world"},`,
|
||||
`)`,
|
||||
`print(res.json())`,
|
||||
].join('\n')
|
||||
|
||||
return { configToml, cli, curl, javascript, python }
|
||||
}
|
||||
|
||||
export interface WorkerCliCommand {
|
||||
comment: string
|
||||
command: string
|
||||
}
|
||||
|
||||
export function buildWorkerCliCommands(name: string): WorkerCliCommand[] {
|
||||
const slug = safeName(name)
|
||||
return [
|
||||
{ comment: 'Recreate the source locally', command: `supabase ${CLI_NAME} pull ${slug}` },
|
||||
{ comment: 'Run it locally on a port', command: `supabase ${CLI_NAME} serve ${slug}` },
|
||||
{ comment: 'Deploy a new version', command: `supabase ${CLI_NAME} push ${slug}` },
|
||||
{ comment: 'Stream logs', command: `supabase ${CLI_NAME} logs ${slug} --follow` },
|
||||
{ comment: 'Delete the worker', command: `supabase ${CLI_NAME} delete ${slug}` },
|
||||
]
|
||||
}
|
||||
@@ -148,6 +148,7 @@ export const generateProductRoutes = (
|
||||
disabled: !isProjectActive,
|
||||
icon: <Box size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
|
||||
link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/workers`),
|
||||
isNew: true,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
@@ -85,7 +85,7 @@ const WorkersLayoutContent = ({ children, title }: PropsWithChildren<WorkersLayo
|
||||
{canReadWorkers ? (
|
||||
children
|
||||
) : (
|
||||
<NoPermission isFullPage resourceText={`view this project's ${PRODUCT_NAME} workers`} />
|
||||
<NoPermission isFullPage resourceText="view this project's workers" />
|
||||
)}
|
||||
</ProjectLayout>
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface Route {
|
||||
disabled?: boolean
|
||||
linkElement?: ReactNode
|
||||
items?: any | Route[]
|
||||
isNew?: boolean
|
||||
/**
|
||||
* Binds a registered keyboard shortcut to this route when set. The sidebar
|
||||
* entry shows the keybind on hover and jumps to `link` when the shortcut
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const PRODUCT_NAME = 'Compute'
|
||||
export const CLI_NAME = 'compute'
|
||||
export const PRODUCT_NAME = 'Workers'
|
||||
export const CLI_NAME = 'workers'
|
||||
|
||||
export const WORKERS_CLI_DEPLOY = `supabase ${CLI_NAME} push`
|
||||
|
||||
@@ -14,7 +14,7 @@ Add a block to \`supabase/config.toml\`:
|
||||
\`\`\`toml
|
||||
[${CLI_NAME}.embed]
|
||||
runtime = "python" # node | deno | bun | python | dockerfile
|
||||
size = "2x1" # 2x1 (2GB/1vCPU) | 4x2 (4GB/2vCPU) — fixed at deploy
|
||||
size = "2gb-1vcpu" # 2gb-1vcpu | 4gb-2vcpu — fixed at deploy
|
||||
access = "public" # public | private
|
||||
instances = 1 # 1..10 per deploy; 100 cap per project
|
||||
secrets = ["OPENAI_API_KEY"] # names only — values live in the Secrets API
|
||||
@@ -33,7 +33,7 @@ Config precedence: \`--flag\` > \`config.toml\` > interactive prompt > default.
|
||||
|
||||
- Workers under \`supabase/${CLI_NAME}/<name>/\` are auto-discovered; the folder name is the slug.
|
||||
- \`entrypoint\` is inferred from the runtime (node index.js, deno run main.ts, python main.py, Dockerfile CMD).
|
||||
- \`region\` is locked to us-west-1 at alpha — do not set it.
|
||||
- \`region\` is locked to us-west-2 at alpha — do not set it.
|
||||
- Sizes are fixed at deploy time. To change size, delete the worker and redeploy.
|
||||
|
||||
## Manage
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useParams } from 'common'
|
||||
import { useState } from 'react'
|
||||
import { Admonition } from 'ui-patterns/Admonition'
|
||||
import { PageContainer } from 'ui-patterns/PageContainer'
|
||||
import {
|
||||
@@ -12,14 +13,17 @@ import {
|
||||
import { PageSection, PageSectionContent } from 'ui-patterns/PageSection'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { DeployWorkerDialog } from '@/components/interfaces/Workers/DeployWorkerDialog'
|
||||
import {
|
||||
isWorkersForbidden,
|
||||
isWorkersUnavailable,
|
||||
} from '@/components/interfaces/Workers/Workers.utils'
|
||||
import { WorkersEmptyState } from '@/components/interfaces/Workers/WorkersEmptyState'
|
||||
import { WorkersList } from '@/components/interfaces/Workers/WorkersList'
|
||||
import { DefaultLayout } from '@/components/layouts/DefaultLayout'
|
||||
import { WorkersLayout } from '@/components/layouts/WorkersLayout/WorkersLayout'
|
||||
import { AlertError } from '@/components/ui/AlertError'
|
||||
import { AlphaNotice } from '@/components/ui/AlphaNotice'
|
||||
import { NoPermission } from '@/components/ui/NoPermission'
|
||||
import { workersQueryOptions } from '@/data/workers/workers-query'
|
||||
import { PRODUCT_NAME } from '@/lib/constants/workers'
|
||||
@@ -27,6 +31,7 @@ import type { NextPageWithLayout } from '@/types'
|
||||
|
||||
const WorkersPage: NextPageWithLayout = () => {
|
||||
const { ref } = useParams()
|
||||
const [isDeployInstructionsOpen, setIsDeployInstructionsOpen] = useState(false)
|
||||
const {
|
||||
data: workers,
|
||||
error,
|
||||
@@ -46,7 +51,7 @@ const WorkersPage: NextPageWithLayout = () => {
|
||||
<PageHeaderSummary>
|
||||
<PageHeaderTitle>{PRODUCT_NAME}</PageHeaderTitle>
|
||||
<PageHeaderDescription>
|
||||
Run backend workers in microVMs next to your database
|
||||
Run fully managed compute in isolation next to your database
|
||||
</PageHeaderDescription>
|
||||
</PageHeaderSummary>
|
||||
</PageHeaderMeta>
|
||||
@@ -54,7 +59,12 @@ const WorkersPage: NextPageWithLayout = () => {
|
||||
|
||||
<PageContainer size="large">
|
||||
<PageSection>
|
||||
<PageSectionContent>
|
||||
<PageSectionContent className="flex flex-col gap-y-8">
|
||||
<AlphaNotice
|
||||
entity="Workers"
|
||||
feedbackUrl="https://github.com/orgs/supabase/discussions"
|
||||
/>
|
||||
|
||||
{isPending && <GenericSkeletonLoader />}
|
||||
{isNotEnrolled && (
|
||||
<Admonition
|
||||
@@ -63,21 +73,26 @@ const WorkersPage: NextPageWithLayout = () => {
|
||||
description={`${PRODUCT_NAME} is in private alpha. Contact support to have this project added to the alpha.`}
|
||||
/>
|
||||
)}
|
||||
{isMissingPermission && (
|
||||
<NoPermission resourceText={`view this project's ${PRODUCT_NAME} workers`} />
|
||||
)}
|
||||
{isMissingPermission && <NoPermission resourceText="view this project's workers" />}
|
||||
{isUnexpectedError && <AlertError error={error} subject="Failed to retrieve workers" />}
|
||||
{isSuccess && workers.length === 0 && (
|
||||
<p className="text-sm text-foreground-light">
|
||||
No workers yet. Deploy your first worker with the Supabase CLI.
|
||||
</p>
|
||||
<WorkersEmptyState onDeploy={() => setIsDeployInstructionsOpen(true)} />
|
||||
)}
|
||||
{isSuccess && workers.length > 0 && ref && (
|
||||
<WorkersList projectRef={ref} workers={workers} />
|
||||
<WorkersList
|
||||
projectRef={ref}
|
||||
workers={workers}
|
||||
onDeploy={() => setIsDeployInstructionsOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
</PageContainer>
|
||||
|
||||
<DeployWorkerDialog
|
||||
open={isDeployInstructionsOpen}
|
||||
onOpenChange={setIsDeployInstructionsOpen}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ describe('/project/[ref]/workers', () => {
|
||||
|
||||
await renderWorkersPage()
|
||||
|
||||
expect(screen.getByText(/No workers yet/)).toBeVisible()
|
||||
expect(screen.getByText('Deploy your first worker')).toBeVisible()
|
||||
expect(screen.queryByRole('table')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -105,9 +105,7 @@ describe('/project/[ref]/workers', () => {
|
||||
await renderWorkersPage()
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
`You need additional permissions to view this project's ${PRODUCT_NAME} workers`
|
||||
)
|
||||
screen.getByText("You need additional permissions to view this project's workers")
|
||||
).toBeVisible()
|
||||
expect(screen.queryByRole('table')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user