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. -

) }