diff --git a/apps/studio/data/analytics/infra-monitoring-queries.test.ts b/apps/studio/data/analytics/infra-monitoring-queries.test.ts new file mode 100644 index 00000000000..5f324108724 --- /dev/null +++ b/apps/studio/data/analytics/infra-monitoring-queries.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' + +import type { InfraMonitoringMultiData } from './infra-monitoring-query' +import { mapMultiResponseToAnalyticsData } from './infra-monitoring-queries' + +const mockResponse: InfraMonitoringMultiData = { + data: [ + { + period_start: '2024-01-02 03:04:00', + values: { + max_cpu_usage: '1.5', + ram_usage: undefined, + }, + }, + { + period_start: '2024-01-02 04:04:00', + values: { + max_cpu_usage: undefined, + ram_usage: '3', + }, + }, + ], + series: { + max_cpu_usage: { format: 'percent', total: 2.5, totalAverage: 1.25, yAxisLimit: 100 }, + ram_usage: { format: 'bytes', total: 3, totalAverage: 1.5, yAxisLimit: 200 }, + }, +} + +describe('mapMultiResponseToAnalyticsData', () => { + it('maps attribute series and coerces values to numbers', () => { + const result = mapMultiResponseToAnalyticsData(mockResponse, ['max_cpu_usage', 'ram_usage']) + + expect(result.max_cpu_usage?.data).toStrictEqual([ + { + period_start: '2024-01-02 03:04:00', + periodStartFormatted: '03:04 02 Jan', + max_cpu_usage: 1.5, + }, + { + period_start: '2024-01-02 04:04:00', + periodStartFormatted: '04:04 02 Jan', + max_cpu_usage: 0, + }, + ]) + + expect(result.ram_usage?.data[0].ram_usage).toBe(0) + expect(result.ram_usage?.data[1].ram_usage).toBe(3) + expect(result.max_cpu_usage).toMatchObject({ + format: 'percent', + total: 2.5, + yAxisLimit: 100, + }) + }) + + it('omits attributes that are missing series metadata', () => { + const result = mapMultiResponseToAnalyticsData(mockResponse, [ + 'max_cpu_usage', + 'ram_usage', + 'pg_stat_database_num_backends', + ]) + + expect(result.max_cpu_usage).toBeDefined() + expect(result.ram_usage).toBeDefined() + expect(result.pg_stat_database_num_backends).toBeUndefined() + }) + + it('uses a custom date format when provided', () => { + const result = mapMultiResponseToAnalyticsData(mockResponse, ['max_cpu_usage'], 'YYYY/MM/DD') + + expect(result.max_cpu_usage?.data[0].periodStartFormatted).toBe('2024/01/02') + }) +}) diff --git a/apps/studio/data/analytics/infra-monitoring-queries.ts b/apps/studio/data/analytics/infra-monitoring-queries.ts index 7cc001a9390..4ea93a774d9 100644 --- a/apps/studio/data/analytics/infra-monitoring-queries.ts +++ b/apps/studio/data/analytics/infra-monitoring-queries.ts @@ -1,6 +1,24 @@ -import { AnalyticsInterval } from './constants' +import { useMemo } from 'react' +import type { UseQueryResult } from '@tanstack/react-query' + +import dayjs from 'dayjs' + +import type { AnalyticsData, AnalyticsInterval } from './constants' import type { InfraMonitoringAttribute } from './infra-monitoring-query' -import { useInfraMonitoringQuery } from './infra-monitoring-query' +import { + InfraMonitoringMultiData, + InfraMonitoringError, + useInfraMonitoringAttributesQuery, + useInfraMonitoringQuery, +} from './infra-monitoring-query' + +const DEFAULT_DATE_FORMAT = 'HH:mm DD MMM' +const DEFAULT_ATTRIBUTE: InfraMonitoringAttribute = 'max_cpu_usage' + +type InfraQueryResult = Pick< + UseQueryResult, + 'data' | 'error' | 'isError' | 'isFetching' | 'isLoading' | 'status' +> export function useInfraMonitoringQueries( attributes: InfraMonitoringAttribute[], @@ -12,18 +30,83 @@ export function useInfraMonitoringQueries( data: any, isVisible: boolean ) { - return attributes.map((attribute) => - // eslint-disable-next-line react-hooks/rules-of-hooks - useInfraMonitoringQuery( - { - projectRef: ref as string, - attribute, - startDate, - endDate, - interval, - databaseIdentifier, - }, - { enabled: data === undefined && isVisible } - ) + const shouldFetch = data === undefined && isVisible + const hasAttributes = attributes.length > 0 + const isSingleAttribute = attributes.length === 1 + const primaryAttribute = attributes[0] ?? DEFAULT_ATTRIBUTE + + const singleAttributeQuery = useInfraMonitoringQuery( + { + projectRef: ref as string, + attribute: primaryAttribute, + startDate, + endDate, + interval, + databaseIdentifier, + }, + { enabled: shouldFetch && isSingleAttribute && hasAttributes } ) + + const multiQuery = useInfraMonitoringAttributesQuery( + { + projectRef: ref as string, + attributes, + startDate, + endDate, + interval, + databaseIdentifier, + }, + { enabled: shouldFetch && attributes.length > 1 } + ) + + const seriesByAttribute = useMemo(() => { + if (!multiQuery.data) return undefined + return mapMultiResponseToAnalyticsData(multiQuery.data, attributes) + }, [multiQuery.data, attributes]) + + if (!hasAttributes) { + return [] + } + + if (isSingleAttribute) { + return [singleAttributeQuery] + } + + return attributes.map((attribute) => ({ + data: seriesByAttribute?.[attribute], + error: multiQuery.error, + isError: multiQuery.isError, + isFetching: multiQuery.isFetching, + isLoading: multiQuery.isLoading, + status: multiQuery.status, + })) +} + +export function mapMultiResponseToAnalyticsData( + response: InfraMonitoringMultiData, + attributes: InfraMonitoringAttribute[], + dateFormat: string = DEFAULT_DATE_FORMAT +) { + return attributes.reduce>((acc, attribute) => { + const metadata = response.series?.[attribute] + if (!metadata) return acc + + const dataPoints = response.data.map((point) => { + const value = point.values?.[attribute] + return { + period_start: point.period_start, + periodStartFormatted: dayjs(point.period_start).format(dateFormat), + [attribute]: value === undefined ? 0 : Number(value), + } + }) + + acc[attribute] = { + data: dataPoints, + format: metadata.format, + total: metadata.total, + yAxisLimit: metadata.yAxisLimit, + } + + return acc + }, {}) } diff --git a/apps/studio/data/analytics/infra-monitoring-query.ts b/apps/studio/data/analytics/infra-monitoring-query.ts index b7604bbeefd..60dfd4b3381 100644 --- a/apps/studio/data/analytics/infra-monitoring-query.ts +++ b/apps/studio/data/analytics/infra-monitoring-query.ts @@ -35,6 +35,28 @@ export type InfraMonitoringVariables = { modifier?: (x: number) => number } +export type InfraMonitoringSeriesMetadata = { + yAxisLimit: number + format: string + total: number + totalAverage: number +} + +export type InfraMonitoringMultiResponse = { + data: { + period_start: string + values: Record + }[] + series: Record +} + +export type InfraMonitoringMultiVariables = Omit< + InfraMonitoringVariables, + 'attribute' | 'modifier' +> & { + attributes: InfraMonitoringAttribute[] +} + export async function getInfraMonitoring( { projectRef, @@ -69,8 +91,44 @@ export async function getInfraMonitoring( return data as unknown as AnalyticsData } +export async function getInfraMonitoringAttributes( + { + projectRef, + attributes, + startDate, + endDate, + interval = '1h', + databaseIdentifier, + }: InfraMonitoringMultiVariables, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('Project ref is required') + if (!attributes?.length) throw new Error('At least one attribute is required') + if (!startDate) throw new Error('Start date is required') + if (!endDate) throw new Error('End date is required') + + const { data, error } = await get('/platform/projects/{ref}/infra-monitoring', { + params: { + path: { ref: projectRef }, + // Attributes support is not yet reflected in the generated client types. + query: { + attributes, + startDate, + endDate, + interval, + databaseIdentifier, + } as any, + }, + signal, + }) + + if (error) handleError(error) + return data as unknown as InfraMonitoringMultiResponse +} + export type InfraMonitoringData = Awaited> export type InfraMonitoringError = unknown +export type InfraMonitoringMultiData = Awaited> export const useInfraMonitoringQuery = ( { @@ -123,3 +181,40 @@ export const useInfraMonitoringQuery = ( staleTime: 1000 * 60, ...options, }) + +export const useInfraMonitoringAttributesQuery = ( + { + projectRef, + attributes, + startDate, + endDate, + interval = '1h', + databaseIdentifier, + }: InfraMonitoringMultiVariables, + { + enabled = true, + ...options + }: UseCustomQueryOptions = {} +) => + useQuery({ + queryKey: analyticsKeys.infraMonitoringGroup(projectRef, { + attributes, + startDate, + endDate, + interval, + databaseIdentifier, + }), + queryFn: ({ signal }) => + getInfraMonitoringAttributes( + { projectRef, attributes, startDate, endDate, interval, databaseIdentifier }, + signal + ), + enabled: + enabled && + typeof projectRef !== 'undefined' && + !!attributes?.length && + typeof startDate !== 'undefined' && + typeof endDate !== 'undefined', + staleTime: 1000 * 60, + ...options, + }) diff --git a/apps/studio/data/analytics/keys.ts b/apps/studio/data/analytics/keys.ts index 26aca0b0f36..82ed6a13322 100644 --- a/apps/studio/data/analytics/keys.ts +++ b/apps/studio/data/analytics/keys.ts @@ -121,6 +121,35 @@ export const analyticsKeys = { 'infra-monitoring', { attribute, startDate, endDate, interval, databaseIdentifier }, ] as const, + infraMonitoringGroup: ( + projectRef: string | undefined, + { + attributes, + startDate, + endDate, + interval, + databaseIdentifier, + }: { + attributes?: string[] + startDate?: string + endDate?: string + interval?: string + databaseIdentifier?: string + } + ) => + [ + 'projects', + projectRef, + 'infra-monitoring', + 'group', + { + attributes: attributes ? [...attributes].sort() : undefined, + startDate, + endDate, + interval, + databaseIdentifier, + }, + ] as const, projectMetrics: (projectRef: string | undefined, { interval }: { interval?: string }) => ['projects', projectRef, 'project.metrics', { interval }] as const, usageApiCounts: (projectRef: string | undefined, interval: string | undefined) =>