Files
supabase/apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.utils.test.ts
Saxon Fletcher b455d871e5 Add compute and disk usage charts (#48369)
## Summary

This is the second step towards merging compute and disk with
infrastructure. There are some usage charts on the current
infrastructure page that are useful to have in the context of compute
and disk settings. This branch adds two charts which give a general
sense of usage and whether an upgrade needs to happen. Other data points
in infrastructure can be found within observability and organisation
usage.

- Adds rolling seven-day Compute and Disk charts to the existing Compute
and Disk page.
- Shows CPU, memory, optional burstable disk IO, and disk usage split
into database, WAL, and system data.
- Covers loading, error, empty, warning, and critical states, retaining
the 75% warning and 90% critical thresholds.
- Uses a dedicated PageSection and keeps the charts in two columns from
680px.
- Uses the concise primary labels Compute and Disk, removes the database
report link, and removes tooltip icons from secondary metrics.
- Adds transformation, summary, and component tests covering dedicated
IO behavior, legacy anchors, responsive layout, rolling refetch, and
tooltip behavior.

## Stack

1. #48368
2. #48369 (this PR)
3. #48370

## How to test

1. Check out `chore/infra-compute-2-charts` and start Studio with `pnpm
dev:studio`.
2. Open `/project/<ref>/settings/compute-and-disk` on a project with
recent metrics.
3. Confirm the charts are in their own page section with standard
spacing below the page header.
4. Confirm the Compute chart shows CPU and memory, plus disk IO when
applicable, and the Disk chart splits usage into database, WAL, and
system data.
5. Confirm the primary labels are Compute and Disk, secondary metrics do
not show tooltip icons, and there is no Database Observability/report
link.
6. Resize across 680px. The charts should remain in two columns at and
above the breakpoint and stack into two rows below it.
7. Exercise loading, error, empty, warning, and critical responses with
the metrics mocks or response overrides. Confirm warning styling begins
at 75%, critical styling begins at 90%, and an error or empty response
does not break the configuration form.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added compute and disk usage charts to the disk management interface,
including metric cards for CPU, memory, disk I/O, database, WAL, and
system.
* Added usage status indicators, peak calculations, tooltips, and a
detailed disk breakdown with placeholders when data is missing.
* Added special handling for dedicated-I/O instances to hide burst-only
disk I/O.
* **Style**
* Simplified the disk space display by removing supplemental explanatory
text.
* **Tests**
* Added comprehensive test coverage for chart rendering,
loading/error/empty states, status/peak calculations, and rolling 7-day
data window behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
2026-07-29 17:57:10 +08:00

360 lines
11 KiB
TypeScript

import { describe, expect, test } from 'vitest'
import {
buildUsageChartData,
clampPercentage,
formatUsagePercent,
getComputeUsageSummary,
getDiskUsageSummary,
getPeakChartValue,
getUsageMetricStatus,
getWorstUsageMetricStatus,
toNumber,
type ComputeUsageChartDatum,
type DiskUsageChartDatum,
} from './ComputeAndDiskUsageCharts.utils'
import type {
InfraMonitoringMultiResponse,
InfraMonitoringSingleResponse,
} from '@/data/analytics/infra-monitoring-query'
const GB = 1024 ** 3
const buildMultiResponse = (
data: InfraMonitoringMultiResponse['data']
): InfraMonitoringMultiResponse => ({
data,
series: {},
})
describe('ComputeAndDiskUsageCharts utils', () => {
describe('toNumber', () => {
test('parses numeric strings', () => {
expect(toNumber('42')).toBe(42)
expect(toNumber('3.14')).toBe(3.14)
expect(toNumber('-5')).toBe(-5)
})
test('passes through finite numbers', () => {
expect(toNumber(0)).toBe(0)
expect(toNumber(99)).toBe(99)
})
test('falls back to 0 for undefined, empty, and non-numeric values', () => {
expect(toNumber(undefined)).toBe(0)
expect(toNumber('')).toBe(0)
expect(toNumber('not-a-number')).toBe(0)
})
test('falls back to 0 for non-finite values', () => {
expect(toNumber(Infinity)).toBe(0)
expect(toNumber('Infinity')).toBe(0)
expect(toNumber(NaN)).toBe(0)
})
})
describe('clampPercentage', () => {
test('returns the value when within range', () => {
expect(clampPercentage(50)).toBe(50)
})
test('clamps to the [0, 100] bounds', () => {
expect(clampPercentage(-10)).toBe(0)
expect(clampPercentage(150)).toBe(100)
})
test('keeps exact boundary values', () => {
expect(clampPercentage(0)).toBe(0)
expect(clampPercentage(100)).toBe(100)
})
})
describe('formatUsagePercent', () => {
test('renders an em dash for undefined', () => {
expect(formatUsagePercent(undefined)).toBe('—')
})
test('renders a rounded whole-number percentage', () => {
expect(formatUsagePercent(0)).toBe('0%')
expect(formatUsagePercent(42.4)).toBe('42%')
expect(formatUsagePercent(42.6)).toBe('43%')
expect(formatUsagePercent(100)).toBe('100%')
})
})
describe('getUsageMetricStatus', () => {
test('returns default for undefined', () => {
expect(getUsageMetricStatus(undefined)).toBe('default')
})
test('returns default below the warning threshold', () => {
expect(getUsageMetricStatus(0)).toBe('default')
expect(getUsageMetricStatus(74.9)).toBe('default')
})
test('returns warning between 75 and 90 (inclusive of 75)', () => {
expect(getUsageMetricStatus(75)).toBe('warning')
expect(getUsageMetricStatus(89.9)).toBe('warning')
})
test('returns negative at or above 90', () => {
expect(getUsageMetricStatus(90)).toBe('negative')
expect(getUsageMetricStatus(100)).toBe('negative')
})
})
describe('getWorstUsageMetricStatus', () => {
test('returns default with no values or all-default values', () => {
expect(getWorstUsageMetricStatus()).toBe('default')
expect(getWorstUsageMetricStatus(10, 20, undefined)).toBe('default')
})
test('escalates to warning when any value is in the warning band', () => {
expect(getWorstUsageMetricStatus(10, 80, 50)).toBe('warning')
})
test('escalates to negative when any value is critical, regardless of others', () => {
expect(getWorstUsageMetricStatus(80, 95, 10)).toBe('negative')
})
})
describe('getPeakChartValue', () => {
test('returns undefined for an empty array', () => {
expect(getPeakChartValue([], 'maxCpuUsage')).toBeUndefined()
})
test('returns undefined when no values are numbers', () => {
const data = [{ maxCpuUsage: undefined }, { maxCpuUsage: undefined }] as unknown as Array<
Record<string, number>
>
expect(getPeakChartValue(data, 'maxCpuUsage')).toBeUndefined()
})
test('returns the maximum numeric value for the key', () => {
const data = [{ value: 10 }, { value: 55 }, { value: 30 }]
expect(getPeakChartValue(data, 'value')).toBe(55)
})
test('ignores non-numeric entries when computing the peak', () => {
const data = [{ value: 10 }, { value: undefined }, { value: 42 }] as unknown as Array<
Record<string, number>
>
expect(getPeakChartValue(data, 'value')).toBe(42)
})
})
describe('buildUsageChartData', () => {
test('returns empty series for undefined data', () => {
expect(buildUsageChartData(undefined)).toEqual({
computeChartData: [],
diskChartData: [],
})
})
test('returns empty series for the single-attribute response shape', () => {
const singleResponse = {
yAxisLimit: 0,
format: '%',
total: 0,
totalAverage: 0,
data: [{ period_start: '2024-01-01T00:00:00Z', max_cpu_usage: '50' }],
} satisfies InfraMonitoringSingleResponse
expect(buildUsageChartData(singleResponse)).toEqual({
computeChartData: [],
diskChartData: [],
})
})
test('maps compute attributes and clamps them to [0, 100]', () => {
const result = buildUsageChartData(
buildMultiResponse([
{
period_start: '2024-01-01T00:00:00Z',
values: {
max_cpu_usage: '120', // over 100 -> clamped
ram_usage: '-5', // under 0 -> clamped
disk_io_consumption: '33',
},
},
])
)
expect(result.computeChartData).toEqual([
{
timestamp: '2024-01-01T00:00:00Z',
maxCpuUsage: 100,
ramUsage: 0,
diskIoConsumption: 33,
},
])
})
test('computes disk usage percentages relative to the disk size', () => {
const result = buildUsageChartData(
buildMultiResponse([
{
period_start: '2024-01-01T00:00:00Z',
values: {
pg_database_size: String(25 * GB),
disk_fs_used_wal: String(25 * GB),
disk_fs_used_system: String(10 * GB),
disk_fs_size: String(100 * GB),
},
},
])
)
expect(result.diskChartData).toEqual([
{
timestamp: '2024-01-01T00:00:00Z',
databaseBytes: 25 * GB,
walBytes: 25 * GB,
systemBytes: 10 * GB,
diskSizeBytes: 100 * GB,
databaseUsagePercent: 25,
walUsagePercent: 25,
systemUsagePercent: 10,
},
])
})
test('drops disk points without a known disk size', () => {
const result = buildUsageChartData(
buildMultiResponse([
{
period_start: '2024-01-01T00:00:00Z',
values: { pg_database_size: String(GB), disk_fs_size: '0' },
},
{
period_start: '2024-01-02T00:00:00Z',
values: { pg_database_size: String(GB), disk_fs_size: String(10 * GB) },
},
])
)
// First point dropped (disk_fs_size = 0), compute still has both timestamps
expect(result.diskChartData).toHaveLength(1)
expect(result.diskChartData[0].timestamp).toBe('2024-01-02T00:00:00Z')
expect(result.computeChartData).toHaveLength(2)
})
test('treats missing compute values as 0', () => {
const result = buildUsageChartData(
buildMultiResponse([{ period_start: '2024-01-01T00:00:00Z', values: {} }])
)
expect(result.computeChartData).toEqual([
{
timestamp: '2024-01-01T00:00:00Z',
maxCpuUsage: 0,
ramUsage: 0,
diskIoConsumption: 0,
},
])
})
})
describe('getComputeUsageSummary', () => {
test('returns undefined peaks and default status for empty data', () => {
expect(getComputeUsageSummary([])).toEqual({
peakCpuUsage: undefined,
peakMemoryUsage: undefined,
peakDiskIoUsage: undefined,
peakComputeUsage: undefined,
status: 'default',
})
})
test('computes per-metric peaks, the overall peak, and the worst status', () => {
const data: ComputeUsageChartDatum[] = [
{ timestamp: 't1', maxCpuUsage: 40, ramUsage: 92, diskIoConsumption: 10 },
{ timestamp: 't2', maxCpuUsage: 60, ramUsage: 80, diskIoConsumption: 20 },
]
expect(getComputeUsageSummary(data)).toEqual({
peakCpuUsage: 60,
peakMemoryUsage: 92,
peakDiskIoUsage: 20,
peakComputeUsage: 92,
status: 'negative', // memory peak of 92 is critical
})
})
test('excludes disk IO from the overall peak and status for dedicated-I/O instances', () => {
const data: ComputeUsageChartDatum[] = [
{ timestamp: 't1', maxCpuUsage: 40, ramUsage: 50, diskIoConsumption: 95 },
]
expect(getComputeUsageSummary(data, false)).toEqual({
peakCpuUsage: 40,
peakMemoryUsage: 50,
peakDiskIoUsage: 95,
peakComputeUsage: 50,
status: 'default',
})
})
})
describe('getDiskUsageSummary', () => {
test('returns undefined usage and default status for empty data', () => {
expect(getDiskUsageSummary([])).toEqual({
latestDataPoint: undefined,
usedBytes: 0,
sizeBytes: 0,
usagePercent: undefined,
status: 'default',
})
})
test('summarizes the latest data point and clamps the usage percentage', () => {
const data: DiskUsageChartDatum[] = [
{
timestamp: 't1',
databaseBytes: 10 * GB,
walBytes: 0,
systemBytes: 0,
diskSizeBytes: 100 * GB,
databaseUsagePercent: 10,
walUsagePercent: 0,
systemUsagePercent: 0,
},
{
timestamp: 't2',
databaseBytes: 80 * GB,
walBytes: 10 * GB,
systemBytes: 5 * GB,
diskSizeBytes: 100 * GB,
databaseUsagePercent: 80,
walUsagePercent: 10,
systemUsagePercent: 5,
},
]
const summary = getDiskUsageSummary(data)
expect(summary.latestDataPoint?.timestamp).toBe('t2')
expect(summary.usedBytes).toBe(95 * GB)
expect(summary.sizeBytes).toBe(100 * GB)
expect(summary.usagePercent).toBe(95)
expect(summary.status).toBe('negative')
})
test('returns undefined usage when the latest disk size is 0', () => {
const data: DiskUsageChartDatum[] = [
{
timestamp: 't1',
databaseBytes: 0,
walBytes: 0,
systemBytes: 0,
diskSizeBytes: 0,
databaseUsagePercent: 0,
walUsagePercent: 0,
systemUsagePercent: 0,
},
]
expect(getDiskUsageSummary(data).usagePercent).toBeUndefined()
})
})
})