feat(studio): workers list behind the workers flag (FE-4188) (#49193)

## What

The Workers list page at `/project/[ref]/workers`, behind
`useFlag('workers')`. Reads `GET /v2/projects/{ref}/workers`.

- Sidebar and command-menu entries, both hidden when the flag is off
- Name search, state and access filters, pagination
- Read-only

Gating, in order: flag off redirects to the project home; a 404 from the
API means the project is outside the alpha allow-list ("not enabled for
this project"); a 403 means the caller lacks the permission
(`NoPermission`); anything else is an `AlertError`.

`parseWorker` in `data/workers/workers.utils.ts` is the only place the
API shape becomes the view model. It validates with zod, so a drifted
response fails the query instead of half-rendering a row.

## How to test

Only on the **Mockamaster** project in staging — it is the one project
in the alpha allow-list, and standing a worker up anywhere else is
involved right now.

1. Staging dashboard → Mockamaster → **Compute** in the sidebar
2. Expect the `dashboard-test` worker: state `Active`, runtime Deno,
private, US West, 2 GB · 1 vCPU · 1 inst
3. Open any other project's `/workers` URL → "Compute is not enabled for
this project"
4. Turn the `workers` flag off → the sidebar entry disappears and the
URL redirects to the project home

Closes FE-4188
This commit is contained in:
Jordi Enric authored and GitHub committed 2026-08-20 17:54:57 +02:00
1 parent 01d12e83c1
commit 6c6ac567b1
29 files changed
+1398 -5

No files matched your search

+5
View File
@@ -93,6 +93,7 @@ These are the layout-only TanStack files. Most hold a single product layout comp
- [x] `routes/project/$ref/branches.tsx` — BranchLayout only. **Delta vs plan:** the per-page `PageLayout` (with different titles + primary/secondary actions) stays in each leaf. Hoisted `BranchesPageWrapper` and `MergeRequestsPageWrapper` to top-level exports in their respective `pages/...` files so the route files can import + re-use the same wrapping.
- [x] `routes/project/$ref/logs.tsx` — LogsLayout (reads `logsLayoutTitle` from leaf staticData). Honours `skipLogsLayout: true` for `logs/index` (page handles its own ProjectLayout-wrapped content for the UnifiedLogs / no-permission cases). Refactored `pages/.../logs/index.tsx` to move the inline `<DefaultLayout>` into `getLayout` so it isn't duplicated when the TanStack project shell already provides DefaultLayout.
- [x] `routes/project/$ref/observability.tsx` — ObservabilityLayout (reads `observabilityLayoutTitle` from leaf staticData)
- [x] `routes/project/$ref/workers.tsx` — WorkersLayout (reads `workersLayoutTitle` from leaf `staticData`). Flag-gated: `WorkersLayout` itself redirects to the project home when `useFlag('workers')` is off, so the shell needs no extra guard.
- [x] `routes/project/$ref/advisors.tsx` — AdvisorsLayout (reads `advisorsLayoutTitle` from leaf staticData). Honours `skipAdvisorsLayout: true` opt-out for the rules sub-shell, which provides its own AdvisorsLayout-less-DefaultLayout wrap. Scans whole match chain (same pattern as functions.tsx).
- [x] `routes/project/$ref/advisors/rules.tsx` — sub-shell that inlines the inner body of `AdvisorRulesLayout` (AdvisorsLayout + PageLayout with title/tabs/feature-preview badge), minus the outer DefaultLayout (already provided by the parent project shell). Sets `skipAdvisorsLayout: true` on its own staticData. **Delta vs plan:** the existing `AdvisorRulesLayout` component wraps in DefaultLayout + AdvisorsLayout internally, so reusing it as-is would double-wrap both. Inlined the inner part; the Next-side component is untouched.
- [x] `routes/project/$ref/settings.tsx` — SettingsLayout (reads `settingsLayoutTitle` from leaf staticData). Honours `skipSettingsLayout: true` for `settings/api` (redirect-only page). Adds a sub-shell at `routes/project/$ref/settings/api-keys.tsx` providing `ApiKeysLayout` for both api-keys leaves; `jwt/index` wraps in `JWTKeysLayout` inline since `jwt/legacy` doesn't share it.
@@ -227,6 +228,10 @@ These are the layout-only TanStack files. Most hold a single product layout comp
- [x] A `routes/project/$ref/realtime/policies.tsx` ← `pages/project/[ref]/realtime/policies.tsx`
- [x] A `routes/project/$ref/realtime/settings.tsx` ← `pages/project/[ref]/realtime/settings.tsx`
### Project shell — `/workers/*`
- [x] A `routes/project/$ref/workers/index.tsx` ← `pages/project/[ref]/workers/index.tsx`
### Project shell — `/functions/*`
- [x] A `routes/project/$ref/functions/index.tsx` ← `pages/project/[ref]/functions/index.tsx` (route wraps in exported `EdgeFunctionsIndexPageWrapper` for the inline PageHeader + actions)
@@ -267,6 +267,7 @@ const ProjectLinks = () => {
])
const authOverviewPageEnabled = useFlag('authOverviewPage')
const workersEnabled = useFlag('workers')
const toolRoutes = generateToolRoutes(ref, project)
const productRoutes = generateProductRoutes(ref, project, {
@@ -275,6 +276,7 @@ const ProjectLinks = () => {
storage: storageEnabled,
realtime: realtimeEnabled,
authOverviewPage: authOverviewPageEnabled,
workers: workersEnabled,
})
const otherRoutes = useGenerateOtherRoutes()
const settingsRoutes = generateSettingsRoutes(ref)
@@ -0,0 +1,20 @@
import { cn } from 'ui'
import { formatRuntime, getRuntimeMeta } from './Workers.utils'
interface RuntimeBadgeProps {
runtime: string | undefined
className?: string
}
export const RuntimeBadge = ({ runtime, className }: RuntimeBadgeProps) => {
const meta = getRuntimeMeta(runtime)
return (
<span className={cn('inline-flex items-center gap-2 text-sm text-foreground-light', className)}>
<span
className={cn('h-2.5 w-2.5 rounded-[3px]', meta?.swatchClassName ?? 'bg-foreground-muted')}
/>
{formatRuntime(runtime)}
</span>
)
}
@@ -0,0 +1,31 @@
import { cn } from 'ui'
import { getWorkerStateMeta } from './Workers.constants'
import type { Worker } from './Workers.types'
interface WorkerStatePillProps {
worker: Worker
className?: string
}
export const WorkerStatePill = ({ worker, className }: WorkerStatePillProps) => {
const meta = getWorkerStateMeta(worker)
const isPulsing = worker.buildState === 'building' || worker.isDeleting
return (
<span className={cn('inline-flex items-center gap-2', className)}>
<span className="relative flex h-2 w-2">
{isPulsing && (
<span
className={cn(
'absolute inline-flex h-full w-full animate-ping rounded-full opacity-75',
meta.dotClassName
)}
/>
)}
<span className={cn('relative inline-flex h-2 w-2 rounded-full', meta.dotClassName)} />
</span>
<span className={cn('text-sm', meta.textClassName)}>{meta.label}</span>
</span>
)
}
@@ -0,0 +1,89 @@
import type { Worker, WorkerBuildState } from './Workers.types'
// Locked to one region at alpha, so nothing in the UI lets you change it.
export const WORKERS_REGION = 'us-west-2'
export const WORKERS_REGION_LABEL = 'US West (Oregon)'
export const WORKERS_REGION_SHORT = 'US West'
// Workers answer on the project's own domain, alongside /functions/v1.
export const workerUrl = ({
endpoint,
protocol = 'https',
name,
}: {
endpoint: string | undefined
protocol?: string
name: string
}) => (endpoint ? `${protocol}://${endpoint}/workers/v1/${name}` : undefined)
export const LISTENING_PORT = 8080
export interface RuntimeMeta {
label: string
baseImage: string
entrypoint: string
swatchClassName: string
}
// Keyed by the `spec.runtime` values the Management API returns.
export const RUNTIMES: Record<string, RuntimeMeta> = {
node: {
label: 'Node.js 24',
baseImage: 'node:24-slim',
entrypoint: 'node index.js',
swatchClassName: 'bg-[#5FA04E]',
},
deno: {
label: 'Deno 2',
baseImage: 'denoland/deno:latest',
entrypoint: 'deno run main.ts',
swatchClassName: 'bg-[#70FFAF]',
},
bun: {
label: 'Bun 1',
baseImage: 'oven/bun:latest',
entrypoint: 'bun run index.ts',
swatchClassName: 'bg-[#FBF0DF]',
},
python: {
label: 'Python 3.14',
baseImage: 'python:3.14-slim',
entrypoint: 'python main.py',
swatchClassName: 'bg-[#3776AB]',
},
dockerfile: {
label: 'Dockerfile',
baseImage: 'From ./Dockerfile',
entrypoint: 'CMD from Dockerfile',
swatchClassName: 'bg-[#2496ED]',
},
}
interface WorkerStateMeta {
label: string
dotClassName: string
textClassName: string
}
const BUILD_STATE_META: Record<WorkerBuildState, WorkerStateMeta> = {
building: {
label: 'Building',
dotClassName: 'bg-warning',
textClassName: 'text-foreground-light',
},
active: { label: 'Active', dotClassName: 'bg-brand', textClassName: 'text-foreground-light' },
failed: {
label: 'Failed',
dotClassName: 'bg-destructive',
textClassName: 'text-foreground-light',
},
}
const DELETING_STATE_META: WorkerStateMeta = {
label: 'Deleting',
dotClassName: 'bg-foreground-muted',
textClassName: 'text-foreground-lighter',
}
export const getWorkerStateMeta = (worker: Worker): WorkerStateMeta =>
worker.isDeleting ? DELETING_STATE_META : BUILD_STATE_META[worker.buildState]
@@ -0,0 +1,24 @@
import type { components } from 'api-types'
type WorkerAttributes = components['schemas']['V2WorkerResponse']['data']['attributes']
export type WorkerBuildState = WorkerAttributes['build_state']
export type WorkerInstanceTally = NonNullable<WorkerAttributes['instances']>
// The API types `spec.exposure` as a free-form string; the UI only handles these two values.
export type WorkerAccess = 'public' | 'private'
export interface Worker {
name: string
buildState: WorkerBuildState
isDeleting: boolean
runtime?: string
size: string
access: WorkerAccess
declaredInstances: number
instances?: WorkerInstanceTally
imageVersion?: string
stateReason?: string
instancesError?: string
}
@@ -0,0 +1,178 @@
import { describe, expect, it } from 'vitest'
import { getWorkerStateMeta, workerUrl } from './Workers.constants'
import type { Worker } from './Workers.types'
import {
filterWorkers,
formatResources,
formatRuntime,
formatSize,
getPage,
isWorkersForbidden,
isWorkersUnavailable,
} from './Workers.utils'
import { ResponseError } from '@/types'
const worker = (overrides: Partial<Worker> & Pick<Worker, 'name'>): Worker => ({
buildState: 'active',
isDeleting: false,
runtime: 'node',
size: '2gb-1vcpu',
access: 'public',
declaredInstances: 1,
...overrides,
})
const NO_FILTERS = { search: '', state: 'all', access: 'all' } as const
describe('filterWorkers', () => {
const workers = [
worker({ name: 'embed', buildState: 'active', access: 'public' }),
worker({ name: 'resize-images', buildState: 'building', access: 'private' }),
worker({ name: 'embed-batch', buildState: 'failed', access: 'public' }),
]
it('returns every worker when no filters are set', () => {
expect(filterWorkers(workers, NO_FILTERS)).toHaveLength(3)
})
it('matches names case-insensitively on a partial term', () => {
const names = filterWorkers(workers, { ...NO_FILTERS, search: 'EMBED' }).map((w) => w.name)
expect(names).toEqual(['embed', 'embed-batch'])
})
it('ignores surrounding whitespace in the search term', () => {
expect(filterWorkers(workers, { ...NO_FILTERS, search: ' resize ' })).toHaveLength(1)
})
it('filters by build state and by access', () => {
expect(filterWorkers(workers, { ...NO_FILTERS, state: 'failed' }).map((w) => w.name)).toEqual([
'embed-batch',
])
expect(filterWorkers(workers, { ...NO_FILTERS, access: 'private' }).map((w) => w.name)).toEqual(
['resize-images']
)
})
it('combines filters', () => {
expect(filterWorkers(workers, { search: 'embed', state: 'active', access: 'public' })).toEqual([
workers[0],
])
})
})
describe('getPage', () => {
const items = Array.from({ length: 25 }, (_, index) => index)
it('returns the first window and the total page count', () => {
const page = getPage(items, 1, 10)
expect(page.items).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
expect(page).toMatchObject({ currentPage: 1, totalPages: 3, startIndex: 0 })
})
it('returns the remainder on the last page', () => {
expect(getPage(items, 3, 10).items).toEqual([20, 21, 22, 23, 24])
})
it('clamps a page beyond the end so filtering never strands an empty page', () => {
const page = getPage(items.slice(0, 5), 3, 10)
expect(page.currentPage).toBe(1)
expect(page.items).toHaveLength(5)
})
it('reports a single page when there are no items', () => {
expect(getPage([], 1, 10)).toEqual({
items: [],
currentPage: 1,
totalPages: 1,
startIndex: 0,
})
})
})
describe('formatSize', () => {
it('splits the memory and vCPU parts the API reports', () => {
expect(formatSize('2gb-1vcpu')).toBe('2 GB · 1 vCPU')
expect(formatSize('4gb-2vcpu')).toBe('4 GB · 2 vCPU')
})
it('falls back to the raw value for a shape it does not recognize', () => {
expect(formatSize('8gb-4vcpu-gpu')).toBe('8gb-4vcpu-gpu')
expect(formatSize('')).toBe('')
})
})
describe('formatRuntime', () => {
it('labels a known runtime', () => {
expect(formatRuntime('python')).toBe('Python 3.14')
})
it('shows the raw value for a runtime it does not know', () => {
expect(formatRuntime('rust')).toBe('rust')
})
it('reports unknown when the API omits the runtime', () => {
expect(formatRuntime(undefined)).toBe('Unknown')
})
})
describe('formatResources', () => {
it('combines size and declared instance count', () => {
expect(formatResources(worker({ name: 'embed', declaredInstances: 3 }))).toBe(
'2 GB · 1 vCPU · 3 inst'
)
})
})
describe('workers error classification', () => {
const responseError = (code?: number) => new ResponseError('Denied', code)
it('reads a 404 as the project being outside the alpha allow-list', () => {
expect(isWorkersUnavailable(responseError(404))).toBe(true)
expect(isWorkersForbidden(responseError(404))).toBe(false)
})
it('reads a 403 as a missing workers permission, not a missing enrollment', () => {
expect(isWorkersForbidden(responseError(403))).toBe(true)
expect(isWorkersUnavailable(responseError(403))).toBe(false)
})
it('leaves other failures to the generic error UI', () => {
for (const error of [responseError(500), responseError(undefined), new Error('boom'), null]) {
expect(isWorkersUnavailable(error)).toBe(false)
expect(isWorkersForbidden(error)).toBe(false)
}
})
})
describe('workerUrl', () => {
it('answers on the project domain alongside /functions/v1', () => {
expect(workerUrl({ endpoint: 'abcdefgh.supabase.co', name: 'embed' })).toBe(
'https://abcdefgh.supabase.co/workers/v1/embed'
)
})
it('honors a non-https protocol', () => {
expect(workerUrl({ endpoint: 'localhost:8000', protocol: 'http', name: 'embed' })).toBe(
'http://localhost:8000/workers/v1/embed'
)
})
it('has no url until the project settings resolve', () => {
expect(workerUrl({ endpoint: undefined, name: 'embed' })).toBeUndefined()
})
})
describe('getWorkerStateMeta', () => {
it('labels every build state', () => {
expect(getWorkerStateMeta(worker({ name: 'a', buildState: 'building' })).label).toBe('Building')
expect(getWorkerStateMeta(worker({ name: 'a', buildState: 'active' })).label).toBe('Active')
expect(getWorkerStateMeta(worker({ name: 'a', buildState: 'failed' })).label).toBe('Failed')
})
it('reports deleting over the build state', () => {
expect(
getWorkerStateMeta(worker({ name: 'a', buildState: 'active', isDeleting: true })).label
).toBe('Deleting')
})
})
@@ -0,0 +1,63 @@
import { RUNTIMES, type RuntimeMeta } from './Workers.constants'
import type { Worker, WorkerAccess, WorkerBuildState } from './Workers.types'
import { ResponseError } from '@/types'
export interface WorkerFilters {
search: string
state: WorkerBuildState | 'all'
access: WorkerAccess | 'all'
}
export const filterWorkers = (workers: Worker[], filters: WorkerFilters): Worker[] => {
const search = filters.search.trim().toLowerCase()
return workers.filter((worker) => {
const matchesSearch = worker.name.toLowerCase().includes(search)
const matchesState = filters.state === 'all' || worker.buildState === filters.state
const matchesAccess = filters.access === 'all' || worker.access === filters.access
return matchesSearch && matchesState && matchesAccess
})
}
export interface Page<T> {
items: T[]
currentPage: number
totalPages: number
startIndex: number
}
// Clamps the requested page so filtering down to fewer results never strands an empty page.
export const getPage = <T>(items: T[], requestedPage: number, pageSize: number): Page<T> => {
const totalPages = Math.max(1, Math.ceil(items.length / pageSize))
const currentPage = Math.min(Math.max(1, requestedPage), totalPages)
const startIndex = (currentPage - 1) * pageSize
return {
items: items.slice(startIndex, startIndex + pageSize),
currentPage,
totalPages,
startIndex,
}
}
export const getRuntimeMeta = (runtime: string | undefined): RuntimeMeta | undefined =>
runtime === undefined ? undefined : RUNTIMES[runtime]
export const formatRuntime = (runtime: string | undefined): string =>
getRuntimeMeta(runtime)?.label ?? runtime ?? 'Unknown'
// The API reports size as e.g. "2gb-1vcpu"; render the parts when they parse, the raw value if not.
export const formatSize = (size: string): string => {
const match = size.match(/^(\d+)gb-(\d+)vcpu$/)
if (!match) return size
return `${match[1]} GB · ${match[2]} vCPU`
}
export const formatResources = (worker: Worker): string =>
`${formatSize(worker.size)} · ${worker.declaredInstances} inst`
// A project outside the alpha allow-list gets a 404, not a 403.
export const isWorkersUnavailable = (error: Error | null): boolean =>
error instanceof ResponseError && error.code === 404
// An enrolled project still answers 403 when the caller lacks the workers permission.
export const isWorkersForbidden = (error: Error | null): boolean =>
error instanceof ResponseError && error.code === 403
@@ -0,0 +1,82 @@
import { screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it } from 'vitest'
import type { Worker } from './Workers.types'
import { WorkersList } from './WorkersList'
import { customRender } from '@/tests/lib/custom-render'
import { routerMock } from '@/tests/lib/route-mock'
const worker = (name: string, overrides: Partial<Worker> = {}): Worker => ({
name,
buildState: 'active',
isDeleting: false,
runtime: 'node',
size: '2gb-1vcpu',
access: 'public',
declaredInstances: 1,
...overrides,
})
const renderList = (workers: Worker[]) =>
customRender(<WorkersList projectRef="default" workers={workers} />)
const rowNames = () =>
screen
.getAllByRole('row')
.slice(1)
.map((row) => within(row).getAllByRole('cell')[0].textContent)
describe('WorkersList', () => {
beforeEach(() => {
routerMock.setCurrentUrl('/project/default/workers')
})
it('renders a row per worker, linking to its detail page', () => {
renderList([worker('embed'), worker('resize', { buildState: 'building' })])
expect(rowNames()).toEqual(['embed', 'resize'])
expect(screen.getByRole('link', { name: 'embed' })).toHaveAttribute(
'href',
'/project/default/workers/embed'
)
expect(screen.getByText('Building')).toBeVisible()
})
it('narrows the rows as you search, and says so when nothing matches', async () => {
renderList([worker('embed'), worker('resize-images')])
await userEvent.type(screen.getByPlaceholderText('Search by name'), 'resize')
expect(rowNames()).toEqual(['resize-images'])
expect(screen.getByText('1 worker')).toBeVisible()
await userEvent.type(screen.getByPlaceholderText('Search by name'), '-nope')
expect(screen.getByText('No workers match your filters')).toBeVisible()
})
it('pages through the workers ten at a time', async () => {
const workers = Array.from({ length: 12 }, (_, index) => worker(`worker-${index}`))
renderList(workers)
expect(rowNames()).toHaveLength(10)
expect(screen.getByText('Page 1 of 2')).toBeVisible()
expect(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled()
await userEvent.click(screen.getByRole('button', { name: 'Next page' }))
expect(rowNames()).toEqual(['worker-10', 'worker-11'])
expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled()
})
it('returns to the first page when a search shrinks the results', async () => {
const workers = Array.from({ length: 12 }, (_, index) => worker(`worker-${index}`))
renderList(workers)
await userEvent.click(screen.getByRole('button', { name: 'Next page' }))
expect(screen.getByText('Page 2 of 2')).toBeVisible()
await userEvent.type(screen.getByPlaceholderText('Search by name'), 'worker-1')
expect(screen.getByText('Page 1 of 1')).toBeVisible()
expect(rowNames()).toEqual(['worker-1', 'worker-10', 'worker-11'])
})
})
@@ -0,0 +1,230 @@
import { ChevronLeft, ChevronRight } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useState } from 'react'
import {
Badge,
Card,
CardFooter,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from 'ui'
import { RuntimeBadge } from './RuntimeBadge'
import { WORKERS_REGION_SHORT } from './Workers.constants'
import type { Worker, WorkerAccess, WorkerBuildState } from './Workers.types'
import { filterWorkers, formatResources, getPage } from './Workers.utils'
import { WorkerStatePill } from './WorkerStatePill'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
interface WorkersListProps {
projectRef: string
workers: Worker[]
}
const STATE_FILTERS: { value: WorkerBuildState | 'all'; label: string }[] = [
{ value: 'all', label: 'All states' },
{ value: 'active', label: 'Active' },
{ value: 'building', label: 'Building' },
{ value: 'failed', label: 'Failed' },
]
const ACCESS_FILTERS: { value: WorkerAccess | 'all'; label: string }[] = [
{ value: 'all', label: 'All access' },
{ value: 'public', label: 'Public' },
{ value: 'private', label: 'Private' },
]
const PAGE_SIZE = 10
const parseStateFilter = (value: string): WorkerBuildState | 'all' =>
STATE_FILTERS.find((option) => option.value === value)?.value ?? 'all'
const parseAccessFilter = (value: string): WorkerAccess | 'all' =>
ACCESS_FILTERS.find((option) => option.value === value)?.value ?? 'all'
export const WorkersList = ({ projectRef, workers }: WorkersListProps) => {
const router = useRouter()
const [search, setSearch] = useState('')
const [stateFilter, setStateFilter] = useState<WorkerBuildState | 'all'>('all')
const [accessFilter, setAccessFilter] = useState<WorkerAccess | 'all'>('all')
const [page, setPage] = useState(1)
const filtered = filterWorkers(workers, {
search,
state: stateFilter,
access: accessFilter,
})
const { items: paged, currentPage, totalPages, startIndex } = getPage(filtered, page, PAGE_SIZE)
const resetToFirstPage = () => setPage(1)
const workerPagePath = (name: string) => `/project/${projectRef}/workers/${name}`
return (
<div className="space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center">
<Input
size="small"
className="w-full md:w-64"
placeholder="Search by name"
value={search}
onChange={(e) => {
setSearch(e.target.value)
resetToFirstPage()
}}
/>
<Select
value={stateFilter}
onValueChange={(value) => {
setStateFilter(parseStateFilter(value))
resetToFirstPage()
}}
>
<SelectTrigger size="small" className="w-full md:w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATE_FILTERS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={accessFilter}
onValueChange={(value) => {
setAccessFilter(parseAccessFilter(value))
resetToFirstPage()
}}
>
<SelectTrigger size="small" className="w-full md:w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ACCESS_FILTERS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-3 md:ml-auto">
<span className="text-sm text-foreground-lighter">
{filtered.length} worker{filtered.length === 1 ? '' : 's'}
</span>
</div>
</div>
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>State</TableHead>
<TableHead>Runtime</TableHead>
<TableHead>Access</TableHead>
<TableHead className="hidden xl:table-cell">Region</TableHead>
<TableHead className="hidden lg:table-cell">Resources</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center text-foreground-lighter">
No workers match your filters
</TableCell>
</TableRow>
)}
{paged.map((worker) => (
<TableRow
key={worker.name}
className="cursor-pointer"
onClick={() => router.push(workerPagePath(worker.name))}
>
<TableCell className="font-medium text-foreground">
<Link href={workerPagePath(worker.name)} onClick={(e) => e.stopPropagation()}>
{worker.name}
</Link>
</TableCell>
<TableCell>
<WorkerStatePill worker={worker} />
</TableCell>
<TableCell>
<RuntimeBadge runtime={worker.runtime} />
</TableCell>
<TableCell>
{worker.access === 'public' ? (
<Badge variant="success">Public</Badge>
) : (
<Badge>Private</Badge>
)}
</TableCell>
<TableCell className="hidden text-foreground-light xl:table-cell">
{WORKERS_REGION_SHORT}
</TableCell>
<TableCell className="hidden text-foreground-light lg:table-cell">
{formatResources(worker)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{filtered.length > 0 && (
<CardFooter className="flex items-center justify-between border-t p-4">
<p className="text-sm text-foreground-muted">
Showing {startIndex + 1} to {startIndex + paged.length} of {filtered.length} worker
{filtered.length === 1 ? '' : 's'}
</p>
<div className="flex items-center gap-x-2" aria-label="Pagination">
<ButtonTooltip
icon={<ChevronLeft />}
aria-label="Previous page"
variant="default"
size="tiny"
disabled={currentPage === 1}
onClick={() => setPage(currentPage - 1)}
tooltip={{
content: {
side: 'top',
text: currentPage === 1 ? 'Already on the first page' : undefined,
},
}}
/>
<span className="text-sm text-foreground-light tabular-nums">
Page {currentPage} of {totalPages}
</span>
<ButtonTooltip
icon={<ChevronRight />}
aria-label="Next page"
variant="default"
size="tiny"
disabled={currentPage >= totalPages}
onClick={() => setPage(currentPage + 1)}
tooltip={{
content: {
side: 'top',
text: currentPage >= totalPages ? 'Already on the last page' : undefined,
},
}}
/>
</div>
</CardFooter>
)}
</Card>
</div>
)
}
@@ -54,6 +54,11 @@ describe('generateProductRoutes', () => {
expect(keys(routes)).toEqual(['database', 'auth', 'storage', 'functions', 'realtime'])
})
it('includes workers only when the workers flag is enabled', () => {
expect(keys(generateProductRoutes(REF, activeProject))).not.toContain('workers')
expect(keys(generateProductRoutes(REF, activeProject, { workers: true }))).toContain('workers')
})
it('excludes auth when auth feature is disabled', () => {
const routes = generateProductRoutes(REF, activeProject, { auth: false })
expect(keys(routes)).not.toContain('auth')
@@ -1,6 +1,6 @@
import { useParams } from 'common'
import { Auth, Database, EdgeFunctions, Realtime, SqlEditor, Storage, TableEditor } from 'icons'
import { Blocks, Lightbulb, List, Settings, Telescope } from 'lucide-react'
import { Blocks, Box, Lightbulb, List, Settings, Telescope } from 'lucide-react'
import { useUnifiedLogsPreview } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { ICON_SIZE, ICON_STROKE_WIDTH } from '@/components/interfaces/Sidebar'
@@ -10,6 +10,7 @@ import type { Project } from '@/data/projects/project-detail-query'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { IS_PLATFORM, PROJECT_STATUS } from '@/lib/constants'
import { PRODUCT_NAME } from '@/lib/constants/workers'
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
interface RouteContext {
@@ -25,6 +26,7 @@ interface ProductFeatures {
storage?: boolean
realtime?: boolean
authOverviewPage?: boolean
workers?: boolean
}
interface OtherFeatures {
@@ -79,6 +81,7 @@ export const generateProductRoutes = (
const storageEnabled = features?.storage ?? true
const realtimeEnabled = features?.realtime ?? true
const authOverviewPageEnabled = features?.authOverviewPage ?? false
const workersEnabled = features?.workers ?? false
return [
{
@@ -137,6 +140,17 @@ export const generateProductRoutes = (
},
]
: []),
...(workersEnabled
? [
{
key: 'workers',
label: PRODUCT_NAME,
disabled: !isProjectActive,
icon: <Box size={ICON_SIZE} strokeWidth={ICON_STROKE_WIDTH} />,
link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/workers`),
},
]
: []),
...(realtimeEnabled
? [
{
@@ -1,15 +1,17 @@
import { useFlag, useParams } from 'common'
import Link from 'next/link'
import { PropsWithChildren } from 'react'
import { PropsWithChildren, ReactNode } from 'react'
import { Button, cn } from 'ui'
interface ProductMenuBarProps {
title: string
titleBadge?: ReactNode
className?: string
}
export const ProductMenuBar = ({
title,
titleBadge,
children,
className,
}: PropsWithChildren<ProductMenuBarProps>) => {
@@ -30,7 +32,10 @@ export const ProductMenuBar = ({
)}
>
<div className="border-default flex min-h-(--header-height) items-center border-b px-6 justify-between">
<h4 className="text-lg">{title}</h4>
<div className="flex items-center gap-2">
<h4 className="text-lg">{title}</h4>
{titleBadge}
</div>
{showExplorerCTA && (
<Button asChild variant="default">
<Link href={`/project/${ref}/explorer`}>Explorer</Link>
@@ -62,6 +62,7 @@ export function MobileMenuContent({
'realtime:all',
])
const authOverviewPageEnabled = useFlag('authOverviewPage')
const workersEnabled = useFlag('workers')
const toolRoutes = useMemo(() => generateToolRoutes(ref, project), [ref, project])
const productRoutes = useMemo(
@@ -72,6 +73,7 @@ export function MobileMenuContent({
storage: storageEnabled,
realtime: realtimeEnabled,
authOverviewPage: authOverviewPageEnabled,
workers: workersEnabled,
}),
[
ref,
@@ -81,6 +83,7 @@ export function MobileMenuContent({
storageEnabled,
realtimeEnabled,
authOverviewPageEnabled,
workersEnabled,
]
)
const otherRoutes = useGenerateOtherRoutes()
@@ -105,6 +105,7 @@ export interface ProjectLayoutProps {
isLoading?: boolean
isBlocking?: boolean
product?: string
productMenuBadge?: ReactNode
productMenu?: ReactNode
browserTitle?: {
entity?: string
@@ -123,6 +124,7 @@ export const ProjectLayout = forwardRef<HTMLDivElement, PropsWithChildren<Projec
isLoading = false,
isBlocking = true,
product = '',
productMenuBadge,
productMenu,
browserTitle,
children,
@@ -279,7 +281,11 @@ export const ProjectLayout = forwardRef<HTMLDivElement, PropsWithChildren<Projec
isBlocking={isBlocking}
productMenu={productMenu}
>
<ProductMenuBar title={product} className={productMenuClassName}>
<ProductMenuBar
title={product}
titleBadge={productMenuBadge}
className={productMenuClassName}
>
{productMenu}
</ProductMenuBar>
</MenuBarWrapper>
@@ -0,0 +1,23 @@
import { useParams } from 'common'
import type { CommandOptions } from 'ui-patterns/CommandMenu'
import { useRegisterCommands } from 'ui-patterns/CommandMenu'
import { COMMAND_MENU_SECTIONS } from '@/components/interfaces/App/CommandMenu/CommandMenu.utils'
import { PRODUCT_NAME } from '@/lib/constants/workers'
export function useWorkersGotoCommands(options?: CommandOptions) {
let { ref } = useParams()
ref ||= '_'
useRegisterCommands(
COMMAND_MENU_SECTIONS.NAVIGATE,
[
{
id: 'nav-workers',
name: PRODUCT_NAME,
route: `/project/${ref}/workers`,
defaultHidden: true,
},
],
{ ...options, deps: [ref] }
)
}
@@ -0,0 +1,94 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { FeatureFlagContext, useFlag, useParams } from 'common'
import { useRouter } from 'next/router'
import { useContext, useEffect, useMemo, type PropsWithChildren } from 'react'
import { Badge } from 'ui'
import { ProjectLayout } from '../ProjectLayout'
import { NoPermission } from '@/components/ui/NoPermission'
import { ProductMenu } from '@/components/ui/ProductMenu'
import type { ProductMenuGroup } from '@/components/ui/ProductMenu/ProductMenu.types'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { withAuth } from '@/hooks/misc/withAuth'
import { PRODUCT_NAME } from '@/lib/constants/workers'
const useGenerateWorkersMenu = (): ProductMenuGroup[] => {
const { ref: projectRef = 'default' } = useParams()
return useMemo(
() => [
{
title: 'Manage',
items: [
{
name: PRODUCT_NAME,
key: 'main',
pages: ['', '[name]'],
url: `/project/${projectRef}/workers`,
items: [],
},
],
},
],
[projectRef]
)
}
export const WorkersProductMenu = () => {
const router = useRouter()
const page = router.pathname.split('/')[4]
const menu = useGenerateWorkersMenu()
return <ProductMenu page={page} menu={menu} />
}
interface WorkersLayoutProps {
title?: string
}
const WorkersLayoutContent = ({ children, title }: PropsWithChildren<WorkersLayoutProps>) => {
const router = useRouter()
const { ref: projectRef } = useParams()
const { hasLoaded } = useContext(FeatureFlagContext)
const workersEnabled = useFlag('workers')
// The v2 workers routes require the FGA workers_read permission, which shared-types does not
// expose yet; they reuse the Edge Functions OAuth scope, so gate on the same product here.
const { isLoading: isLoadingPermissions, can: canReadWorkers } = useAsyncCheckPermissions(
PermissionAction.FUNCTIONS_READ,
'*'
)
useEffect(() => {
if (hasLoaded && !workersEnabled) {
router.replace(`/project/${projectRef}`)
}
}, [router, hasLoaded, workersEnabled, projectRef])
if (!workersEnabled) return null
if (isLoadingPermissions) {
return (
<ProjectLayout
isLoading
product={PRODUCT_NAME}
browserTitle={{ entity: PRODUCT_NAME, section: title }}
/>
)
}
return (
<ProjectLayout
product={PRODUCT_NAME}
productMenuBadge={<Badge variant="warning">Private Alpha</Badge>}
productMenu={<WorkersProductMenu />}
isBlocking={false}
browserTitle={{ entity: PRODUCT_NAME, section: title }}
>
{canReadWorkers ? (
children
) : (
<NoPermission isFullPage resourceText={`view this project's ${PRODUCT_NAME} workers`} />
)}
</ProjectLayout>
)
}
export const WorkersLayout = withAuth(WorkersLayoutContent)
@@ -1,4 +1,4 @@
import { useIsLoggedIn } from 'common'
import { useFlag, useIsLoggedIn } from 'common'
import { useStorageGotoCommands } from '../interfaces/Storage/Storage.Commands'
import { useAdvisorsGoToCommands } from './AdvisorsLayout/Advisors.Commands'
@@ -12,10 +12,12 @@ import { useProjectSettingsGotoCommands } from './ProjectSettingsLayout/ProjectS
import { useReportsGotoCommands } from './ReportsLayout/Reports.Commands'
import { useSqlEditorGotoCommands } from './SQLEditorLayout/SqlEditor.Commands'
import { useTableEditorGotoCommands } from './TableEditorLayout/TableEditor.Commands'
import { useWorkersGotoCommands } from './WorkersLayout/Workers.Commands'
import { useApiDocsGotoCommands } from '@/components/interfaces/ProjectAPIDocs/ProjectAPIDocs.Commands'
export function useLayoutNavCommands() {
const isLoggedIn = useIsLoggedIn()
const workersEnabled = useFlag('workers')
useTableEditorGotoCommands({ enabled: isLoggedIn })
useSqlEditorGotoCommands({ enabled: isLoggedIn })
@@ -24,6 +26,7 @@ export function useLayoutNavCommands() {
useAdvisorsGoToCommands({ enabled: isLoggedIn })
useStorageGotoCommands({ enabled: isLoggedIn })
useFunctionsGotoCommands({ enabled: isLoggedIn })
useWorkersGotoCommands({ enabled: isLoggedIn && workersEnabled })
useLogsGotoCommands({ enabled: isLoggedIn })
useReportsGotoCommands({ enabled: isLoggedIn })
useApiDocsGotoCommands({ enabled: isLoggedIn })
+5
View File
@@ -0,0 +1,5 @@
export const workersKeys = {
list: (projectRef: string | undefined) => ['projects', projectRef, 'workers'] as const,
detail: (projectRef: string | undefined, name: string | undefined) =>
['projects', projectRef, 'worker', name, 'detail'] as const,
}
+32
View File
@@ -0,0 +1,32 @@
import { queryOptions } from '@tanstack/react-query'
import { workersKeys } from './keys'
import { parseWorker } from './workers.utils'
import { get, handleError } from '@/data/fetchers'
import { IS_PLATFORM } from '@/lib/constants'
import type { ResponseError } from '@/types'
export type WorkerVariables = { projectRef?: string; name?: string }
export type WorkerError = ResponseError
async function getWorker({ projectRef, name }: WorkerVariables, signal?: AbortSignal) {
if (!projectRef) throw new Error('projectRef is required')
if (!name) throw new Error('name is required')
const { data, error } = await get('/v2/projects/{ref}/workers/{name}', {
params: { path: { ref: projectRef, name } },
signal,
})
if (error) return handleError(error)
return parseWorker(data.data)
}
export type WorkerData = Awaited<ReturnType<typeof getWorker>>
export const workerQueryOptions = ({ projectRef, name }: WorkerVariables) =>
queryOptions({
queryKey: workersKeys.detail(projectRef, name),
queryFn: ({ signal }) => getWorker({ projectRef, name }, signal),
enabled: IS_PLATFORM && typeof projectRef !== 'undefined' && typeof name !== 'undefined',
})
+31
View File
@@ -0,0 +1,31 @@
import { queryOptions } from '@tanstack/react-query'
import { workersKeys } from './keys'
import { parseWorker } from './workers.utils'
import { get, handleError } from '@/data/fetchers'
import { IS_PLATFORM } from '@/lib/constants'
import type { ResponseError } from '@/types'
export type WorkersVariables = { projectRef?: string }
export type WorkersError = ResponseError
async function getWorkers({ projectRef }: WorkersVariables, signal?: AbortSignal) {
if (!projectRef) throw new Error('projectRef is required')
const { data, error } = await get('/v2/projects/{ref}/workers', {
params: { path: { ref: projectRef } },
signal,
})
if (error) return handleError(error)
return data.data.map((worker) => parseWorker(worker))
}
export type WorkersData = Awaited<ReturnType<typeof getWorkers>>
export const workersQueryOptions = ({ projectRef }: WorkersVariables) =>
queryOptions({
queryKey: workersKeys.list(projectRef),
queryFn: ({ signal }) => getWorkers({ projectRef }, signal),
enabled: IS_PLATFORM && typeof projectRef !== 'undefined',
})
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import { parseWorker } from './workers.utils'
const datum = (attributes: Record<string, unknown>) => ({
id: 'embed',
type: 'project_worker',
attributes: {
build_state: 'active',
secret_generation: '1',
spec: { exposure: 'private', instances: 1, size: '2gb-1vcpu' },
...attributes,
},
})
describe('parseWorker', () => {
it('maps the API response onto the view model', () => {
expect(
parseWorker(
datum({
spec: { exposure: 'public', instances: 3, runtime: 'node', size: '4gb-2vcpu' },
instances: { declared: 3, live: 3, ready: 2, stale: 0 },
image_version: '1.0',
})
)
).toEqual({
name: 'embed',
buildState: 'active',
isDeleting: false,
runtime: 'node',
size: '4gb-2vcpu',
access: 'public',
declaredInstances: 3,
instances: { declared: 3, live: 3, ready: 2, stale: 0 },
imageVersion: '1.0',
stateReason: undefined,
instancesError: undefined,
})
})
it('treats any exposure other than public as private', () => {
expect(
parseWorker(datum({ spec: { exposure: 'internal', instances: 1, size: '2gb-1vcpu' } }))
).toHaveProperty('access', 'private')
})
it('defaults deleting to false when the API omits it', () => {
expect(parseWorker(datum({}))).toHaveProperty('isDeleting', false)
expect(parseWorker(datum({ deleting: true }))).toHaveProperty('isDeleting', true)
})
it('falls back to failed for a build state it does not know', () => {
expect(parseWorker(datum({ build_state: 'exploded' }))).toHaveProperty('buildState', 'failed')
})
it('rejects a response missing the fields the UI renders', () => {
expect(() => parseWorker({ id: 'embed', attributes: {} })).toThrow()
expect(() => parseWorker(undefined)).toThrow()
})
})
+47
View File
@@ -0,0 +1,47 @@
import { z } from 'zod'
import type { Worker, WorkerBuildState } from '@/components/interfaces/Workers/Workers.types'
const BUILD_STATES = ['building', 'active', 'failed'] as const satisfies readonly WorkerBuildState[]
const WorkerResponseSchema = z.object({
id: z.string(),
attributes: z.object({
build_state: z.enum(BUILD_STATES).catch('failed'),
deleting: z.boolean().optional(),
image_version: z.string().optional(),
instances: z
.object({
declared: z.number(),
live: z.number(),
ready: z.number(),
stale: z.number(),
})
.optional(),
instances_error: z.string().optional(),
spec: z.object({
exposure: z.string(),
instances: z.number(),
runtime: z.string().optional(),
size: z.string(),
}),
state_reason: z.string().optional(),
}),
})
export const parseWorker = (datum: unknown): Worker => {
const { id, attributes } = WorkerResponseSchema.parse(datum)
return {
name: id,
buildState: attributes.build_state,
isDeleting: attributes.deleting ?? false,
runtime: attributes.spec.runtime,
size: attributes.spec.size,
access: attributes.spec.exposure === 'public' ? 'public' : 'private',
declaredInstances: attributes.spec.instances,
instances: attributes.instances,
imageVersion: attributes.image_version,
stateReason: attributes.state_reason,
instancesError: attributes.instances_error,
}
}
+47
View File
@@ -0,0 +1,47 @@
export const PRODUCT_NAME = 'Compute'
export const CLI_NAME = 'compute'
export const WORKERS_CLI_DEPLOY = `supabase ${CLI_NAME} push`
export const WORKERS_SKILL_MARKDOWN = `# Supabase ${PRODUCT_NAME} — agent skill
Deploy and manage backend workers (microVMs) that run next to a Supabase project's Postgres.
## Deploy from config.toml
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
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
\`\`\`
Then run:
\`\`\`bash
supabase ${CLI_NAME} new embed --runtime python
supabase ${CLI_NAME} push embed
\`\`\`
Config precedence: \`--flag\` > \`config.toml\` > interactive prompt > default.
## Conventions
- 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.
- Sizes are fixed at deploy time. To change size, delete the worker and redeploy.
## Manage
- \`supabase ${CLI_NAME} list\`
- \`supabase ${CLI_NAME} status <name>\`
- \`supabase ${CLI_NAME} logs <name> --follow\`
- \`supabase ${CLI_NAME} stop <name>\` # scale to zero (reversible)
- \`supabase ${CLI_NAME} start <name>\`
- \`supabase ${CLI_NAME} delete <name>\`
`
@@ -0,0 +1,91 @@
import { useQuery } from '@tanstack/react-query'
import { useParams } from 'common'
import { Admonition } from 'ui-patterns/Admonition'
import { PageContainer } from 'ui-patterns/PageContainer'
import {
PageHeader,
PageHeaderDescription,
PageHeaderMeta,
PageHeaderSummary,
PageHeaderTitle,
} from 'ui-patterns/PageHeader'
import { PageSection, PageSectionContent } from 'ui-patterns/PageSection'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import {
isWorkersForbidden,
isWorkersUnavailable,
} from '@/components/interfaces/Workers/Workers.utils'
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 { NoPermission } from '@/components/ui/NoPermission'
import { workersQueryOptions } from '@/data/workers/workers-query'
import { PRODUCT_NAME } from '@/lib/constants/workers'
import type { NextPageWithLayout } from '@/types'
const WorkersPage: NextPageWithLayout = () => {
const { ref } = useParams()
const {
data: workers,
error,
isPending,
isError,
isSuccess,
} = useQuery(workersQueryOptions({ projectRef: ref }))
const isNotEnrolled = isError && isWorkersUnavailable(error)
const isMissingPermission = isError && isWorkersForbidden(error)
const isUnexpectedError = isError && !isNotEnrolled && !isMissingPermission
return (
<div className="w-full min-h-full flex flex-col items-stretch">
<PageHeader size="large">
<PageHeaderMeta>
<PageHeaderSummary>
<PageHeaderTitle>{PRODUCT_NAME}</PageHeaderTitle>
<PageHeaderDescription>
Run backend workers in microVMs next to your database
</PageHeaderDescription>
</PageHeaderSummary>
</PageHeaderMeta>
</PageHeader>
<PageContainer size="large">
<PageSection>
<PageSectionContent>
{isPending && <GenericSkeletonLoader />}
{isNotEnrolled && (
<Admonition
type="default"
title={`${PRODUCT_NAME} is not enabled for this project`}
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`} />
)}
{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>
)}
{isSuccess && workers.length > 0 && ref && (
<WorkersList projectRef={ref} workers={workers} />
)}
</PageSectionContent>
</PageSection>
</PageContainer>
</div>
)
}
WorkersPage.getLayout = (page) => (
<DefaultLayout>
<WorkersLayout title={PRODUCT_NAME}>{page}</WorkersLayout>
</DefaultLayout>
)
export default WorkersPage
+49
View File
@@ -55,6 +55,7 @@ import { Route as ApiConnectIndexRouteImport } from './routes/api/connect/index'
import { Route as AppOrgIndexRouteImport } from './routes/_app/org/index'
import { Route as AppNewIndexRouteImport } from './routes/_app/new/index'
import { Route as ProjectChar91_Char93SplatRouteImport } from './routes/project.[_].$'
import { Route as ProjectRefWorkersRouteImport } from './routes/project/$ref/workers'
import { Route as ProjectRefStorageRouteImport } from './routes/project/$ref/storage'
import { Route as ProjectRefSqlRouteImport } from './routes/project/$ref/sql'
import { Route as ProjectRefSettingsRouteImport } from './routes/project/$ref/settings'
@@ -84,6 +85,7 @@ import { Route as AppSupportLinkRouteImport } from './routes/_app/support/link'
import { Route as AppAccountSecurityRouteImport } from './routes/_app/account/security'
import { Route as AppAccountMeRouteImport } from './routes/_app/account/me'
import { Route as AppAccountAuditRouteImport } from './routes/_app/account/audit'
import { Route as ProjectRefWorkersIndexRouteImport } from './routes/project/$ref/workers/index'
import { Route as ProjectRefSqlIndexRouteImport } from './routes/project/$ref/sql/index'
import { Route as ProjectRefObservabilityIndexRouteImport } from './routes/project/$ref/observability/index'
import { Route as ProjectRefLogsIndexRouteImport } from './routes/project/$ref/logs/index'
@@ -555,6 +557,11 @@ const ProjectChar91_Char93SplatRoute =
path: '/$',
getParentRoute: () => ProjectChar91_Char93Route,
} as any)
const ProjectRefWorkersRoute = ProjectRefWorkersRouteImport.update({
id: '/workers',
path: '/workers',
getParentRoute: () => ProjectRefRoute,
} as any)
const ProjectRefStorageRoute = ProjectRefStorageRouteImport.update({
id: '/storage',
path: '/storage',
@@ -704,6 +711,11 @@ const AppAccountAuditRoute = AppAccountAuditRouteImport.update({
path: '/audit',
getParentRoute: () => AppAccountRoute,
} as any)
const ProjectRefWorkersIndexRoute = ProjectRefWorkersIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => ProjectRefWorkersRoute,
} as any)
const ProjectRefSqlIndexRoute = ProjectRefSqlIndexRouteImport.update({
id: '/',
path: '/',
@@ -2136,6 +2148,7 @@ export interface FileRoutesByFullPath {
'/project/$ref/settings': typeof ProjectRefSettingsRouteWithChildren
'/project/$ref/sql': typeof ProjectRefSqlRouteWithChildren
'/project/$ref/storage': typeof ProjectRefStorageRouteWithChildren
'/project/$ref/workers': typeof ProjectRefWorkersRouteWithChildren
'/project/_/$': typeof ProjectChar91_Char93SplatRoute
'/new/': typeof AppNewIndexRoute
'/org/': typeof AppOrgIndexRoute
@@ -2257,6 +2270,7 @@ export interface FileRoutesByFullPath {
'/project/$ref/logs/': typeof ProjectRefLogsIndexRoute
'/project/$ref/observability/': typeof ProjectRefObservabilityIndexRoute
'/project/$ref/sql/': typeof ProjectRefSqlIndexRoute
'/project/$ref/workers/': typeof ProjectRefWorkersIndexRoute
'/org/$slug/webhooks/$endpointId': typeof AppOrgSlugWebhooksEndpointIdRoute
'/partners/stripe/projects/login': typeof AuthPartnersStripeProjectsLoginRoute
'/api/platform/auth/$ref/invite': typeof ApiPlatformAuthRefInviteRoute
@@ -2556,6 +2570,7 @@ export interface FileRoutesByTo {
'/project/$ref/logs': typeof ProjectRefLogsIndexRoute
'/project/$ref/observability': typeof ProjectRefObservabilityIndexRoute
'/project/$ref/sql': typeof ProjectRefSqlIndexRoute
'/project/$ref/workers': typeof ProjectRefWorkersIndexRoute
'/org/$slug/webhooks/$endpointId': typeof AppOrgSlugWebhooksEndpointIdRoute
'/partners/stripe/projects/login': typeof AuthPartnersStripeProjectsLoginRoute
'/api/platform/auth/$ref/invite': typeof ApiPlatformAuthRefInviteRoute
@@ -2750,6 +2765,7 @@ export interface FileRoutesById {
'/project/$ref/settings': typeof ProjectRefSettingsRouteWithChildren
'/project/$ref/sql': typeof ProjectRefSqlRouteWithChildren
'/project/$ref/storage': typeof ProjectRefStorageRouteWithChildren
'/project/$ref/workers': typeof ProjectRefWorkersRouteWithChildren
'/project/_/$': typeof ProjectChar91_Char93SplatRoute
'/_app/new/': typeof AppNewIndexRoute
'/_app/org/': typeof AppOrgIndexRoute
@@ -2871,6 +2887,7 @@ export interface FileRoutesById {
'/project/$ref/logs/': typeof ProjectRefLogsIndexRoute
'/project/$ref/observability/': typeof ProjectRefObservabilityIndexRoute
'/project/$ref/sql/': typeof ProjectRefSqlIndexRoute
'/project/$ref/workers/': typeof ProjectRefWorkersIndexRoute
'/_app/org/$slug/webhooks/$endpointId': typeof AppOrgSlugWebhooksEndpointIdRoute
'/_auth/partners/stripe/projects/login': typeof AuthPartnersStripeProjectsLoginRoute
'/api/platform/auth/$ref/invite': typeof ApiPlatformAuthRefInviteRoute
@@ -3064,6 +3081,7 @@ export interface FileRouteTypes {
| '/project/$ref/settings'
| '/project/$ref/sql'
| '/project/$ref/storage'
| '/project/$ref/workers'
| '/project/_/$'
| '/new/'
| '/org/'
@@ -3185,6 +3203,7 @@ export interface FileRouteTypes {
| '/project/$ref/logs/'
| '/project/$ref/observability/'
| '/project/$ref/sql/'
| '/project/$ref/workers/'
| '/org/$slug/webhooks/$endpointId'
| '/partners/stripe/projects/login'
| '/api/platform/auth/$ref/invite'
@@ -3484,6 +3503,7 @@ export interface FileRouteTypes {
| '/project/$ref/logs'
| '/project/$ref/observability'
| '/project/$ref/sql'
| '/project/$ref/workers'
| '/org/$slug/webhooks/$endpointId'
| '/partners/stripe/projects/login'
| '/api/platform/auth/$ref/invite'
@@ -3677,6 +3697,7 @@ export interface FileRouteTypes {
| '/project/$ref/settings'
| '/project/$ref/sql'
| '/project/$ref/storage'
| '/project/$ref/workers'
| '/project/_/$'
| '/_app/new/'
| '/_app/org/'
@@ -3798,6 +3819,7 @@ export interface FileRouteTypes {
| '/project/$ref/logs/'
| '/project/$ref/observability/'
| '/project/$ref/sql/'
| '/project/$ref/workers/'
| '/_app/org/$slug/webhooks/$endpointId'
| '/_auth/partners/stripe/projects/login'
| '/api/platform/auth/$ref/invite'
@@ -4369,6 +4391,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ProjectChar91_Char93SplatRouteImport
parentRoute: typeof ProjectChar91_Char93Route
}
'/project/$ref/workers': {
id: '/project/$ref/workers'
path: '/workers'
fullPath: '/project/$ref/workers'
preLoaderRoute: typeof ProjectRefWorkersRouteImport
parentRoute: typeof ProjectRefRoute
}
'/project/$ref/storage': {
id: '/project/$ref/storage'
path: '/storage'
@@ -4572,6 +4601,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppAccountAuditRouteImport
parentRoute: typeof AppAccountRoute
}
'/project/$ref/workers/': {
id: '/project/$ref/workers/'
path: '/'
fullPath: '/project/$ref/workers/'
preLoaderRoute: typeof ProjectRefWorkersIndexRouteImport
parentRoute: typeof ProjectRefWorkersRoute
}
'/project/$ref/sql/': {
id: '/project/$ref/sql/'
path: '/'
@@ -6844,6 +6880,17 @@ const ProjectRefStorageRouteChildren: ProjectRefStorageRouteChildren = {
const ProjectRefStorageRouteWithChildren =
ProjectRefStorageRoute._addFileChildren(ProjectRefStorageRouteChildren)
interface ProjectRefWorkersRouteChildren {
ProjectRefWorkersIndexRoute: typeof ProjectRefWorkersIndexRoute
}
const ProjectRefWorkersRouteChildren: ProjectRefWorkersRouteChildren = {
ProjectRefWorkersIndexRoute: ProjectRefWorkersIndexRoute,
}
const ProjectRefWorkersRouteWithChildren =
ProjectRefWorkersRoute._addFileChildren(ProjectRefWorkersRouteChildren)
interface ProjectRefRouteChildren {
ProjectRefAdvisorsRoute: typeof ProjectRefAdvisorsRouteWithChildren
ProjectRefAuthRoute: typeof ProjectRefAuthRouteWithChildren
@@ -6860,6 +6907,7 @@ interface ProjectRefRouteChildren {
ProjectRefSettingsRoute: typeof ProjectRefSettingsRouteWithChildren
ProjectRefSqlRoute: typeof ProjectRefSqlRouteWithChildren
ProjectRefStorageRoute: typeof ProjectRefStorageRouteWithChildren
ProjectRefWorkersRoute: typeof ProjectRefWorkersRouteWithChildren
ProjectRefIndexRoute: typeof ProjectRefIndexRoute
ProjectRefApiIndexRoute: typeof ProjectRefApiIndexRoute
}
@@ -6880,6 +6928,7 @@ const ProjectRefRouteChildren: ProjectRefRouteChildren = {
ProjectRefSettingsRoute: ProjectRefSettingsRouteWithChildren,
ProjectRefSqlRoute: ProjectRefSqlRouteWithChildren,
ProjectRefStorageRoute: ProjectRefStorageRouteWithChildren,
ProjectRefWorkersRoute: ProjectRefWorkersRouteWithChildren,
ProjectRefIndexRoute: ProjectRefIndexRoute,
ProjectRefApiIndexRoute: ProjectRefApiIndexRoute,
}
@@ -0,0 +1,25 @@
import { createFileRoute, Outlet, useMatches } from '@tanstack/react-router'
import { WorkersLayout } from '@/components/layouts/WorkersLayout/WorkersLayout'
export const Route = createFileRoute('/project/$ref/workers')({
component: WorkersShell,
})
type WorkersStaticData = {
workersLayoutTitle?: string
}
function WorkersShell() {
const title = useMatches({
select: (matches) =>
(matches[matches.length - 1]?.staticData as WorkersStaticData | undefined)
?.workersLayoutTitle ?? '',
})
return (
<WorkersLayout title={title}>
<Outlet />
</WorkersLayout>
)
}
@@ -0,0 +1,15 @@
import { createFileRoute } from '@tanstack/react-router'
import { PRODUCT_NAME } from '@/lib/constants/workers'
import WorkersPage from '@/pages/project/[ref]/workers/index'
export const Route = createFileRoute('/project/$ref/workers/')({
component: WorkersIndexRoute,
staticData: {
workersLayoutTitle: PRODUCT_NAME,
},
})
function WorkersIndexRoute() {
return <WorkersPage dehydratedState={undefined} />
}
@@ -0,0 +1,114 @@
import { QueryClient } from '@tanstack/react-query'
import { screen } from '@testing-library/react'
import type { components } from 'api-types'
import { HttpResponse } from 'msw'
import { beforeEach, describe, expect, it } from 'vitest'
import { workersQueryOptions } from '@/data/workers/workers-query'
import { PRODUCT_NAME } from '@/lib/constants/workers'
import WorkersPage from '@/pages/project/[ref]/workers/index'
import { customRender } from '@/tests/lib/custom-render'
import { addAPIMock, type APIErrorBody } from '@/tests/lib/msw'
import { routerMock } from '@/tests/lib/route-mock'
type ListWorkersResponse = components['schemas']['V2ListWorkersResponse']
type WorkerDatum = ListWorkersResponse['data'][number]
// `tests/vitestSetup.ts` mocks `common`'s useParams to always answer with this ref, so the page
// reads it no matter what the router URL says.
const PROJECT_REF = 'default'
const workerDatum = (
id: string,
attributes: Partial<WorkerDatum['attributes']> = {}
): WorkerDatum => ({
id,
type: 'project_worker' as const,
attributes: {
build_state: 'active' as const,
secret_generation: '1',
spec: { exposure: 'public', instances: 1, runtime: 'node', size: '2gb-1vcpu' },
...attributes,
},
})
const mockWorkersList = (workers: WorkerDatum[]) =>
addAPIMock({ method: 'get', path: '/v2/projects/:ref/workers', response: { data: workers } })
const mockWorkersListFailure = (status: number) =>
addAPIMock({
method: 'get',
path: '/v2/projects/:ref/workers',
response: () => HttpResponse.json<APIErrorBody>({ message: 'Denied' }, { status }),
})
// The workers list is platform-only, and `IS_PLATFORM` is false under vitest, so the page's own
// query never leaves the idle state. Filling the cache imperatively still goes through MSW, and
// the page reads whatever landed there.
const renderWorkersPage = async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
await queryClient
.fetchQuery(workersQueryOptions({ projectRef: PROJECT_REF }))
.catch(() => undefined)
return customRender(<WorkersPage dehydratedState={undefined} />, { queryClient })
}
describe('/project/[ref]/workers', () => {
beforeEach(() => {
routerMock.setCurrentUrl(`/project/${PROJECT_REF}/workers`)
})
it('lists the workers the API returns', async () => {
mockWorkersList([
workerDatum('embed'),
workerDatum('resize', {
build_state: 'building',
spec: { exposure: 'private', instances: 2, runtime: 'python', size: '4gb-2vcpu' },
}),
])
await renderWorkersPage()
expect(screen.getByRole('link', { name: 'embed' })).toHaveAttribute(
'href',
`/project/${PROJECT_REF}/workers/embed`
)
expect(screen.getByRole('link', { name: 'resize' })).toBeVisible()
expect(screen.getByText('Active')).toBeVisible()
expect(screen.getByText('Building')).toBeVisible()
expect(screen.getByText('Python 3.14')).toBeVisible()
expect(screen.getByText('4 GB · 2 vCPU · 2 inst')).toBeVisible()
})
it('invites you to deploy one when the project has none', async () => {
mockWorkersList([])
await renderWorkersPage()
expect(screen.getByText(/No workers yet/)).toBeVisible()
expect(screen.queryByRole('table')).not.toBeInTheDocument()
})
it('explains that a project outside the alpha is not enrolled', async () => {
mockWorkersListFailure(404)
await renderWorkersPage()
expect(screen.getByText(`${PRODUCT_NAME} is not enabled for this project`)).toBeVisible()
expect(screen.queryByRole('table')).not.toBeInTheDocument()
})
it('asks for permissions when the project is enrolled but the caller is not allowed', async () => {
mockWorkersListFailure(403)
await renderWorkersPage()
expect(
screen.getByText(
`You need additional permissions to view this project's ${PRODUCT_NAME} workers`
)
).toBeVisible()
expect(screen.queryByRole('table')).not.toBeInTheDocument()
})
})