mirror of
https://github.com/supabase/supabase.git
synced 2026-10-07 18:35:07 +03:00
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:
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
|
||||
Reference in new issue
Block a user