From 3d55164563db987c9eaf077f6de310f835c8deb8 Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Wed, 17 Sep 2025 12:59:07 +0200 Subject: [PATCH 01/12] propagate sentry issue id to form (#38714) * propagate sentry issue id to form * fix, add to payload * empty * name fix * add test button * rm thrower * Update apps/studio/components/ui/GlobalErrorBoundaryState.tsx Co-authored-by: Charis <26616127+charislam@users.noreply.github.com> --------- Co-authored-by: Charis <26616127+charislam@users.noreply.github.com> --- .../components/interfaces/Support/SupportFormV2.tsx | 4 ++++ apps/studio/components/ui/GlobalErrorBoundaryState.tsx | 8 ++++---- apps/studio/data/feedback/support-ticket-send.ts | 3 +++ apps/studio/pages/_app.tsx | 7 +++++-- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/apps/studio/components/interfaces/Support/SupportFormV2.tsx b/apps/studio/components/interfaces/Support/SupportFormV2.tsx index c0c1c7ef988ee..5d8077f91a8a2 100644 --- a/apps/studio/components/interfaces/Support/SupportFormV2.tsx +++ b/apps/studio/components/interfaces/Support/SupportFormV2.tsx @@ -66,6 +66,7 @@ import { SEVERITY_OPTIONS, } from './Support.constants' import { formatMessage, uploadAttachments } from './SupportForm.utils' +import { useRouter } from 'next/router' const MAX_ATTACHMENTS = 5 const INCLUDE_DISCUSSIONS = ['Problem', 'Database_unresponsive'] @@ -93,6 +94,8 @@ export const SupportFormV2 = ({ message: urlMessage, error, } = useParams() + const router = useRouter() + const dashboardSentryIssueId = router.query.sid as string const uploadButtonRef = useRef(null) const [isSubmitting, setIsSubmitting] = useState(false) @@ -239,6 +242,7 @@ export const SupportFormV2 = ({ .map((x) => x.trim().replace(/ /g, '_').toLowerCase()) .join(';'), browserInformation: detectBrowser(), + ...(dashboardSentryIssueId && { dashboardSentryIssueId }), } if (values.projectRef !== 'no-project') { diff --git a/apps/studio/components/ui/GlobalErrorBoundaryState.tsx b/apps/studio/components/ui/GlobalErrorBoundaryState.tsx index 87bb6b27f228f..92f916d1964c0 100644 --- a/apps/studio/components/ui/GlobalErrorBoundaryState.tsx +++ b/apps/studio/components/ui/GlobalErrorBoundaryState.tsx @@ -15,7 +15,6 @@ import { } from 'ui' import { InlineLinkClassName } from './InlineLink' -// More correct version of FallbackProps from react-error-boundary export type FallbackProps = { error: unknown resetErrorBoundary: (...args: any[]) => void @@ -31,6 +30,9 @@ export const GlobalErrorBoundaryState = ({ error, resetErrorBoundary }: Fallback ? errorMessage.includes("Failed to execute 'removeChild' on 'Node'") : false + // Get Sentry issue ID from error if available + const sentryIssueId = (!!error && typeof error === 'object' && (error as any).sentryId) ?? '' + const handleClearStorage = () => { try { localStorage.clear() @@ -67,7 +69,6 @@ export const GlobalErrorBoundaryState = ({ error, resetErrorBoundary }: Fallback

{errorMessage}

- {isRemoveChildError ? ( @@ -130,11 +131,10 @@ export const GlobalErrorBoundaryState = ({ error, resetErrorBoundary }: Fallback )} -
) } 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 33f20b979aeb6..0000000000000 --- 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 0b98e1bd2d171..0000000000000 --- 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 67629df57fc86..0c6b38b61c272 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 0000000000000..198b1ce902af1 --- /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 0000000000000..d291195be6c7d --- /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 64d7307f1ce46..40f78efd1d24e 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 cfa86deab9375..28377e0e1e3f8 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 29f129b464a95..0000000000000 --- 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 34838356c356e..c70822a3a0138 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 d792f2b9114b3..c5314558f909a 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 = () => { } /> ) : ( - ) ))} From ff7f5021f41f1a9d74e27a851701f90b3781a154 Mon Sep 17 00:00:00 2001 From: Riccardo Busetti Date: Wed, 17 Sep 2025 17:49:51 +0200 Subject: [PATCH 11/12] feat(replication): Show warning when publication is not found (#38786) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(replication): Show warning when publication is not found * Fix * Pretty ✨ * Smol tweaks --------- Co-authored-by: Joshen Lim --- .../Database/Replication/DestinationPanel.tsx | 110 +++++++++++------- .../Replication/PublicationsComboBox.tsx | 6 +- 2 files changed, 76 insertions(+), 40 deletions(-) diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel.tsx index 36b0407e88d6b..10c152d69ee98 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel.tsx @@ -21,6 +21,7 @@ import { AccordionItem_Shadcn_, AccordionTrigger_Shadcn_, Button, + DialogSectionSeparator, Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, @@ -30,7 +31,6 @@ import { SelectGroup_Shadcn_, SelectItem_Shadcn_, SelectTrigger_Shadcn_, - Separator, Sheet, SheetContent, SheetDescription, @@ -40,6 +40,7 @@ import { SheetTitle, TextArea_Shadcn_, } from 'ui' +import { Admonition } from 'ui-patterns' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import NewPublicationPanel from './NewPublicationPanel' import PublicationsComboBox from './PublicationsComboBox' @@ -96,10 +97,12 @@ export const DestinationPanel = ({ const { mutateAsync: startPipeline, isLoading: startingPipeline } = useStartPipelineMutation() - const { data: publications, isLoading: loadingPublications } = useReplicationPublicationsQuery({ - projectRef, - sourceId, - }) + const { + data: publications = [], + isLoading: isLoadingPublications, + isSuccess: isSuccessPublications, + refetch: refetchPublications, + } = useReplicationPublicationsQuery({ projectRef, sourceId }) const { data: destinationData } = useReplicationDestinationByIdQuery({ projectRef, @@ -132,11 +135,21 @@ export const DestinationPanel = ({ resolver: zodResolver(FormSchema), defaultValues, }) + const publicationName = form.watch('publicationName') const isSaving = creatingDestinationPipeline || updatingDestinationPipeline || startingPipeline + const publicationNames = useMemo(() => publications?.map((pub) => pub.name) ?? [], [publications]) + const isSelectedPublicationMissing = + isSuccessPublications && !!publicationName && !publicationNames.includes(publicationName) + + const isSubmitDisabled = isSaving || isSelectedPublicationMissing + const onSubmit = async (data: z.infer) => { if (!projectRef) return console.error('Project ref is required') if (!sourceId) return console.error('Source id is required') + if (isSelectedPublicationMissing) { + return toast.error('Please select another publication before continuing') + } try { if (editMode && existingDestination) { @@ -236,6 +249,12 @@ export const DestinationPanel = ({ } }, [visible, defaultValues, form]) + useEffect(() => { + if (visible && projectRef && sourceId) { + refetchPublications() + } + }, [visible, projectRef, sourceId, refetchPublications]) + return ( <> @@ -247,59 +266,72 @@ export const DestinationPanel = ({ {editMode ? null : 'Send data to a new destination'} - + +
-
- ( - - - - - - )} - /> - -

What data to send

- + ( + + + + + + )} + /> + + + +
+

What data to send

( pub.name) || []} - loading={loadingPublications} + publications={publicationNames} + loading={isLoadingPublications} field={field} onNewPublicationClick={() => setPublicationPanelVisible(true)} /> + {isSelectedPublicationMissing && ( + +

+ The publication{' '} + {publicationName} was + not found, it may have been renamed or deleted, please select + another one. +

+
+ )}
)} /> +
-

Where to send that data

+ +
+

Where to send that data

( ( ( - + @@ -373,7 +400,7 @@ export const DestinationPanel = ({ />
- +
@@ -443,7 +470,12 @@ export const DestinationPanel = ({ -