mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
1 parent
01d12e83c1
commit
6c6ac567b1
29 files changed
+1398
-5
No files matched your search
@@ -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>
|
||||
|
||||
+3
@@ -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 })
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user