diff --git a/apps/studio/TANSTACK_MIGRATION.md b/apps/studio/TANSTACK_MIGRATION.md index e9f5d3bb0b6..e112a426448 100644 --- a/apps/studio/TANSTACK_MIGRATION.md +++ b/apps/studio/TANSTACK_MIGRATION.md @@ -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 `` 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) diff --git a/apps/studio/components/interfaces/Sidebar.tsx b/apps/studio/components/interfaces/Sidebar.tsx index 89468267fa4..bf6ce2c63da 100644 --- a/apps/studio/components/interfaces/Sidebar.tsx +++ b/apps/studio/components/interfaces/Sidebar.tsx @@ -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) diff --git a/apps/studio/components/interfaces/Workers/RuntimeBadge.tsx b/apps/studio/components/interfaces/Workers/RuntimeBadge.tsx new file mode 100644 index 00000000000..96775239540 --- /dev/null +++ b/apps/studio/components/interfaces/Workers/RuntimeBadge.tsx @@ -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 ( + + + {formatRuntime(runtime)} + + ) +} diff --git a/apps/studio/components/interfaces/Workers/WorkerStatePill.tsx b/apps/studio/components/interfaces/Workers/WorkerStatePill.tsx new file mode 100644 index 00000000000..8ad3a227375 --- /dev/null +++ b/apps/studio/components/interfaces/Workers/WorkerStatePill.tsx @@ -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 ( + + + {isPulsing && ( + + )} + + + {meta.label} + + ) +} diff --git a/apps/studio/components/interfaces/Workers/Workers.constants.ts b/apps/studio/components/interfaces/Workers/Workers.constants.ts new file mode 100644 index 00000000000..7c3b911d0f1 --- /dev/null +++ b/apps/studio/components/interfaces/Workers/Workers.constants.ts @@ -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 = { + 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 = { + 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] diff --git a/apps/studio/components/interfaces/Workers/Workers.types.ts b/apps/studio/components/interfaces/Workers/Workers.types.ts new file mode 100644 index 00000000000..16fa531860a --- /dev/null +++ b/apps/studio/components/interfaces/Workers/Workers.types.ts @@ -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 + +// 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 +} diff --git a/apps/studio/components/interfaces/Workers/Workers.utils.test.ts b/apps/studio/components/interfaces/Workers/Workers.utils.test.ts new file mode 100644 index 00000000000..275f0763f5c --- /dev/null +++ b/apps/studio/components/interfaces/Workers/Workers.utils.test.ts @@ -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 & Pick): 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') + }) +}) diff --git a/apps/studio/components/interfaces/Workers/Workers.utils.ts b/apps/studio/components/interfaces/Workers/Workers.utils.ts new file mode 100644 index 00000000000..d0f32d15edf --- /dev/null +++ b/apps/studio/components/interfaces/Workers/Workers.utils.ts @@ -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 { + 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 = (items: T[], requestedPage: number, pageSize: number): Page => { + 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 diff --git a/apps/studio/components/interfaces/Workers/WorkersList.test.tsx b/apps/studio/components/interfaces/Workers/WorkersList.test.tsx new file mode 100644 index 00000000000..8dbdef26a66 --- /dev/null +++ b/apps/studio/components/interfaces/Workers/WorkersList.test.tsx @@ -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 => ({ + name, + buildState: 'active', + isDeleting: false, + runtime: 'node', + size: '2gb-1vcpu', + access: 'public', + declaredInstances: 1, + ...overrides, +}) + +const renderList = (workers: Worker[]) => + customRender() + +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']) + }) +}) diff --git a/apps/studio/components/interfaces/Workers/WorkersList.tsx b/apps/studio/components/interfaces/Workers/WorkersList.tsx new file mode 100644 index 00000000000..0d32d1d1866 --- /dev/null +++ b/apps/studio/components/interfaces/Workers/WorkersList.tsx @@ -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('all') + const [accessFilter, setAccessFilter] = useState('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 ( +
+
+ { + setSearch(e.target.value) + resetToFirstPage() + }} + /> + + + +
+ + {filtered.length} worker{filtered.length === 1 ? '' : 's'} + +
+
+ + + + + + Name + State + Runtime + Access + Region + Resources + + + + {filtered.length === 0 && ( + + + No workers match your filters + + + )} + {paged.map((worker) => ( + router.push(workerPagePath(worker.name))} + > + + e.stopPropagation()}> + {worker.name} + + + + + + + + + + {worker.access === 'public' ? ( + Public + ) : ( + Private + )} + + + {WORKERS_REGION_SHORT} + + + {formatResources(worker)} + + + ))} + +
+ + {filtered.length > 0 && ( + +

+ Showing {startIndex + 1} to {startIndex + paged.length} of {filtered.length} worker + {filtered.length === 1 ? '' : 's'} +

+
+ } + 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, + }, + }} + /> + + Page {currentPage} of {totalPages} + + } + 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, + }, + }} + /> +
+
+ )} +
+
+ ) +} diff --git a/apps/studio/components/layouts/Navigation/NavigationBar/NavigationBar.utils.test.tsx b/apps/studio/components/layouts/Navigation/NavigationBar/NavigationBar.utils.test.tsx index 2c73a67fab9..197c4ac5f5b 100644 --- a/apps/studio/components/layouts/Navigation/NavigationBar/NavigationBar.utils.test.tsx +++ b/apps/studio/components/layouts/Navigation/NavigationBar/NavigationBar.utils.test.tsx @@ -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') diff --git a/apps/studio/components/layouts/Navigation/NavigationBar/NavigationBar.utils.tsx b/apps/studio/components/layouts/Navigation/NavigationBar/NavigationBar.utils.tsx index 74c808895e9..b09833e41ad 100644 --- a/apps/studio/components/layouts/Navigation/NavigationBar/NavigationBar.utils.tsx +++ b/apps/studio/components/layouts/Navigation/NavigationBar/NavigationBar.utils.tsx @@ -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: , + link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/workers`), + }, + ] + : []), ...(realtimeEnabled ? [ { diff --git a/apps/studio/components/layouts/Navigation/ProductMenuBar.tsx b/apps/studio/components/layouts/Navigation/ProductMenuBar.tsx index f36c6d8c4d9..53db489bf35 100644 --- a/apps/studio/components/layouts/Navigation/ProductMenuBar.tsx +++ b/apps/studio/components/layouts/Navigation/ProductMenuBar.tsx @@ -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) => { @@ -30,7 +32,10 @@ export const ProductMenuBar = ({ )} >
-

{title}

+
+

{title}

+ {titleBadge} +
{showExplorerCTA && (