feat(studio): read the workers list from the v2 Management API

Replaces the seeded store with GET /v2/projects/{ref}/workers, and reshapes the
view model, state pill and resource formatting to what the endpoint returns.
This commit is contained in:
Jordi Enric committed 2026-08-18 14:45:52 +02:00
1 parent c3df56c22f
commit fa58d0753e
11 files changed
+177 -254

No files matched your search

@@ -1,10 +1,9 @@
import { cn } from 'ui'
import { getRuntimeMeta } from './Workers.constants'
import type { WorkerRuntime } from './Workers.types'
import { formatRuntime, getRuntimeMeta } from './Workers.utils'
interface RuntimeBadgeProps {
runtime: WorkerRuntime
runtime: string | undefined
className?: string
}
@@ -12,8 +11,10 @@ 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)} />
{meta.label}
<span
className={cn('h-2.5 w-2.5 rounded-[3px]', meta?.swatchClassName ?? 'bg-foreground-muted')}
/>
{formatRuntime(runtime)}
</span>
)
}
@@ -1,16 +1,16 @@
import { cn } from 'ui'
import { WORKER_STATE_META } from './Workers.constants'
import type { WorkerState } from './Workers.types'
import { getWorkerStateMeta } from './Workers.constants'
import type { Worker } from './Workers.types'
interface WorkerStatePillProps {
state: WorkerState
worker: Worker
className?: string
}
export const WorkerStatePill = ({ state, className }: WorkerStatePillProps) => {
const meta = WORKER_STATE_META[state]
const isPulsing = state === 'deploying' || state === 'draining' || state === 'resuming'
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)}>
@@ -1,4 +1,4 @@
import type { WorkerRuntime, WorkerSize, WorkerState } from './Workers.types'
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-1'
@@ -8,127 +8,74 @@ export const WORKERS_REGION_SHORT = 'US West'
// Private workers share this URL shape; access is enforced by the gateway, not the path.
export const workerGatewayUrl = (name: string) => `https://workers.supabase.co/v1/${name}`
export const LISTENING_PORT = 8080
export interface RuntimeMeta {
value: WorkerRuntime
label: string
cli: string
baseImage: string
entrypoint: string
swatchClassName: string
}
export const RUNTIMES: RuntimeMeta[] = [
{
value: 'node',
// Keyed by the `spec.runtime` values the Management API returns.
export const RUNTIMES: Record<string, RuntimeMeta> = {
node: {
label: 'Node.js 24',
cli: 'node',
baseImage: 'node:24-slim',
entrypoint: 'node index.js',
swatchClassName: 'bg-[#5FA04E]',
},
{
value: 'deno',
deno: {
label: 'Deno 2',
cli: 'deno',
baseImage: 'denoland/deno:latest',
entrypoint: 'deno run main.ts',
swatchClassName: 'bg-[#70FFAF]',
},
{
value: 'dockerfile',
label: 'Dockerfile',
cli: 'dockerfile',
baseImage: 'From ./Dockerfile',
entrypoint: 'CMD from Dockerfile',
swatchClassName: 'bg-[#2496ED]',
},
{
value: 'bun',
bun: {
label: 'Bun 1',
cli: 'bun',
baseImage: 'oven/bun:latest',
entrypoint: 'bun run index.ts',
swatchClassName: 'bg-[#FBF0DF]',
},
{
value: 'python',
python: {
label: 'Python 3.14',
cli: 'python',
baseImage: 'python:3.14-slim',
entrypoint: 'python main.py',
swatchClassName: 'bg-[#3776AB]',
},
]
export const getRuntimeMeta = (runtime: WorkerRuntime): RuntimeMeta =>
RUNTIMES.find((r) => r.value === runtime) ?? RUNTIMES[0]
export interface SizeMeta {
value: WorkerSize
memory: string
vcpu: string
label: string
dockerfile: {
label: 'Dockerfile',
baseImage: 'From ./Dockerfile',
entrypoint: 'CMD from Dockerfile',
swatchClassName: 'bg-[#2496ED]',
},
}
export const SIZES: SizeMeta[] = [
{ value: '2x1', memory: '2 GB', vcpu: '1 vCPU', label: '2×1 · 2 GB · 1 vCPU' },
{ value: '4x2', memory: '4 GB', vcpu: '2 vCPU', label: '4×2 · 4 GB · 2 vCPU' },
]
export const getSizeMeta = (size: WorkerSize): SizeMeta =>
SIZES.find((s) => s.value === size) ?? SIZES[0]
export const formatResources = (size: WorkerSize, instances: number): string => {
const meta = getSizeMeta(size)
return `${meta.vcpu} · ${meta.memory} · ${instances} inst`
}
export const LISTENING_PORT = 8080
export interface WorkerStateMeta {
label: string
dotClassName: string
textClassName: string
}
export const WORKER_STATE_META: Record<WorkerState, WorkerStateMeta> = {
deploying: {
label: 'Deploying',
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' },
draining: {
label: 'Draining',
dotClassName: 'bg-warning',
textClassName: 'text-foreground-light',
},
suspended: {
label: 'Suspended',
dotClassName: 'bg-foreground-muted',
textClassName: 'text-foreground-light',
},
resuming: {
label: 'Resuming',
dotClassName: 'bg-warning',
textClassName: 'text-foreground-light',
},
errored: {
label: 'Errored',
failed: {
label: 'Failed',
dotClassName: 'bg-destructive',
textClassName: 'text-foreground-light',
},
killed: {
label: 'Killed',
dotClassName: 'bg-foreground-muted',
textClassName: 'text-foreground-lighter',
},
}
export const WORKER_ERROR_REASON_LABEL: Record<string, string> = {
crash: 'The worker crashed with a non-zero exit code',
unresponsive: 'The worker did not respond on $PORT',
build: 'The build failed before the worker could start',
entrypoint: 'The entrypoint could not be found or executed',
dependency: 'A dependency failed to install during build',
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]
@@ -1,53 +1,24 @@
export type WorkerRuntime = 'node' | 'deno' | 'bun' | 'python' | 'dockerfile'
export type WorkerSize = '2x1' | '4x2'
export type WorkerBuildState = 'building' | 'active' | 'failed'
export type WorkerAccess = 'public' | 'private'
// A health-check failure is `errored` with errorReason 'unresponsive', not its own state,
// which is how infra reports it.
export type WorkerState =
| 'deploying'
| 'active'
| 'draining'
| 'suspended'
| 'resuming'
| 'errored'
| 'killed'
export type WorkerErrorReason = 'crash' | 'unresponsive' | 'build' | 'entrypoint' | 'dependency'
export type WorkerLifecycleLevel = 'info' | 'error'
export interface WorkerLifecycleEvent {
id: string
at: string
level: WorkerLifecycleLevel
message: string
reason?: WorkerErrorReason
export interface WorkerInstanceTally {
declared: number
live: number
ready: number
stale: number
}
export interface Worker {
id: string
name: string
runtime: WorkerRuntime
size: WorkerSize
buildState: WorkerBuildState
isDeleting: boolean
runtime?: string
size: string
access: WorkerAccess
instances: number
region: string
state: WorkerState
errorReason?: WorkerErrorReason
createdAt: string
updatedAt: string
events: WorkerLifecycleEvent[]
}
export interface WorkerMetrics {
requests24h: number
errors24h: number
errorRate: number
avgLatencyMs: number
p99LatencyMs: number
cpuPercent: number
memoryPercent: number
declaredInstances: number
instances?: WorkerInstanceTally
imageVersion?: string
stateReason?: string
instancesError?: string
}
@@ -1,19 +1,15 @@
import { describe, expect, it } from 'vitest'
import type { Worker } from './Workers.types'
import { filterWorkers, getPage } from './Workers.utils'
import { filterWorkers, formatResources, formatRuntime, formatSize, getPage } from './Workers.utils'
const worker = (overrides: Partial<Worker> & Pick<Worker, 'name'>): Worker => ({
id: overrides.name,
buildState: 'active',
isDeleting: false,
runtime: 'node',
size: '2x1',
size: '2gb-1vcpu',
access: 'public',
instances: 1,
region: 'us-west-1',
state: 'active',
createdAt: '2026-08-10T09:00:00.000Z',
updatedAt: '2026-08-10T09:00:00.000Z',
events: [],
declaredInstances: 1,
...overrides,
})
@@ -21,9 +17,9 @@ const NO_FILTERS = { search: '', state: 'all', access: 'all' } as const
describe('filterWorkers', () => {
const workers = [
worker({ name: 'embed', state: 'active', access: 'public' }),
worker({ name: 'resize-images', state: 'suspended', access: 'private' }),
worker({ name: 'embed-batch', state: 'errored', access: 'public' }),
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', () => {
@@ -39,8 +35,8 @@ describe('filterWorkers', () => {
expect(filterWorkers(workers, { ...NO_FILTERS, search: ' resize ' })).toHaveLength(1)
})
it('filters by state and by access', () => {
expect(filterWorkers(workers, { ...NO_FILTERS, state: 'errored' }).map((w) => w.name)).toEqual([
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(
@@ -83,3 +79,37 @@ describe('getPage', () => {
})
})
})
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'
)
})
})
@@ -1,8 +1,9 @@
import type { Worker, WorkerAccess, WorkerState } from './Workers.types'
import { RUNTIMES, type RuntimeMeta } from './Workers.constants'
import type { Worker, WorkerAccess, WorkerBuildState } from './Workers.types'
export interface WorkerFilters {
search: string
state: WorkerState | 'all'
state: WorkerBuildState | 'all'
access: WorkerAccess | 'all'
}
@@ -10,7 +11,7 @@ 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.state === filters.state
const matchesState = filters.state === 'all' || worker.buildState === filters.state
const matchesAccess = filters.access === 'all' || worker.access === filters.access
return matchesSearch && matchesState && matchesAccess
})
@@ -35,3 +36,19 @@ export const getPage = <T>(items: T[], requestedPage: number, pageSize: number):
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`
@@ -21,9 +21,9 @@ import {
} from 'ui'
import { RuntimeBadge } from './RuntimeBadge'
import { formatResources, WORKERS_REGION_SHORT } from './Workers.constants'
import type { Worker, WorkerAccess, WorkerState } from './Workers.types'
import { filterWorkers, getPage } from './Workers.utils'
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'
@@ -32,12 +32,11 @@ interface WorkersListProps {
workers: Worker[]
}
const STATE_FILTERS: { value: WorkerState | 'all'; label: string }[] = [
const STATE_FILTERS: { value: WorkerBuildState | 'all'; label: string }[] = [
{ value: 'all', label: 'All states' },
{ value: 'active', label: 'Active' },
{ value: 'deploying', label: 'Deploying' },
{ value: 'suspended', label: 'Suspended' },
{ value: 'errored', label: 'Errored' },
{ value: 'building', label: 'Building' },
{ value: 'failed', label: 'Failed' },
]
const ACCESS_FILTERS: { value: WorkerAccess | 'all'; label: string }[] = [
@@ -48,7 +47,7 @@ const ACCESS_FILTERS: { value: WorkerAccess | 'all'; label: string }[] = [
const PAGE_SIZE = 10
const parseStateFilter = (value: string): WorkerState | 'all' =>
const parseStateFilter = (value: string): WorkerBuildState | 'all' =>
STATE_FILTERS.find((option) => option.value === value)?.value ?? 'all'
const parseAccessFilter = (value: string): WorkerAccess | 'all' =>
@@ -57,7 +56,7 @@ const parseAccessFilter = (value: string): WorkerAccess | 'all' =>
export const WorkersList = ({ projectRef, workers }: WorkersListProps) => {
const router = useRouter()
const [search, setSearch] = useState('')
const [stateFilter, setStateFilter] = useState<WorkerState | 'all'>('all')
const [stateFilter, setStateFilter] = useState<WorkerBuildState | 'all'>('all')
const [accessFilter, setAccessFilter] = useState<WorkerAccess | 'all'>('all')
const [page, setPage] = useState(1)
@@ -151,7 +150,7 @@ export const WorkersList = ({ projectRef, workers }: WorkersListProps) => {
)}
{paged.map((worker) => (
<TableRow
key={worker.id}
key={worker.name}
className="cursor-pointer"
onClick={() => router.push(workerUrl(worker.name))}
>
@@ -161,7 +160,7 @@ export const WorkersList = ({ projectRef, workers }: WorkersListProps) => {
</Link>
</TableCell>
<TableCell>
<WorkerStatePill state={worker.state} />
<WorkerStatePill worker={worker} />
</TableCell>
<TableCell>
<RuntimeBadge runtime={worker.runtime} />
@@ -177,7 +176,7 @@ export const WorkersList = ({ projectRef, workers }: WorkersListProps) => {
{WORKERS_REGION_SHORT}
</TableCell>
<TableCell className="hidden text-foreground-light lg:table-cell">
{formatResources(worker.size, worker.instances)}
{formatResources(worker)}
</TableCell>
</TableRow>
))}
+12 -5
View File
@@ -1,18 +1,25 @@
import { queryOptions } from '@tanstack/react-query'
import { workersKeys } from './keys'
import { mockWorkers } from './workers.mock'
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
// Mirrors GET /v2/projects/{ref}/workers/{name}, seeded for the same reason as the list query.
async function getWorker({ projectRef, name }: WorkerVariables) {
async function getWorker({ projectRef, name }: WorkerVariables, signal?: AbortSignal) {
if (!projectRef) throw new Error('projectRef is required')
if (!name) throw new Error('name is required')
return mockWorkers().find((worker) => worker.name === name) ?? null
const { data, error } = await get('/v2/projects/{ref}/workers/{name}', {
params: { path: { ref: projectRef, name } },
signal,
})
if (error) handleError(error)
return parseWorker(data.data)
}
export type WorkerData = Awaited<ReturnType<typeof getWorker>>
@@ -20,6 +27,6 @@ export type WorkerData = Awaited<ReturnType<typeof getWorker>>
export const workerQueryOptions = ({ projectRef, name }: WorkerVariables) =>
queryOptions({
queryKey: workersKeys.detail(projectRef, name),
queryFn: () => getWorker({ projectRef, name }),
queryFn: ({ signal }) => getWorker({ projectRef, name }, signal),
enabled: IS_PLATFORM && typeof projectRef !== 'undefined' && typeof name !== 'undefined',
})
+12 -6
View File
@@ -1,18 +1,24 @@
import { queryOptions } from '@tanstack/react-query'
import { workersKeys } from './keys'
import { mockWorkers } from './workers.mock'
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
// GET /v2/projects/{ref}/workers exists but is restricted to allowlisted projects, and its response
// carries neither lifecycle events nor metrics. Seeded data until both are available (FUNC-774).
async function getWorkers({ projectRef }: WorkersVariables) {
async function getWorkers({ projectRef }: WorkersVariables, signal?: AbortSignal) {
if (!projectRef) throw new Error('projectRef is required')
return mockWorkers()
const { data, error } = await get('/v2/projects/{ref}/workers', {
params: { path: { ref: projectRef } },
signal,
})
if (error) handleError(error)
return data.data.map(parseWorker)
}
export type WorkersData = Awaited<ReturnType<typeof getWorkers>>
@@ -20,6 +26,6 @@ export type WorkersData = Awaited<ReturnType<typeof getWorkers>>
export const workersQueryOptions = ({ projectRef }: WorkersVariables) =>
queryOptions({
queryKey: workersKeys.list(projectRef),
queryFn: () => getWorkers({ projectRef }),
queryFn: ({ signal }) => getWorkers({ projectRef }, signal),
enabled: IS_PLATFORM && typeof projectRef !== 'undefined',
})
-79
View File
@@ -1,79 +0,0 @@
import { WORKERS_REGION } from '@/components/interfaces/Workers/Workers.constants'
import type { Worker } from '@/components/interfaces/Workers/Workers.types'
// Timestamps derive from a fixed base rather than Date.now() so SSR and client render identically.
const SEED_BASE = new Date('2026-08-10T09:00:00.000Z').getTime()
const seedTime = (minutesAgo: number) => new Date(SEED_BASE - minutesAgo * 60_000).toISOString()
export function mockWorkers(): Worker[] {
return [
{
id: 'wk-embed',
name: 'embed',
runtime: 'python',
size: '4x2',
access: 'public',
instances: 2,
region: WORKERS_REGION,
state: 'active',
createdAt: seedTime(60 * 26),
updatedAt: seedTime(42),
events: [
{ id: 'ev-embed-1', at: seedTime(60 * 26), level: 'info', message: 'Deploy started' },
{
id: 'ev-embed-2',
at: seedTime(60 * 26 - 1),
level: 'info',
message: 'Built image python:3.14-slim',
},
{
id: 'ev-embed-3',
at: seedTime(60 * 26 - 1),
level: 'info',
message: 'Worker active on 2 instances',
},
],
},
{
id: 'wk-resize-images',
name: 'resize-images',
runtime: 'node',
size: '2x1',
access: 'private',
instances: 1,
region: WORKERS_REGION,
state: 'active',
createdAt: seedTime(60 * 8),
updatedAt: seedTime(15),
events: [
{ id: 'ev-resize-1', at: seedTime(60 * 8), level: 'info', message: 'Deploy started' },
{
id: 'ev-resize-2',
at: seedTime(60 * 8 - 1),
level: 'info',
message: 'Worker active on 1 instance',
},
],
},
{
id: 'wk-nightly-report',
name: 'nightly-report',
runtime: 'deno',
size: '2x1',
access: 'private',
instances: 1,
region: WORKERS_REGION,
state: 'suspended',
createdAt: seedTime(60 * 72),
updatedAt: seedTime(60 * 5),
events: [
{
id: 'ev-nightly-1',
at: seedTime(60 * 5),
level: 'info',
message: 'Suspended after idle timeout',
},
],
},
]
}
+24
View File
@@ -0,0 +1,24 @@
import type { components } from 'api-types'
import type { Worker, WorkerBuildState } from '@/components/interfaces/Workers/Workers.types'
type WorkerDatum = components['schemas']['V2WorkerResponse']['data']
const BUILD_STATES: WorkerBuildState[] = ['building', 'active', 'failed']
const parseBuildState = (value: string): WorkerBuildState =>
BUILD_STATES.find((state) => state === value) ?? 'failed'
export const parseWorker = ({ id, attributes }: WorkerDatum): Worker => ({
name: id,
buildState: parseBuildState(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,
})