feat(studio): read the worker detail from the v2 Management API

Overview now shows the instance tally, build failure reason and instance errors
the endpoint returns, replacing the seeded request and resource charts.
This commit is contained in:
Jordi Enric committed 2026-08-20 18:25:44 +02:00
1 parent f712c773e1
commit 06f4f15c61
4 files changed
+106 -529

No files matched your search

@@ -1,7 +1,6 @@
import { useQuery } from '@tanstack/react-query'
import { useParams } from 'common'
import dayjs from 'dayjs'
import { Clock, Container, Copy, Info } from 'lucide-react'
import { Container, Copy, Info, Package } from 'lucide-react'
import Link from 'next/link'
import { parseAsStringEnum, useQueryState } from 'nuqs'
import {
@@ -118,12 +117,14 @@ export const WorkerDetail = () => {
<PageHeaderSummary>
<PageHeaderTitle>{worker.name}</PageHeaderTitle>
<PageHeaderDescription className="flex flex-row flex-wrap items-center gap-x-4 gap-y-1 text-sm">
<WorkerStatePill state={worker.state} />
<WorkerStatePill worker={worker} />
<RuntimeBadge runtime={worker.runtime} />
<span className="flex items-center gap-2 text-foreground-light">
<Clock size={14} strokeWidth={1.5} className="text-foreground-lighter" />
Last deployed {dayjs(worker.updatedAt).format('MMM D, YYYY HH:mm')}
</span>
{worker.imageVersion !== undefined && (
<span className="flex items-center gap-2 text-foreground-light">
<Package size={14} strokeWidth={1.5} className="text-foreground-lighter" />
Image {worker.imageVersion}
</span>
)}
</PageHeaderDescription>
</PageHeaderSummary>
<PageHeaderAside>
@@ -140,7 +141,7 @@ export const WorkerDetail = () => {
runtime: worker.runtime,
size: worker.size,
access: worker.access,
instances: worker.instances,
instances: worker.declaredInstances,
}}
tabs={['curl', 'js', 'python']}
/>
@@ -175,7 +176,7 @@ export const WorkerDetail = () => {
</NavMenu>
</PageNav>
{tab === 'overview' && <WorkerOverviewTab projectRef={projectRef} worker={worker} />}
{tab === 'overview' && <WorkerOverviewTab worker={worker} />}
{tab === 'logs' && (
<PageContainer size="full" className="px-0 xl:px-0">
<WorkerLogsTab workerName={worker.name} />
@@ -1,22 +1,7 @@
import { BarChart2, Check, ExternalLink } from 'lucide-react'
import { useMemo, useState } from 'react'
import { Button } from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
import {
Chart,
ChartCard,
ChartContent,
ChartEmptyState,
ChartHeader,
ChartLine,
ChartLoadingState,
ChartMetric,
} from 'ui-patterns/Chart'
import { LogsBarChart } from 'ui-patterns/LogsBarChart'
import { PageContainer } from 'ui-patterns/PageContainer'
import {
PageSection,
PageSectionAside,
PageSectionContent,
PageSectionMeta,
PageSectionSummary,
@@ -24,345 +9,84 @@ import {
} from 'ui-patterns/PageSection'
import { WorkerCommandLine } from '../WorkerCommandLine'
import { WORKER_ERROR_REASON_LABEL } from '../Workers.constants'
import type { Worker } from '../Workers.types'
import {
buildWorkerChartData,
CHART_INTERVALS,
CPU_TIME_CHART_CONFIG,
formatMetric,
formatRate,
getSegmentedButtonClassName,
meanBy,
MEMORY_CHART_CONFIG,
RESPONSE_TIME_CHART_CONFIG,
sumBy,
} from './workerCharts'
import { CLI_NAME } from '@/lib/constants/workers'
interface WorkerOverviewTabProps {
projectRef: string
worker: Worker
}
const WorkerChartEmpty = () => (
<ChartEmptyState
icon={<BarChart2 size={16} />}
title="No data to show"
description="It may take up to 24 hours for data to refresh"
/>
const InstanceCount = ({ label, value }: { label: string; value: number }) => (
<div className="flex flex-col gap-1 px-5 py-4">
<span className="text-sm text-foreground-light">{label}</span>
<span className="text-2xl tabular-nums text-foreground">{value}</span>
</div>
)
export const WorkerOverviewTab = ({ projectRef, worker }: WorkerOverviewTabProps) => {
const [interval, setInterval] = useState<string>('1hr')
const selectedInterval =
CHART_INTERVALS.find((item) => item.key === interval) ?? CHART_INTERVALS[1]
const dateTimeFormat = selectedInterval.format
const { invocation, metrics } = useMemo(
() => buildWorkerChartData(worker, interval),
[worker, interval]
)
const { totalRequests, totalErrors, totalWarnings } = useMemo(
() => ({
totalRequests: sumBy(invocation, (d) => d.ok_count + d.warning_count + d.error_count),
totalErrors: sumBy(invocation, (d) => d.error_count),
totalWarnings: sumBy(invocation, (d) => d.warning_count),
}),
[invocation]
)
const { averageResponseTime, maxResponseTime } = useMemo(
() => ({
averageResponseTime: meanBy(metrics, (d) => d.avg_response_time),
maxResponseTime:
metrics.length === 0 ? 0 : Math.max(...metrics.map((d) => d.max_response_time)),
}),
[metrics]
)
const { averageCpuTime, maxCpuTime, averageMemory, peakMemoryPercent } = useMemo(
() => ({
averageCpuTime: meanBy(metrics, (d) => d.avg_cpu_time_used),
maxCpuTime: metrics.length === 0 ? 0 : Math.max(...metrics.map((d) => d.max_cpu_time_used)),
averageMemory: meanBy(metrics, (d) => d.avg_memory_used),
peakMemoryPercent:
metrics.length === 0 ? 0 : Math.max(...metrics.map((d) => d.memory_percent)),
}),
[metrics]
)
const isErrored = worker.state === 'errored'
const hasNoData = invocation.length === 0
return (
<>
<PageSection className="border-b bg-surface-100/50 pb-8 pt-0">
<PageContainer size="full">
<div className="flex flex-col gap-5">
<PageSectionMeta className="items-center! pt-8">
<PageSectionSummary>
<div className="flex flex-wrap items-start gap-x-8 gap-y-4">
<ChartMetric
label="Total Requests"
value={totalRequests}
status="default"
tooltip="Total number of requests to this worker"
/>
<ChartMetric
label="5xx Rate"
value={formatRate(totalErrors, totalRequests)}
status="negative"
tooltip="Share of requests that returned a 5xx status code"
/>
<ChartMetric
label="4xx Rate"
value={formatRate(totalWarnings, totalRequests)}
status="warning"
tooltip="Share of requests that returned a 4xx status code"
/>
</div>
</PageSectionSummary>
<PageSectionAside className="flex-wrap @xl:self-center">
<div className="flex items-center">
{CHART_INTERVALS.map((item, index) => (
<Button
key={item.key}
variant={interval === item.key ? 'secondary' : 'default'}
onClick={() => setInterval(item.key)}
className={getSegmentedButtonClassName(index, CHART_INTERVALS.length)}
>
{item.label}
</Button>
))}
</div>
</PageSectionAside>
</PageSectionMeta>
<Chart>
<div className="h-40">
<LogsBarChart
data={invocation}
DateTimeFormat={dateTimeFormat}
isFullHeight
EmptyState={<WorkerChartEmpty />}
/>
</div>
</Chart>
</div>
</PageContainer>
</PageSection>
<PageContainer size="full">
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Errors in the last 24h</PageSectionTitle>
</PageSectionSummary>
<PageSectionAside>
<Button variant="default" size="tiny" icon={<ExternalLink size={14} />} asChild>
<a href={`/project/${projectRef}/workers/${worker.name}?tab=logs`}>View logs</a>
</Button>
</PageSectionAside>
</PageSectionMeta>
<PageSectionContent>
{isErrored ? (
<Admonition
type="destructive"
title="This worker errored"
description={
<div className="space-y-3">
<p>
{worker.errorReason
? WORKER_ERROR_REASON_LABEL[worker.errorReason]
: 'The worker stopped unexpectedly.'}
</p>
<WorkerCommandLine
comment="Redeploy after fixing the build"
command={`supabase ${CLI_NAME} push ${worker.name}`}
/>
</div>
}
export const WorkerOverviewTab = ({ worker }: WorkerOverviewTabProps) => (
<PageContainer size="small">
{worker.buildState === 'failed' && (
<PageSection>
<PageSectionContent>
<Admonition type="destructive" title="This worker failed to build">
<div className="space-y-3">
<p>{worker.stateReason ?? 'The build did not complete.'}</p>
<WorkerCommandLine
comment="Redeploy after fixing the build"
command={`supabase ${CLI_NAME} push ${worker.name}`}
/>
) : (
<div className="rounded-md border border-dashed px-5 py-6 text-sm text-foreground-light">
<div className="flex items-start gap-3">
<Check size={16} strokeWidth={1.5} className="mt-0.5 shrink-0 text-brand" />
<div>
There have been{' '}
<span className="text-foreground">
{totalRequests.toLocaleString('en-US')} requests
</span>{' '}
since last deploy and no errors.
</div>
</div>
</div>
)}
</PageSectionContent>
</PageSection>
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Performance</PageSectionTitle>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<Chart>
<ChartCard>
<ChartHeader align="start">
<div className="flex flex-wrap gap-x-8 gap-y-4">
<ChartMetric
label="Average Response Time"
value={formatMetric(averageResponseTime, 'ms')}
tooltip="Average response time across requests"
/>
<ChartMetric
label="Max Response Time"
value={formatMetric(maxResponseTime, 'ms')}
tooltip="Slowest response time across requests"
/>
</div>
</ChartHeader>
<ChartContent
isEmpty={hasNoData}
emptyState={<WorkerChartEmpty />}
loadingState={<ChartLoadingState />}
>
<div className="h-40">
<ChartLine
data={metrics}
dataKey="max_response_time"
dataKeys={['avg_response_time', 'max_response_time']}
DateTimeFormat={dateTimeFormat}
config={RESPONSE_TIME_CHART_CONFIG}
isFullHeight
showYAxis
referenceLines={[
{
y: averageResponseTime,
label: 'average',
stroke: 'var(--foreground-default)',
strokeWidth: 1.5,
},
]}
YAxisProps={{
width: 64,
tickFormatter: (value: number) => `${Math.round(value)}ms`,
}}
/>
</div>
</ChartContent>
</ChartCard>
</Chart>
</PageSectionContent>
</PageSection>
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Usage</PageSectionTitle>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<div className="flex flex-col gap-6">
<Chart>
<ChartCard>
<ChartHeader align="start">
<div className="flex flex-wrap gap-x-8 gap-y-4">
<ChartMetric
label="Average CPU Time"
value={formatMetric(averageCpuTime, 'ms')}
tooltip="Average CPU time per request"
/>
<ChartMetric
label="Max CPU Time"
value={formatMetric(maxCpuTime, 'ms')}
tooltip="Peak CPU time per request"
/>
</div>
</ChartHeader>
<ChartContent
isEmpty={hasNoData}
emptyState={<WorkerChartEmpty />}
loadingState={<ChartLoadingState />}
>
<div className="h-40">
<ChartLine
data={metrics}
dataKey="max_cpu_time_used"
DateTimeFormat={dateTimeFormat}
config={CPU_TIME_CHART_CONFIG}
isFullHeight
showYAxis
referenceLines={[
{
y: averageCpuTime,
label: 'average',
stroke: 'var(--foreground-default)',
strokeWidth: 1.5,
},
]}
YAxisProps={{
width: 64,
tickFormatter: (value: number) => `${Math.round(value)}ms`,
}}
/>
</div>
</ChartContent>
</ChartCard>
</Chart>
<Chart>
<ChartCard>
<ChartHeader align="start">
<div className="flex flex-wrap gap-x-8 gap-y-4">
<ChartMetric
label="Average Memory Usage"
value={formatMetric(averageMemory, 'MB')}
tooltip="Average memory used by the worker"
/>
<ChartMetric
label="Peak Memory"
value={formatRate(peakMemoryPercent, 1)}
tooltip="Peak memory as a share of the allocated memory"
/>
</div>
</ChartHeader>
<ChartContent
isEmpty={hasNoData}
emptyState={<WorkerChartEmpty />}
loadingState={<ChartLoadingState />}
>
<div className="h-40">
<ChartLine
data={metrics}
dataKey="avg_memory_used"
DateTimeFormat={dateTimeFormat}
config={MEMORY_CHART_CONFIG}
isFullHeight
showYAxis
referenceLines={[
{
y: averageMemory,
label: 'average',
stroke: 'var(--foreground-default)',
strokeWidth: 1.5,
},
]}
YAxisProps={{
width: 64,
tickFormatter: (value: number) => `${Number(value).toFixed(1)}MB`,
}}
/>
</div>
</ChartContent>
</ChartCard>
</Chart>
</div>
</PageSectionContent>
</PageSection>
</PageContainer>
</>
)
}
</Admonition>
</PageSectionContent>
</PageSection>
)}
{worker.instancesError !== undefined && (
<PageSection>
<PageSectionContent>
<Admonition type="warning" title="Instances reported an error">
{worker.instancesError}
</Admonition>
</PageSectionContent>
</PageSection>
)}
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Instances</PageSectionTitle>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
{worker.instances === undefined ? (
<p className="text-sm text-foreground-light">
No instances are running for this worker yet.
</p>
) : (
<div className="grid grid-cols-2 divide-x divide-y rounded-md border border-default bg-surface-100 sm:grid-cols-4 sm:divide-y-0">
<InstanceCount label="Declared" value={worker.instances.declared} />
<InstanceCount label="Live" value={worker.instances.live} />
<InstanceCount label="Ready" value={worker.instances.ready} />
<InstanceCount label="Stale" value={worker.instances.stale} />
</div>
)}
</PageSectionContent>
</PageSection>
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Logs and metrics</PageSectionTitle>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<p className="text-sm text-foreground-light">
Request metrics are not exposed by the Management API yet. Stream logs from the CLI.
</p>
<div className="mt-3">
<WorkerCommandLine command={`supabase ${CLI_NAME} logs ${worker.name} --follow`} />
</div>
</PageSectionContent>
</PageSection>
</PageContainer>
)
@@ -13,13 +13,9 @@ import {
import { RuntimeBadge } from '../RuntimeBadge'
import { WorkerCommandLine } from '../WorkerCommandLine'
import {
getRuntimeMeta,
getSizeMeta,
LISTENING_PORT,
WORKERS_REGION_LABEL,
} from '../Workers.constants'
import { LISTENING_PORT, WORKERS_REGION_LABEL } from '../Workers.constants'
import type { Worker } from '../Workers.types'
import { formatSize, getRuntimeMeta } from '../Workers.utils'
import { buildWorkerCliCommands } from '../workerSnippets'
import { WorkerSnippetTabs } from '../WorkerSnippetTabs'
import CopyButton from '@/components/ui/CopyButton'
@@ -50,7 +46,6 @@ const SettingsRow = ({
export const WorkerSettingsTab = ({ worker }: WorkerSettingsTabProps) => {
const runtime = getRuntimeMeta(worker.runtime)
const size = getSizeMeta(worker.size)
const commands = buildWorkerCliCommands(worker.name)
const snippetInput = {
@@ -58,7 +53,7 @@ export const WorkerSettingsTab = ({ worker }: WorkerSettingsTabProps) => {
runtime: worker.runtime,
size: worker.size,
access: worker.access,
instances: worker.instances,
instances: worker.declaredInstances,
}
return (
@@ -78,14 +73,27 @@ export const WorkerSettingsTab = ({ worker }: WorkerSettingsTabProps) => {
<SettingsRow label="Runtime" isFirst>
<RuntimeBadge runtime={worker.runtime} />
</SettingsRow>
<SettingsRow label="Base image">
<span className="font-mono text-xs text-foreground-light">{runtime.baseImage}</span>
</SettingsRow>
<SettingsRow label="Entrypoint">
<span className="font-mono text-xs text-foreground-light">
{runtime.entrypoint}
</span>
</SettingsRow>
{worker.imageVersion !== undefined && (
<SettingsRow label="Image version">
<span className="font-mono text-xs text-foreground-light">
{worker.imageVersion}
</span>
</SettingsRow>
)}
{runtime !== undefined && (
<SettingsRow label="Base image">
<span className="font-mono text-xs text-foreground-light">
{runtime.baseImage}
</span>
</SettingsRow>
)}
{runtime !== undefined && (
<SettingsRow label="Entrypoint">
<span className="font-mono text-xs text-foreground-light">
{runtime.entrypoint}
</span>
</SettingsRow>
)}
<SettingsRow label="Listening port">
<span className="font-mono text-xs text-foreground-light">
$PORT → {LISTENING_PORT}
@@ -104,9 +112,9 @@ export const WorkerSettingsTab = ({ worker }: WorkerSettingsTabProps) => {
<PageSectionContent>
<div className="rounded-md border border-default bg-surface-100">
<SettingsRow label="Size" isFirst>
{size.label}
{formatSize(worker.size)}
</SettingsRow>
<SettingsRow label="Instances">{worker.instances}</SettingsRow>
<SettingsRow label="Instances">{worker.declaredInstances}</SettingsRow>
<SettingsRow label="Access">
{worker.access === 'public' ? (
<Badge variant="success">Public</Badge>
@@ -1,156 +0,0 @@
import type { ChartConfig } from 'ui'
import type { Worker } from '../Workers.types'
// Seeded time-series until the workers metrics endpoint ships.
export const CHART_INTERVALS = [
{ key: '15min', label: '15 min', format: 'MMM D, h:mm:ssa', minutes: 15 },
{ key: '1hr', label: '1 hour', format: 'MMM D, h:mma', minutes: 60 },
{ key: '3hr', label: '3 hours', format: 'MMM D, h:mma', minutes: 180 },
{ key: '1day', label: '1 day', format: 'MMM D, h:mma', minutes: 24 * 60 },
] as const
export type ChartIntervalKey = (typeof CHART_INTERVALS)[number]['key']
// Fixed base so SSR and client renders produce identical timestamps.
const CHART_END_ISO = '2026-08-10T09:00:00.000Z'
export type WorkerInvocationDatum = {
timestamp: string
ok_count: number
warning_count: number
error_count: number
}
export type WorkerMetricsDatum = {
timestamp: string
ok_count: number
warning_count: number
error_count: number
avg_response_time: number
max_response_time: number
avg_cpu_time_used: number
max_cpu_time_used: number
avg_memory_used: number
memory_percent: number
}
const hashString = (input: string): number => {
let hash = 2166136261
for (let i = 0; i < input.length; i++) {
hash ^= input.charCodeAt(i)
hash = Math.imul(hash, 16777619)
}
return (hash >>> 0) / 0xffffffff
}
const pseudoNoise = (seed: number, amplitude = 1) => {
const value = Math.sin(seed * 12.9898) * 43758.5453
return (value - Math.floor(value)) * amplitude
}
export const getIntervalMinutes = (intervalKey: string) =>
CHART_INTERVALS.find((item) => item.key === intervalKey)?.minutes ?? 60
// Not serving traffic, so the charts render empty rather than stale numbers.
export const isIdleState = (worker: Pick<Worker, 'state'>) =>
worker.state === 'suspended' ||
worker.state === 'killed' ||
worker.state === 'deploying' ||
worker.state === 'draining'
export interface WorkerChartData {
invocation: WorkerInvocationDatum[]
metrics: WorkerMetricsDatum[]
}
export const buildWorkerChartData = (
worker: Pick<Worker, 'name' | 'state' | 'instances'>,
intervalKey: string
): WorkerChartData => {
if (isIdleState(worker)) return { invocation: [], metrics: [] }
const isErrored = worker.state === 'errored'
const seedBase = hashString(worker.name) * 1000
const minutes = getIntervalMinutes(intervalKey)
const end = new Date(CHART_END_ISO)
end.setSeconds(0, 0)
const invocation: WorkerInvocationDatum[] = Array.from({ length: minutes }, (_, index) => {
const timestamp = new Date(end)
timestamp.setMinutes(end.getMinutes() - (minutes - 1 - index))
const progress = minutes <= 1 ? 0 : index / (minutes - 1)
const baseLoad = (14 + Math.round(progress * 30)) * worker.instances
const noise = Math.round(pseudoNoise(seedBase + index * 0.1, 10))
const ok_count = Math.max(0, baseLoad + noise)
const warning_count = index % 15 === 0 ? 2 + (index % 3) : index % 9 === 0 ? 1 : 0
const error_count = isErrored
? index % 4 === 0
? 4 + (index % 5)
: 1
: index % 47 === 0
? 2
: 0
return { timestamp: timestamp.toISOString(), ok_count, warning_count, error_count }
})
const metrics: WorkerMetricsDatum[] = invocation.map((datum, index) => {
const avg_response_time = Math.round(42 + pseudoNoise(seedBase + index + 2, 26) + index * 0.12)
const max_response_time = Math.round(
avg_response_time * (1.3 + pseudoNoise(seedBase + index + minutes, 0.4))
)
return {
...datum,
avg_response_time,
max_response_time,
avg_cpu_time_used: Math.round(9 + pseudoNoise(seedBase + index + 4, 11) + index * 0.04),
max_cpu_time_used: Math.round(18 + pseudoNoise(seedBase + index + 6, 20) + index * 0.08),
avg_memory_used: Number(
((worker.state === 'errored' ? 55 : 42) + pseudoNoise(seedBase + index + 8, 14)).toFixed(1)
),
memory_percent: Number((0.4 + pseudoNoise(seedBase + index + 9, 0.25)).toFixed(3)),
}
})
return { invocation, metrics }
}
export const formatRate = (count: number, total: number) =>
new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 1 }).format(
total === 0 ? 0 : count / total
)
export const formatMetric = (value: number, unit?: string) => {
const formatted = unit === 'MB' ? value.toFixed(1) : Math.round(value).toLocaleString('en-US')
return unit ? `${formatted}${unit}` : formatted
}
export const getSegmentedButtonClassName = (index: number, total: number) => {
if (index === 0) return 'rounded-tr-none rounded-br-none'
if (index === total - 1) return 'rounded-tl-none rounded-bl-none'
return 'rounded-none'
}
export const sumBy = <T>(items: T[], getValue: (item: T) => number) =>
items.reduce((total, item) => total + getValue(item), 0)
export const meanBy = <T>(items: T[], getValue: (item: T) => number) =>
items.length === 0 ? 0 : sumBy(items, getValue) / items.length
export const RESPONSE_TIME_CHART_CONFIG = {
avg_response_time: { label: 'Average Response Time', color: 'var(--foreground-default)' },
max_response_time: { label: 'Max Response Time', color: 'hsl(var(--brand-default))' },
} satisfies ChartConfig
export const CPU_TIME_CHART_CONFIG = {
max_cpu_time_used: { label: 'Max CPU Time', color: 'hsl(var(--brand-default))' },
} satisfies ChartConfig
export const MEMORY_CHART_CONFIG = {
avg_memory_used: { label: 'Memory Usage', color: 'hsl(var(--brand-default))' },
} satisfies ChartConfig