mirror of
https://github.com/supabase/supabase.git
synced 2026-09-09 03:19:36 +08:00
Selecting "Last 7 days" on an Observability report rendered only the most recent ~10 hours, while the header still showed the full range. `timestamp_trunc` only does minute/hour/day, so `analyticsIntervalToGranularity` floors any sub-hour interval to `minute`. The `'30m'` and `'10m'` intervals used for 7-day and 24-hour ranges therefore asked for 10,080 and 1,440 per-minute buckets. The analytics endpoint caps each query at 1,000 data points, and report queries are ordered newest-first with no `LIMIT`, so the oldest buckets got dropped. Confirmed against a production HAR: 168h requested, exactly 1,000 rows returned, 10.6h of data. Both tiers now use `'1h'` — 168 and 24 buckets respectively, well inside the cap. Applies to Edge Functions, Auth and Realtime reports, which share this helper. Remaining sites that can exceed 1,000 points, not touched here: - Logs event chart (`calcChartStart`) — minute buckets over a range extended 6h earlier (~1,080), and hourly over a range extended 5 days earlier (90d → 2,280) - API and Storage reports (`PRESET_CONFIG`) and `SharedAPIReport` — hardcoded `hour`, so a 90-day custom range is 2,160 points - Query Insights — hardcoded `MINUTE` grouped per query, so unbounded UnifiedLogs is fine; its ladder already caps at 720. Fixes FE-4023 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Updated report date-range interval selection for ranges up to seven days to use hourly granularity instead of 30-minute intervals for clearer, more consistent reporting. * **Tests** * Added automated coverage for interval granularity across minute-, hour-, and multi-week ranges. * Included assertions validating expected hourly bucket counts for a seven-day window. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
298 lines
9.7 KiB
TypeScript
298 lines
9.7 KiB
TypeScript
import dayjs from 'dayjs'
|
|
import { createParser, useQueryState } from 'nuqs'
|
|
import { useCallback, useMemo, useState } from 'react'
|
|
|
|
import {
|
|
REPORT_DATERANGE_HELPER_LABELS,
|
|
REPORTS_DATEPICKER_HELPERS,
|
|
ReportsDatetimeHelper,
|
|
} from '@/components/interfaces/Reports/Reports.constants'
|
|
import { DatePickerValue } from '@/components/interfaces/Settings/Logs/Logs.DatePickers'
|
|
import { maybeShowUpgradePromptIfNotEntitled } from '@/components/interfaces/Settings/Logs/Logs.utils'
|
|
import { AnalyticsInterval } from '@/data/analytics/constants'
|
|
import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
|
|
import { useCurrentOrgPlan } from '@/hooks/misc/useCurrentOrgPlan'
|
|
|
|
export const getIntervalGranularity = (from: string, to: string): AnalyticsInterval => {
|
|
const diffInDays = dayjs(to).diff(from, 'day', true)
|
|
const diffInHours = dayjs(to).diff(from, 'hour', true)
|
|
|
|
if (diffInHours <= 1) return '1m'
|
|
if (diffInHours <= 12) return '2m'
|
|
if (diffInDays <= 7) return '1h'
|
|
return '1d'
|
|
}
|
|
|
|
export const DATERANGE_LIMITS: { [key: string]: number } = {
|
|
free: 1,
|
|
pro: 7,
|
|
team: 28,
|
|
enterprise: 90,
|
|
platform: 1,
|
|
}
|
|
|
|
export interface ReportDateRange {
|
|
period_start: { date: string; time_period: string }
|
|
period_end: { date: string; time_period: string }
|
|
interval: AnalyticsInterval
|
|
}
|
|
|
|
// Create parsers for individual URL parameters
|
|
const stringWithDefault = (defaultValue: string) =>
|
|
createParser({
|
|
parse: (v) => v ?? defaultValue,
|
|
serialize: (v) => v || '',
|
|
})
|
|
|
|
const booleanWithDefault = (defaultValue: boolean) =>
|
|
createParser({
|
|
parse: (v) => {
|
|
if (v === 'true') return true
|
|
if (v === 'false') return false
|
|
return defaultValue
|
|
},
|
|
serialize: (v) => String(v),
|
|
})
|
|
|
|
export const useReportDateRange = (
|
|
defaultHelper:
|
|
| REPORT_DATERANGE_HELPER_LABELS
|
|
| string
|
|
| ReportsDatetimeHelper = REPORT_DATERANGE_HELPER_LABELS.LAST_60_MINUTES
|
|
) => {
|
|
const { plan: orgPlan, isLoading: isOrgPlanLoading } = useCurrentOrgPlan()
|
|
const { getEntitlementNumericValue } = useCheckEntitlements('log.retention_days')
|
|
const entitledToAuditLogDays = getEntitlementNumericValue()
|
|
const [showUpgradePrompt, setShowUpgradePrompt] = useState(false)
|
|
|
|
// Get filtered date picker helpers based on organization plan
|
|
const datePickerHelpers: ReportsDatetimeHelper[] = useMemo(
|
|
() =>
|
|
REPORTS_DATEPICKER_HELPERS.map((helper) => ({
|
|
...helper,
|
|
disabled: false,
|
|
})),
|
|
[]
|
|
)
|
|
|
|
// Use nuqs for URL state management with individual parameters (empty defaults)
|
|
const [timestampStartValue, setTimestampStart] = useQueryState('its', stringWithDefault(''))
|
|
const [timestampEndValue, setTimestampEnd] = useQueryState('ite', stringWithDefault(''))
|
|
const [isHelperValue, setIsHelper] = useQueryState('isHelper', booleanWithDefault(false))
|
|
const [helperTextValue, setHelperText] = useQueryState('helperText', stringWithDefault(''))
|
|
|
|
const hasAccessToHelper = useCallback(
|
|
(helper: ReportsDatetimeHelper) => {
|
|
if (!entitledToAuditLogDays) return true
|
|
const days = Math.abs(dayjs().diff(dayjs(helper.calcFrom()), 'day'))
|
|
return days <= entitledToAuditLogDays
|
|
},
|
|
[entitledToAuditLogDays]
|
|
)
|
|
|
|
const getDefaultHelper = useCallback(() => {
|
|
let targetHelper: ReportsDatetimeHelper | undefined
|
|
|
|
if (typeof defaultHelper === 'string') {
|
|
// Find helper by text (supports both enum values and direct strings)
|
|
targetHelper = REPORTS_DATEPICKER_HELPERS.find((helper) => helper.text === defaultHelper)
|
|
} else if (defaultHelper && typeof defaultHelper === 'object' && 'text' in defaultHelper) {
|
|
// Use the provided helper object directly
|
|
targetHelper = defaultHelper
|
|
}
|
|
|
|
// Check if the target helper is available for the current entitlement
|
|
if (targetHelper && hasAccessToHelper(targetHelper)) {
|
|
return {
|
|
start: targetHelper.calcFrom(),
|
|
end: targetHelper.calcTo(),
|
|
helper: { isHelper: true, text: targetHelper.text },
|
|
}
|
|
}
|
|
|
|
// Fallback: look for default helper marked in REPORTS_DATEPICKER_HELPERS
|
|
const fallbackHelper = REPORTS_DATEPICKER_HELPERS.find(
|
|
(helper) => helper.default && hasAccessToHelper(helper)
|
|
)
|
|
|
|
if (fallbackHelper) {
|
|
return {
|
|
start: fallbackHelper.calcFrom(),
|
|
end: fallbackHelper.calcTo(),
|
|
helper: { isHelper: true, text: fallbackHelper.text },
|
|
}
|
|
}
|
|
|
|
// Final fallback: use first available helper
|
|
const firstAvailable = REPORTS_DATEPICKER_HELPERS.find((helper) => hasAccessToHelper(helper))
|
|
|
|
if (firstAvailable) {
|
|
return {
|
|
start: firstAvailable.calcFrom(),
|
|
end: firstAvailable.calcTo(),
|
|
helper: { isHelper: true, text: firstAvailable.text },
|
|
}
|
|
}
|
|
|
|
// Ultimate fallback to manual date calculation (should rarely happen)
|
|
const defaultStart = dayjs().subtract(1, 'hour').toISOString()
|
|
const defaultEnd = dayjs().toISOString()
|
|
return {
|
|
start: defaultStart,
|
|
end: defaultEnd,
|
|
helper: { isHelper: false },
|
|
}
|
|
}, [defaultHelper, hasAccessToHelper])
|
|
|
|
// Get current effective values (from URL or defaults, but don't set URL)
|
|
const timestampStart = useMemo(() => {
|
|
if (timestampStartValue) {
|
|
// Validate that stored date is reasonable
|
|
const storedFrom = dayjs(timestampStartValue)
|
|
const now = dayjs()
|
|
if (
|
|
storedFrom.isValid() &&
|
|
storedFrom.isAfter(now.subtract(90, 'day')) &&
|
|
storedFrom.isBefore(now.add(1, 'day')) &&
|
|
!maybeShowUpgradePromptIfNotEntitled(timestampStartValue, entitledToAuditLogDays)
|
|
) {
|
|
return timestampStartValue
|
|
}
|
|
}
|
|
// Return default without setting URL
|
|
return getDefaultHelper().start
|
|
}, [timestampStartValue, getDefaultHelper, entitledToAuditLogDays])
|
|
|
|
const timestampEnd = useMemo(() => {
|
|
if (timestampEndValue) {
|
|
// Validate that stored date is reasonable
|
|
const storedTo = dayjs(timestampEndValue)
|
|
const now = dayjs()
|
|
if (
|
|
storedTo.isValid() &&
|
|
storedTo.isAfter(now.subtract(90, 'day')) &&
|
|
storedTo.isBefore(now.add(1, 'day'))
|
|
) {
|
|
return timestampEndValue
|
|
}
|
|
}
|
|
// Return default without setting URL
|
|
return getDefaultHelper().end
|
|
}, [timestampEndValue, getDefaultHelper])
|
|
|
|
// For helper state, use URL values or defaults from helper when no URL params exist
|
|
const isHelper = useMemo(() => {
|
|
if (timestampStartValue && timestampEndValue) {
|
|
return Boolean(isHelperValue)
|
|
}
|
|
// If no URL params, get default from helper
|
|
return getDefaultHelper().helper.isHelper
|
|
}, [timestampStartValue, timestampEndValue, isHelperValue, getDefaultHelper])
|
|
|
|
const helperText = useMemo(() => {
|
|
if (timestampStartValue && timestampEndValue) {
|
|
return helperTextValue || undefined
|
|
}
|
|
// If no URL params, get default from helper
|
|
return getDefaultHelper().helper.text
|
|
}, [timestampStartValue, timestampEndValue, helperTextValue, getDefaultHelper])
|
|
|
|
// Derive selectedDateRange from current values
|
|
const selectedDateRange: ReportDateRange = useMemo(
|
|
() => ({
|
|
period_start: { date: timestampStart, time_period: '1d' },
|
|
period_end: { date: timestampEnd, time_period: 'today' },
|
|
interval: getIntervalGranularity(timestampStart, timestampEnd),
|
|
}),
|
|
[timestampStart, timestampEnd]
|
|
)
|
|
|
|
const updateDateRange = useCallback(
|
|
(from: string, to: string) => {
|
|
setTimestampStart(from)
|
|
setTimestampEnd(to)
|
|
setIsHelper(false)
|
|
setHelperText('')
|
|
},
|
|
[setTimestampStart, setTimestampEnd, setIsHelper, setHelperText]
|
|
)
|
|
|
|
// For backward compatibility, provide a setter that updates the derived state
|
|
const setSelectedDateRange = useCallback(
|
|
(newRange: ReportDateRange) => {
|
|
setTimestampStart(newRange.period_start.date)
|
|
setTimestampEnd(newRange.period_end.date)
|
|
// When setting directly, assume it's not from a helper
|
|
setIsHelper(false)
|
|
setHelperText('')
|
|
},
|
|
[setTimestampStart, setTimestampEnd, setIsHelper, setHelperText]
|
|
)
|
|
|
|
// Convert to DatePickerValue format for LogsDatePicker
|
|
const datePickerValue: DatePickerValue = useMemo(
|
|
() => ({
|
|
from: timestampStart,
|
|
to: timestampEnd,
|
|
isHelper,
|
|
text: helperText,
|
|
}),
|
|
[timestampStart, timestampEnd, isHelper, helperText]
|
|
)
|
|
|
|
const handleDatePickerChange = (values: DatePickerValue) => {
|
|
const shouldShowUpgradePrompt = maybeShowUpgradePromptIfNotEntitled(
|
|
values.from,
|
|
entitledToAuditLogDays
|
|
)
|
|
if (shouldShowUpgradePrompt) {
|
|
setShowUpgradePrompt(true)
|
|
return true
|
|
} else {
|
|
if (values.from && values.to) {
|
|
setTimestampStart(values.from)
|
|
setTimestampEnd(values.to)
|
|
setIsHelper(values.isHelper || false)
|
|
setHelperText(values.text || '')
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
return {
|
|
selectedDateRange,
|
|
setSelectedDateRange,
|
|
updateDateRange,
|
|
datePickerValue,
|
|
datePickerHelpers,
|
|
isOrgPlanLoading,
|
|
orgPlan,
|
|
showUpgradePrompt,
|
|
setShowUpgradePrompt,
|
|
handleDatePickerChange,
|
|
}
|
|
}
|
|
|
|
export function useRefreshHandler(
|
|
datePickerValue: DatePickerValue,
|
|
datePickerHelpers: ReportsDatetimeHelper[],
|
|
handleDatePickerChange: (value: DatePickerValue) => boolean | void,
|
|
refresh: () => void | Promise<void>
|
|
): () => void | Promise<void> {
|
|
return useCallback(async () => {
|
|
if (datePickerValue.isHelper && datePickerValue.text) {
|
|
const selectedHelper = datePickerHelpers.find((h) => h.text === datePickerValue.text)
|
|
if (selectedHelper) {
|
|
handleDatePickerChange({
|
|
from: selectedHelper.calcFrom(),
|
|
to: selectedHelper.calcTo(),
|
|
isHelper: true,
|
|
text: selectedHelper.text,
|
|
})
|
|
}
|
|
}
|
|
|
|
await refresh()
|
|
}, [datePickerValue, datePickerHelpers, handleDatePickerChange, refresh])
|
|
}
|