diff --git a/apps/studio/components/interfaces/Observability/DatabaseInfrastructureMetric.test.tsx b/apps/studio/components/interfaces/Observability/DatabaseInfrastructureMetric.test.tsx new file mode 100644 index 00000000000..8489965673e --- /dev/null +++ b/apps/studio/components/interfaces/Observability/DatabaseInfrastructureMetric.test.tsx @@ -0,0 +1,150 @@ +import { QueryClient } from '@tanstack/react-query' +import { screen, waitFor, within } from '@testing-library/react' +import { HttpResponse } from 'msw' +import { describe, expect, it } from 'vitest' + +import { DatabaseInfrastructureSection } from './DatabaseInfrastructureSection' +import type { InfraMonitoringMultiResponse } from '@/data/analytics/infra-monitoring-query' +import { databaseKeys } from '@/data/database/keys' +import { projectKeys } from '@/data/projects/keys' +import { customRender } from '@/tests/lib/custom-render' +import { addAPIMock, type APIErrorBody } from '@/tests/lib/msw' + +const values: Record = { + avg_cpu_usage: 25, + ram_usage: 60, + disk_io_consumption: 40, + disk_fs_used_system: 10, + disk_fs_used_wal: 5, + pg_database_size: 35, + disk_fs_size: 100, + pg_stat_database_num_backends: 12, +} + +function mockMetrics(failedAttribute?: string, includeErrors = true) { + const requests: string[][] = [] + addAPIMock({ + method: 'get', + path: '/platform/projects/:ref/infra-monitoring', + response: ({ request }) => { + const attributes = new URL(request.url).searchParams.get('attributes')!.split(',') + requests.push(attributes) + const succeeded = attributes.filter( + (attribute) => failedAttribute !== '*' && attribute !== failedAttribute + ) + const failed = attributes.filter((attribute) => !succeeded.includes(attribute)) + const series = Object.fromEntries( + succeeded.map((attribute) => [ + attribute, + { + totalAverage: values[attribute], + total: values[attribute], + format: '%', + yAxisLimit: 100, + }, + ]) + ) + const pointValues = Object.fromEntries( + succeeded.map((attribute) => [attribute, String(values[attribute])]) + ) + return HttpResponse.json({ + series, + data: succeeded.length + ? [{ period_start: '2026-09-24T00:00:00Z', values: pointValues }] + : [], + ...(includeErrors + ? { + errors: Object.fromEntries( + failed.map((attribute) => [attribute, { message: 'Metric is not supported' }]) + ), + } + : {}), + }) + }, + }) + return requests +} + +function renderMetrics() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }) + queryClient.setQueryData(projectKeys.detail('default'), { + ref: 'default', + status: 'ACTIVE_HEALTHY', + connectionString: 'postgresql://localhost/postgres', + }) + queryClient.setQueryData(databaseKeys.maxConnections('default'), { maxConnections: 100 }) + customRender( + , + { queryClient } + ) +} + +function card(label: string) { + return within(screen.getByRole('link', { name: new RegExp(label) })) +} + +describe('DatabaseInfrastructureSection batched metrics', () => { + it.each([false, true])( + 'renders all metrics from one request (errors field: %s)', + async (includeErrors) => { + const requests = mockMetrics(undefined, includeErrors) + renderMetrics() + + expect(await card('CPU').findByText('25%')).toBeInTheDocument() + expect(await card('Memory').findByText('60%')).toBeInTheDocument() + expect(await card('Disk IO').findByText('40%')).toBeInTheDocument() + expect(await card('Disk Usage').findByText('50%')).toBeInTheDocument() + expect(await card('Peak Connections').findByText('12/100')).toBeInTheDocument() + expect(requests).toHaveLength(1) + expect(requests[0].sort()).toEqual(Object.keys(values).sort()) + } + ) + + it.each([ + ['disk_io_consumption', 'Disk IO'], + ['disk_fs_used_wal', 'Disk Usage'], + ['pg_stat_database_num_backends', 'Peak Connections'], + ])('isolates a failed %s result to %s', async (attribute, label) => { + const requests = mockMetrics(attribute) + renderMetrics() + + expect(await card(label).findByText('Metric is not supported')).toBeInTheDocument() + expect(await card('CPU').findByText('25%')).toBeInTheDocument() + expect(await card('Memory').findByText('60%')).toBeInTheDocument() + expect(screen.getAllByText('Metric is not supported')).toHaveLength(1) + expect(card(label).queryByText('0%')).not.toBeInTheDocument() + expect(requests).toHaveLength(1) + }) + + it('shows every affected card error when all results fail', async () => { + const requests = mockMetrics('*') + renderMetrics() + await waitFor(() => expect(screen.getAllByText('Metric is not supported')).toHaveLength(5)) + expect(requests).toHaveLength(1) + }) + + it('does not display missing series as zero', async () => { + mockMetrics('disk_io_consumption', false) + renderMetrics() + expect(await card('Disk IO').findByText('Error loading data')).toBeInTheDocument() + expect(await card('CPU').findByText('25%')).toBeInTheDocument() + }) + + it('shows request-wide failures on every infrastructure card', async () => { + addAPIMock({ + method: 'get', + path: '/platform/projects/:ref/infra-monitoring', + response: () => + HttpResponse.json({ message: 'Project not found' }, { status: 404 }), + }) + renderMetrics() + await waitFor(() => expect(screen.getAllByText('Project not found')).toHaveLength(5)) + }) +}) diff --git a/apps/studio/components/interfaces/Observability/DatabaseInfrastructureMetric.tsx b/apps/studio/components/interfaces/Observability/DatabaseInfrastructureMetric.tsx new file mode 100644 index 00000000000..edb59f9790e --- /dev/null +++ b/apps/studio/components/interfaces/Observability/DatabaseInfrastructureMetric.tsx @@ -0,0 +1,121 @@ +import type { UseQueryResult } from '@tanstack/react-query' +import Link from 'next/link' +import { + MetricCard, + MetricCardContent, + MetricCardHeader, + MetricCardLabel, + MetricCardValue, +} from 'ui-patterns/MetricCard' + +import { + parseConnectionsData, + parseInfrastructureMetrics, +} from './DatabaseInfrastructureSection.utils' +import { + type InfraMonitoringAttribute, + type InfraMonitoringError, + type InfraMonitoringMultiData, +} from '@/data/analytics/infra-monitoring-query' +import type { MaxConnectionsData, MaxConnectionsError } from '@/data/database/max-connections-query' + +const METRICS = { + connections: { + label: 'Peak Connections', + tooltip: + 'Highest concurrent database connections observed in the selected window, against the connection limit. Monitor to avoid connection exhaustion.', + attributes: ['pg_stat_database_num_backends'], + }, + disk: { + label: 'Disk Usage', + tooltip: 'Disk usage percentage of total disk space used', + attributes: ['disk_fs_used_system', 'disk_fs_used_wal', 'pg_database_size', 'disk_fs_size'], + }, + diskIo: { + label: 'Disk IO', + tooltip: 'Disk I/O consumption percentage. High values may indicate disk bottlenecks', + attributes: ['disk_io_consumption'], + }, + ram: { + label: 'Memory', + tooltip: 'RAM usage percentage. Sustained high usage may indicate memory pressure', + attributes: ['ram_usage'], + }, + cpu: { + label: 'CPU', + tooltip: 'CPU usage percentage. High values may suggest CPU-intensive queries or workloads', + attributes: ['avg_cpu_usage'], + }, +} satisfies Record< + string, + { label: string; tooltip: string; attributes: InfraMonitoringAttribute[] } +> + +export const INFRASTRUCTURE_METRIC_ATTRIBUTES = Object.values(METRICS).flatMap( + ({ attributes }) => attributes +) + +type DatabaseInfrastructureMetricProps = { + infraQuery: Pick< + UseQueryResult, + 'data' | 'error' | 'isLoading' + > + metric: keyof typeof METRICS + href: string + maxConnectionsQuery: Pick< + UseQueryResult, + 'data' | 'error' | 'isLoading' + > +} + +export const DatabaseInfrastructureMetric = ({ + metric, + href, + maxConnectionsQuery, + infraQuery, +}: DatabaseInfrastructureMetricProps) => { + const { label, tooltip, attributes } = METRICS[metric] + const isConnections = metric === 'connections' + const isLoading = infraQuery.isLoading || (isConnections && maxConnectionsQuery.isLoading) + const data = infraQuery.data + const errors = data && 'errors' in data ? data.errors : undefined + const metricError = attributes.map((attribute) => errors?.[attribute]).find(Boolean) + const hasMissingSeries = + data && 'series' in data && attributes.some((attribute) => !data.series[attribute]) + const error = + infraQuery.error ?? + metricError ?? + (isConnections ? maxConnectionsQuery.error : null) ?? + (hasMissingSeries ? { message: 'Error loading data' } : null) + const errorMessage = + error && typeof error === 'object' && 'message' in error + ? String(error.message) + : 'Error loading data' + + let value = '--' + if (data) { + if (isConnections) { + const connections = parseConnectionsData(data, maxConnectionsQuery.data) + if (connections.max > 0) value = `${connections.peak}/${connections.max}` + } else { + const metrics = parseInfrastructureMetrics(data) + if (metrics) value = `${metrics[metric].current.toFixed(0)}%` + } + } + + return ( + + + + {label} + + + {!!error && ( +
{errorMessage}
+ )} + {!error && {value}} +
+
+ + ) +} diff --git a/apps/studio/components/interfaces/Observability/DatabaseInfrastructureSection.tsx b/apps/studio/components/interfaces/Observability/DatabaseInfrastructureSection.tsx index 13c5d8eeb54..0f8cda2c25c 100644 --- a/apps/studio/components/interfaces/Observability/DatabaseInfrastructureSection.tsx +++ b/apps/studio/components/interfaces/Observability/DatabaseInfrastructureSection.tsx @@ -11,9 +11,9 @@ import { } from 'ui-patterns/MetricCard' import { - parseConnectionsData, - parseInfrastructureMetrics, -} from './DatabaseInfrastructureSection.utils' + DatabaseInfrastructureMetric, + INFRASTRUCTURE_METRIC_ATTRIBUTES, +} from './DatabaseInfrastructureMetric' import { useInfraMonitoringAttributesQuery } from '@/data/analytics/infra-monitoring-query' import { useMaxConnectionsQuery } from '@/data/database/max-connections-query' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' @@ -66,44 +66,19 @@ export const DatabaseInfrastructureSection = ({ return { startDate: start, endDate: end, infraInterval } }, [interval, refreshKey]) - const { - data: infraData, - isLoading: infraLoading, - error: infraError, - } = useInfraMonitoringAttributesQuery({ + const infraQuery = useInfraMonitoringAttributesQuery({ projectRef, - attributes: [ - 'avg_cpu_usage', - 'ram_usage', - 'disk_fs_used_system', - 'disk_fs_used_wal', - 'pg_database_size', - 'disk_fs_size', - 'disk_io_consumption', - 'pg_stat_database_num_backends', - ], + attributes: INFRASTRUCTURE_METRIC_ATTRIBUTES, startDate, endDate, interval: infraInterval, }) - const { data: maxConnectionsData } = useMaxConnectionsQuery({ + const maxConnectionsQuery = useMaxConnectionsQuery({ projectRef, connectionString: project?.connectionString, }) - const metrics = useMemo(() => parseInfrastructureMetrics(infraData), [infraData]) - - const connections = useMemo( - () => parseConnectionsData(infraData, maxConnectionsData), - [infraData, maxConnectionsData] - ) - - const errorMessage = - infraError && typeof infraError === 'object' && 'message' in infraError - ? String(infraError.message) - : 'Error loading data' - // Generate database report URL with time range parameters const getDatabaseReportUrl = () => { const now = dayjs() @@ -162,102 +137,15 @@ export const DatabaseInfrastructureSection = ({ - - - - - Peak Connections - - - - {infraError ? ( -
{errorMessage}
- ) : connections.max > 0 ? ( - - {connections.peak}/{connections.max} - - ) : ( - -- - )} -
-
- - - - - - - Disk Usage - - - - {infraError ? ( -
{errorMessage}
- ) : metrics ? ( - {metrics.disk.current.toFixed(0)}% - ) : ( - -- - )} -
-
- - - - - - - Disk IO - - - - {infraError ? ( -
{errorMessage}
- ) : metrics ? ( - {metrics.diskIo.current.toFixed(0)}% - ) : ( - -- - )} -
-
- - - - - - - Memory - - - - {infraError ? ( -
{errorMessage}
- ) : metrics ? ( - {metrics.ram.current.toFixed(0)}% - ) : ( - -- - )} -
-
- - - - - - - CPU - - - - {infraError ? ( -
{errorMessage}
- ) : metrics ? ( - {metrics.cpu.current.toFixed(0)}% - ) : ( - -- - )} -
-
- + {(['connections', 'disk', 'diskIo', 'ram', 'cpu'] as const).map((metric) => ( + + ))} ) diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/ComputeMetricsFooter.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/ComputeMetricsFooter.tsx index 4ca4b8d3b43..2250c7d0a84 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/ComputeMetricsFooter.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/ComputeMetricsFooter.tsx @@ -33,10 +33,17 @@ export const ComputeMetricsFooter = ({ showConnections = true }: { showConnectio href={observabilityUrl} className="border-t px-3 py-2 hover:bg-surface-200 transition flex items-center gap-x-3 text-xs" > - {/* Stable live region: announces loading/failure, never the polled values */} + {/* Stable live region: announces loading/unavailability, never the polled values */} {isMetricsLoading && 'Loading metrics'} {!isMetricsLoading && isMetricsError && 'Metrics unavailable'} + {!isMetricsLoading && !isMetricsError && ( + <> + {cpu === null && 'CPU unavailable. '} + {disk === null && 'Disk unavailable. '} + {memory === null && 'RAM unavailable.'} + + )} {/* h-4 matches the text-xs line height so the card doesn't shift when metrics load */} {isMetricsLoading && ( @@ -53,17 +60,17 @@ export const ComputeMetricsFooter = ({ showConnections = true }: { showConnectio {!isMetricsLoading && !isMetricsError && ( <> - CPU {cpu.toFixed(0)}% + CPU · - Disk {disk.toFixed(0)}% + Disk · - RAM {memory.toFixed(0)}% + RAM - {showConnections && connections.max > 0 && ( + {showConnections && connections !== null && connections.max > 0 && ( <> · @@ -79,3 +86,11 @@ export const ComputeMetricsFooter = ({ showConnections = true }: { showConnectio ) } + +function MetricValue({ value }: { value: number | null }) { + if (value === null) { + return Unavailable + } + + return {value.toFixed(0)}% +} diff --git a/apps/studio/data/analytics/infra-monitoring-queries.test.ts b/apps/studio/data/analytics/infra-monitoring-queries.test.ts index b8be88aa2f3..504bab80a7d 100644 --- a/apps/studio/data/analytics/infra-monitoring-queries.test.ts +++ b/apps/studio/data/analytics/infra-monitoring-queries.test.ts @@ -1,12 +1,20 @@ +import { waitFor } from '@testing-library/react' import dayjs from 'dayjs' +import { HttpResponse } from 'msw' import { describe, expect, it } from 'vitest' -import { aggregate1MinTo2Min, mapResponseToAnalyticsData } from './infra-monitoring-queries' +import { + aggregate1MinTo2Min, + mapResponseToAnalyticsData, + useInfraMonitoringQueries, +} from './infra-monitoring-queries' import type { InfraMonitoringMultiData, InfraMonitoringMultiResponse, InfraMonitoringSingleResponse, } from './infra-monitoring-query' +import { customRenderHook } from '@/tests/lib/custom-render' +import { addAPIMock } from '@/tests/lib/msw' const mockMultiResponse: InfraMonitoringMultiResponse = { data: [ @@ -500,3 +508,43 @@ describe('aggregate1MinTo2Min', () => { expect(typeof result[0].periodStartFormatted).toBe('string') }) }) + +describe('useInfraMonitoringQueries partial failures', () => { + it('reports errors only for failed attributes in a successful batch response', async () => { + addAPIMock({ + method: 'get', + path: '/platform/projects/:ref/infra-monitoring', + response: () => + HttpResponse.json({ + ...mockMultiResponse, + series: { ram_usage: mockMultiResponse.series.ram_usage }, + errors: { max_cpu_usage: { message: 'CPU unavailable' } }, + }), + }) + const { result } = customRenderHook(() => + useInfraMonitoringQueries( + ['max_cpu_usage', 'ram_usage'], + 'default', + '2024-01-02T00:00:00Z', + '2024-01-03T00:00:00Z', + '1h', + undefined, + undefined, + true + ) + ) + + await waitFor(() => expect(result.current[1].status).toBe('success')) + expect(result.current[0]).toMatchObject({ + data: undefined, + error: { message: 'CPU unavailable' }, + isError: true, + status: 'error', + }) + expect(result.current[1]).toMatchObject({ + error: null, + isError: false, + data: { total: 3 }, + }) + }) +}) diff --git a/apps/studio/data/analytics/infra-monitoring-queries.ts b/apps/studio/data/analytics/infra-monitoring-queries.ts index 6c58dc24906..2d1ad996a18 100644 --- a/apps/studio/data/analytics/infra-monitoring-queries.ts +++ b/apps/studio/data/analytics/infra-monitoring-queries.ts @@ -66,14 +66,18 @@ export function useInfraMonitoringQueries( return [] } - return attributes.map((attribute) => ({ - data: seriesByAttribute?.[attribute], - error: query.error, - isError: query.isError, - isFetching: query.isFetching, - isLoading: query.isLoading, - status: query.status, - })) + return attributes.map((attribute) => { + const metricError = + query.data && 'errors' in query.data ? query.data.errors?.[attribute] : undefined + return { + data: seriesByAttribute?.[attribute], + error: query.error ?? metricError ?? null, + isError: query.isError || !!metricError, + isFetching: query.isFetching, + isLoading: query.isLoading, + status: metricError ? 'error' : query.status, + } + }) } type AggregatedBucket = { diff --git a/apps/studio/data/analytics/infra-monitoring-query.ts b/apps/studio/data/analytics/infra-monitoring-query.ts index fe4b5fcdabe..69846bb3195 100644 --- a/apps/studio/data/analytics/infra-monitoring-query.ts +++ b/apps/studio/data/analytics/infra-monitoring-query.ts @@ -33,6 +33,7 @@ export type InfraMonitoringMultiResponse = { values: Record }[] series: Record + errors?: Record } // TODO(raulb): Simplify to just InfraMonitoringMultiResponse once API always returns multi-attribute format. diff --git a/apps/studio/hooks/analytics/useComputeMetrics.ts b/apps/studio/hooks/analytics/useComputeMetrics.ts index 63e3a5ba620..6f716e12849 100644 --- a/apps/studio/hooks/analytics/useComputeMetrics.ts +++ b/apps/studio/hooks/analytics/useComputeMetrics.ts @@ -1,6 +1,7 @@ import dayjs from 'dayjs' import { useMemo } from 'react' +import { COMPUTE_METRICS_ATTRIBUTES, getComputeMetricAvailability } from './useComputeMetrics.utils' import { parseConnectionsData, parseInfrastructureMetrics, @@ -10,10 +11,10 @@ import { useMaxConnectionsQuery } from '@/data/database/max-connections-query' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' export type ComputeMetrics = { - cpu: number - disk: number - memory: number - connections: { peak: number; max: number } + cpu: number | null + disk: number | null + memory: number | null + connections: { peak: number; max: number } | null isLoading: boolean isError: boolean } @@ -37,21 +38,17 @@ export function useComputeMetrics({ projectRef }: { projectRef?: string }): Comp isError, } = useInfraMonitoringAttributesQuery({ projectRef, - attributes: [ - 'avg_cpu_usage', - 'ram_usage', - 'disk_fs_used_system', - 'disk_fs_used_wal', - 'pg_database_size', - 'disk_fs_size', - 'pg_stat_database_num_backends', - ], + attributes: COMPUTE_METRICS_ATTRIBUTES, startDate, endDate, interval: '1h', }) - const { data: maxConnectionsData, isLoading: connectionsLoading } = useMaxConnectionsQuery({ + const { + data: maxConnectionsData, + isLoading: connectionsLoading, + isError: isConnectionsError, + } = useMaxConnectionsQuery({ projectRef, connectionString: project?.connectionString, }) @@ -61,12 +58,13 @@ export function useComputeMetrics({ projectRef }: { projectRef?: string }): Comp () => parseConnectionsData(infraData, maxConnectionsData), [infraData, maxConnectionsData] ) + const availability = getComputeMetricAvailability(infraData) return { - cpu: metrics?.cpu.current ?? 0, - disk: metrics?.disk.current ?? 0, - memory: metrics?.ram.current ?? 0, - connections, + cpu: availability.cpu ? (metrics?.cpu.current ?? null) : null, + disk: availability.disk ? (metrics?.disk.current ?? null) : null, + memory: availability.memory ? (metrics?.ram.current ?? null) : null, + connections: availability.connections && !isConnectionsError ? connections : null, isLoading: infraLoading || connectionsLoading, isError, } diff --git a/apps/studio/hooks/analytics/useComputeMetrics.utils.ts b/apps/studio/hooks/analytics/useComputeMetrics.utils.ts new file mode 100644 index 00000000000..c411933cd98 --- /dev/null +++ b/apps/studio/hooks/analytics/useComputeMetrics.utils.ts @@ -0,0 +1,27 @@ +import type { + InfraMonitoringAttribute, + InfraMonitoringResponse, +} from '@/data/analytics/infra-monitoring-query' + +const COMPUTE_METRIC_ATTRIBUTES = { + cpu: ['avg_cpu_usage'], + memory: ['ram_usage'], + disk: ['disk_fs_used_system', 'disk_fs_used_wal', 'pg_database_size', 'disk_fs_size'], + connections: ['pg_stat_database_num_backends'], +} satisfies Record + +export const COMPUTE_METRICS_ATTRIBUTES = Object.values(COMPUTE_METRIC_ATTRIBUTES).flat() + +export function getComputeMetricAvailability(data: InfraMonitoringResponse | undefined) { + const hasMetricData = (attributes: InfraMonitoringAttribute[]) => + !!data && + 'series' in data && + attributes.every((attribute) => !!data.series[attribute] && !data.errors?.[attribute]) + + return { + cpu: hasMetricData(COMPUTE_METRIC_ATTRIBUTES.cpu), + memory: hasMetricData(COMPUTE_METRIC_ATTRIBUTES.memory), + disk: hasMetricData(COMPUTE_METRIC_ATTRIBUTES.disk), + connections: hasMetricData(COMPUTE_METRIC_ATTRIBUTES.connections), + } +} diff --git a/apps/studio/tests/hooks/analytics/useComputeMetrics.utils.test.ts b/apps/studio/tests/hooks/analytics/useComputeMetrics.utils.test.ts new file mode 100644 index 00000000000..8b224201467 --- /dev/null +++ b/apps/studio/tests/hooks/analytics/useComputeMetrics.utils.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' + +import type { InfraMonitoringMultiResponse } from '@/data/analytics/infra-monitoring-query' +import { + COMPUTE_METRICS_ATTRIBUTES, + getComputeMetricAvailability, +} from '@/hooks/analytics/useComputeMetrics.utils' + +function createMetricsResponse(): InfraMonitoringMultiResponse { + return { + data: [], + series: Object.fromEntries( + COMPUTE_METRICS_ATTRIBUTES.map((attribute) => [ + attribute, + { yAxisLimit: 100, format: '', total: 0, totalAverage: 0 }, + ]) + ), + } +} + +const allAvailable = { cpu: true, memory: true, disk: true, connections: true } + +describe('getComputeMetricAvailability', () => { + it('keeps successful zero values available', () => { + expect(getComputeMetricAvailability(createMetricsResponse())).toEqual(allAvailable) + }) + + it.each([ + ['avg_cpu_usage', 'cpu'], + ['ram_usage', 'memory'], + ['disk_fs_used_system', 'disk'], + ['disk_fs_used_wal', 'disk'], + ['pg_database_size', 'disk'], + ['disk_fs_size', 'disk'], + ['pg_stat_database_num_backends', 'connections'], + ])( + 'only marks the affected metric unavailable when %s fails or is missing', + (attribute, metric) => { + const response = createMetricsResponse() + response.errors = { [attribute]: { message: 'Failed to load attribute' } } + expect(getComputeMetricAvailability(response)).toEqual({ ...allAvailable, [metric]: false }) + + delete response.errors + delete response.series[attribute] + expect(getComputeMetricAvailability(response)).toEqual({ ...allAvailable, [metric]: false }) + } + ) + + it('ignores errors for unrelated attributes', () => { + const response = createMetricsResponse() + response.errors = { disk_io_consumption: { message: 'Failed to load attribute' } } + expect(getComputeMetricAvailability(response)).toEqual(allAvailable) + }) + + it.each([undefined, { data: [], series: {} }])('handles absent metrics', (response) => { + expect(getComputeMetricAvailability(response)).toEqual({ + cpu: false, + memory: false, + disk: false, + connections: false, + }) + }) + + it('handles a response without multi-attribute series', () => { + expect( + getComputeMetricAvailability({ + data: [], + yAxisLimit: 100, + format: '', + total: 0, + totalAverage: 0, + }) + ).toEqual({ cpu: false, memory: false, disk: false, connections: false }) + }) +})