diff --git a/apps/studio/components/interfaces/Workers/RuntimeBadge.tsx b/apps/studio/components/interfaces/Workers/RuntimeBadge.tsx index 92a743b2799..96775239540 100644 --- a/apps/studio/components/interfaces/Workers/RuntimeBadge.tsx +++ b/apps/studio/components/interfaces/Workers/RuntimeBadge.tsx @@ -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 ( - - {meta.label} + + {formatRuntime(runtime)} ) } diff --git a/apps/studio/components/interfaces/Workers/WorkerStatePill.tsx b/apps/studio/components/interfaces/Workers/WorkerStatePill.tsx index 91e0bf95fda..8ad3a227375 100644 --- a/apps/studio/components/interfaces/Workers/WorkerStatePill.tsx +++ b/apps/studio/components/interfaces/Workers/WorkerStatePill.tsx @@ -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 ( diff --git a/apps/studio/components/interfaces/Workers/Workers.constants.ts b/apps/studio/components/interfaces/Workers/Workers.constants.ts index 02e01bf4e0f..c76000c4816 100644 --- a/apps/studio/components/interfaces/Workers/Workers.constants.ts +++ b/apps/studio/components/interfaces/Workers/Workers.constants.ts @@ -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 = { + 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 = { - deploying: { - label: 'Deploying', +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' }, - 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 = { - 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] diff --git a/apps/studio/components/interfaces/Workers/Workers.types.ts b/apps/studio/components/interfaces/Workers/Workers.types.ts index 32db6541b7f..17a43de2564 100644 --- a/apps/studio/components/interfaces/Workers/Workers.types.ts +++ b/apps/studio/components/interfaces/Workers/Workers.types.ts @@ -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 } diff --git a/apps/studio/components/interfaces/Workers/Workers.utils.test.ts b/apps/studio/components/interfaces/Workers/Workers.utils.test.ts index f15e3a7c94e..1419224cc38 100644 --- a/apps/studio/components/interfaces/Workers/Workers.utils.test.ts +++ b/apps/studio/components/interfaces/Workers/Workers.utils.test.ts @@ -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 & Pick): 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' + ) + }) +}) diff --git a/apps/studio/components/interfaces/Workers/Workers.utils.ts b/apps/studio/components/interfaces/Workers/Workers.utils.ts index 494ade860c1..773afac4e23 100644 --- a/apps/studio/components/interfaces/Workers/Workers.utils.ts +++ b/apps/studio/components/interfaces/Workers/Workers.utils.ts @@ -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 = (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` diff --git a/apps/studio/components/interfaces/Workers/WorkersList.tsx b/apps/studio/components/interfaces/Workers/WorkersList.tsx index 6674dac9b08..0d25530670c 100644 --- a/apps/studio/components/interfaces/Workers/WorkersList.tsx +++ b/apps/studio/components/interfaces/Workers/WorkersList.tsx @@ -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('all') + const [stateFilter, setStateFilter] = useState('all') const [accessFilter, setAccessFilter] = useState('all') const [page, setPage] = useState(1) @@ -151,7 +150,7 @@ export const WorkersList = ({ projectRef, workers }: WorkersListProps) => { )} {paged.map((worker) => ( router.push(workerUrl(worker.name))} > @@ -161,7 +160,7 @@ export const WorkersList = ({ projectRef, workers }: WorkersListProps) => { - + @@ -177,7 +176,7 @@ export const WorkersList = ({ projectRef, workers }: WorkersListProps) => { {WORKERS_REGION_SHORT} - {formatResources(worker.size, worker.instances)} + {formatResources(worker)} ))} diff --git a/apps/studio/data/workers/worker-query.ts b/apps/studio/data/workers/worker-query.ts index f3225e2c67c..ea2f4257724 100644 --- a/apps/studio/data/workers/worker-query.ts +++ b/apps/studio/data/workers/worker-query.ts @@ -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> @@ -20,6 +27,6 @@ export type WorkerData = Awaited> 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', }) diff --git a/apps/studio/data/workers/workers-query.ts b/apps/studio/data/workers/workers-query.ts index 7f463a18bb4..1b593f3f59d 100644 --- a/apps/studio/data/workers/workers-query.ts +++ b/apps/studio/data/workers/workers-query.ts @@ -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> @@ -20,6 +26,6 @@ export type WorkersData = Awaited> export const workersQueryOptions = ({ projectRef }: WorkersVariables) => queryOptions({ queryKey: workersKeys.list(projectRef), - queryFn: () => getWorkers({ projectRef }), + queryFn: ({ signal }) => getWorkers({ projectRef }, signal), enabled: IS_PLATFORM && typeof projectRef !== 'undefined', }) diff --git a/apps/studio/data/workers/workers.mock.ts b/apps/studio/data/workers/workers.mock.ts deleted file mode 100644 index ff7ec79d7cd..00000000000 --- a/apps/studio/data/workers/workers.mock.ts +++ /dev/null @@ -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', - }, - ], - }, - ] -} diff --git a/apps/studio/data/workers/workers.utils.ts b/apps/studio/data/workers/workers.utils.ts new file mode 100644 index 00000000000..10c11caa5e1 --- /dev/null +++ b/apps/studio/data/workers/workers.utils.ts @@ -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, +})