From 88ed2aad977fc3a9ff03285ddee9f79fe69ed463 Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Wed, 28 Jan 2026 18:02:00 +0100 Subject: [PATCH 1/2] new home: refactor charts to use old sources (#42245) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - refactors new charts in homepage to use stable analytics endpoints - changes are behind newHomepageUsageV2 flag ## Summary by CodeRabbit * **New Features** * Centralized per-service health metrics hook with per-service data, loading/error states and refresh. * **Improvements** * Time-series normalization into fixed buckets aligned to an end time. * Updated UI: success-rate formatting, per-service loading/error surfaced, refreshed click/refresh behavior; removed delta display. * **Removals** * Legacy project-metrics query and mapping utilities removed. * **Tests** * Extensive unit tests added for date ranges, bucket normalization, and health metric calculations; some obsolete tests removed. ✏️ Tip: You can customize this high-level summary in your review settings. --- .../HomeNew/ChartDataTransform.utils.test.ts | 221 +++++++++++ .../HomeNew/ChartDataTransform.utils.ts | 88 +++++ .../HomeNew/ProjectUsage.metrics.test.ts | 11 - .../HomeNew/ProjectUsage.metrics.ts | 7 - .../HomeNew/ProjectUsageSection.tsx | 138 ++++--- .../HomeNew/ProjectUsageSection.utils.test.ts | 70 ---- .../HomeNew/ProjectUsageSection.utils.ts | 126 ------- .../Observability/useServiceHealthMetrics.ts | 225 +++++++++++ .../useServiceHealthMetrics.utils.test.ts | 350 ++++++++++++++++++ .../useServiceHealthMetrics.utils.ts | 103 ++++++ apps/studio/data/analytics/keys.ts | 2 - .../data/analytics/project-metrics-query.ts | 115 ------ 12 files changed, 1054 insertions(+), 402 deletions(-) create mode 100644 apps/studio/components/interfaces/HomeNew/ChartDataTransform.utils.test.ts create mode 100644 apps/studio/components/interfaces/HomeNew/ChartDataTransform.utils.ts delete mode 100644 apps/studio/components/interfaces/HomeNew/ProjectUsageSection.utils.test.ts delete mode 100644 apps/studio/components/interfaces/HomeNew/ProjectUsageSection.utils.ts create mode 100644 apps/studio/components/interfaces/Observability/useServiceHealthMetrics.ts create mode 100644 apps/studio/components/interfaces/Observability/useServiceHealthMetrics.utils.test.ts create mode 100644 apps/studio/components/interfaces/Observability/useServiceHealthMetrics.utils.ts delete mode 100644 apps/studio/data/analytics/project-metrics-query.ts diff --git a/apps/studio/components/interfaces/HomeNew/ChartDataTransform.utils.test.ts b/apps/studio/components/interfaces/HomeNew/ChartDataTransform.utils.test.ts new file mode 100644 index 0000000000000..3f7dbffdc381b --- /dev/null +++ b/apps/studio/components/interfaces/HomeNew/ChartDataTransform.utils.test.ts @@ -0,0 +1,221 @@ +import dayjs from 'dayjs' +import { describe, expect, it } from 'vitest' + +import { normalizeChartBuckets } from './ChartDataTransform.utils' +import type { LogsBarChartDatum } from './ProjectUsage.metrics' + +describe('normalizeChartBuckets', () => { + const now = dayjs('2024-01-28T12:00:00.000Z') + + describe('1hr interval', () => { + it('should create exactly 30 buckets with 2-minute intervals', () => { + const result = normalizeChartBuckets([], '1hr', now.toDate()) + + expect(result).toHaveLength(30) + + // Check first bucket + expect(result[0].timestamp).toBe(now.subtract(60, 'minute').toISOString()) + + // Check last bucket + expect(result[29].timestamp).toBe(now.subtract(2, 'minute').toISOString()) + + // Check all buckets are 2 minutes apart + for (let i = 0; i < result.length - 1; i++) { + const diff = dayjs(result[i + 1].timestamp).diff(dayjs(result[i].timestamp), 'minute') + expect(diff).toBe(2) + } + }) + + it('should aggregate data points into correct 2-minute buckets', () => { + const data: LogsBarChartDatum[] = [ + { + // First bucket starts at -60 minutes, so -60 to -59 minutes is in bucket 0 + timestamp: now.subtract(60, 'minute').toISOString(), + ok_count: 10, + warning_count: 1, + error_count: 2, + }, + { + timestamp: now.subtract(59, 'minute').add(30, 'second').toISOString(), + ok_count: 5, + warning_count: 0, + error_count: 1, + }, + { + timestamp: now.subtract(30, 'minute').toISOString(), + ok_count: 20, + warning_count: 2, + error_count: 0, + }, + ] + + const result = normalizeChartBuckets(data, '1hr', now.toDate()) + + // First bucket (60-58 minutes ago) should contain aggregated data + const firstBucket = result[0] + expect(firstBucket.ok_count).toBe(15) // 10 + 5 + expect(firstBucket.warning_count).toBe(1) // 1 + 0 + expect(firstBucket.error_count).toBe(3) // 2 + 1 + + // Bucket at 30 minutes ago + const bucket15 = result[15] // 30 minutes / 2 minutes per bucket = bucket 15 + expect(bucket15.ok_count).toBe(20) + expect(bucket15.warning_count).toBe(2) + expect(bucket15.error_count).toBe(0) + }) + + it('should return empty buckets when no data provided', () => { + const result = normalizeChartBuckets([], '1hr', now.toDate()) + + expect(result).toHaveLength(30) + result.forEach((bucket) => { + expect(bucket.ok_count).toBe(0) + expect(bucket.warning_count).toBe(0) + expect(bucket.error_count).toBe(0) + }) + }) + }) + + describe('1day interval', () => { + it('should create exactly 24 buckets with 1-hour intervals', () => { + const result = normalizeChartBuckets([], '1day', now.toDate()) + + expect(result).toHaveLength(24) + + // Check first bucket + expect(result[0].timestamp).toBe(now.subtract(24, 'hour').toISOString()) + + // Check last bucket + expect(result[23].timestamp).toBe(now.subtract(1, 'hour').toISOString()) + + // Check all buckets are 1 hour apart + for (let i = 0; i < result.length - 1; i++) { + const diff = dayjs(result[i + 1].timestamp).diff(dayjs(result[i].timestamp), 'hour') + expect(diff).toBe(1) + } + }) + + it('should aggregate multiple data points into hourly buckets', () => { + const data: LogsBarChartDatum[] = [ + { + timestamp: now.subtract(23, 'hour').subtract(30, 'minute').toISOString(), + ok_count: 100, + warning_count: 5, + error_count: 3, + }, + { + timestamp: now.subtract(23, 'hour').subtract(15, 'minute').toISOString(), + ok_count: 50, + warning_count: 2, + error_count: 1, + }, + ] + + const result = normalizeChartBuckets(data, '1day', now.toDate()) + + // First bucket should contain aggregated data + expect(result[0].ok_count).toBe(150) + expect(result[0].warning_count).toBe(7) + expect(result[0].error_count).toBe(4) + }) + }) + + describe('7day interval', () => { + it('should create exactly 28 buckets with 6-hour intervals', () => { + const result = normalizeChartBuckets([], '7day', now.toDate()) + + expect(result).toHaveLength(28) + + // Check first bucket (7 days = 168 hours ago) + expect(result[0].timestamp).toBe(now.subtract(168, 'hour').toISOString()) + + // Check last bucket + expect(result[27].timestamp).toBe(now.subtract(6, 'hour').toISOString()) + + // Check all buckets are 6 hours apart + for (let i = 0; i < result.length - 1; i++) { + const diff = dayjs(result[i + 1].timestamp).diff(dayjs(result[i].timestamp), 'hour') + expect(diff).toBe(6) + } + }) + + it('should aggregate data points into 6-hour buckets', () => { + const data: LogsBarChartDatum[] = [ + { + timestamp: now.subtract(167, 'hour').toISOString(), + ok_count: 1000, + warning_count: 10, + error_count: 5, + }, + { + timestamp: now.subtract(165, 'hour').toISOString(), + ok_count: 500, + warning_count: 5, + error_count: 2, + }, + ] + + const result = normalizeChartBuckets(data, '7day', now.toDate()) + + // First bucket should contain aggregated data + expect(result[0].ok_count).toBe(1500) + expect(result[0].warning_count).toBe(15) + expect(result[0].error_count).toBe(7) + }) + }) + + describe('edge cases', () => { + it('should handle data points outside the time range', () => { + const data: LogsBarChartDatum[] = [ + { + timestamp: now.subtract(120, 'minute').toISOString(), // Outside 1hr range + ok_count: 100, + warning_count: 10, + error_count: 5, + }, + { + timestamp: now.add(10, 'minute').toISOString(), // Future data + ok_count: 50, + warning_count: 5, + error_count: 2, + }, + { + timestamp: now.subtract(30, 'minute').toISOString(), // Within range + ok_count: 25, + warning_count: 2, + error_count: 1, + }, + ] + + const result = normalizeChartBuckets(data, '1hr', now.toDate()) + + // Should only include the data within range + const validBucket = result[15] // 30 minutes ago + expect(validBucket.ok_count).toBe(25) + expect(validBucket.warning_count).toBe(2) + expect(validBucket.error_count).toBe(1) + + // Other buckets should be empty + expect(result[0].ok_count).toBe(0) + expect(result[29].ok_count).toBe(0) + }) + + it('should handle undefined/null values in data', () => { + const data: LogsBarChartDatum[] = [ + { + timestamp: now.subtract(30, 'minute').toISOString(), + ok_count: undefined as any, + warning_count: null as any, + error_count: 5, + }, + ] + + const result = normalizeChartBuckets(data, '1hr', now.toDate()) + + const bucket = result[15] + expect(bucket.ok_count).toBe(0) + expect(bucket.warning_count).toBe(0) + expect(bucket.error_count).toBe(5) + }) + }) +}) diff --git a/apps/studio/components/interfaces/HomeNew/ChartDataTransform.utils.ts b/apps/studio/components/interfaces/HomeNew/ChartDataTransform.utils.ts new file mode 100644 index 0000000000000..b2884fb93550b --- /dev/null +++ b/apps/studio/components/interfaces/HomeNew/ChartDataTransform.utils.ts @@ -0,0 +1,88 @@ +import dayjs from 'dayjs' +import type { LogsBarChartDatum } from './ProjectUsage.metrics' + +/** + * Configuration for chart bucket sizes based on time interval + */ +const BUCKET_CONFIG = { + '1hr': { + bucketMinutes: 2, // 2-minute buckets + expectedBuckets: 30, // 60 minutes / 2 = 30 buckets + }, + '1day': { + bucketMinutes: 60, // 1-hour buckets + expectedBuckets: 24, // 24 hours + }, + '7day': { + bucketMinutes: 360, // 6-hour buckets + expectedBuckets: 28, // 168 hours / 6 = 28 buckets + }, +} as const + +type IntervalKey = keyof typeof BUCKET_CONFIG + +/** + * Normalizes chart data to consistent bucket sizes regardless of backend data density. + * + * For 1hr interval: Creates 30 buckets of 2 minutes each + * For 1day interval: Creates 24 buckets of 1 hour each + * For 7day interval: Creates 28 buckets of 6 hours each + * + * This ensures consistent bar width in charts and proper data aggregation. + * + * @param data - Raw chart data from backend + * @param interval - Time interval key ('1hr', '1day', '7day') + * @param endDate - End date for the chart (defaults to now) + * @returns Array of exactly the expected number of buckets with aggregated data + */ +export function normalizeChartBuckets( + data: LogsBarChartDatum[], + interval: IntervalKey, + endDate: Date = new Date() +): LogsBarChartDatum[] { + const config = BUCKET_CONFIG[interval] + const { bucketMinutes, expectedBuckets } = config + + // Calculate start time based on expected buckets + const end = dayjs(endDate) + const start = end.subtract(expectedBuckets * bucketMinutes, 'minute') + + // Create empty buckets + const buckets: LogsBarChartDatum[] = [] + let currentBucketStart = start + + for (let i = 0; i < expectedBuckets; i++) { + buckets.push({ + timestamp: currentBucketStart.toISOString(), + ok_count: 0, + warning_count: 0, + error_count: 0, + }) + currentBucketStart = currentBucketStart.add(bucketMinutes, 'minute') + } + + // If no data, return empty buckets + if (!data || data.length === 0) { + return buckets + } + + // Aggregate data into buckets + for (const datum of data) { + const datumTime = dayjs(datum.timestamp) + + // Find which bucket this datum belongs to + const bucketIndex = Math.floor(datumTime.diff(start, 'minute') / bucketMinutes) + + // Skip data points outside our time range + if (bucketIndex < 0 || bucketIndex >= expectedBuckets) { + continue + } + + // Aggregate counts into the appropriate bucket + buckets[bucketIndex].ok_count += datum.ok_count || 0 + buckets[bucketIndex].warning_count += datum.warning_count || 0 + buckets[bucketIndex].error_count += datum.error_count || 0 + } + + return buckets +} diff --git a/apps/studio/components/interfaces/HomeNew/ProjectUsage.metrics.test.ts b/apps/studio/components/interfaces/HomeNew/ProjectUsage.metrics.test.ts index b6725227bf1a6..33ac93f7d2278 100644 --- a/apps/studio/components/interfaces/HomeNew/ProjectUsage.metrics.test.ts +++ b/apps/studio/components/interfaces/HomeNew/ProjectUsage.metrics.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect } from 'vitest' import { - computeChangePercent, computeSuccessAndNonSuccessRates, sumErrors, sumTotal, @@ -43,14 +42,4 @@ describe('ProjectUsage.metrics', () => { expect(successRate).toBeCloseTo(87.5) expect(nonSuccessRate).toBeCloseTo(12.5) }) - - it('computeChangePercent handles zero previous safely', () => { - expect(computeChangePercent(10, 0)).toBe(100) - expect(computeChangePercent(0, 0)).toBe(0) - }) - - it('computeChangePercent returns standard percentage delta', () => { - expect(computeChangePercent(120, 100)).toBe(20) - expect(computeChangePercent(80, 100)).toBe(-20) - }) }) diff --git a/apps/studio/components/interfaces/HomeNew/ProjectUsage.metrics.ts b/apps/studio/components/interfaces/HomeNew/ProjectUsage.metrics.ts index f98ec14a4b217..59355e3237840 100644 --- a/apps/studio/components/interfaces/HomeNew/ProjectUsage.metrics.ts +++ b/apps/studio/components/interfaces/HomeNew/ProjectUsage.metrics.ts @@ -35,10 +35,3 @@ export const computeSuccessAndNonSuccessRates = ( const successRate = 100 - nonSuccessRate return { successRate, nonSuccessRate } } - -export const computeChangePercent = (current: number, previous: number): number => { - if (previous === 0) return current > 0 ? 100 : 0 - return ((current - previous) / previous) * 100 -} - -export const formatDelta = (v: number): string => `${v >= 0 ? '+' : ''}${v.toFixed(1)}%` diff --git a/apps/studio/components/interfaces/HomeNew/ProjectUsageSection.tsx b/apps/studio/components/interfaces/HomeNew/ProjectUsageSection.tsx index 654d4fab77de1..6600e507d9d40 100644 --- a/apps/studio/components/interfaces/HomeNew/ProjectUsageSection.tsx +++ b/apps/studio/components/interfaces/HomeNew/ProjectUsageSection.tsx @@ -1,16 +1,15 @@ -import dayjs from 'dayjs' -import { ChevronDown } from 'lucide-react' -import Link from 'next/link' -import { useRouter } from 'next/router' -import { useMemo, useState } from 'react' - import { useParams } from 'common' import NoDataPlaceholder from 'components/ui/Charts/NoDataPlaceholder' import { InlineLink } from 'components/ui/InlineLink' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import dayjs from 'dayjs' import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' +import { ChevronDown } from 'lucide-react' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { useMemo, useState } from 'react' import type { ChartIntervals } from 'types' import { Button, @@ -31,16 +30,15 @@ import { } from 'ui' import { Row } from 'ui-patterns' import { LogsBarChart } from 'ui-patterns/LogsBarChart' -import { useServiceStats } from './ProjectUsageSection.utils' -import type { StatsLike } from './ProjectUsageSection.utils' + +import { useServiceHealthMetrics } from '../Observability/useServiceHealthMetrics' +import { normalizeChartBuckets } from './ChartDataTransform.utils' import type { LogsBarChartDatum } from './ProjectUsage.metrics' import { + computeSuccessAndNonSuccessRates, + sumErrors, sumTotal, sumWarnings, - sumErrors, - computeSuccessAndNonSuccessRates, - computeChangePercent, - formatDelta, } from './ProjectUsage.metrics' const LOG_RETENTION = { free: 1, pro: 7, team: 28, enterprise: 90, platform: 1 } @@ -89,7 +87,8 @@ type ServiceComputed = ServiceEntry & { total: number warn: number err: number - stats: StatsLike + isLoading: boolean + error: unknown | null } export const ProjectUsageSection = () => { @@ -105,6 +104,7 @@ export const ProjectUsageSection = () => { const DEFAULT_INTERVAL: ChartIntervalKey = plan?.id === 'free' ? '1hr' : '1day' const [interval, setInterval] = useState(DEFAULT_INTERVAL) + const [refreshKey, setRefreshKey] = useState(0) const selectedInterval = CHART_INTERVALS.find((i) => i.key === interval) || CHART_INTERVALS[1] @@ -113,7 +113,11 @@ export const ProjectUsageSection = () => { return { datetimeFormat: format } }, [selectedInterval]) - const statsByService = useServiceStats(projectRef!, interval) + const { + services: healthServices, + isLoading: isHealthLoading, + endDate, + } = useServiceHealthMetrics(projectRef!, interval, refreshKey) const serviceBase: ServiceEntry[] = useMemo( () => [ @@ -158,77 +162,71 @@ export const ProjectUsageSection = () => { const services: ServiceComputed[] = useMemo( () => serviceBase.map((s) => { - const currentStats = statsByService[s.key].current - const data = currentStats.eventChartData - const total = sumTotal(data) - const warn = sumWarnings(data) - const err = sumErrors(data) - return { ...s, stats: currentStats, data, total, warn, err } + const healthData = healthServices[s.key] + // Normalize chart data to consistent bucket sizes using the same endDate from the query + const normalizedData = normalizeChartBuckets( + healthData.eventChartData, + interval, + new Date(endDate) + ) + const total = sumTotal(normalizedData) + const warn = sumWarnings(normalizedData) + const err = sumErrors(normalizedData) + return { + ...s, + data: normalizedData, + total, + warn, + err, + isLoading: healthData.isLoading, + error: healthData.error, + } }), - [serviceBase, statsByService] + [serviceBase, healthServices, interval, endDate] ) - const isLoading = services.some((s) => s.stats.isLoading) + const isLoading = isHealthLoading - const handleBarClick = (logRoute: string, serviceKey: ServiceKey) => (datum: any) => { - if (!datum?.timestamp) return + const handleBarClick = + (logRoute: string, serviceKey: ServiceKey) => (datum: LogsBarChartDatum) => { + if (!datum?.timestamp) return - const datumTimestamp = dayjs(datum.timestamp).toISOString() - const start = dayjs(datumTimestamp).subtract(1, 'minute').toISOString() - const end = dayjs(datumTimestamp).add(1, 'minute').toISOString() + const datumTimestamp = dayjs(datum.timestamp).toISOString() + const start = dayjs(datumTimestamp).subtract(1, 'minute').toISOString() + const end = dayjs(datumTimestamp).add(1, 'minute').toISOString() - const queryParams = new URLSearchParams({ - iso_timestamp_start: start, - iso_timestamp_end: end, - }) + const queryParams = new URLSearchParams({ + iso_timestamp_start: start, + iso_timestamp_end: end, + }) - router.push(`/project/${projectRef}${logRoute}?${queryParams.toString()}`) + router.push(`/project/${projectRef}${logRoute}?${queryParams.toString()}`) - if (projectRef && organization?.slug) { - sendEvent({ - action: 'home_project_usage_chart_clicked', - properties: { - service_type: serviceKey, - bar_timestamp: datum.timestamp, - }, - groups: { - project: projectRef, - organization: organization.slug, - }, - }) + if (projectRef && organization?.slug) { + sendEvent({ + action: 'home_project_usage_chart_clicked', + properties: { + service_type: serviceKey, + bar_timestamp: datum.timestamp, + }, + groups: { + project: projectRef, + organization: organization.slug, + }, + }) + } } - } const enabledServices = services.filter((s) => s.enabled) const totalRequests = enabledServices.reduce((sum, s) => sum + (s.total || 0), 0) const totalErrors = enabledServices.reduce((sum, s) => sum + (s.err || 0), 0) const totalWarnings = enabledServices.reduce((sum, s) => sum + (s.warn || 0), 0) - const { successRate, nonSuccessRate } = computeSuccessAndNonSuccessRates( + const { successRate } = computeSuccessAndNonSuccessRates( totalRequests, totalWarnings, totalErrors ) - const prevServiceTotals = useMemo( - () => - serviceBase.map((s) => { - const previousStats = statsByService[s.key].previous - const data = previousStats.eventChartData - return { - enabled: s.enabled, - total: sumTotal(data), - } - }), - [serviceBase, statsByService] - ) - - const enabledPrev = prevServiceTotals.filter((s) => s.enabled) - const prevTotalRequests = enabledPrev.reduce((sum, s) => sum + (s.total || 0), 0) - - const totalRequestsChangePct = computeChangePercent(totalRequests, prevTotalRequests) - const totalDeltaClass = totalRequestsChangePct >= 0 ? 'text-brand-link' : 'text-destructive' - const nonSuccessClass = nonSuccessRate > 0 ? 'text-destructive' : 'text-brand-link' - return (
@@ -236,14 +234,12 @@ export const ProjectUsageSection = () => {
{totalRequests.toLocaleString()} Total Requests - - {formatDelta(totalRequestsChangePct)} -
- {successRate.toFixed(1)}% + + {successRate === 100 ? '100' : successRate.toFixed(1)}% + Success Rate - {formatDelta(nonSuccessRate)}
diff --git a/apps/studio/components/interfaces/HomeNew/ProjectUsageSection.utils.test.ts b/apps/studio/components/interfaces/HomeNew/ProjectUsageSection.utils.test.ts deleted file mode 100644 index 87504e2398398..0000000000000 --- a/apps/studio/components/interfaces/HomeNew/ProjectUsageSection.utils.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' - -import { toServiceStatsMap } from './ProjectUsageSection.utils' -import type { ProjectMetricsRow } from 'data/analytics/project-metrics-query' - -const mkRow = ( - n: number, - service: ProjectMetricsRow['service'], - time_window: ProjectMetricsRow['time_window'] -): ProjectMetricsRow => ({ - timestamp: (1700000000000 + n * 60000) * 1000, // microseconds - service, - time_window, - ok_count: n, - warning_count: 0, - error_count: 0, -}) - -const emptyRows: ProjectMetricsRow[] = [] - -describe('toServiceStatsMap', () => { - it('returns empty arrays when no data', () => { - const onRefresh = vi.fn() - const map = toServiceStatsMap({ - data: emptyRows, - isLoading: false, - error: undefined, - onRefresh, - }) - - expect(map.db.current.eventChartData).toEqual([]) - expect(map.functions.previous.eventChartData).toEqual([]) - expect(map.auth.current.isLoading).toBe(false) - expect(map.storage.current.error).toBeNull() - - map.realtime.current.refresh() - expect(onRefresh).toHaveBeenCalledTimes(1) - }) - - it('maps data rows through for each service', () => { - const rows: ProjectMetricsRow[] = [ - mkRow(1, 'db', 'current'), - mkRow(2, 'db', 'current'), - mkRow(0, 'db', 'previous'), - ] - const map = toServiceStatsMap({ - data: rows, - isLoading: true, - error: undefined, - onRefresh: () => {}, - }) - - expect(map.db.current.eventChartData.length).toBe(2) - expect(map.db.previous.eventChartData.length).toBe(1) - expect(map.db.current.isLoading).toBe(true) - }) - - it('propagates errors to all services', () => { - const err = new Error('boom') - const map = toServiceStatsMap({ - data: emptyRows, - isLoading: false, - error: err, - onRefresh: () => {}, - }) - - expect(map.db.current.error).toBe(err) - expect(map.functions.previous.error).toBe(err) - }) -}) diff --git a/apps/studio/components/interfaces/HomeNew/ProjectUsageSection.utils.ts b/apps/studio/components/interfaces/HomeNew/ProjectUsageSection.utils.ts deleted file mode 100644 index 41037c7bae32f..0000000000000 --- a/apps/studio/components/interfaces/HomeNew/ProjectUsageSection.utils.ts +++ /dev/null @@ -1,126 +0,0 @@ -import type { ProjectMetricsRow } from 'data/analytics/project-metrics-query' -import { useProjectMetricsQuery } from 'data/analytics/project-metrics-query' - -type ServiceKey = 'db' | 'functions' | 'auth' | 'storage' | 'realtime' - -export type StatsLike = { - error: unknown | null - isLoading: boolean - eventChartData: Array<{ - timestamp: string - ok_count: number - warning_count: number - error_count: number - }> - refresh: () => void -} - -type ServiceStatsMap = Record< - ServiceKey, - { - current: StatsLike - previous: StatsLike - } -> - -/** - * Transform backend project metrics into a UI-friendly structure with consistent - * loading/error/refresh state per service. - * - * Why this exists - * - Backend returns flat rows: one record per (time_window, service, bucket_ts). - * - UI needs per-service objects with two series (current/previous) to drive 5 cards and compute per-service totals. - * - Charts expect ISO string timestamps; backend gives TIMESTAMP (coming to client as microseconds). We convert to ISO. - * - We also need stable sorting and consistent empty arrays when a series has no points. - * - We attach loading/error/refresh per service to keep UI simple. - */ -export const toServiceStatsMap = (args: { - data?: ProjectMetricsRow[] - isLoading: boolean - error?: unknown - onRefresh: () => void -}): ServiceStatsMap => { - const { data, isLoading, error, onRefresh } = args - - const base = { - error: error ?? null, - isLoading, - refresh: () => { - onRefresh() - }, - } - - const empty: StatsLike = { ...base, eventChartData: [] } - - const grouped: Record< - ServiceKey, - { current: StatsLike['eventChartData']; previous: StatsLike['eventChartData'] } - > = { - db: { current: [], previous: [] }, - functions: { current: [], previous: [] }, - auth: { current: [], previous: [] }, - storage: { current: [], previous: [] }, - realtime: { current: [], previous: [] }, - } - - const toIso = (microseconds: number) => new Date(microseconds / 1000).toISOString() - - for (const r of data ?? []) { - const bucket = grouped[r.service as ServiceKey] - const target = r.time_window === 'current' ? bucket.current : bucket.previous - target.push({ - timestamp: toIso(r.timestamp), - ok_count: r.ok_count, - warning_count: r.warning_count, - error_count: r.error_count, - }) - } - - const byTime = (a: { timestamp: string }, b: { timestamp: string }) => - Date.parse(a.timestamp) - Date.parse(b.timestamp) - for (const key of Object.keys(grouped) as ServiceKey[]) { - grouped[key].current.sort(byTime) - grouped[key].previous.sort(byTime) - } - - const toStats = (rows: StatsLike['eventChartData'] | undefined): StatsLike => - rows ? { ...base, eventChartData: rows } : empty - - return { - db: { current: toStats(grouped.db.current), previous: toStats(grouped.db.previous) }, - functions: { - current: toStats(grouped.functions.current), - previous: toStats(grouped.functions.previous), - }, - auth: { current: toStats(grouped.auth.current), previous: toStats(grouped.auth.previous) }, - storage: { - current: toStats(grouped.storage.current), - previous: toStats(grouped.storage.previous), - }, - realtime: { - current: toStats(grouped.realtime.current), - previous: toStats(grouped.realtime.previous), - }, - } -} - -export const useServiceStats = ( - projectRef: string, - interval: '1hr' | '1day' | '7day' -): ServiceStatsMap => { - const { - data, - isPending: isLoading, - error, - refetch, - } = useProjectMetricsQuery({ projectRef, interval }) - - return toServiceStatsMap({ - data, - isLoading, - error, - onRefresh: () => { - void refetch() - }, - }) -} diff --git a/apps/studio/components/interfaces/Observability/useServiceHealthMetrics.ts b/apps/studio/components/interfaces/Observability/useServiceHealthMetrics.ts new file mode 100644 index 0000000000000..6b5b8a680b23f --- /dev/null +++ b/apps/studio/components/interfaces/Observability/useServiceHealthMetrics.ts @@ -0,0 +1,225 @@ +import { useQuery } from '@tanstack/react-query' +import { get } from 'data/fetchers' +import { useFillTimeseriesSorted } from 'hooks/analytics/useFillTimeseriesSorted' +import useTimeseriesUnixToIso from 'hooks/analytics/useTimeseriesUnixToIso' +import { useMemo } from 'react' + +import type { LogsBarChartDatum } from '../HomeNew/ProjectUsage.metrics' +import { LogsTableName } from '../Settings/Logs/Logs.constants' +import { genChartQuery } from '../Settings/Logs/Logs.utils' +import { + calculateAggregatedMetrics, + calculateDateRange, + calculateHealthMetrics, + transformToBarChartData, +} from './useServiceHealthMetrics.utils' + +export type ServiceKey = 'db' | 'functions' | 'auth' | 'storage' | 'realtime' | 'postgrest' + +export type ServiceHealthData = { + total: number + errorRate: number + successRate: number + errorCount: number + warningCount: number + okCount: number + eventChartData: LogsBarChartDatum[] + isLoading: boolean + error: unknown | null + refresh: () => void +} + +type ServiceConfig = { + table: LogsTableName + enabled: boolean +} + +const SERVICE_CONFIG: Record = { + db: { table: LogsTableName.POSTGRES, enabled: true }, + auth: { table: LogsTableName.AUTH, enabled: true }, + functions: { table: LogsTableName.FN_EDGE, enabled: true }, + storage: { table: LogsTableName.STORAGE, enabled: true }, + realtime: { table: LogsTableName.REALTIME, enabled: true }, + postgrest: { table: LogsTableName.POSTGREST, enabled: true }, +} + +type ChartQueryResult = { + timestamp: string | number + ok_count: number + warning_count: number + error_count: number +} + +/** + * Fetches service health metrics using the same logic as the logs pages + */ +const fetchServiceHealthMetrics = async ( + projectRef: string, + table: LogsTableName, + startDate: string, + endDate: string, + signal?: AbortSignal +): Promise => { + const sql = genChartQuery( + table, + { + iso_timestamp_start: startDate, + iso_timestamp_end: endDate, + }, + {} + ) + + const { data, error } = await get(`/platform/projects/{ref}/analytics/endpoints/logs.all`, { + params: { + path: { ref: projectRef }, + query: { + sql, + iso_timestamp_start: startDate, + iso_timestamp_end: endDate, + }, + }, + signal, + }) + + if (error || data?.error) { + throw error || data?.error + } + + return (data?.result || []) as ChartQueryResult[] +} + +/** + * Hook to fetch health metrics for a single service + */ +const useServiceHealthQuery = ({ + projectRef, + serviceKey, + startDate, + endDate, + enabled, +}: { + projectRef: string + serviceKey: ServiceKey + startDate: string + endDate: string + enabled: boolean +}) => { + const config = SERVICE_CONFIG[serviceKey] + const table = config.table + + const queryResult = useQuery({ + queryKey: ['service-health-metrics', projectRef, serviceKey, startDate, endDate, table], + queryFn: ({ signal }) => + fetchServiceHealthMetrics(projectRef, table, startDate, endDate, signal), + enabled: enabled && config.enabled && Boolean(projectRef), + staleTime: 1000 * 60, // 1 minute + }) + + // Convert unix microseconds to ISO timestamps + const normalizedData = useTimeseriesUnixToIso(queryResult.data ?? [], 'timestamp') + + // Fill gaps in timeseries + const { data: filledData } = useFillTimeseriesSorted( + normalizedData, + 'timestamp', + 'ok_count', + 0, + startDate, + endDate + ) + + // Transform to LogsBarChartDatum format + const eventChartData: LogsBarChartDatum[] = useMemo( + () => transformToBarChartData(filledData), + [filledData] + ) + + // Calculate metrics + const metrics = useMemo(() => calculateHealthMetrics(eventChartData), [eventChartData]) + + return { + ...metrics, + eventChartData, + isLoading: queryResult.isLoading, + error: queryResult.error, + refresh: queryResult.refetch, + } +} + +/** + * Hook to fetch observability overview data for all services using logs page queries + */ +export const useServiceHealthMetrics = ( + projectRef: string, + interval: '1hr' | '1day' | '7day', + refreshKey: number +) => { + // Calculate date range based on interval + // refreshKey is intentionally included to force recalculation when user refreshes + // eslint-disable-next-line react-hooks/exhaustive-deps + const { startDate, endDate } = useMemo(() => calculateDateRange(interval), [interval, refreshKey]) + + const enabled = Boolean(projectRef) + + // Fetch metrics for each service + const db = useServiceHealthQuery({ projectRef, serviceKey: 'db', startDate, endDate, enabled }) + const auth = useServiceHealthQuery({ + projectRef, + serviceKey: 'auth', + startDate, + endDate, + enabled, + }) + const functions = useServiceHealthQuery({ + projectRef, + serviceKey: 'functions', + startDate, + endDate, + enabled, + }) + const storage = useServiceHealthQuery({ + projectRef, + serviceKey: 'storage', + startDate, + endDate, + enabled, + }) + const realtime = useServiceHealthQuery({ + projectRef, + serviceKey: 'realtime', + startDate, + endDate, + enabled, + }) + const postgrest = useServiceHealthQuery({ + projectRef, + serviceKey: 'postgrest', + startDate, + endDate, + enabled, + }) + + const services: Record = useMemo( + () => ({ + db, + auth, + functions, + storage, + realtime, + postgrest, + }), + [db, auth, functions, storage, realtime, postgrest] + ) + + // Calculate aggregated metrics + const aggregated = useMemo(() => calculateAggregatedMetrics(Object.values(services)), [services]) + + const isLoading = Object.values(services).some((s) => s.isLoading) + + return { + services, + aggregated, + isLoading, + endDate, + } +} diff --git a/apps/studio/components/interfaces/Observability/useServiceHealthMetrics.utils.test.ts b/apps/studio/components/interfaces/Observability/useServiceHealthMetrics.utils.test.ts new file mode 100644 index 0000000000000..5f71475be393a --- /dev/null +++ b/apps/studio/components/interfaces/Observability/useServiceHealthMetrics.utils.test.ts @@ -0,0 +1,350 @@ +import dayjs from 'dayjs' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import type { LogsBarChartDatum } from '../HomeNew/ProjectUsage.metrics' +import { + calculateAggregatedMetrics, + calculateDateRange, + calculateHealthMetrics, + transformToBarChartData, +} from './useServiceHealthMetrics.utils' + +describe('calculateDateRange', () => { + beforeEach(() => { + // Mock the current time to ensure consistent test results + vi.useFakeTimers() + vi.setSystemTime(new Date('2024-01-15T12:00:00Z')) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('calculates correct date range for 1hr interval', () => { + const result = calculateDateRange('1hr') + + expect(result.startDate).toBe('2024-01-15T11:00:00.000Z') + expect(result.endDate).toBe('2024-01-15T12:00:00.000Z') + }) + + it('calculates correct date range for 1day interval', () => { + const result = calculateDateRange('1day') + + expect(result.startDate).toBe('2024-01-14T12:00:00.000Z') + expect(result.endDate).toBe('2024-01-15T12:00:00.000Z') + }) + + it('calculates correct date range for 7day interval', () => { + const result = calculateDateRange('7day') + + expect(result.startDate).toBe('2024-01-08T12:00:00.000Z') + expect(result.endDate).toBe('2024-01-15T12:00:00.000Z') + }) + + it('returns ISO format strings', () => { + const result = calculateDateRange('1hr') + + expect(dayjs(result.startDate).isValid()).toBe(true) + expect(dayjs(result.endDate).isValid()).toBe(true) + }) + + it('end date is always after start date', () => { + const intervals: Array<'1hr' | '1day' | '7day'> = ['1hr', '1day', '7day'] + + intervals.forEach((interval) => { + const result = calculateDateRange(interval) + expect(dayjs(result.endDate).isAfter(dayjs(result.startDate))).toBe(true) + }) + }) +}) + +describe('transformToBarChartData', () => { + it('transforms raw data to LogsBarChartDatum format', () => { + const rawData = [ + { timestamp: '2024-01-15T12:00:00Z', ok_count: 10, warning_count: 2, error_count: 1 }, + { timestamp: '2024-01-15T12:01:00Z', ok_count: 15, warning_count: 0, error_count: 0 }, + ] + + const result = transformToBarChartData(rawData) + + expect(result).toEqual([ + { timestamp: '2024-01-15T12:00:00Z', ok_count: 10, warning_count: 2, error_count: 1 }, + { timestamp: '2024-01-15T12:01:00Z', ok_count: 15, warning_count: 0, error_count: 0 }, + ]) + }) + + it('handles missing count fields by setting them to 0', () => { + const rawData = [ + { timestamp: '2024-01-15T12:00:00Z' }, + { timestamp: '2024-01-15T12:01:00Z', ok_count: 5 }, + ] + + const result = transformToBarChartData(rawData) + + expect(result).toEqual([ + { timestamp: '2024-01-15T12:00:00Z', ok_count: 0, warning_count: 0, error_count: 0 }, + { timestamp: '2024-01-15T12:01:00Z', ok_count: 5, warning_count: 0, error_count: 0 }, + ]) + }) + + it('handles null values by converting to 0', () => { + const rawData = [ + { + timestamp: '2024-01-15T12:00:00Z', + ok_count: null, + warning_count: null, + error_count: null, + }, + ] + + const result = transformToBarChartData(rawData) + + expect(result).toEqual([ + { timestamp: '2024-01-15T12:00:00Z', ok_count: 0, warning_count: 0, error_count: 0 }, + ]) + }) + + it('handles empty array', () => { + const result = transformToBarChartData([]) + + expect(result).toEqual([]) + }) + + it('preserves timestamp values', () => { + const rawData = [ + { timestamp: '2024-01-15T12:00:00Z', ok_count: 10, warning_count: 0, error_count: 0 }, + ] + + const result = transformToBarChartData(rawData) + + expect(result[0].timestamp).toBe('2024-01-15T12:00:00Z') + }) +}) + +describe('calculateHealthMetrics', () => { + it('calculates metrics correctly with only ok requests', () => { + const eventChartData: LogsBarChartDatum[] = [ + { timestamp: '2024-01-15T12:00:00Z', ok_count: 100, warning_count: 0, error_count: 0 }, + { timestamp: '2024-01-15T12:01:00Z', ok_count: 50, warning_count: 0, error_count: 0 }, + ] + + const result = calculateHealthMetrics(eventChartData) + + expect(result).toEqual({ + total: 150, + errorRate: 0, + successRate: 100, + errorCount: 0, + warningCount: 0, + okCount: 150, + }) + }) + + it('calculates metrics correctly with errors', () => { + const eventChartData: LogsBarChartDatum[] = [ + { timestamp: '2024-01-15T12:00:00Z', ok_count: 80, warning_count: 10, error_count: 10 }, + ] + + const result = calculateHealthMetrics(eventChartData) + + expect(result).toEqual({ + total: 100, + errorRate: 10, + successRate: 80, + errorCount: 10, + warningCount: 10, + okCount: 80, + }) + }) + + it('calculates metrics correctly with warnings', () => { + const eventChartData: LogsBarChartDatum[] = [ + { timestamp: '2024-01-15T12:00:00Z', ok_count: 70, warning_count: 30, error_count: 0 }, + ] + + const result = calculateHealthMetrics(eventChartData) + + expect(result).toEqual({ + total: 100, + errorRate: 0, + successRate: 70, + errorCount: 0, + warningCount: 30, + okCount: 70, + }) + }) + + it('returns 0 error rate when total is 0', () => { + const eventChartData: LogsBarChartDatum[] = [ + { timestamp: '2024-01-15T12:00:00Z', ok_count: 0, warning_count: 0, error_count: 0 }, + ] + + const result = calculateHealthMetrics(eventChartData) + + expect(result).toEqual({ + total: 0, + errorRate: 0, + successRate: 0, + errorCount: 0, + warningCount: 0, + okCount: 0, + }) + }) + + it('handles empty array', () => { + const result = calculateHealthMetrics([]) + + expect(result).toEqual({ + total: 0, + errorRate: 0, + successRate: 0, + errorCount: 0, + warningCount: 0, + okCount: 0, + }) + }) + + it('aggregates metrics across multiple time periods', () => { + const eventChartData: LogsBarChartDatum[] = [ + { timestamp: '2024-01-15T12:00:00Z', ok_count: 50, warning_count: 5, error_count: 5 }, + { timestamp: '2024-01-15T12:01:00Z', ok_count: 30, warning_count: 5, error_count: 5 }, + { timestamp: '2024-01-15T12:02:00Z', ok_count: 20, warning_count: 0, error_count: 0 }, + ] + + const result = calculateHealthMetrics(eventChartData) + + expect(result).toEqual({ + total: 120, + errorRate: (10 / 120) * 100, + successRate: (100 / 120) * 100, + errorCount: 10, + warningCount: 10, + okCount: 100, + }) + }) + + it('calculates correct error rate for high error scenario', () => { + const eventChartData: LogsBarChartDatum[] = [ + { timestamp: '2024-01-15T12:00:00Z', ok_count: 10, warning_count: 10, error_count: 80 }, + ] + + const result = calculateHealthMetrics(eventChartData) + + expect(result.errorRate).toBe(80) + expect(result.successRate).toBe(10) + }) +}) + +describe('calculateAggregatedMetrics', () => { + it('aggregates metrics from multiple services', () => { + const services = [ + { total: 100, errorCount: 10, warningCount: 5 }, + { total: 200, errorCount: 20, warningCount: 10 }, + { total: 50, errorCount: 5, warningCount: 2 }, + ] + + const result = calculateAggregatedMetrics(services) + + expect(result).toEqual({ + totalRequests: 350, + totalErrors: 35, + totalWarnings: 17, + overallErrorRate: ((35 + 17) / 350) * 100, + overallSuccessRate: ((350 - 35 - 17) / 350) * 100, + }) + }) + + it('handles empty services array', () => { + const result = calculateAggregatedMetrics([]) + + expect(result).toEqual({ + totalRequests: 0, + totalErrors: 0, + totalWarnings: 0, + overallErrorRate: 0, + overallSuccessRate: 0, + }) + }) + + it('handles single service', () => { + const services = [{ total: 100, errorCount: 10, warningCount: 5 }] + + const result = calculateAggregatedMetrics(services) + + expect(result).toEqual({ + totalRequests: 100, + totalErrors: 10, + totalWarnings: 5, + overallErrorRate: 15, + overallSuccessRate: 85, + }) + }) + + it('handles services with zero metrics', () => { + const services = [ + { total: 0, errorCount: 0, warningCount: 0 }, + { total: 100, errorCount: 10, warningCount: 5 }, + ] + + const result = calculateAggregatedMetrics(services) + + expect(result).toEqual({ + totalRequests: 100, + totalErrors: 10, + totalWarnings: 5, + overallErrorRate: 15, + overallSuccessRate: 85, + }) + }) + + it('calculates correct rates when all requests are successful', () => { + const services = [ + { total: 100, errorCount: 0, warningCount: 0 }, + { total: 200, errorCount: 0, warningCount: 0 }, + ] + + const result = calculateAggregatedMetrics(services) + + expect(result).toEqual({ + totalRequests: 300, + totalErrors: 0, + totalWarnings: 0, + overallErrorRate: 0, + overallSuccessRate: 100, + }) + }) + + it('calculates correct rates when all requests fail', () => { + const services = [ + { total: 100, errorCount: 100, warningCount: 0 }, + { total: 200, errorCount: 200, warningCount: 0 }, + ] + + const result = calculateAggregatedMetrics(services) + + expect(result).toEqual({ + totalRequests: 300, + totalErrors: 300, + totalWarnings: 0, + overallErrorRate: 100, + overallSuccessRate: 0, + }) + }) + + it('handles mixed success/warning/error scenarios', () => { + const services = [ + { total: 100, errorCount: 20, warningCount: 30 }, // 50% success + { total: 100, errorCount: 10, warningCount: 10 }, // 80% success + { total: 100, errorCount: 0, warningCount: 0 }, // 100% success + ] + + const result = calculateAggregatedMetrics(services) + + expect(result.totalRequests).toBe(300) + expect(result.totalErrors).toBe(30) + expect(result.totalWarnings).toBe(40) + // Overall: 230 success, 40 warnings, 30 errors out of 300 + expect(result.overallSuccessRate).toBeCloseTo((230 / 300) * 100, 2) + expect(result.overallErrorRate).toBeCloseTo((70 / 300) * 100, 2) + }) +}) diff --git a/apps/studio/components/interfaces/Observability/useServiceHealthMetrics.utils.ts b/apps/studio/components/interfaces/Observability/useServiceHealthMetrics.utils.ts new file mode 100644 index 0000000000000..9baab7b547651 --- /dev/null +++ b/apps/studio/components/interfaces/Observability/useServiceHealthMetrics.utils.ts @@ -0,0 +1,103 @@ +import dayjs from 'dayjs' + +import type { LogsBarChartDatum } from '../HomeNew/ProjectUsage.metrics' +import { + computeSuccessAndNonSuccessRates, + sumErrors, + sumTotal, + sumWarnings, +} from '../HomeNew/ProjectUsage.metrics' + +/** + * Calculates the date range for fetching service health metrics + * based on the selected interval + */ +export const calculateDateRange = ( + interval: '1hr' | '1day' | '7day' +): { startDate: string; endDate: string } => { + const now = dayjs() + const end = now.toISOString() + let start: string + + switch (interval) { + case '1hr': + start = now.subtract(1, 'hour').toISOString() + break + case '1day': + start = now.subtract(1, 'day').toISOString() + break + case '7day': + start = now.subtract(7, 'day').toISOString() + break + default: + start = now.subtract(1, 'hour').toISOString() + } + + return { startDate: start, endDate: end } +} + +type RawChartData = { + timestamp: string | number + ok_count?: number | null + warning_count?: number | null + error_count?: number | null +} + +/** + * Transforms raw chart query results to LogsBarChartDatum format + */ +export const transformToBarChartData = (data: RawChartData[]): LogsBarChartDatum[] => { + return data.map((row) => ({ + timestamp: typeof row.timestamp === 'string' ? row.timestamp : String(row.timestamp), + ok_count: row.ok_count || 0, + warning_count: row.warning_count || 0, + error_count: row.error_count || 0, + })) +} + +/** + * Calculates health metrics from bar chart data + */ +export const calculateHealthMetrics = (eventChartData: LogsBarChartDatum[]) => { + const total = sumTotal(eventChartData) + const errorCount = sumErrors(eventChartData) + const warningCount = sumWarnings(eventChartData) + const okCount = total - errorCount - warningCount + const errorRate = total > 0 ? (errorCount / total) * 100 : 0 + const { successRate } = computeSuccessAndNonSuccessRates(total, warningCount, errorCount) + + return { + total, + errorRate, + successRate, + errorCount, + warningCount, + okCount, + } +} + +/** + * Calculates aggregated metrics across all services + */ +export const calculateAggregatedMetrics = ( + services: { + total: number + errorCount: number + warningCount: number + }[] +) => { + const totalRequests = services.reduce((sum, s) => sum + s.total, 0) + const totalErrors = services.reduce((sum, s) => sum + s.errorCount, 0) + const totalWarnings = services.reduce((sum, s) => sum + s.warningCount, 0) + + const { successRate: overallSuccessRate, nonSuccessRate: overallErrorRate } = + computeSuccessAndNonSuccessRates(totalRequests, totalWarnings, totalErrors) + + return { + totalRequests, + totalErrors, + totalWarnings, + overallErrorRate, + overallSuccessRate, + } +} diff --git a/apps/studio/data/analytics/keys.ts b/apps/studio/data/analytics/keys.ts index 82ed6a133220e..f3aea771094e5 100644 --- a/apps/studio/data/analytics/keys.ts +++ b/apps/studio/data/analytics/keys.ts @@ -150,8 +150,6 @@ export const analyticsKeys = { databaseIdentifier, }, ] as const, - projectMetrics: (projectRef: string | undefined, { interval }: { interval?: string }) => - ['projects', projectRef, 'project.metrics', { interval }] as const, usageApiCounts: (projectRef: string | undefined, interval: string | undefined) => ['projects', projectRef, 'usage.api-counts', interval] as const, diff --git a/apps/studio/data/analytics/project-metrics-query.ts b/apps/studio/data/analytics/project-metrics-query.ts deleted file mode 100644 index 041296b577801..0000000000000 --- a/apps/studio/data/analytics/project-metrics-query.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import { z } from 'zod' - -import { fetchGet } from 'data/fetchers' -import { API_URL, IS_PLATFORM } from 'lib/constants' -import { analyticsKeys } from './keys' -import { UseCustomQueryOptions } from 'types' - -export type ProjectMetricsVariables = { - projectRef?: string - interval?: '1hr' | '1day' | '7day' -} - -const MetricsRow = z.object({ - timestamp: z - .number({ - required_error: 'Timestamp is required', - invalid_type_error: 'Timestamp must be a number (microseconds since epoch)', - }) - .int('Timestamp must be an integer') - .positive('Timestamp must be positive'), - service: z.enum(['auth', 'db', 'functions', 'realtime', 'storage'], { - required_error: 'Service field is required', - invalid_type_error: 'Service must be one of: auth, db, functions, realtime, storage', - }), - time_window: z.enum(['current', 'previous'], { - required_error: 'Time window field is required', - invalid_type_error: 'Time window must be either "current" or "previous"', - }), - ok_count: z - .number({ - required_error: 'ok_count is required', - invalid_type_error: 'ok_count must be a number', - }) - .int('ok_count must be an integer') - .nonnegative('ok_count cannot be negative'), - warning_count: z - .number({ - required_error: 'warning_count is required', - invalid_type_error: 'warning_count must be a number', - }) - .int('warning_count must be an integer') - .nonnegative('warning_count cannot be negative'), - error_count: z - .number({ - required_error: 'error_count is required', - invalid_type_error: 'error_count must be a number', - }) - .int('error_count must be an integer') - .nonnegative('error_count cannot be negative'), -}) - -const MetricsRows = z.array(MetricsRow, { - required_error: 'Metrics response must be an array', - invalid_type_error: 'Metrics response must be an array of metric rows', -}) - -export type ProjectMetricsRow = z.infer - -export type ServiceKey = 'db' | 'functions' | 'auth' | 'storage' | 'realtime' - -export async function getProjectMetrics( - { projectRef, interval }: ProjectMetricsVariables, - signal?: AbortSignal -) { - if (!projectRef) throw new Error('projectRef is required') - - const search = new URLSearchParams() - if (interval) search.set('interval', interval) - - const url = IS_PLATFORM - ? `${API_URL}/projects/${projectRef}/analytics/endpoints/project.metrics?${search.toString()}` - : `/api/platform/projects/${projectRef}/analytics/endpoints/project.metrics?${search.toString()}` - - const response = await fetchGet(url, { - abortSignal: signal, - }) - if (response instanceof Error || (response as any)?.error) { - // normalize to throw - throw (response as any).error ?? response - } - - const payload = Array.isArray(response) ? response : (response as any)?.result - const parsed = MetricsRows.safeParse(payload) - if (!parsed.success) { - const firstError = parsed.error.errors[0] - const errorPath = firstError.path.length > 0 ? ` at path: ${firstError.path.join('.')}` : '' - throw new Error( - `Invalid metrics response${errorPath}: ${firstError.message}. Received: ${JSON.stringify(payload?.slice(0, 2))}` - ) - } - - return parsed.data -} - -export type ProjectMetricsData = Awaited> -export type ProjectMetricsError = unknown - -export const useProjectMetricsQuery = ( - vars: ProjectMetricsVariables, - { - enabled = true, - ...options - }: UseCustomQueryOptions = {} -) => { - const { projectRef, interval } = vars - - return useQuery({ - queryKey: analyticsKeys.projectMetrics(projectRef, { interval }), - queryFn: ({ signal }) => getProjectMetrics({ projectRef, interval }, signal), - enabled: enabled && typeof projectRef !== 'undefined', - refetchOnWindowFocus: false, - ...options, - }) -} From a87387b56e5f3b6951b40b8bee84f3e280547408 Mon Sep 17 00:00:00 2001 From: Monica Khoury <99693443+monicakh@users.noreply.github.com> Date: Wed, 28 Jan 2026 20:15:41 +0200 Subject: [PATCH 2/2] feat: show "Allow support access to your project" toggle for all support categories (#42254) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, the “Allow support access to your project” toggle was only shown for specific issue categories in our support form. This change makes the toggle available for all categories. ## Summary by CodeRabbit * **Bug Fixes** * Support access toggle and submission now suppress support access for Account Deletion, Sales Enquiry, and Refund categories. * **Refactor** * Reworked category gating so UI visibility and submitted payload consistently respect the disabled-category list. * **UI** * Category list updated—"Others" removed and category options adjusted so all available options are shown. ✏️ Tip: You can customize this high-level summary in your review settings. --------- Co-authored-by: Joshen Lim --- .../Support/CategoryAndSeverityInfo.tsx | 10 ++--- .../Support/LinkSupportTicketForm.tsx | 21 ++++----- .../interfaces/Support/Support.constants.ts | 8 ---- .../Support/SupportAccessToggle.tsx | 18 ++++---- .../interfaces/Support/SupportFormV2.tsx | 45 +++++++------------ .../__tests__/SupportFormPage.test.tsx | 6 +-- 6 files changed, 43 insertions(+), 65 deletions(-) diff --git a/apps/studio/components/interfaces/Support/CategoryAndSeverityInfo.tsx b/apps/studio/components/interfaces/Support/CategoryAndSeverityInfo.tsx index 1d6bf95ae82e4..58720a1cd279e 100644 --- a/apps/studio/components/interfaces/Support/CategoryAndSeverityInfo.tsx +++ b/apps/studio/components/interfaces/Support/CategoryAndSeverityInfo.tsx @@ -1,21 +1,21 @@ -import type { UseFormReturn } from 'react-hook-form' // End of third-party imports - import { SupportCategories } from '@supabase/shared-types/out/constants' import { InlineLink } from 'components/ui/InlineLink' +import type { UseFormReturn } from 'react-hook-form' import { - cn, FormControl_Shadcn_, FormField_Shadcn_, - Select_Shadcn_, SelectContent_Shadcn_, SelectGroup_Shadcn_, SelectItem_Shadcn_, SelectTrigger_Shadcn_, SelectValue_Shadcn_, + Select_Shadcn_, + cn, } from 'ui' import { Admonition } from 'ui-patterns/admonition' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' + import { CATEGORY_OPTIONS, type ExtendedSupportCategories, @@ -91,7 +91,7 @@ function CategorySelector({ form }: CategorySelectorProps) { - {CATEGORY_OPTIONS.filter((option) => !option.hidden).map((option) => ( + {CATEGORY_OPTIONS.map((option) => ( {option.label} diff --git a/apps/studio/components/interfaces/Support/LinkSupportTicketForm.tsx b/apps/studio/components/interfaces/Support/LinkSupportTicketForm.tsx index aeb328934676e..68f35b267180e 100644 --- a/apps/studio/components/interfaces/Support/LinkSupportTicketForm.tsx +++ b/apps/studio/components/interfaces/Support/LinkSupportTicketForm.tsx @@ -1,21 +1,21 @@ import { zodResolver } from '@hookform/resolvers/zod' +import { useLinkSupportTicketMutation } from 'data/feedback/link-support-ticket-mutation' +import { useOrganizationsQuery } from 'data/organizations/organizations-query' import { Link2 } from 'lucide-react' import { useEffect } from 'react' import type { SubmitHandler } from 'react-hook-form' import { useForm } from 'react-hook-form' import { toast } from 'sonner' - -import { useLinkSupportTicketMutation } from 'data/feedback/link-support-ticket-mutation' -import { useOrganizationsQuery } from 'data/organizations/organizations-query' import { Button, DialogSectionSeparator, - Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, + Form_Shadcn_, Input_Shadcn_, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' + import { CategoryAndSeverityInfo } from './CategoryAndSeverityInfo' import { LinkSupportTicketFormSchema, @@ -23,8 +23,8 @@ import { } from './LinkSupportTicketForm.schema' import { OrganizationSelector } from './OrganizationSelector' import { ProjectAndPlanInfo } from './ProjectAndPlanInfo' -import { SUPPORT_ACCESS_CATEGORIES, SupportAccessToggle } from './SupportAccessToggle' -import { getOrgSubscriptionPlan, NO_ORG_MARKER, NO_PROJECT_MARKER } from './SupportForm.utils' +import { DISABLE_SUPPORT_ACCESS_CATEGORIES, SupportAccessToggle } from './SupportAccessToggle' +import { NO_ORG_MARKER, NO_PROJECT_MARKER, getOrgSubscriptionPlan } from './SupportForm.utils' interface LinkSupportTicketFormProps { conversationId: string @@ -88,9 +88,10 @@ export const LinkSupportTicketForm = ({ ? values.projectRef : undefined, category: values.category, - allow_support_access: SUPPORT_ACCESS_CATEGORIES.includes(values.category) - ? values.allowSupportAccess - : false, + allow_support_access: + values.category && !DISABLE_SUPPORT_ACCESS_CATEGORIES.includes(values.category) + ? values.allowSupportAccess + : false, }) } @@ -153,7 +154,7 @@ export const LinkSupportTicketForm = ({ - {SUPPORT_ACCESS_CATEGORIES.includes(category) && ( + {!!category && !DISABLE_SUPPORT_ACCESS_CATEGORIES.includes(category) && ( <>
diff --git a/apps/studio/components/interfaces/Support/Support.constants.ts b/apps/studio/components/interfaces/Support/Support.constants.ts index f7ddf76f92005..8e02f560bfd2e 100644 --- a/apps/studio/components/interfaces/Support/Support.constants.ts +++ b/apps/studio/components/interfaces/Support/Support.constants.ts @@ -10,7 +10,6 @@ export const CATEGORY_OPTIONS: { label: string description: string query?: string - hidden?: boolean }[] = [ { value: SupportCategories.PROBLEM, @@ -78,13 +77,6 @@ export const CATEGORY_OPTIONS: { query: undefined, }, ]), - { - value: 'Others' as const, - label: 'Others', - description: 'Issues that are not related to any of the other categories', - query: undefined, - hidden: true, - }, ] export const SEVERITY_OPTIONS = [ diff --git a/apps/studio/components/interfaces/Support/SupportAccessToggle.tsx b/apps/studio/components/interfaces/Support/SupportAccessToggle.tsx index a5b49bc2e010b..0c24ef7d63aec 100644 --- a/apps/studio/components/interfaces/Support/SupportAccessToggle.tsx +++ b/apps/studio/components/interfaces/Support/SupportAccessToggle.tsx @@ -1,26 +1,26 @@ -import { ChevronRight } from 'lucide-react' -import Link from 'next/link' -import type { UseFormReturn } from 'react-hook-form' // End of third-party imports import { SupportCategories } from '@supabase/shared-types/out/constants' +import { ChevronRight } from 'lucide-react' +import Link from 'next/link' +import type { UseFormReturn } from 'react-hook-form' import { Badge, - Collapsible_Shadcn_, CollapsibleContent_Shadcn_, CollapsibleTrigger_Shadcn_, + Collapsible_Shadcn_, FormField_Shadcn_, Switch, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' + import type { ExtendedSupportCategories } from './Support.constants' import type { SupportFormValues } from './SupportForm.schema' -export const SUPPORT_ACCESS_CATEGORIES: ExtendedSupportCategories[] = [ - SupportCategories.DATABASE_UNRESPONSIVE, - SupportCategories.PERFORMANCE_ISSUES, - SupportCategories.PROBLEM, - SupportCategories.DASHBOARD_BUG, +export const DISABLE_SUPPORT_ACCESS_CATEGORIES: ExtendedSupportCategories[] = [ + SupportCategories.ACCOUNT_DELETION, + SupportCategories.SALES_ENQUIRY, + SupportCategories.REFUND, ] interface SupportAccessToggleProps { diff --git a/apps/studio/components/interfaces/Support/SupportFormV2.tsx b/apps/studio/components/interfaces/Support/SupportFormV2.tsx index 38e928934a7ed..26bbf789add65 100644 --- a/apps/studio/components/interfaces/Support/SupportFormV2.tsx +++ b/apps/studio/components/interfaces/Support/SupportFormV2.tsx @@ -1,7 +1,4 @@ -import { useEffect, type Dispatch, type MouseEventHandler } from 'react' -import type { SubmitHandler, UseFormReturn } from 'react-hook-form' // End of third-party imports - import { SupportCategories } from '@supabase/shared-types/out/constants' import { useConstant, useFlag } from 'common' import { CLIENT_LIBRARIES } from 'common/constants' @@ -13,7 +10,10 @@ import { useGenerateAttachmentURLsMutation } from 'data/support/generate-attachm import { useDeploymentCommitQuery } from 'data/utils/deployment-commit-query' import { detectBrowser } from 'lib/helpers' import { useProfile } from 'lib/profile' +import { type Dispatch, type MouseEventHandler } from 'react' +import type { SubmitHandler, UseFormReturn } from 'react-hook-form' import { DialogSectionSeparator, Form_Shadcn_ } from 'ui' + import { AffectedServicesSelector, CATEGORIES_WITHOUT_AFFECTED_SERVICES, @@ -27,15 +27,15 @@ import { OrganizationSelector } from './OrganizationSelector' import { ProjectAndPlanInfo } from './ProjectAndPlanInfo' import { SubjectAndSuggestionsInfo } from './SubjectAndSuggestionsInfo' import { SubmitButton } from './SubmitButton' -import { SUPPORT_ACCESS_CATEGORIES, SupportAccessToggle } from './SupportAccessToggle' +import { DISABLE_SUPPORT_ACCESS_CATEGORIES, SupportAccessToggle } from './SupportAccessToggle' import type { SupportFormValues } from './SupportForm.schema' import type { SupportFormActions, SupportFormState } from './SupportForm.state' import { + NO_ORG_MARKER, + NO_PROJECT_MARKER, formatMessage, formatStudioVersion, getOrgSubscriptionPlan, - NO_ORG_MARKER, - NO_PROJECT_MARKER, } from './SupportForm.utils' import { DASHBOARD_LOG_CATEGORIES, @@ -128,12 +128,12 @@ export const SupportFormV2 = ({ form, initialError, state, dispatch }: SupportFo const payload = { ...values, - category, organizationSlug: values.organizationSlug ?? NO_ORG_MARKER, projectRef: values.projectRef ?? NO_PROJECT_MARKER, - allowSupportAccess: SUPPORT_ACCESS_CATEGORIES.includes(values.category) - ? values.allowSupportAccess - : false, + allowSupportAccess: + values.category && !DISABLE_SUPPORT_ACCESS_CATEGORIES.includes(values.category) + ? values.allowSupportAccess + : false, library: values.category === SupportCategories.PROBLEM && selectedLibrary !== undefined ? selectedLibrary.key @@ -179,15 +179,6 @@ export const SupportFormV2 = ({ form, initialError, state, dispatch }: SupportFo handleFormSubmit(event) } - useEffect(() => { - if (simplifiedSupportForm) { - form.setValue('category', 'Others') - } else { - form.setValue('category', '' as any) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [simplifiedSupportForm]) - return (
@@ -202,14 +193,12 @@ export const SupportFormV2 = ({ form, initialError, state, dispatch }: SupportFo subscriptionPlanId={subscriptionPlanId} category={category} /> - {!simplifiedSupportForm && ( - - )} +
@@ -235,7 +224,7 @@ export const SupportFormV2 = ({ form, initialError, state, dispatch }: SupportFo )} - {SUPPORT_ACCESS_CATEGORIES.includes(category) && ( + {!!category && !DISABLE_SUPPORT_ACCESS_CATEGORIES.includes(category) && ( <> diff --git a/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx b/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx index 4c85b45f3ca92..9313f6d4d9c90 100644 --- a/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx +++ b/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx @@ -891,10 +891,6 @@ describe('SupportFormPage', () => { await userEvent.clear(messageField) await userEvent.type(messageField, 'MFA challenge fails with an unknown error code') - expect( - screen.queryByRole('switch', { name: /allow support access to your project/i }) - ).toBeNull() - await userEvent.click(getSubmitButton(screen)) await waitFor(() => { @@ -910,7 +906,7 @@ describe('SupportFormPage', () => { organizationSlug: 'org-2', library: '', affectedServices: '', - allowSupportAccess: false, + allowSupportAccess: true, verified: true, tags: ['dashboard-support-form'], siteUrl: 'https://project-2.supabase.dev',