From b455d871e549dff092cfecda10369bf8eecfa6e3 Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Wed, 29 Jul 2026 19:57:10 +1000 Subject: [PATCH] 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//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. ## 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. --------- Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com> --- .../ComputeAndDiskUsageCharts.test.tsx | 277 ++++++++++++++ .../ComputeAndDiskUsageCharts.tsx | 327 ++++++++++++++++ .../ComputeAndDiskUsageCharts.utils.test.ts | 359 ++++++++++++++++++ .../ComputeAndDiskUsageCharts.utils.ts | 149 ++++++++ .../DiskManagement/DiskManagementForm.tsx | 9 +- .../DiskManagement/ui/DiskSpaceBar.tsx | 9 - 6 files changed, 1120 insertions(+), 10 deletions(-) create mode 100644 apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.test.tsx create mode 100644 apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.tsx create mode 100644 apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.utils.test.ts create mode 100644 apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.utils.ts diff --git a/apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.test.tsx b/apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.test.tsx new file mode 100644 index 00000000000..155dc75c4ea --- /dev/null +++ b/apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.test.tsx @@ -0,0 +1,277 @@ +import { render, screen } from '@testing-library/react' +import type { ReactNode } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { ComputeAndDiskUsageCharts } from './ComputeAndDiskUsageCharts' +import type { InfraMonitoringMultiResponse } from '@/data/analytics/infra-monitoring-query' + +const { mockGetInfraMonitoringAttributes, mockUseInfraMonitoringAttributesQuery, mockUseProject } = + vi.hoisted(() => ({ + mockGetInfraMonitoringAttributes: vi.fn(), + mockUseInfraMonitoringAttributesQuery: vi.fn(), + mockUseProject: vi.fn(), + })) + +vi.mock('common', () => ({ + useParams: () => ({ ref: 'project-ref' }), +})) + +vi.mock('ui', () => ({ + cn: (...classes: Array) => classes.filter(Boolean).join(' '), +})) + +vi.mock('ui-patterns/Chart', async () => { + const React = await vi.importActual('react') + const ChartStateContext = React.createContext({ isLoading: false, isErrored: false }) + + return { + Chart: ({ + children, + isLoading, + isErrored, + }: { + children: ReactNode + isLoading: boolean + isErrored: boolean + }) => ( + + {children} + + ), + ChartCard: ({ children, className }: { children: ReactNode; className?: string }) => ( +
{children}
+ ), + ChartContent: ({ + children, + isEmpty, + loadingState, + errorState, + emptyState, + }: { + children: ReactNode + isEmpty: boolean + loadingState: ReactNode + errorState: ReactNode + emptyState: ReactNode + }) => { + const { isLoading, isErrored } = React.useContext(ChartStateContext) + + if (isLoading) return loadingState + if (isErrored) return errorState + if (isEmpty) return emptyState + return children + }, + ChartEmptyState: ({ title, description }: { title: string; description?: string }) => ( +
+ {title} + {description && {description}} +
+ ), + ChartHeader: ({ children }: { children: ReactNode }) =>
{children}
, + ChartLine: ({ dataKey, dataKeys }: { dataKey: string; dataKeys: string[] }) => ( +
+ ), + ChartLoadingState: () =>
Loading chart
, + ChartMetric: ({ + label, + value, + status, + tooltip, + }: { + label: string + value: ReactNode + status?: string + tooltip?: ReactNode + }) => ( +
+ {label}: {value} +
+ ), + } +}) + +vi.mock('@/data/analytics/infra-monitoring-query', () => ({ + getInfraMonitoringAttributes: mockGetInfraMonitoringAttributes, + useInfraMonitoringAttributesQuery: mockUseInfraMonitoringAttributesQuery, +})) + +vi.mock('@/hooks/misc/useSelectedProject', () => ({ + useSelectedProjectQuery: mockUseProject, +})) + +vi.mock('@/lib/helpers', () => ({ + formatBytes: (bytes: number) => `${bytes} bytes`, +})) + +const buildUsageResponse = ({ + cpu = 40, + memory = 50, + diskIo = 60, + database = 40, + wal = 5, + system = 5, + diskSize = 100, +}: { + cpu?: number + memory?: number + diskIo?: number + database?: number + wal?: number + system?: number + diskSize?: number +} = {}): InfraMonitoringMultiResponse => ({ + series: {}, + data: [ + { + period_start: '2026-07-20T00:00:00.000Z', + values: { + max_cpu_usage: String(cpu), + ram_usage: String(memory), + disk_io_consumption: String(diskIo), + pg_database_size: String(database), + disk_fs_used_wal: String(wal), + disk_fs_used_system: String(system), + disk_fs_size: String(diskSize), + }, + }, + ], +}) + +describe('ComputeAndDiskUsageCharts', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseProject.mockReturnValue({ data: { infra_compute_size: 'micro' } }) + mockUseInfraMonitoringAttributesQuery.mockReturnValue({ + data: buildUsageResponse(), + isLoading: false, + isError: false, + }) + mockGetInfraMonitoringAttributes.mockResolvedValue(buildUsageResponse()) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('renders warning and critical summaries and all chart data', () => { + mockUseInfraMonitoringAttributesQuery.mockReturnValue({ + data: buildUsageResponse({ + cpu: 80, + memory: 91, + diskIo: 70, + database: 80, + wal: 5, + system: 5, + }), + isLoading: false, + isError: false, + }) + + const { container } = render() + + expect(screen.getByTestId('metric-Compute')).toHaveTextContent('91%') + expect(screen.getByTestId('metric-Compute')).toHaveAttribute('data-status', 'negative') + expect(screen.getByTestId('metric-CPU')).toHaveAttribute('data-status', 'warning') + expect(screen.getByTestId('metric-Memory')).toHaveAttribute('data-status', 'negative') + expect(screen.getByTestId('metric-Disk IO')).toHaveAttribute('data-status', 'default') + expect(screen.getByTestId('metric-Compute')).toHaveAttribute('data-has-tooltip', 'true') + expect(screen.getByTestId('metric-Disk')).toHaveAttribute('data-has-tooltip', 'true') + for (const label of ['CPU', 'Memory', 'Disk IO', 'Database', 'WAL', 'System']) { + expect(screen.getByTestId(`metric-${label}`)).toHaveAttribute('data-has-tooltip', 'false') + } + expect(screen.getByTestId('metric-Disk')).toHaveTextContent('90%') + expect(screen.getByTestId('metric-Disk')).toHaveAttribute('data-status', 'negative') + expect(screen.getByTestId('chart-line-maxCpuUsage')).toHaveAttribute( + 'data-keys', + 'maxCpuUsage,ramUsage,diskIoConsumption' + ) + expect(container.firstElementChild).toHaveClass( + 'grid-cols-1', + '@[680px]:grid-cols-2', + '@[680px]:items-stretch' + ) + expect(container.querySelector('#cpu')).toBeInTheDocument() + expect(container.querySelector('#ram')).toBeInTheDocument() + expect(container.querySelector('#disk_io')).toBeInTheDocument() + expect(container.querySelector('#disk')).toBeInTheDocument() + }) + + it('hides burst-only disk IO and excludes it from status on dedicated-I/O compute', () => { + mockUseProject.mockReturnValue({ data: { infra_compute_size: '4xlarge' } }) + mockUseInfraMonitoringAttributesQuery.mockReturnValue({ + data: buildUsageResponse({ cpu: 40, memory: 50, diskIo: 95 }), + isLoading: false, + isError: false, + }) + + render() + + expect(screen.getByTestId('metric-Compute')).toHaveTextContent('50%') + expect(screen.getByTestId('metric-Compute')).toHaveAttribute('data-status', 'default') + expect(screen.queryByTestId('metric-Disk IO')).not.toBeInTheDocument() + expect(screen.getByTestId('chart-line-maxCpuUsage')).toHaveAttribute( + 'data-keys', + 'maxCpuUsage,ramUsage' + ) + }) + + it('renders empty states without presenting missing disk metrics as zero', () => { + mockUseInfraMonitoringAttributesQuery.mockReturnValue({ + data: undefined, + isLoading: false, + isError: false, + }) + + render() + + expect(screen.getByText('No compute data')).toBeInTheDocument() + expect(screen.getByText('No disk data')).toBeInTheDocument() + expect(screen.getByTestId('metric-Database')).toHaveTextContent('—') + expect(screen.getByTestId('metric-WAL')).toHaveTextContent('—') + expect(screen.getByTestId('metric-System')).toHaveTextContent('—') + }) + + it.each([ + { + state: 'loading', + queryState: { data: undefined, isLoading: true, isError: false }, + expectedText: 'Loading chart', + }, + { + state: 'error', + queryState: { data: undefined, isLoading: false, isError: true }, + expectedText: 'Failed to load usage data', + }, + ])('renders both $state states', ({ queryState, expectedText }) => { + mockUseInfraMonitoringAttributesQuery.mockReturnValue(queryState) + + render() + + expect(screen.getAllByText(expectedText)).toHaveLength(2) + }) + + it('uses a fresh rolling seven-day window when the query refetches', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-07-20T12:00:00.000Z')) + + render() + + const [, options] = mockUseInfraMonitoringAttributesQuery.mock.calls[0] + vi.setSystemTime(new Date('2026-07-27T12:00:00.000Z')) + await options.queryFn({ signal: undefined }) + + expect(mockGetInfraMonitoringAttributes).toHaveBeenCalledWith( + expect.objectContaining({ + projectRef: 'project-ref', + startDate: '2026-07-20T12:00:00.000Z', + endDate: '2026-07-27T12:00:00.000Z', + interval: '1d', + }), + undefined + ) + }) +}) diff --git a/apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.tsx b/apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.tsx new file mode 100644 index 00000000000..ec8d8b68dbc --- /dev/null +++ b/apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.tsx @@ -0,0 +1,327 @@ +import { useParams } from 'common' +import dayjs from 'dayjs' +import { Activity, BarChart2, Database } from 'lucide-react' +import { useMemo } from 'react' +import type { ChartConfig } from 'ui' +import { cn } from 'ui' +import { + Chart, + ChartCard, + ChartContent, + ChartEmptyState, + ChartHeader, + ChartLine, + ChartLoadingState, + ChartMetric, +} from 'ui-patterns/Chart' + +import { + buildUsageChartData, + formatUsagePercent, + getComputeUsageSummary, + getDiskUsageSummary, + getUsageMetricStatus, + type UsageMetricStatus, +} from './ComputeAndDiskUsageCharts.utils' +import { hasBurstableIO } from './DiskManagement.utils' +import type { InfraMonitoringAttribute } from '@/data/analytics/infra-monitoring-query' +import { + getInfraMonitoringAttributes, + useInfraMonitoringAttributesQuery, +} from '@/data/analytics/infra-monitoring-query' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { formatBytes } from '@/lib/helpers' + +const USAGE_ATTRIBUTES = [ + 'max_cpu_usage', + 'ram_usage', + 'disk_io_consumption', + 'disk_fs_used_system', + 'disk_fs_used_wal', + 'pg_database_size', + 'disk_fs_size', +] satisfies InfraMonitoringAttribute[] + +const ROLLING_WINDOW_DAYS = 7 + +const getRollingWindow = () => { + const now = dayjs() + + return { + startDate: now.subtract(ROLLING_WINDOW_DAYS, 'day').toISOString(), + endDate: now.toISOString(), + } +} + +const COMPUTE_CHART_CONFIG = { + maxCpuUsage: { + label: 'CPU', + color: 'hsl(var(--brand-default))', + }, + ramUsage: { + label: 'Memory', + color: 'hsl(var(--warning-default))', + }, + diskIoConsumption: { + label: 'Disk IO', + color: 'hsl(var(--destructive-500))', + }, +} satisfies ChartConfig + +const DISK_CHART_CONFIG = { + databaseUsagePercent: { + label: 'Database', + color: 'hsl(var(--brand-default))', + }, + walUsagePercent: { + label: 'WAL', + color: 'hsl(var(--warning-default))', + }, + systemUsagePercent: { + label: 'System', + color: 'hsl(var(--destructive-500))', + }, +} satisfies ChartConfig + +const PERCENTAGE_Y_AXIS_PROPS = { + domain: [0, 100] as [number, number], + allowDataOverflow: true, + ticks: [0, 25, 50, 75, 100], + tickFormatter: (value: number) => `${Math.round(Number(value))}%`, + width: 64, +} + +const getUsageCardClassName = (status: UsageMetricStatus) => + cn( + 'h-full flex flex-col transition-colors', + status === 'warning' && 'border-warning-400 bg-warning-200/30', + status === 'negative' && 'border-destructive-400 bg-destructive-200/30' + ) + +export const ComputeAndDiskUsageCharts = ({ className }: { className?: string }) => { + const { ref: projectRef } = useParams() + const { data: project } = useSelectedProjectQuery() + const supportsBurstableIO = hasBurstableIO(project?.infra_compute_size) + + // Anchor query key dates to mount time; fetch uses a rolling 7-day window on each refetch. + const { startDate, endDate } = useMemo(getRollingWindow, []) + + const { + data: usageData, + isLoading, + isError, + } = useInfraMonitoringAttributesQuery( + { + projectRef, + attributes: USAGE_ATTRIBUTES, + startDate, + endDate, + interval: '1d', + }, + { + queryFn: ({ signal }) => + getInfraMonitoringAttributes( + { + projectRef, + attributes: USAGE_ATTRIBUTES, + ...getRollingWindow(), + interval: '1d', + }, + signal + ), + } + ) + + const { computeChartData, diskChartData } = useMemo( + () => buildUsageChartData(usageData), + [usageData] + ) + + const { + peakCpuUsage, + peakMemoryUsage, + peakDiskIoUsage, + peakComputeUsage, + status: computeUsageStatus, + } = useMemo( + () => getComputeUsageSummary(computeChartData, supportsBurstableIO), + [computeChartData, supportsBurstableIO] + ) + + const { + latestDataPoint: latestDiskDataPoint, + usagePercent: latestDiskUsage, + status: diskUsageStatus, + } = useMemo(() => getDiskUsageSummary(diskChartData), [diskChartData]) + + const isComputeEmpty = computeChartData.length === 0 + const isDiskEmpty = diskChartData.length === 0 + + return ( +
+
+ + + + + + +
+ + + {supportsBurstableIO && ( + + )} +
+
+ } + errorState={ + } + title="Failed to load usage data" + /> + } + emptyState={ + } + title="No compute data" + description="Usage data may take a few minutes to appear." + /> + } + > +
+ +
+
+
+
+
+ +
+ + + + +
+ + + +
+
+ } + errorState={ + } + title="Failed to load usage data" + /> + } + emptyState={ + } + title="No disk data" + description="Disk metrics may take a few minutes to appear." + /> + } + > +
+ +
+
+
+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.utils.test.ts b/apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.utils.test.ts new file mode 100644 index 00000000000..ad79f432675 --- /dev/null +++ b/apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.utils.test.ts @@ -0,0 +1,359 @@ +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 + > + 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 + > + 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() + }) + }) +}) diff --git a/apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.utils.ts b/apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.utils.ts new file mode 100644 index 00000000000..5f18c7a23d7 --- /dev/null +++ b/apps/studio/components/interfaces/DiskManagement/ComputeAndDiskUsageCharts.utils.ts @@ -0,0 +1,149 @@ +import type { InfraMonitoringResponse } from '@/data/analytics/infra-monitoring-query' + +export type UsageMetricStatus = 'default' | 'warning' | 'negative' + +export type ComputeUsageChartDatum = { + timestamp: string + maxCpuUsage: number + ramUsage: number + diskIoConsumption: number +} + +export type DiskUsageChartDatum = { + timestamp: string + databaseBytes: number + walBytes: number + systemBytes: number + diskSizeBytes: number + databaseUsagePercent: number + walUsagePercent: number + systemUsagePercent: number +} + +/** Coerces an API value (string | number | undefined) into a finite number, defaulting to 0. */ +export const toNumber = (value: string | number | undefined) => { + const parsedValue = Number(value) + return Number.isFinite(parsedValue) ? parsedValue : 0 +} + +/** Constrains a percentage to the [0, 100] range so charts never overflow their axis. */ +export const clampPercentage = (value: number) => Math.min(Math.max(value, 0), 100) + +export const formatUsagePercent = (value: number | undefined) => + value === undefined ? '—' : `${value.toFixed(0)}%` + +export const getUsageMetricStatus = (value: number | undefined): UsageMetricStatus => { + if (value === undefined) return 'default' + if (value >= 90) return 'negative' + if (value >= 75) return 'warning' + return 'default' +} + +export const getWorstUsageMetricStatus = ( + ...values: Array +): UsageMetricStatus => { + const statuses = values.map(getUsageMetricStatus) + if (statuses.includes('negative')) return 'negative' + if (statuses.includes('warning')) return 'warning' + return 'default' +} + +/** Returns the highest numeric value for a given key across all data points, or undefined when empty. */ +export const getPeakChartValue = >( + data: T[], + dataKey: keyof T +): number | undefined => { + const values = data + .map((point) => point[dataKey] as unknown) + .filter((value): value is number => typeof value === 'number') + + if (values.length === 0) return undefined + + return Math.max(...values) +} + +/** + * Transforms a raw infra-monitoring response into the compute and disk chart series. + * Disk points without a known disk size are dropped so usage percentages stay meaningful. + */ +export const buildUsageChartData = ( + usageData: InfraMonitoringResponse | undefined +): { computeChartData: ComputeUsageChartDatum[]; diskChartData: DiskUsageChartDatum[] } => { + if (!usageData || !('series' in usageData)) { + return { computeChartData: [], diskChartData: [] } + } + + const computeChartData = usageData.data.map((point) => ({ + timestamp: point.period_start, + maxCpuUsage: clampPercentage(toNumber(point.values.max_cpu_usage)), + ramUsage: clampPercentage(toNumber(point.values.ram_usage)), + diskIoConsumption: clampPercentage(toNumber(point.values.disk_io_consumption)), + })) + + const diskChartData = usageData.data.flatMap((point) => { + const databaseBytes = toNumber(point.values.pg_database_size) + const walBytes = toNumber(point.values.disk_fs_used_wal) + const systemBytes = toNumber(point.values.disk_fs_used_system) + const totalBytes = toNumber(point.values.disk_fs_size) + if (totalBytes <= 0) return [] + + return { + timestamp: point.period_start, + databaseBytes, + walBytes, + systemBytes, + diskSizeBytes: totalBytes, + databaseUsagePercent: clampPercentage((databaseBytes / totalBytes) * 100), + walUsagePercent: clampPercentage((walBytes / totalBytes) * 100), + systemUsagePercent: clampPercentage((systemBytes / totalBytes) * 100), + } + }) + + return { computeChartData, diskChartData } +} + +/** Derives peak compute values and the worst-case status for metrics supported by the instance. */ +export const getComputeUsageSummary = ( + data: ComputeUsageChartDatum[], + includeDiskIo: boolean = true +) => { + const peakCpuUsage = getPeakChartValue(data, 'maxCpuUsage') + const peakMemoryUsage = getPeakChartValue(data, 'ramUsage') + const peakDiskIoUsage = getPeakChartValue(data, 'diskIoConsumption') + + const supportedPeaks = [ + peakCpuUsage, + peakMemoryUsage, + ...(includeDiskIo ? [peakDiskIoUsage] : []), + ] + const peaks = supportedPeaks.filter((value): value is number => value !== undefined) + const peakComputeUsage = peaks.length > 0 ? Math.max(...peaks) : undefined + + return { + peakCpuUsage, + peakMemoryUsage, + peakDiskIoUsage, + peakComputeUsage, + status: getWorstUsageMetricStatus(...supportedPeaks), + } +} + +/** Derives the latest used/total bytes, overall usage percentage, and status for the disk card. */ +export const getDiskUsageSummary = (data: DiskUsageChartDatum[]) => { + const latestDataPoint = data[data.length - 1] + + const usedBytes = + (latestDataPoint?.databaseBytes ?? 0) + + (latestDataPoint?.walBytes ?? 0) + + (latestDataPoint?.systemBytes ?? 0) + const sizeBytes = latestDataPoint?.diskSizeBytes ?? 0 + const usagePercent = sizeBytes > 0 ? clampPercentage((usedBytes / sizeBytes) * 100) : undefined + + return { + latestDataPoint, + usedBytes, + sizeBytes, + usagePercent, + status: getUsageMetricStatus(usagePercent), + } +} diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx b/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx index f3e54c03a10..e6fb89e3de5 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx @@ -16,6 +16,7 @@ import { PageSectionTitle, } from 'ui-patterns/PageSection' +import { ComputeAndDiskUsageCharts } from './ComputeAndDiskUsageCharts' import { CreateDiskStorageSchema, DiskStorageSchemaType } from './DiskManagement.schema' import { DiskManagementMessage } from './DiskManagement.types' import { @@ -61,7 +62,7 @@ import { } from '@/hooks/misc/useSelectedProject' import { GB, PROJECT_STATUS } from '@/lib/constants' -export function DiskManagementForm() { +export function DiskManagementForm({ chartsClassName }: { chartsClassName?: string } = {}) { const { ref: projectRef } = useParams() const { data: project, isPending: isProjectPending } = useSelectedProjectQuery() const { data: org } = useSelectedOrganizationQuery() @@ -390,6 +391,12 @@ export function DiskManagementForm() {
+ + + + + + {(isProjectResizing || isProjectRequestingDiskChanges || (isEntitlementsLoaded && !isPlanUpgradeRequired && noPermissions)) && ( diff --git a/apps/studio/components/interfaces/DiskManagement/ui/DiskSpaceBar.tsx b/apps/studio/components/interfaces/DiskManagement/ui/DiskSpaceBar.tsx index 1e7491d44e9..b5787f9f657 100644 --- a/apps/studio/components/interfaces/DiskManagement/ui/DiskSpaceBar.tsx +++ b/apps/studio/components/interfaces/DiskManagement/ui/DiskSpaceBar.tsx @@ -238,15 +238,6 @@ export const DiskSpaceBar = ({ form }: DiskSpaceBarProps) => { />
)} -

- Note: Disk Size refers to the total space your - project occupies on disk, including the database itself (currently{' '} - {formatBytes(diskBreakdownBytes?.dbSizeBytes, 2, 'GB')}), additional files like - the write-ahead log (currently{' '} - {formatBytes(diskBreakdownBytes?.walSizeBytes, 2, 'GB')}), and other system - resources (currently {formatBytes(diskBreakdownBytes?.systemBytes, 2, 'GB')}). - Data can take 5 minutes to refresh. -

) }