mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
new home: refactor charts to use old sources (#42245)
- refactors new charts in homepage to use stable analytics endpoints
- changes are behind newHomepageUsageV2 flag
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
1 parent
ba5538576b
commit
88ed2aad97
12 files changed
+1055
-403
No files matched your search
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)}%`
|
||||
@@ -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<ChartIntervalKey>(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,
|
||||
})
|
||||
|
||||
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,
|
||||
},
|
||||
const queryParams = new URLSearchParams({
|
||||
iso_timestamp_start: start,
|
||||
iso_timestamp_end: end,
|
||||
})
|
||||
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-row justify-between items-center gap-x-2">
|
||||
@@ -236,14 +234,12 @@ export const ProjectUsageSection = () => {
|
||||
<div className="flex items-start gap-2 heading-section text-foreground-light">
|
||||
<span className="text-foreground">{totalRequests.toLocaleString()}</span>
|
||||
<span>Total Requests</span>
|
||||
<span className={cn('text-sm', totalDeltaClass)}>
|
||||
{formatDelta(totalRequestsChangePct)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 heading-section text-foreground-light">
|
||||
<span className="text-foreground">{successRate.toFixed(1)}%</span>
|
||||
<span className="text-foreground">
|
||||
{successRate === 100 ? '100' : successRate.toFixed(1)}%
|
||||
</span>
|
||||
<span>Success Rate</span>
|
||||
<span className={cn('text-sm', nonSuccessClass)}>{formatDelta(nonSuccessRate)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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<ServiceKey, ServiceConfig> = {
|
||||
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<ChartQueryResult[]> => {
|
||||
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<ServiceKey, ServiceHealthData> = 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,
|
||||
}
|
||||
}
|
||||
+350
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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<typeof MetricsRow>
|
||||
|
||||
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<ProjectMetricsRow[] | { result: ProjectMetricsRow[] }>(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<ReturnType<typeof getProjectMetrics>>
|
||||
export type ProjectMetricsError = unknown
|
||||
|
||||
export const useProjectMetricsQuery = <TData = ProjectMetricsData>(
|
||||
vars: ProjectMetricsVariables,
|
||||
{
|
||||
enabled = true,
|
||||
...options
|
||||
}: UseCustomQueryOptions<ProjectMetricsData, ProjectMetricsError, TData> = {}
|
||||
) => {
|
||||
const { projectRef, interval } = vars
|
||||
|
||||
return useQuery<ProjectMetricsData, ProjectMetricsError, TData>({
|
||||
queryKey: analyticsKeys.projectMetrics(projectRef, { interval }),
|
||||
queryFn: ({ signal }) => getProjectMetrics({ projectRef, interval }, signal),
|
||||
enabled: enabled && typeof projectRef !== 'undefined',
|
||||
refetchOnWindowFocus: false,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user