diff --git a/apps/studio/components/interfaces/Reports/ReportChart.tsx b/apps/studio/components/interfaces/Reports/ReportChart.tsx deleted file mode 100644 index de4d39b45d8..00000000000 --- a/apps/studio/components/interfaces/Reports/ReportChart.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import type { MultiAttribute } from 'components/ui/Charts/ComposedChart.utils' -import LogChartHandler from 'components/ui/Charts/LogChartHandler' -import { useFillTimeseriesSorted } from 'hooks/analytics/useFillTimeseriesSorted' -import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan' -import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' -import { useChartData } from 'hooks/useChartData' -import type { UpdateDateRange } from 'pages/project/[ref]/reports/database' -import { ReportChartUpsell } from './v2/ReportChartUpsell' - -interface ReportChartProps { - chart: any - startDate: string - endDate: string - interval: string - updateDateRange: UpdateDateRange - functionIds?: string[] - isLoading?: boolean -} - -/** - * A wrapper component that uses the useChartData hook to fetch data for a chart - * and then passes the data and loading state to the ComposedChartHandler. - * - * This component acts as a bridge between the data-fetching logic and the - * presentational chart component. - */ -export const ReportChart = ({ - chart, - startDate, - endDate, - interval, - updateDateRange, - functionIds, - isLoading, -}: ReportChartProps) => { - const { data: org } = useSelectedOrganizationQuery() - const { plan: orgPlan } = useCurrentOrgPlan() - const orgPlanId = orgPlan?.id - - const isAvailable = - chart.availableIn === undefined || (orgPlanId && chart.availableIn.includes(orgPlanId)) - - const canFetch = orgPlanId !== undefined - const { - data, - isLoading: isLoadingChart, - highlightedValue, - } = useChartData({ - attributes: chart.attributes, - startDate, - endDate, - interval, - functionIds, - data: undefined, - enabled: canFetch, - highlightedValue: - chart.id === 'client-connections' || chart.id === 'pgbouncer-connections' - ? true - : chart.showMaxValue, - }) - - const isTopListChart = chart.id === 'top-api-routes' || chart.id === 'top-rpc-functions' - - const chartDataArray = Array.isArray(data) ? data : [] - - const { data: filledData, isError: isFillError } = useFillTimeseriesSorted( - chartDataArray, - 'period_start', - chart.attributes.map((attr: any) => attr.attribute), - 0, - startDate, - endDate, - undefined, - interval - ) - - const finalData = - chartDataArray.length > 0 && chartDataArray.length < 20 && !isFillError && !isTopListChart - ? filledData - : chartDataArray - - if (!isAvailable && !isLoading) { - return - } - - return ( - - ) -} diff --git a/apps/studio/components/interfaces/Reports/v2/ReportChartUpsell.tsx b/apps/studio/components/interfaces/Reports/v2/ReportChartUpsell.tsx index 5a31fa5a00e..942445df9fa 100644 --- a/apps/studio/components/interfaces/Reports/v2/ReportChartUpsell.tsx +++ b/apps/studio/components/interfaces/Reports/v2/ReportChartUpsell.tsx @@ -5,7 +5,16 @@ import { LogChartHandler } from 'components/ui/Charts/LogChartHandler' import { ReportConfig } from 'data/reports/v2/reports.types' import { Button, Card, cn } from 'ui' -export function ReportChartUpsell({ report, orgSlug }: { report: ReportConfig; orgSlug: string }) { +export function ReportChartUpsell({ + report, + orgSlug, +}: { + report: { + label: string + availableIn: string[] + } + orgSlug: string +}) { const [isHoveringUpgrade, setIsHoveringUpgrade] = useState(false) const startDate = '2025-01-01' diff --git a/apps/studio/components/interfaces/Reports/v2/ReportChartV2.tsx b/apps/studio/components/interfaces/Reports/v2/ReportChartV2.tsx index c021a855ac6..19e8e013910 100644 --- a/apps/studio/components/interfaces/Reports/v2/ReportChartV2.tsx +++ b/apps/studio/components/interfaces/Reports/v2/ReportChartV2.tsx @@ -58,7 +58,7 @@ export const ReportChartV2 = ({ return await report.dataProvider(projectRef, startDate, endDate, interval, filters) }, { - enabled: Boolean(projectRef && canFetch && isAvailable), + enabled: Boolean(projectRef && canFetch && isAvailable && !report.hide), refetchOnWindowFocus: false, staleTime: 0, } @@ -67,9 +67,15 @@ export const ReportChartV2 = ({ const chartData = queryResult?.data || [] const dynamicAttributes = queryResult?.attributes || [] + /** + * Depending on the source the timestamp key could be 'timestamp' or 'period_start' + */ + const firstItem = chartData[0] + const timestampKey = firstItem?.hasOwnProperty('timestamp') ? 'timestamp' : 'period_start' + const { data: filledChartData, isError: isFillError } = useFillTimeseriesSorted( chartData, - 'timestamp', + timestampKey, (dynamicAttributes as any[]).map((attr: any) => attr.attribute), 0, startDate, @@ -78,9 +84,6 @@ export const ReportChartV2 = ({ interval ) - const finalChartData = - filledChartData && filledChartData.length > 0 && !isFillError ? filledChartData : chartData - const [chartStyle, setChartStyle] = useState(report.defaultChartStyle) if (!isAvailable) { @@ -88,7 +91,8 @@ export const ReportChartV2 = ({ } const isErrorState = error && !isLoadingChart - const showEmptyState = (!finalChartData || finalChartData.length === 0) && !isLoadingChart + + if (report.hide) return null return ( @@ -100,10 +104,6 @@ export const ReportChartV2 = ({ > {isLoadingChart ? ( - ) : showEmptyState ? ( -

- No data available for the selected time range and filters -

) : isErrorState ? (

Error loading chart data @@ -112,10 +112,11 @@ export const ReportChartV2 = ({

(null) const [hoveredLabel, setHoveredLabel] = useState(null) const [isActiveHoveredChart, setIsActiveHoveredChart] = useState(false) + const [hiddenAttributes, setHiddenAttributes] = useState>(new Set()) const isDarkMode = resolvedTheme?.includes('dark') useEffect(() => { updateStackedChartColors(isDarkMode ?? false) - }, [resolvedTheme]) + }, [isDarkMode]) const { Container } = useChartSize(size) @@ -174,6 +175,7 @@ export function ComposedChart({ ...attributesToIgnore, ...(referenceLines?.map((a: MultiAttribute) => a.attribute) ?? []), ...(maxAttribute?.attribute ? [maxAttribute?.attribute] : []), + ...Array.from(hiddenAttributes), ] const lastDataPoint = data[data.length - 1] @@ -265,6 +267,7 @@ export function ComposedChart({ const attribute = attributes.find((attr) => attr.attribute === att.name) return !attribute?.isMaxValue }) + const visibleAttributes = stackedAttributes.filter((att) => !hiddenAttributes.has(att.name)) const isPercentage = format === '%' const isRamChart = !chartData?.some((att: any) => att.name.toLowerCase() === 'ram_usage') && @@ -282,10 +285,11 @@ export function ComposedChart({ // to the highest value in the chart data for percentage charts // to vertically zoom in on the data // */ - const yDomain = [ + const yMaxFromVisible = Math.max( 0, - Math.max(...chartData.map((att) => (typeof att.value === 'number' ? att.value : 0))), - ] + ...visibleAttributes.map((att) => (typeof att.value === 'number' ? att.value : 0)) + ) + const yDomain = [0, yMaxFromVisible] if (data.length === 0) { return ( @@ -303,6 +307,7 @@ export function ComposedChart({ return (
{chartStyle === 'bar' - ? stackedAttributes.map((attribute) => ( + ? visibleAttributes.map((attribute) => ( a.attribute === attribute?.name)?.stackId ?? '1'} fill={attribute.color} - fillOpacity={hoveredLabel && hoveredLabel !== attribute?.name ? 0.25 : 1} radius={0.75} - opacity={hoveredLabel && hoveredLabel !== attribute?.name ? 0.5 : 1} + opacity={1} name={ attributes?.find((a) => a.attribute === attribute?.name)?.label || attribute?.name } + maxBarSize={24} /> )) - : stackedAttributes.map((attribute, i) => ( + : visibleAttributes.map((attribute, i) => ( a.attribute === attribute.name)?.stackId ?? '1'} fill={attribute.color} - strokeOpacity={hoveredLabel && hoveredLabel !== attribute.name ? 0.4 : 1} stroke={attribute.color} radius={20} animationDuration={375} @@ -509,6 +512,28 @@ export function ComposedChart({ attributes={attributes} showMaxValue={_showMaxValue} onLabelHover={setHoveredLabel} + onToggleAttribute={(attribute, options) => { + setHiddenAttributes((prev) => { + if (options?.exclusive) { + const next = new Set() + // Hide every attribute except the selected one. If all but one are hidden, clicking again will reset to all visible. + const allNames = chartData.map((c) => c.name) + const allHiddenExcept = allNames.filter((n) => n !== attribute) + const isAlreadyExclusive = + allHiddenExcept.every((n) => prev.has(n)) && !prev.has(attribute) + return isAlreadyExclusive ? new Set() : new Set(allHiddenExcept) + } + + const next = new Set(prev) + if (next.has(attribute)) { + next.delete(attribute) + } else { + next.add(attribute) + } + return next + }) + }} + hiddenAttributes={hiddenAttributes} /> )}
diff --git a/apps/studio/components/ui/Charts/ComposedChart.utils.tsx b/apps/studio/components/ui/Charts/ComposedChart.utils.tsx index 1fc029b1f34..ecbdbfd2a5e 100644 --- a/apps/studio/components/ui/Charts/ComposedChart.utils.tsx +++ b/apps/studio/components/ui/Charts/ComposedChart.utils.tsx @@ -141,7 +141,12 @@ const CustomTooltip = ({ isActiveHoveredChart, }: TooltipProps) => { if (active && payload && payload.length) { - const timestamp = payload[0].payload.timestamp + /** + * Depending on the data source, the timestamp key could be 'timestamp' or 'period_start' + */ + const firstItem = payload[0].payload + const timestampKey = firstItem?.hasOwnProperty('timestamp') ? 'timestamp' : 'period_start' + const timestamp = payload[0].payload[timestampKey] const maxValueAttribute = isMaxAttribute(attributes) const maxValueData = maxValueAttribute && payload?.find((p: any) => p.dataKey === maxValueAttribute.attribute) @@ -250,9 +255,18 @@ interface CustomLabelProps { attributes?: MultiAttribute[] showMaxValue?: boolean onLabelHover?: (label: string | null) => void + onToggleAttribute?: (attribute: string, options?: { exclusive?: boolean }) => void + hiddenAttributes?: Set } -const CustomLabel = ({ payload, attributes, showMaxValue, onLabelHover }: CustomLabelProps) => { +const CustomLabel = ({ + payload, + attributes, + showMaxValue, + onLabelHover, + onToggleAttribute, + hiddenAttributes, +}: CustomLabelProps) => { const items = payload ?? [] const maxValueAttribute = isMaxAttribute(attributes) const [hoveredLabel, setHoveredLabel] = useState(null) @@ -280,16 +294,12 @@ const CustomLabel = ({ payload, attributes, showMaxValue, onLabelHover }: Custom const attribute = attributes?.find((a) => a.attribute === entry.name) const isMax = entry.name === maxValueAttribute?.attribute const isHovered = hoveredLabel === entry.name + const isHidden = hiddenAttributes?.has(entry.name) const Label = () => ( -
+
{getIcon(entry.name, entry.color)} - + {attribute?.label || entry.name}
@@ -298,11 +308,12 @@ const CustomLabel = ({ payload, attributes, showMaxValue, onLabelHover }: Custom if (!showMaxValue && isMax) return null return ( -
handleMouseEnter(entry.name)} onMouseOutCapture={handleMouseLeave} + onClick={(e) => onToggleAttribute?.(entry.name, { exclusive: e.metaKey || e.ctrlKey })} > {!!attribute?.tooltip ? ( @@ -316,12 +327,12 @@ const CustomLabel = ({ payload, attributes, showMaxValue, onLabelHover }: Custom ) : (
+ ) } return ( -
+
{items?.map((entry, index) => )}
diff --git a/apps/studio/data/reports/auth-charts.ts b/apps/studio/data/reports/auth-charts.ts deleted file mode 100644 index 33f20b979ae..00000000000 --- a/apps/studio/data/reports/auth-charts.ts +++ /dev/null @@ -1,200 +0,0 @@ -export const getAuthReportAttributes = () => [ - { - id: 'active-users', - label: 'Active Users', - valuePrecision: 0, - hide: false, - showTooltip: false, - showLegend: false, - showMaxValue: false, - hideChartType: false, - defaultChartStyle: 'bar', - attributes: [ - { attribute: 'ActiveUsers', provider: 'logs', label: 'Active Users', enabled: true }, - ], - }, - { - id: 'sign-in-attempts', - label: 'Sign In Attempts by Type', - valuePrecision: 0, - hide: false, - showTooltip: true, - showLegend: true, - showMaxValue: false, - hideChartType: false, - defaultChartStyle: 'bar', - titleTooltip: 'The total number of sign in attempts by grant type.', - attributes: [ - { - attribute: 'SignInAttempts', - provider: 'logs', - label: 'Password', - login_type_provider: 'password', - enabled: true, - }, - { - attribute: 'SignInAttempts', - provider: 'logs', - label: 'PKCE', - login_type_provider: 'pkce', - enabled: true, - }, - { - attribute: 'SignInAttempts', - provider: 'logs', - label: 'Refresh Token', - login_type_provider: 'token', - enabled: true, - }, - { - attribute: 'SignInAttempts', - provider: 'logs', - label: 'ID Token', - login_type_provider: 'id_token', - enabled: true, - }, - ], - }, - { - id: 'signups', - label: 'Sign Ups', - valuePrecision: 0, - hide: false, - showTooltip: true, - showLegend: false, - showMaxValue: false, - hideChartType: false, - defaultChartStyle: 'bar', - titleTooltip: 'The total number of sign ups.', - attributes: [ - { - attribute: 'TotalSignUps', - provider: 'logs', - label: 'Sign Ups', - enabled: true, - }, - ], - }, - { - id: 'auth-errors', - label: 'Auth Errors', - valuePrecision: 0, - hide: false, - showTooltip: true, - showLegend: true, - showMaxValue: false, - hideChartType: false, - defaultChartStyle: 'bar', - titleTooltip: 'The total number of auth errors by status code.', - attributes: [ - { - attribute: 'ErrorsByStatus', - provider: 'logs', - label: 'Auth Errors', - }, - ], - }, - { - id: 'password-reset-requests', - label: 'Password Reset Requests', - valuePrecision: 0, - hide: false, - showTooltip: false, - showLegend: false, - showMaxValue: false, - hideChartType: false, - defaultChartStyle: 'bar', - attributes: [ - { - attribute: 'PasswordResetRequests', - provider: 'logs', - label: 'Password Reset Requests', - enabled: true, - }, - ], - }, - { - id: 'sign-in-latency', - label: 'Sign In Latency', - valuePrecision: 2, - hide: true, // Jordi: Hidden until we can fix the query - showTooltip: true, - showLegend: true, - showMaxValue: false, - hideChartType: false, - defaultChartStyle: 'line', - titleTooltip: 'Average latency for sign in operations by grant type.', - attributes: [ - { - attribute: 'SignInLatency', - provider: 'logs', - label: 'Password', - grantType: 'password', - enabled: true, - }, - { - attribute: 'SignInLatency', - provider: 'logs', - label: 'PKCE', - grantType: 'pkce', - enabled: true, - }, - { - attribute: 'SignInLatency', - provider: 'logs', - label: 'Refresh Token', - grantType: 'refresh_token', - enabled: true, - }, - { - attribute: 'SignInLatency', - provider: 'logs', - label: 'ID Token', - grantType: 'id_token', - enabled: true, - }, - ], - }, - { - id: 'sign-up-latency', - label: 'Sign Up Latency', - valuePrecision: 2, - hide: true, // Jordi: Hidden until we can fix the query - showTooltip: true, - showLegend: true, - showMaxValue: false, - hideChartType: false, - defaultChartStyle: 'line', - titleTooltip: 'Average latency for sign up operations by provider.', - attributes: [ - { - attribute: 'SignUpLatency', - provider: 'logs', - label: 'Email', - providerType: 'email', - enabled: true, - }, - { - attribute: 'SignUpLatency', - provider: 'logs', - label: 'Google', - providerType: 'google', - enabled: true, - }, - { - attribute: 'SignUpLatency', - provider: 'logs', - label: 'GitHub', - providerType: 'github', - enabled: true, - }, - { - attribute: 'SignUpLatency', - provider: 'logs', - label: 'Apple', - providerType: 'apple', - enabled: true, - }, - ], - }, -] diff --git a/apps/studio/data/reports/auth-report-query.ts b/apps/studio/data/reports/auth-report-query.ts deleted file mode 100644 index 0b98e1bd2d1..00000000000 --- a/apps/studio/data/reports/auth-report-query.ts +++ /dev/null @@ -1,345 +0,0 @@ -import { useQuery } from '@tanstack/react-query' -import { get } from 'data/fetchers' -import { AnalyticsInterval } from 'data/analytics/constants' -import type { MultiAttribute } from 'components/ui/Charts/ComposedChart.utils' -import { getHttpStatusCodeInfo } from 'lib/http-status-codes' -import { analyticsIntervalToGranularity } from './report.utils' - -/** - * METRICS - * Each chart in the UI has a corresponding metric key. - */ - -const METRIC_KEYS = [ - 'ActiveUsers', - 'SignInAttempts', - 'PasswordResetRequests', - 'TotalSignUps', - 'SignInLatency', - 'SignUpLatency', - 'ErrorsByStatus', -] - -const STATUS_CODE_COLORS: { [key: string]: { light: string; dark: string } } = { - '400': { light: '#FFD54F', dark: '#FFF176' }, - '401': { light: '#FF8A65', dark: '#FFAB91' }, - '403': { light: '#FFB74D', dark: '#FFCC80' }, - '404': { light: '#90A4AE', dark: '#B0BEC5' }, - '409': { light: '#BA68C8', dark: '#CE93D8' }, - '410': { light: '#A1887F', dark: '#BCAAA4' }, - '422': { light: '#FF9800', dark: '#FFB74D' }, - '429': { light: '#E65100', dark: '#F57C00' }, - '500': { light: '#B71C1C', dark: '#D32F2F' }, - '502': { light: '#9575CD', dark: '#B39DDB' }, - '503': { light: '#0097A7', dark: '#4DD0E1' }, - '504': { light: '#C0CA33', dark: '#D4E157' }, - default: { light: '#757575', dark: '#9E9E9E' }, -} - -type MetricKey = (typeof METRIC_KEYS)[number] - -/** - * SQL - * Each metric has a corresponding SQL query. - */ - -const METRIC_SQL: Record string> = { - ActiveUsers: (interval) => { - const granularity = analyticsIntervalToGranularity(interval) - return ` - --active-users - select - timestamp_trunc(timestamp, ${granularity}) as timestamp, - count(distinct json_value(f.event_message, "$.auth_event.actor_id")) as count - from auth_logs f - where json_value(f.event_message, "$.auth_event.action") in ( - 'login', 'user_signedup', 'token_refreshed', 'user_modified', - 'user_recovery_requested', 'user_reauthenticate_requested' - ) - group by timestamp - order by timestamp desc - ` - }, - SignInAttempts: (interval) => { - const granularity = analyticsIntervalToGranularity(interval) - return ` - --sign-in-attempts - SELECT - timestamp_trunc(timestamp, ${granularity}) as timestamp, - CASE - WHEN JSON_VALUE(event_message, "$.provider") IS NOT NULL - AND JSON_VALUE(event_message, "$.provider") != '' - THEN CONCAT( - JSON_VALUE(event_message, "$.login_method"), - ' (', - JSON_VALUE(event_message, "$.provider"), - ')' - ) - ELSE JSON_VALUE(event_message, "$.login_method") - END as login_type_provider, - COUNT(*) as count - FROM - auth_logs - WHERE - JSON_VALUE(event_message, "$.action") = 'login' - AND JSON_VALUE(event_message, "$.metering") = "true" - GROUP BY - timestamp, login_type_provider - ORDER BY - timestamp desc, login_type_provider - ` - }, - PasswordResetRequests: (interval) => { - const granularity = analyticsIntervalToGranularity(interval) - return ` - --password-reset-requests - select - timestamp_trunc(timestamp, ${granularity}) as timestamp, - count(*) as count - from auth_logs f - where json_value(f.event_message, "$.auth_event.action") = 'user_recovery_requested' - group by timestamp - order by timestamp desc - ` - }, - TotalSignUps: (interval) => { - const granularity = analyticsIntervalToGranularity(interval) - return ` - --total-signups - select - timestamp_trunc(timestamp, ${granularity}) as timestamp, - count(*) as count - from auth_logs - where json_value(event_message, "$.auth_event.action") = 'user_signedup' - group by timestamp - order by timestamp desc - ` - }, - SignInLatency: (interval) => { - const granularity = analyticsIntervalToGranularity(interval) - return ` - --signin-latency - select - timestamp_trunc(timestamp, ${granularity}) as timestamp, - json_value(event_message, "$.grant_type") as grant_type, - count(*) as request_count, - round(avg(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as avg_latency_ms, - round(min(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as min_latency_ms, - round(max(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as max_latency_ms, - round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(50)] / 1000000, 2) as p50_latency_ms, - round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(95)] / 1000000, 2) as p95_latency_ms, - round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(99)] / 1000000, 2) as p99_latency_ms - from auth_logs - where json_value(event_message, "$.path") = '/token' - group by timestamp, grant_type - order by timestamp desc, grant_type - ` - }, - SignUpLatency: (interval) => { - const granularity = analyticsIntervalToGranularity(interval) - return ` - --signup-latency - select - timestamp_trunc(timestamp, ${granularity}) as timestamp, - json_value(event_message, "$.auth_event.traits.provider") as provider, - round(avg(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as avg_latency_ms, - round(min(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as min_latency_ms, - round(max(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as max_latency_ms, - round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(50)] / 1000000, 2) as p50_latency_ms, - round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(95)] / 1000000, 2) as p95_latency_ms - from auth_logs - where json_value(event_message, "$.auth_event.action") = 'user_signedup' - and json_value(event_message, "$.status") = '200' - group by timestamp, provider - order by timestamp desc, provider - ` - }, - ErrorsByStatus: (interval) => { - const granularity = analyticsIntervalToGranularity(interval) - return ` - --auth-errors-by-status -select - timestamp_trunc(timestamp, ${granularity}) as timestamp, - count(*) as count, - response.status_code -from edge_logs - cross join unnest(metadata) as m - cross join unnest(m.request) as request - cross join unnest(m.response) as response -where path like '%/auth%' - and response.status_code >= 400 and response.status_code <= 599 -group by timestamp, status_code -order by timestamp desc - ` - }, -} - -/** - * FORMATTERS. - * Metrics need to be formatted before being passed on to the UI charts. - */ - -function defaultFormatter(rawData: any, attributes: MultiAttribute[]) { - const chartAttributes = attributes - if (!rawData) return { data: undefined, chartAttributes } - const result = rawData.result || [] - const timestamps = new Set(result.map((p: any) => p.timestamp)) - const data = Array.from(timestamps) - .sort() - .map((timestamp) => { - const point: any = { period_start: timestamp } - chartAttributes.forEach((attr) => { - point[attr.attribute] = 0 - }) - const matchingPoints = result.filter((p: any) => p.timestamp === timestamp) - matchingPoints.forEach((p: any) => { - point[attributes[0].attribute] = p.count - }) - return point - }) - return { data, chartAttributes } -} - -const METRIC_FORMATTER: Record< - MetricKey, - ( - rawData: any, - attributes: MultiAttribute[], - logsMetric: string - ) => { data: any; chartAttributes: any } -> = { - ActiveUsers: (rawData, attributes) => defaultFormatter(rawData, attributes), - SignInAttempts: (rawData, attributes) => { - const chartAttributes = attributes.map((attr) => { - if (attr.attribute === 'SignInAttempts' && attr.login_type_provider) { - return { ...attr, attribute: `${attr.attribute}_${attr.login_type_provider}` } - } - return attr - }) - if (!rawData) return { data: undefined, chartAttributes } - const result = rawData.result || [] - const timestamps = new Set(result.map((p: any) => p.timestamp)) - const data = Array.from(timestamps) - .sort() - .map((timestamp) => { - const point: any = { period_start: timestamp } - chartAttributes.forEach((attr) => { - point[attr.attribute] = 0 - }) - const matchingPoints = result.filter((p: any) => p.timestamp === timestamp) - matchingPoints.forEach((p: any) => { - point[`SignInAttempts_${p.login_type_provider}`] = p.count - }) - return point - }) - return { data, chartAttributes } - }, - PasswordResetRequests: (rawData, attributes) => defaultFormatter(rawData, attributes), - TotalSignUps: (rawData, attributes) => defaultFormatter(rawData, attributes), - SignInLatency: (rawData, attributes) => defaultFormatter(rawData, attributes), - SignUpLatency: (rawData, attributes) => defaultFormatter(rawData, attributes), - ErrorsByStatus: (rawData, attributes) => { - if (!rawData) return { data: undefined, chartAttributes: attributes } - const result = rawData.result || [] - - const statusCodes = Array.from(new Set(result.map((p: any) => p.status_code))) - - const chartAttributes = statusCodes.map((statusCode) => { - const statusCodeInfo = getHttpStatusCodeInfo(Number(statusCode)) - const color = STATUS_CODE_COLORS[String(statusCode)] || STATUS_CODE_COLORS.default - - return { - attribute: `status_${statusCode}`, - label: `${statusCode} ${statusCodeInfo.label}`, - provider: 'logs', - enabled: true, - color: color, - statusCode: String(statusCode), - } - }) - - const timestamps = new Set(result.map((p: any) => p.timestamp)) - const data = Array.from(timestamps) - .sort() - .map((timestamp) => { - const point: any = { period_start: timestamp } - chartAttributes.forEach((attr) => { - point[attr.attribute] = 0 - }) - const matchingPoints = result.filter((p: any) => p.timestamp === timestamp) - matchingPoints.forEach((p: any) => { - point[`status_${p.status_code}`] = p.count - }) - return point - }) - - return { data, chartAttributes } - }, -} - -/** - * REPORT QUERY. - * Fetching and state management for the report. - */ - -export function useAuthLogsReport({ - projectRef, - attributes, - startDate, - endDate, - interval, - enabled = true, -}: { - projectRef: string - attributes: MultiAttribute[] - startDate: string - endDate: string - interval: AnalyticsInterval - enabled?: boolean -}) { - const logsMetric = attributes.length > 0 ? attributes[0].attribute : '' - - const isAuthMetric = METRIC_KEYS.includes(logsMetric) - - const sql = isAuthMetric ? METRIC_SQL[logsMetric](interval) : '' - - const { - data: rawData, - error, - isLoading, - isFetching, - } = useQuery( - ['auth-logs-report', projectRef, logsMetric, startDate, endDate, interval, sql], - async () => { - 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, - }, - }, - }) - if (error) throw error - return data - }, - { - enabled: Boolean(projectRef && sql && enabled && isAuthMetric), - refetchOnWindowFocus: false, - } - ) - - // Use formatter if available - const formatter = - (isAuthMetric ? METRIC_FORMATTER[logsMetric as MetricKey] : undefined) || defaultFormatter - const { data, chartAttributes } = formatter(rawData, attributes, logsMetric) - - return { - data, - attributes: chartAttributes, - isLoading, - error, - isFetching, - } -} diff --git a/apps/studio/data/reports/report.utils.ts b/apps/studio/data/reports/report.utils.ts index 67629df57fc..0c6b38b61c2 100644 --- a/apps/studio/data/reports/report.utils.ts +++ b/apps/studio/data/reports/report.utils.ts @@ -1,5 +1,6 @@ import { AnalyticsInterval } from 'data/analytics/constants' import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query' +import { get } from 'data/fetchers' export type Granularity = 'minute' | 'hour' | 'day' export function analyticsIntervalToGranularity(interval: AnalyticsInterval): Granularity { @@ -51,3 +52,39 @@ export const useEdgeFnIdToName = ({ projectRef }: { projectRef: string }) => { isLoading, } } + +export async function fetchLogs( + projectRef: string, + sql: string, + startDate: string, + endDate: string +) { + 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, + }, + }, + }) + if (error) throw error + return data +} + +export const STATUS_CODE_COLORS: { [key: string]: { light: string; dark: string } } = { + '400': { light: '#FFD54F', dark: '#FFF176' }, + '401': { light: '#FF8A65', dark: '#FFAB91' }, + '403': { light: '#FFB74D', dark: '#FFCC80' }, + '404': { light: '#90A4AE', dark: '#B0BEC5' }, + '409': { light: '#BA68C8', dark: '#CE93D8' }, + '410': { light: '#A1887F', dark: '#BCAAA4' }, + '422': { light: '#FF9800', dark: '#FFB74D' }, + '429': { light: '#E65100', dark: '#F57C00' }, + '500': { light: '#B71C1C', dark: '#D32F2F' }, + '502': { light: '#9575CD', dark: '#B39DDB' }, + '503': { light: '#0097A7', dark: '#4DD0E1' }, + '504': { light: '#C0CA33', dark: '#D4E157' }, + default: { light: '#757575', dark: '#9E9E9E' }, +} diff --git a/apps/studio/data/reports/v2/auth-report.test.tsx b/apps/studio/data/reports/v2/auth-report.test.tsx new file mode 100644 index 00000000000..198b1ce902a --- /dev/null +++ b/apps/studio/data/reports/v2/auth-report.test.tsx @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { defaultAuthReportFormatter } from './auth.config' + +describe('defaultAuthReportFormatter', () => { + const timestamp = new Date('2021-01-01').getTime() + + it('should format the data correctly', () => { + const data = { result: [{ timestamp: String(timestamp), count: 1 }] } + + const attributes = [ + { attribute: 'ActiveUsers', provider: 'logs', label: 'Active Users', enabled: true }, + ] + + const result = defaultAuthReportFormatter(data, attributes) + + expect(result).toEqual({ + data: [{ timestamp: String(timestamp), ActiveUsers: 1 }], + chartAttributes: attributes, + }) + }) + + it('should format the data correctly with multiple attributes', () => { + const data = { + result: [ + { timestamp: String(timestamp), active_users: 1 }, + { timestamp: String(timestamp + 1), sign_in_attempts: 2 }, + ], + } + + const attributes = [ + { attribute: 'active_users', label: 'Active Users' }, + { attribute: 'sign_in_attempts', label: 'Sign In Attempts' }, + ] + + const result = defaultAuthReportFormatter(data, attributes) + + expect(result).toEqual({ + data: [ + { timestamp: String(timestamp), active_users: 1, sign_in_attempts: 0 }, + { timestamp: String(timestamp + 1), active_users: 0, sign_in_attempts: 2 }, + ], + chartAttributes: attributes, + }) + }) + + it('should handle empty data', () => { + const data = { result: [] } + + const attributes = [ + { attribute: 'ActiveUsers', provider: 'logs', label: 'Active Users', enabled: true }, + ] + + const result = defaultAuthReportFormatter(data, attributes) + + expect(result).toEqual({ + data: [], + chartAttributes: attributes, + }) + }) +}) diff --git a/apps/studio/data/reports/v2/auth.config.ts b/apps/studio/data/reports/v2/auth.config.ts new file mode 100644 index 00000000000..d291195be6c --- /dev/null +++ b/apps/studio/data/reports/v2/auth.config.ts @@ -0,0 +1,514 @@ +import type { AnalyticsInterval } from 'data/analytics/constants' + +import { analyticsIntervalToGranularity } from 'data/reports/report.utils' +import { ReportConfig, ReportDataProviderAttribute } from './reports.types' +import { NumericFilter } from 'components/interfaces/Reports/v2/ReportsNumericFilter' +import { fetchLogs } from 'data/reports/report.utils' +import z from 'zod' + +const METRIC_KEYS = [ + 'ActiveUsers', + 'SignInAttempts', + 'PasswordResetRequests', + 'TotalSignUps', + 'SignInLatency', + 'SignUpLatency', + 'ErrorsByStatus', +] + +type MetricKey = (typeof METRIC_KEYS)[number] + +const AUTH_REPORT_SQL: Record< + MetricKey, + (interval: AnalyticsInterval, filters?: AuthReportFilters) => string +> = { + ActiveUsers: (interval) => { + const granularity = analyticsIntervalToGranularity(interval) + return ` + --active-users + select + timestamp_trunc(timestamp, ${granularity}) as timestamp, + count(distinct json_value(f.event_message, "$.auth_event.actor_id")) as count + from auth_logs f + where json_value(f.event_message, "$.auth_event.action") in ( + 'login', 'user_signedup', 'token_refreshed', 'user_modified', + 'user_recovery_requested', 'user_reauthenticate_requested' + ) + group by timestamp + order by timestamp desc + ` + }, + SignInAttempts: (interval) => { + const granularity = analyticsIntervalToGranularity(interval) + return ` + --sign-in-attempts + SELECT + timestamp_trunc(timestamp, ${granularity}) as timestamp, + CASE + WHEN JSON_VALUE(event_message, "$.provider") IS NOT NULL + AND JSON_VALUE(event_message, "$.provider") != '' + THEN CONCAT( + JSON_VALUE(event_message, "$.login_method"), + ' (', + JSON_VALUE(event_message, "$.provider"), + ')' + ) + ELSE JSON_VALUE(event_message, "$.login_method") + END as login_type_provider, + COUNT(*) as count + FROM + auth_logs + WHERE + JSON_VALUE(event_message, "$.action") = 'login' + AND JSON_VALUE(event_message, "$.metering") = "true" + GROUP BY + timestamp, login_type_provider + ORDER BY + timestamp desc, login_type_provider + ` + }, + PasswordResetRequests: (interval) => { + const granularity = analyticsIntervalToGranularity(interval) + return ` + --password-reset-requests + select + timestamp_trunc(timestamp, ${granularity}) as timestamp, + count(*) as count + from auth_logs f + where json_value(f.event_message, "$.auth_event.action") = 'user_recovery_requested' + group by timestamp + order by timestamp desc + ` + }, + TotalSignUps: (interval) => { + const granularity = analyticsIntervalToGranularity(interval) + return ` + --total-signups + select + timestamp_trunc(timestamp, ${granularity}) as timestamp, + count(*) as count + from auth_logs + where json_value(event_message, "$.auth_event.action") = 'user_signedup' + group by timestamp + order by timestamp desc + ` + }, + SignInLatency: (interval) => { + const granularity = analyticsIntervalToGranularity(interval) + return ` + --signin-latency + select + timestamp_trunc(timestamp, ${granularity}) as timestamp, + count(*) as count, + round(avg(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as avg_latency_ms, + round(min(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as min_latency_ms, + round(max(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as max_latency_ms, + round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(50)] / 1000000, 2) as p50_latency_ms, + round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(95)] / 1000000, 2) as p95_latency_ms, + round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(99)] / 1000000, 2) as p99_latency_ms + from auth_logs + where json_value(event_message, "$.auth_event.action") = 'login' + group by timestamp + order by timestamp desc + ` + }, + SignUpLatency: (interval) => { + const granularity = analyticsIntervalToGranularity(interval) + return ` + --signup-latency + select + timestamp_trunc(timestamp, ${granularity}) as timestamp, + count(*) as count, + round(avg(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as avg_latency_ms, + round(min(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as min_latency_ms, + round(max(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as max_latency_ms, + round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(50)] / 1000000, 2) as p50_latency_ms, + round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(95)] / 1000000, 2) as p95_latency_ms, + round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(99)] / 1000000, 2) as p99_latency_ms + from auth_logs + where json_value(event_message, "$.auth_event.action") = 'user_signedup' + group by timestamp + order by timestamp desc + ` + }, + ErrorsByStatus: (interval) => { + const granularity = analyticsIntervalToGranularity(interval) + return ` + --auth-errors-by-status + select + timestamp_trunc(timestamp, ${granularity}) as timestamp, + count(*) as count, + response.status_code + from edge_logs + cross join unnest(metadata) as m + cross join unnest(m.request) as request + cross join unnest(m.response) as response + where path like '%/auth%' + and response.status_code >= 400 and response.status_code <= 599 + group by timestamp, status_code + order by timestamp desc + ` + }, +} + +type AuthReportFilters = { + status_code: NumericFilter | null +} + +function filterToWhereClause(filters?: AuthReportFilters): string { + if (!filters) return '' + return `` +} + +/** + * Transforms raw analytics data into a chart-ready format by ensuring data consistency and completeness. + * + * This function addresses several key requirements for chart rendering: + * 1. Fills missing timestamps with zero values to prevent gaps in charts + * 2. Creates a consistent data structure with `period_start` as the time axis + * 3. Initializes all chart attributes to 0, then populates actual values + * 4. Sorts timestamps chronologically for proper chart ordering + * + * @param rawData - Raw analytics data from backend queries containing timestamp and count fields + * @param attributes - Chart attribute configuration defining what metrics to display + * @returns Formatted data object with consistent time series data and chart attributes + */ +export function defaultAuthReportFormatter( + rawData: unknown, + attributes: ReportDataProviderAttribute[] +) { + const chartAttributes = attributes + + const rawDataSchema = z.object({ + result: z.array( + z + .object({ + timestamp: z.coerce.number(), + }) + .catchall(z.any()) + ), + }) + + const parsedRawData = rawDataSchema.parse(rawData) + const result = parsedRawData.result + + if (!result) return { data: undefined, chartAttributes } + + const timestamps = new Set(result.map((p: any) => String(p.timestamp))) + const data = Array.from(timestamps) + .sort() + .map((timestamp) => { + const point: any = { timestamp } + chartAttributes.forEach((attr) => { + point[attr.attribute] = 0 + }) + const matchingPoints = result.filter((p: any) => String(p.timestamp) === timestamp) + + matchingPoints.forEach((p: any) => { + chartAttributes.forEach((attr) => { + // Optional dimension filters used by some reports + if ('login_type_provider' in (attr as any)) { + if (p.login_type_provider !== (attr as any).login_type_provider) return + } + if ('providerType' in (attr as any)) { + if (p.provider !== (attr as any).providerType) return + } + + const valueFromField = + typeof p[attr.attribute] === 'number' + ? p[attr.attribute] + : typeof p.count === 'number' + ? p.count + : undefined + + if (typeof valueFromField === 'number') { + point[attr.attribute] = (point[attr.attribute] ?? 0) + valueFromField + } + }) + }) + return point + }) + return { data, chartAttributes } +} + +export const createAuthReportConfig = ({ + projectRef, + startDate, + endDate, + interval, + filters, +}: { + projectRef: string + startDate: string + endDate: string + interval: AnalyticsInterval + filters: AuthReportFilters +}): ReportConfig[] => [ + { + id: 'active-user', + label: 'Active Users', + valuePrecision: 0, + hide: false, + showTooltip: true, + showLegend: false, + showMaxValue: false, + hideChartType: false, + defaultChartStyle: 'line', + titleTooltip: 'The total number of active users over time.', + availableIn: ['free', 'pro', 'team', 'enterprise'], + dataProvider: async () => { + const attributes = [ + { attribute: 'ActiveUsers', provider: 'logs', label: 'Active Users', enabled: true }, + ] + + const sql = AUTH_REPORT_SQL.ActiveUsers(interval, filters) + + const rawData = await fetchLogs(projectRef, sql, startDate, endDate) + + const transformedData = defaultAuthReportFormatter(rawData, attributes) + + return { data: transformedData.data, attributes: transformedData.chartAttributes, query: sql } + }, + }, + { + id: 'sign-in-attempts', + label: 'Sign In Attempts by Type', + valuePrecision: 0, + hide: false, + showTooltip: true, + showLegend: true, + showMaxValue: false, + hideChartType: false, + defaultChartStyle: 'line', + titleTooltip: 'The total number of sign in attempts by type.', + availableIn: ['free', 'pro', 'team', 'enterprise'], + dataProvider: async () => { + const attributes = [ + { + attribute: 'SignInAttempts', + provider: 'logs', + label: 'Password', + login_type_provider: 'password', + enabled: true, + }, + { + attribute: 'SignInAttempts', + provider: 'logs', + label: 'PKCE', + login_type_provider: 'pkce', + enabled: true, + }, + { + attribute: 'SignInAttempts', + provider: 'logs', + label: 'Refresh Token', + login_type_provider: 'token', + enabled: true, + }, + { + attribute: 'SignInAttempts', + provider: 'logs', + label: 'ID Token', + login_type_provider: 'id_token', + enabled: true, + }, + ] + + const sql = AUTH_REPORT_SQL.SignInAttempts(interval, filters) + const rawData = await fetchLogs(projectRef, sql, startDate, endDate) + const transformedData = defaultAuthReportFormatter(rawData, attributes) + + return { data: transformedData.data, attributes: transformedData.chartAttributes, query: sql } + }, + }, + { + id: 'signups', + label: 'Sign Ups', + valuePrecision: 0, + hide: false, + showTooltip: true, + showLegend: true, + showMaxValue: false, + hideChartType: false, + defaultChartStyle: 'line', + titleTooltip: 'The total number of sign ups.', + availableIn: ['free', 'pro', 'team', 'enterprise'], + dataProvider: async () => { + const attributes = [ + { + attribute: 'TotalSignUps', + provider: 'logs', + label: 'Sign Ups', + enabled: true, + }, + ] + + const sql = AUTH_REPORT_SQL.TotalSignUps(interval, filters) + const rawData = await fetchLogs(projectRef, sql, startDate, endDate) + const transformedData = defaultAuthReportFormatter(rawData, attributes) + + return { data: transformedData.data, attributes: transformedData.chartAttributes, query: sql } + }, + }, + { + id: 'auth-errors', + label: 'Auth Errors', + valuePrecision: 0, + hide: false, + showTooltip: true, + showLegend: false, + showMaxValue: false, + hideChartType: false, + defaultChartStyle: 'line', + titleTooltip: 'The total number of auth errors by status code.', + availableIn: ['free', 'pro', 'team', 'enterprise'], + dataProvider: async () => { + const attributes = [ + { + attribute: 'ErrorsByStatus', + provider: 'logs', + label: 'Auth Errors', + }, + ] + + const sql = AUTH_REPORT_SQL.ErrorsByStatus(interval, filters) + const rawData = await fetchLogs(projectRef, sql, startDate, endDate) + const transformedData = defaultAuthReportFormatter(rawData, attributes) + + return { data: transformedData.data, attributes: transformedData.chartAttributes, query: sql } + }, + }, + { + id: 'password-reset-requests', + label: 'Password Reset Requests', + valuePrecision: 0, + hide: false, + showTooltip: true, + showLegend: true, + showMaxValue: false, + hideChartType: false, + defaultChartStyle: 'line', + titleTooltip: 'The total number of password reset requests.', + availableIn: ['free', 'pro', 'team', 'enterprise'], + dataProvider: async () => { + const attributes = [ + { + attribute: 'PasswordResetRequests', + provider: 'logs', + label: 'Password Reset Requests', + enabled: true, + }, + ] + + const sql = AUTH_REPORT_SQL.PasswordResetRequests(interval, filters) + const rawData = await fetchLogs(projectRef, sql, startDate, endDate) + const transformedData = defaultAuthReportFormatter(rawData, attributes) + + return { data: transformedData.data, attributes: transformedData.chartAttributes, query: sql } + }, + }, + { + id: 'sign-in-latency', + label: 'Sign In Latency', + valuePrecision: 2, + hide: false, + hideHighlightedValue: true, + showTooltip: true, + showLegend: true, + showMaxValue: false, + hideChartType: false, + defaultChartStyle: 'line', + titleTooltip: 'The average latency for sign in operations by grant type.', + availableIn: ['pro', 'team', 'enterprise'], + dataProvider: async () => { + const attributes = [ + { + attribute: 'avg_latency_ms', + label: 'Avg. Latency (ms)', + }, + { + attribute: 'max_latency_ms', + label: 'Max. Latency (ms)', + }, + { + attribute: 'min_latency_ms', + label: 'Min. Latency (ms)', + }, + { + attribute: 'p50_latency_ms', + label: 'P50 Latency (ms)', + }, + { + attribute: 'p95_latency_ms', + label: 'P95 Latency (ms)', + }, + { + attribute: 'p99_latency_ms', + label: 'P99 Latency (ms)', + }, + { + attribute: 'request_count', + label: 'Request Count', + }, + ] + + const sql = AUTH_REPORT_SQL.SignInLatency(interval, filters) + const rawData = await fetchLogs(projectRef, sql, startDate, endDate) + const transformedData = defaultAuthReportFormatter(rawData, attributes) + + return { data: transformedData.data, attributes: transformedData.chartAttributes, query: sql } + }, + }, + { + id: 'sign-up-latency', + label: 'Sign Up Latency', + valuePrecision: 2, + hide: false, + hideHighlightedValue: true, + showTooltip: true, + showLegend: true, + showMaxValue: false, + hideChartType: false, + defaultChartStyle: 'line', + titleTooltip: 'The average latency for sign up operations by provider.', + availableIn: ['pro', 'team', 'enterprise'], + dataProvider: async () => { + const attributes = [ + { + attribute: 'avg_latency_ms', + label: 'Avg. Latency (ms)', + }, + { + attribute: 'max_latency_ms', + label: 'Max. Latency (ms)', + }, + { + attribute: 'min_latency_ms', + label: 'Min. Latency (ms)', + }, + { + attribute: 'p50_latency_ms', + label: 'P50 Latency (ms)', + }, + { + attribute: 'p95_latency_ms', + label: 'P95 Latency (ms)', + }, + { + attribute: 'p99_latency_ms', + label: 'P99 Latency (ms)', + }, + { + attribute: 'request_count', + label: 'Request Count', + }, + ] + + const sql = AUTH_REPORT_SQL.SignUpLatency(interval, filters) + const rawData = await fetchLogs(projectRef, sql, startDate, endDate) + const transformedData = defaultAuthReportFormatter(rawData, attributes) + + return { data: transformedData.data, attributes: transformedData.chartAttributes, query: sql } + }, + }, +] diff --git a/apps/studio/data/reports/v2/edge-functions.config.ts b/apps/studio/data/reports/v2/edge-functions.config.ts index 64d7307f1ce..40f78efd1d2 100644 --- a/apps/studio/data/reports/v2/edge-functions.config.ts +++ b/apps/studio/data/reports/v2/edge-functions.config.ts @@ -14,6 +14,7 @@ import { getHttpStatusCodeInfo } from 'lib/http-status-codes' import { ReportConfig } from './reports.types' import { NumericFilter } from 'components/interfaces/Reports/v2/ReportsNumericFilter' import { SelectFilters } from 'components/interfaces/Reports/v2/ReportsSelectFilter' +import { fetchLogs } from 'data/reports/report.utils' type EdgeFunctionReportFilters = { status_code: NumericFilter | null @@ -151,21 +152,6 @@ order by }, } -async function runQuery(projectRef: string, sql: string, startDate: string, endDate: string) { - 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, - }, - }, - }) - if (error) throw error - return data -} - export function extractStatusCodesFromData(data: any[]): string[] { const statusCodes = new Set() @@ -272,7 +258,7 @@ export const edgeFunctionReports = ({ availableIn: ['free', 'pro', 'team', 'enterprise'], dataProvider: async () => { const sql = METRIC_SQL.TotalInvocations(interval, filters) - const response = await runQuery(projectRef, sql, startDate, endDate) + const response = await fetchLogs(projectRef, sql, startDate, endDate) if (!response?.result) return { data: [] } @@ -304,7 +290,7 @@ export const edgeFunctionReports = ({ availableIn: ['free', 'pro', 'team', 'enterprise'], dataProvider: async () => { const sql = METRIC_SQL.ExecutionStatusCodes(interval, filters) - const rawData = await runQuery(projectRef, sql, startDate, endDate) + const rawData = await fetchLogs(projectRef, sql, startDate, endDate) if (!rawData?.result) return { data: [] } @@ -341,7 +327,7 @@ export const edgeFunctionReports = ({ format: (value: unknown) => `${Number(value).toFixed(0)}ms`, dataProvider: async () => { const sql = METRIC_SQL.ExecutionTime(interval, filters) - const rawData = await runQuery(projectRef, sql, startDate, endDate) + const rawData = await fetchLogs(projectRef, sql, startDate, endDate) if (!rawData?.result) return { data: [] } @@ -398,7 +384,7 @@ export const edgeFunctionReports = ({ availableIn: ['pro', 'team', 'enterprise'], dataProvider: async () => { const sql = METRIC_SQL.InvocationsByRegion(interval, filters) - const rawData = await runQuery(projectRef, sql, startDate, endDate) + const rawData = await fetchLogs(projectRef, sql, startDate, endDate) const data = rawData.result?.map((point: any) => ({ ...point, timestamp: isUnixMicro(point.timestamp) diff --git a/apps/studio/data/reports/v2/reports.types.ts b/apps/studio/data/reports/v2/reports.types.ts index cfa86deab93..28377e0e1e3 100644 --- a/apps/studio/data/reports/v2/reports.types.ts +++ b/apps/studio/data/reports/v2/reports.types.ts @@ -1,6 +1,12 @@ import { AnalyticsInterval } from 'data/analytics/constants' import { YAxisProps } from 'recharts' +export type ReportDataProviderAttribute = { + attribute: string + label: string + color?: { light: string; dark: string } +} + export interface ReportDataProvider { ( projectRef: string, @@ -10,13 +16,9 @@ export interface ReportDataProvider { filters?: FiltersType ): Promise<{ data: any - attributes?: { - attribute: string - label: string - color?: { light: string; dark: string } - }[] + attributes?: ReportDataProviderAttribute[] query?: string // The SQL used to fetch the data if any - }> // [jordi] would be cool to have a type that forces data keys to match the attributes + }> } export interface ReportConfig { @@ -25,6 +27,7 @@ export interface ReportConfig { dataProvider: ReportDataProvider valuePrecision: number hide: boolean + hideHighlightedValue?: boolean showTooltip: boolean showLegend: boolean showMaxValue: boolean diff --git a/apps/studio/hooks/useChartData.ts b/apps/studio/hooks/useChartData.ts deleted file mode 100644 index 29f129b464a..00000000000 --- a/apps/studio/hooks/useChartData.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * useChartData - * - * A hook for fetching and processing data for a chart. - * This hook is responsible for all the data fetching, combining, and state management logic - * that was previously inside ComposedChartHandler. - * - * It takes all necessary parameters like project reference, date range, and attributes, - * and returns the final chart data, loading state, and derived attributes. - */ -import { useMemo } from 'react' -import { useRouter } from 'next/router' - -import type { AnalyticsInterval, DataPoint } from 'data/analytics/constants' -import { useAuthLogsReport } from 'data/reports/auth-report-query' -import type { ChartData } from 'components/ui/Charts/Charts.types' -import type { MultiAttribute } from 'components/ui/Charts/ComposedChart.utils' - -export const useChartData = ({ - attributes, - startDate, - endDate, - interval, - data, - highlightedValue, - functionIds, - enabled = true, -}: { - attributes: MultiAttribute[] - startDate: string - endDate: string - interval: string - data?: ChartData - highlightedValue?: string | number - functionIds?: string[] - enabled?: boolean -}) => { - const router = useRouter() - const { ref } = router.query - - const logsAttributes = attributes.filter((attr) => attr.provider === 'logs') - - const isEdgeFunctionRoute = router.asPath.includes('/reports/edge-functions') - - const { - data: authData, - attributes: authChartAttributes, - isLoading: isAuthLoading, - } = useAuthLogsReport({ - projectRef: ref as string, - attributes: logsAttributes, - startDate, - endDate, - interval: interval as AnalyticsInterval, - enabled: enabled && logsAttributes.length > 0 && !isEdgeFunctionRoute, - }) - - const logsData = authData - const logsChartAttributes = authChartAttributes - const isLogsLoading = isAuthLoading - - const combinedData = useMemo(() => { - if (data) return data - - // Get all unique timestamps from all datasets - const timestamps = new Set() - if (logsData) { - logsData.forEach((point: any) => { - if (point?.period_start) { - timestamps.add(point.period_start) - } - }) - } - - // Combine data points for each timestamp - const combined = Array.from(timestamps) - .sort() - .map((timestamp) => { - const point: any = { period_start: timestamp } - - const logPoint = logsData?.find((p: any) => p.period_start === timestamp) || {} - Object.assign(point, logPoint) - - return point as DataPoint - }) - - return combined as DataPoint[] - }, [data, attributes, isLogsLoading, logsData, logsAttributes]) - - const loading = logsAttributes.length > 0 && isLogsLoading - - // Calculate highlighted value based on the first attribute's data - const _highlightedValue = useMemo(() => { - if (highlightedValue !== undefined) return highlightedValue - - const firstAttr = attributes[0] - const firstData = logsChartAttributes.find((p: any) => p.attribute === firstAttr.attribute) - - if (!firstData) return undefined - - const shouldHighlightMaxValue = - firstAttr.provider === 'daily-stats' && - !firstAttr.attribute.includes('ingress') && - !firstAttr.attribute.includes('egress') && - 'maximum' in firstData - - const shouldHighlightTotalGroupedValue = 'totalGrouped' in firstData - - return shouldHighlightMaxValue - ? firstData.maximum - : firstAttr.provider === 'daily-stats' - ? firstData.total - : shouldHighlightTotalGroupedValue - ? firstData.totalGrouped?.[firstAttr.attribute as keyof typeof firstData.totalGrouped] - : (firstData.data?.[firstData.data?.length - 1] as any)?.[firstAttr.attribute] - }, [highlightedValue, attributes]) - - return { - data: combinedData, - isLoading: loading, - highlightedValue: _highlightedValue, - } -} diff --git a/apps/studio/pages/project/[ref]/reports/auth.tsx b/apps/studio/pages/project/[ref]/reports/auth.tsx index 34838356c35..c70822a3a01 100644 --- a/apps/studio/pages/project/[ref]/reports/auth.tsx +++ b/apps/studio/pages/project/[ref]/reports/auth.tsx @@ -4,7 +4,7 @@ import dayjs from 'dayjs' import { ArrowRight, RefreshCw } from 'lucide-react' import { useState } from 'react' -import { ReportChart } from 'components/interfaces/Reports/ReportChart' +import { ReportChartV2 } from 'components/interfaces/Reports/v2/ReportChartV2' import ReportHeader from 'components/interfaces/Reports/ReportHeader' import ReportPadding from 'components/interfaces/Reports/ReportPadding' import ReportStickyNav from 'components/interfaces/Reports/ReportStickyNav' @@ -18,9 +18,10 @@ import { REPORT_DATERANGE_HELPER_LABELS } from 'components/interfaces/Reports/Re import { SharedAPIReport } from 'components/interfaces/Reports/SharedAPIReport/SharedAPIReport' import { useSharedAPIReport } from 'components/interfaces/Reports/SharedAPIReport/SharedAPIReport.constants' import UpgradePrompt from 'components/interfaces/Settings/Logs/UpgradePrompt' -import { getAuthReportAttributes } from 'data/reports/auth-charts' import { useReportDateRange } from 'hooks/misc/useReportDateRange' import type { NextPageWithLayout } from 'types' +import { createAuthReportConfig } from 'data/reports/v2/auth.config' +import { ReportSettings } from 'components/ui/Charts/ReportSettings' const AuthReport: NextPageWithLayout = () => { return ( @@ -41,6 +42,7 @@ export default AuthReport const AuthUsage = () => { const { ref } = useParams() + const chartSyncId = `auth-report` const { selectedDateRange, @@ -71,17 +73,19 @@ const AuthUsage = () => { const queryClient = useQueryClient() const [isRefreshing, setIsRefreshing] = useState(false) - const AUTH_REPORT_ATTRIBUTES = getAuthReportAttributes() + const authReportConfig = createAuthReportConfig({ + projectRef: ref || '', + startDate: selectedDateRange?.period_start?.date, + endDate: selectedDateRange?.period_end?.date, + interval: selectedDateRange?.interval, + filters: { status_code: null }, + }) const onRefreshReport = async () => { if (!selectedDateRange) return setIsRefreshing(true) - AUTH_REPORT_ATTRIBUTES.forEach((attr) => { - attr.attributes.forEach((subAttr) => { - queryClient.invalidateQueries(['auth-logs-report', 'auth-metrics']) - }) - }) + refetch() setTimeout(() => setIsRefreshing(false), 1000) } @@ -100,6 +104,7 @@ const AuthUsage = () => { tooltip={{ content: { side: 'bottom', text: 'Refresh report' } }} onClick={onRefreshReport} /> +
{ } > - {selectedDateRange && - AUTH_REPORT_ATTRIBUTES.filter((attr) => !attr.hide).map((attr, i) => ( - - ))} + {authReportConfig.map((metric, i) => ( + + ))}
Auth API Gateway
diff --git a/apps/studio/pages/project/[ref]/reports/database.tsx b/apps/studio/pages/project/[ref]/reports/database.tsx index d792f2b9114..c5314558f90 100644 --- a/apps/studio/pages/project/[ref]/reports/database.tsx +++ b/apps/studio/pages/project/[ref]/reports/database.tsx @@ -7,7 +7,6 @@ import { useEffect, useState } from 'react' import { toast } from 'sonner' import { useFlag, useParams } from 'common' -import { ReportChart } from 'components/interfaces/Reports/ReportChart' import ReportHeader from 'components/interfaces/Reports/ReportHeader' import ReportPadding from 'components/interfaces/Reports/ReportPadding' import { REPORT_DATERANGE_HELPER_LABELS } from 'components/interfaces/Reports/Reports.constants' @@ -42,6 +41,7 @@ import { formatBytes } from 'lib/helpers' import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' import type { NextPageWithLayout } from 'types' import { AlertDescription_Shadcn_, Alert_Shadcn_, Button } from 'ui' +import { ReportChartUpsell } from 'components/interfaces/Reports/v2/ReportChartUpsell' const DatabaseReport: NextPageWithLayout = () => { return ( @@ -307,13 +307,13 @@ const DatabaseUsage = () => { } /> ) : ( - ) ))}