diff --git a/apps/studio/components/interfaces/Auth/Overview/OverviewErrors.constants.ts b/apps/studio/components/interfaces/Auth/Overview/OverviewErrors.constants.ts new file mode 100644 index 0000000000000..2d19f85d4e6f2 --- /dev/null +++ b/apps/studio/components/interfaces/Auth/Overview/OverviewErrors.constants.ts @@ -0,0 +1,67 @@ +import dayjs from 'dayjs' +import { fetchLogs } from 'data/reports/report.utils' + +export type ResponseErrorRow = { + method: string + path: string + status_code: number + count: number +} + +export type AuthErrorCodeRow = { + error_code: string + count: number +} + +export const getDateRange = () => { + return { + start: dayjs().subtract(24, 'hour').toISOString(), + end: dayjs().toISOString(), + } +} + +// Top API response errors for /auth/v1 endpoints (path/method/status) +export const AUTH_TOP_RESPONSE_ERRORS_SQL = ` + select + request.method as method, + request.path as path, + response.status_code as status_code, + count(*) as count + 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/v1%' + and response.status_code between 400 and 599 + group by method, path, status_code + order by count desc + limit 10 +` + +// Top Auth service error codes from x_sb_error_code header for /auth/v1 endpoints +export const AUTH_TOP_ERROR_CODES_SQL = ` + select + h.x_sb_error_code as error_code, + count(*) as count + from edge_logs + cross join unnest(metadata) as m + cross join unnest(m.request) as request + cross join unnest(m.response) as response + cross join unnest(response.headers) as h + where path like '%auth/v1%' + and response.status_code between 400 and 599 + and h.x_sb_error_code is not null + group by error_code + order by count desc + limit 10 +` + +export const fetchTopResponseErrors = async (projectRef: string) => { + const { start, end } = getDateRange() + return await fetchLogs(projectRef, AUTH_TOP_RESPONSE_ERRORS_SQL, start, end) +} + +export const fetchTopAuthErrorCodes = async (projectRef: string) => { + const { start, end } = getDateRange() + return await fetchLogs(projectRef, AUTH_TOP_ERROR_CODES_SQL, start, end) +} diff --git a/apps/studio/components/interfaces/Auth/Overview/OverviewMonitoring.tsx b/apps/studio/components/interfaces/Auth/Overview/OverviewMonitoring.tsx new file mode 100644 index 0000000000000..ad4d748a52d33 --- /dev/null +++ b/apps/studio/components/interfaces/Auth/Overview/OverviewMonitoring.tsx @@ -0,0 +1,210 @@ +import { useQuery } from '@tanstack/react-query' +import { useParams } from 'common' +import { + Card, + CardContent, + CardHeader, + CardTitle, + Tooltip, + TooltipContent, + TooltipTrigger, +} from 'ui' +import { + fetchTopAuthErrorCodes, + fetchTopResponseErrors, + AuthErrorCodeRow, + ResponseErrorRow, +} from './OverviewErrors.constants' +import { OverviewTable } from './OverviewTable' +import { + ScaffoldSection, + ScaffoldSectionContent, + ScaffoldSectionTitle, +} from 'components/layouts/Scaffold' +import { + calculatePercentageChange, + fetchAllAuthMetrics, + processAllAuthMetrics, +} from './OverviewUsage.constants' +import { StatCard } from './OverviewUsage' +import { ChevronRight } from 'lucide-react' +import Link from 'next/link' +import { DataTableColumnStatusCode } from 'components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode' +import { getStatusLevel } from 'components/interfaces/UnifiedLogs/UnifiedLogs.utils' +import dayjs from 'dayjs' + +const LogsLink = ({ href }: { href: string }) => ( + + + + + + + Go to logs + +) + +function isResponseErrorRow(row: unknown): row is ResponseErrorRow { + if (!row || typeof row !== 'object') return false + const r = row as Record + return ( + typeof r.method === 'string' && + typeof r.path === 'string' && + typeof r.status_code === 'number' && + typeof r.count === 'number' + ) +} + +function isAuthErrorCodeRow(row: unknown): row is AuthErrorCodeRow { + if (!row || typeof row !== 'object') return false + const r = row as Record + return typeof r.error_code === 'string' && typeof r.count === 'number' +} + +export const OverviewMonitoring = () => { + const { ref } = useParams() + const endDate = dayjs().toISOString() + const startDate = dayjs().subtract(24, 'hour').toISOString() + + // Success rate metrics (reuse OverviewUsage fetching) + const { data: currentData, isLoading: currentLoading } = useQuery({ + queryKey: ['auth-metrics', ref, 'current'], + queryFn: () => fetchAllAuthMetrics(ref as string, 'current'), + enabled: !!ref, + }) + const { data: previousData, isLoading: previousLoading } = useQuery({ + queryKey: ['auth-metrics', ref, 'previous'], + queryFn: () => fetchAllAuthMetrics(ref as string, 'previous'), + enabled: !!ref, + }) + const metrics = processAllAuthMetrics(currentData?.result || [], previousData?.result || []) + + // Tables + const { data: respErrData, isLoading: isLoadingResp } = useQuery({ + queryKey: ['auth-overview', ref, 'top-response-errors'], + queryFn: () => fetchTopResponseErrors(ref as string), + enabled: !!ref, + }) + + const { data: codeErrData, isLoading: isLoadingCodes } = useQuery({ + queryKey: ['auth-overview', ref, 'top-auth-error-codes'], + queryFn: () => fetchTopAuthErrorCodes(ref as string), + enabled: !!ref, + }) + + const responseErrors: ResponseErrorRow[] = Array.isArray(respErrData?.result) + ? (respErrData.result as unknown[]).filter(isResponseErrorRow) + : [] + const errorCodes: AuthErrorCodeRow[] = Array.isArray(codeErrData?.result) + ? (codeErrData.result as unknown[]).filter(isAuthErrorCodeRow) + : [] + + return ( + +
+ Monitoring +
+ +
+ + +
+ +
+ + + Auth API Errors + + + + isLoading={isLoadingResp || currentLoading || previousLoading} + data={responseErrors} + columns={[ + { + key: 'method', + header: 'Method', + className: 'w-[60px]', + render: (row) => ( + + {row.method} + + ), + }, + { + key: 'status_code', + header: 'Status', + className: 'w-[60px]', + render: (row) => ( + + ), + }, + { key: 'path', header: 'Path', className: 'w-full' }, + { key: 'count', header: 'Count', className: 'text-right' }, + { + key: 'actions', + header: '', + className: 'text-right', + render: (row) => ( +
+ +
+ ), + }, + ]} + /> +
+
+ + + + Auth Server Errors + + + + isLoading={isLoadingCodes || currentLoading || previousLoading} + data={errorCodes} + columns={[ + { key: 'error_code', header: 'Error code', className: 'w-full' }, + { key: 'count', header: 'Count', className: 'text-right' }, + { + key: 'actions', + header: '', + className: 'text-right', + render: (row) => ( +
+ +
+ ), + }, + ]} + /> +
+
+
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Auth/Overview/OverviewTable.tsx b/apps/studio/components/interfaces/Auth/Overview/OverviewTable.tsx new file mode 100644 index 0000000000000..4ef13c45addc0 --- /dev/null +++ b/apps/studio/components/interfaces/Auth/Overview/OverviewTable.tsx @@ -0,0 +1,61 @@ +import { ReactNode } from 'react' +import { cn, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ui' +import { Loader2 } from 'lucide-react' + +export type OverviewTableColumn = { + key: keyof T | string + header: string + render?: (row: T) => ReactNode + className?: string +} + +export interface OverviewTable { + columns: OverviewTableColumn[] + data: T[] + isLoading?: boolean + emptyMessage?: string +} + +export function OverviewTable({ columns, data, isLoading, emptyMessage }: OverviewTable) { + return ( + + + + {columns.map((col) => ( + + {col.header} + + ))} + + + + {isLoading ? ( + + +
+ + Loading… +
+
+
+ ) : data.length === 0 ? ( + + + {emptyMessage || 'No data available'} + + + ) : ( + (data as unknown as T[]).map((row, idx) => ( + + {columns.map((col) => ( + + {col.render ? col.render(row) : (row as any)[col.key as string]} + + ))} + + )) + )} +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Auth/Overview/OverviewUsage.tsx b/apps/studio/components/interfaces/Auth/Overview/OverviewUsage.tsx index 2ddc6452fa863..2c2e7f9c22aa2 100644 --- a/apps/studio/components/interfaces/Auth/Overview/OverviewUsage.tsx +++ b/apps/studio/components/interfaces/Auth/Overview/OverviewUsage.tsx @@ -3,7 +3,16 @@ import { ScaffoldSectionTitle, ScaffoldSectionContent, } from 'components/layouts/Scaffold' -import { Card, CardContent, cn } from 'ui' +import { + Card, + CardContent, + CardHeader, + CardTitle, + cn, + Tooltip, + TooltipTrigger, + TooltipContent, +} from 'ui' import Link from 'next/link' import { useParams } from 'common' import { ChevronRight, Loader2 } from 'lucide-react' @@ -16,9 +25,8 @@ import { } from './OverviewUsage.constants' import { useQuery } from '@tanstack/react-query' import dayjs from 'dayjs' -import { ArrowUpIcon, ArrowDownIcon } from 'lucide-react' -const StatCard = ({ +export const StatCard = ({ title, current, previous, @@ -45,14 +53,27 @@ const StatCard = ({ : getChangeColor(previous) const formattedCurrent = suffix === 'ms' ? current.toFixed(2) : suffix === '%' ? current.toFixed(1) : Math.round(current) - const ArrowIcon = previous >= 0 ? ArrowUpIcon : ArrowDownIcon const signChar = previous > 0 ? '+' : previous < 0 ? '-' : '' - const Inner = ( - + return ( + + + {title} + + + + + + + Go to Auth Report + + @@ -60,10 +81,8 @@ const StatCard = ({ ) : ( <> -

{title}

-

{`${formattedCurrent}${suffix}`}

-
- {!isZeroChange && } +

{`${formattedCurrent}${suffix}`}

+
{`${signChar}${Math.abs(previous).toFixed(1)}%`}
@@ -71,8 +90,6 @@ const StatCard = ({ ) - - return href ? {Inner} : Inner } export const OverviewUsage = () => { @@ -119,42 +136,20 @@ export const OverviewUsage = () => {
-
+
- -
diff --git a/apps/studio/components/interfaces/HomeNew/CustomReportSection.tsx b/apps/studio/components/interfaces/HomeNew/CustomReportSection.tsx index ad0e12284ad4a..91a635f39aac0 100644 --- a/apps/studio/components/interfaces/HomeNew/CustomReportSection.tsx +++ b/apps/studio/components/interfaces/HomeNew/CustomReportSection.tsx @@ -9,7 +9,7 @@ import { import { SortableContext, arrayMove, rectSortingStrategy, useSortable } from '@dnd-kit/sortable' import { PermissionAction } from '@supabase/shared-types/out/constants' import dayjs from 'dayjs' -import { Plus } from 'lucide-react' +import { Plus, RefreshCw } from 'lucide-react' import type { CSSProperties, ReactNode } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' @@ -18,8 +18,10 @@ import { useParams } from 'common' import { SnippetDropdown } from 'components/interfaces/HomeNew/SnippetDropdown' import { ReportBlock } from 'components/interfaces/Reports/ReportBlock/ReportBlock' import type { ChartConfig } from 'components/interfaces/SQLEditor/UtilityPanel/ChartConfig' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { DEFAULT_CHART_CONFIG } from 'components/ui/QueryBlock/QueryBlock' import { AnalyticsInterval } from 'data/analytics/constants' +import { useInvalidateAnalyticsQuery } from 'data/analytics/utils' import { useContentInfiniteQuery } from 'data/content/content-infinite-query' import { Content } from 'data/content/content-query' import { useContentUpsertMutation } from 'data/content/content-upsert-mutation' @@ -28,6 +30,7 @@ import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { uuidv4 } from 'lib/helpers' import { useProfile } from 'lib/profile' +import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' import type { Dashboards } from 'types' import { Button } from 'ui' import { Row } from 'ui-patterns' @@ -35,10 +38,15 @@ import { Row } from 'ui-patterns' export function CustomReportSection() { const startDate = dayjs().subtract(7, 'day').toISOString() const endDate = dayjs().toISOString() + const { ref } = useParams() const { profile } = useProfile() - const { mutate: sendEvent } = useSendEventMutation() + const state = useDatabaseSelectorStateSnapshot() const { data: organization } = useSelectedOrganizationQuery() + const { mutate: sendEvent } = useSendEventMutation() + const { invalidateInfraMonitoringQuery } = useInvalidateAnalyticsQuery() + + const [isRefreshing, setIsRefreshing] = useState(false) const { data: reportsData } = useContentInfiniteQuery( { projectRef: ref, type: 'report', name: 'Home', limit: 1 }, @@ -269,6 +277,25 @@ export function CustomReportSection() { persistReport(updated) } + const onRefreshReport = () => { + if (!ref) return + + setIsRefreshing(true) + const monitoringCharts = editableReport?.layout.filter( + (x) => x.provider === 'infra-monitoring' || x.provider === 'daily-stats' + ) + monitoringCharts?.forEach((x) => { + invalidateInfraMonitoringQuery(ref, { + attribute: x.attribute, + startDate, + endDate, + interval: editableReport?.interval || '1d', + databaseIdentifier: state.selectedDatabaseId, + }) + }) + setTimeout(() => setIsRefreshing(false), 1000) + } + const layout = useMemo(() => editableReport?.layout ?? [], [editableReport]) useEffect(() => { @@ -279,20 +306,32 @@ export function CustomReportSection() {

Reports

- {canUpdateReport || canCreateReport ? ( - }> - Add block - - } - side="bottom" - align="end" - autoFocus - /> - ) : null} +
+ {layout.length > 0 && ( + } + className="w-7" + disabled={isRefreshing} + tooltip={{ content: { side: 'bottom', text: 'Refresh report' } }} + onClick={onRefreshReport} + /> + )} + {canUpdateReport || canCreateReport ? ( + }> + Add block + + } + side="bottom" + align="end" + autoFocus + /> + ) : null} +
{layout.length === 0 ? ( @@ -343,7 +382,7 @@ export function CustomReportSection() { ('1d' as AnalyticsInterval) } disableUpdate={false} - isRefreshing={false} + isRefreshing={isRefreshing} onRemoveChart={handleRemoveChart} onUpdateChart={(config) => handleUpdateChart(item.id, config)} /> diff --git a/apps/studio/components/interfaces/HomeNew/ServiceStatus.tsx b/apps/studio/components/interfaces/HomeNew/ServiceStatus.tsx index 294aecd09b8f6..d5ed3f78d2b33 100644 --- a/apps/studio/components/interfaces/HomeNew/ServiceStatus.tsx +++ b/apps/studio/components/interfaces/HomeNew/ServiceStatus.tsx @@ -37,16 +37,17 @@ const StatusMessage = ({ status, isLoading, isHealthy, + isProjectNew, }: { isLoading: boolean isHealthy: boolean + isProjectNew: boolean status?: ProjectServiceStatus }) => { - if (isHealthy) return 'Healthy' + if (isHealthy || status === 'ACTIVE_HEALTHY') return 'Healthy' if (isLoading) return 'Checking status' if (status === 'UNHEALTHY') return 'Unhealthy' - if (status === 'COMING_UP') return 'Coming up...' - if (status === 'ACTIVE_HEALTHY') return 'Healthy' + if (isProjectNew || status === 'COMING_UP') return 'Coming up...' if (status) return status return 'Unable to connect' } @@ -62,17 +63,16 @@ const CheckIcon = () => const StatusIcon = ({ isLoading, isHealthy, + isProjectNew, projectStatus, }: { isLoading: boolean isHealthy: boolean + isProjectNew: boolean projectStatus?: ProjectServiceStatus }) => { - if (isHealthy) return - if (isLoading) return - if (projectStatus === 'UNHEALTHY') return - if (projectStatus === 'COMING_UP') return - if (projectStatus === 'ACTIVE_HEALTHY') return + if (isHealthy || projectStatus === 'ACTIVE_HEALTHY') return + if (isLoading || isProjectNew || projectStatus === 'COMING_UP') return return } @@ -242,15 +242,23 @@ export const ServiceStatus = () => { const isLoadingChecks = services.some((service) => service.isLoading) const allServicesOperational = services.every((service) => service.isHealthy) + // Check if project or branch is in a startup state + const isProjectNew = + project?.status === 'COMING_UP' || + (isBranch && + (currentBranch?.status === 'CREATING_PROJECT' || + currentBranch?.status === 'RUNNING_MIGRATIONS' || + isMigrationLoading)) + const anyUnhealthy = services.some( (service) => !service.isHealthy && service.status !== 'COMING_UP' ) const anyComingUp = services.some((service) => service.status === 'COMING_UP') const overallStatusLabel = isLoadingChecks ? 'Checking...' - : anyUnhealthy + : anyUnhealthy && !isProjectNew ? 'Unhealthy' - : anyComingUp || isMigrationLoading + : anyComingUp || isMigrationLoading || (isProjectNew && !allServicesOperational) ? 'Coming up...' : 'Healthy' @@ -265,7 +273,9 @@ export const ServiceStatus = () => { key={`${service.name}-${index}`} className={cn( 'w-1.5 h-1.5 rounded-full', - service.isLoading || service.status === 'COMING_UP' + service.isLoading || + service.status === 'COMING_UP' || + (isProjectNew && !service.isHealthy) ? 'bg-foreground-lighter animate-pulse' : service.isHealthy ? 'bg-brand' @@ -290,6 +300,7 @@ export const ServiceStatus = () => {
@@ -298,6 +309,7 @@ export const ServiceStatus = () => {

diff --git a/apps/studio/data/analytics/utils.ts b/apps/studio/data/analytics/utils.ts new file mode 100644 index 0000000000000..543bd472de6b8 --- /dev/null +++ b/apps/studio/data/analytics/utils.ts @@ -0,0 +1,35 @@ +import { useQueryClient } from '@tanstack/react-query' +import { analyticsKeys } from './keys' + +export const useInvalidateAnalyticsQuery = () => { + const queryClient = useQueryClient() + + const invalidateInfraMonitoringQuery = ( + ref: string, + { + attribute, + startDate, + endDate, + interval, + databaseIdentifier, + }: { + attribute?: string + startDate?: string + endDate?: string + interval?: string + databaseIdentifier?: string + } + ) => { + queryClient.invalidateQueries( + analyticsKeys.infraMonitoring(ref, { + attribute, + startDate, + endDate, + interval, + databaseIdentifier, + }) + ) + } + + return { invalidateInfraMonitoringQuery } +} diff --git a/apps/studio/lib/api/self-hosted/migrations.ts b/apps/studio/lib/api/self-hosted/migrations.ts index 8966cf9e5c79e..56d484d5f80d2 100644 --- a/apps/studio/lib/api/self-hosted/migrations.ts +++ b/apps/studio/lib/api/self-hosted/migrations.ts @@ -34,7 +34,7 @@ const applyAndTrackMigrationsQuery = (query: string, name?: string) => { -- track statements in history table insert into supabase_migrations.schema_migrations (version, name, statements) values ( - to_char(current_timestamp, 'YYYYMMDDHHMISS'), + to_char(current_timestamp, 'YYYYMMDDHH24MISS'), ${quote(name)}, array[${quote(query)}] ); diff --git a/apps/studio/pages/project/[ref]/auth/overview.tsx b/apps/studio/pages/project/[ref]/auth/overview.tsx index 0a42fba0eb92a..a0c89ea5d1381 100644 --- a/apps/studio/pages/project/[ref]/auth/overview.tsx +++ b/apps/studio/pages/project/[ref]/auth/overview.tsx @@ -1,12 +1,13 @@ import { NextPageWithLayout } from 'types' import DefaultLayout from 'components/layouts/DefaultLayout' import AuthLayout from 'components/layouts/AuthLayout/AuthLayout' -import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' +import { ScaffoldContainer } from 'components/layouts/Scaffold' import { PageLayout } from 'components/layouts/PageLayout/PageLayout' import { DocsButton } from 'components/ui/DocsButton' import { DOCS_URL } from 'lib/constants' import { OverviewUsage } from 'components/interfaces/Auth/Overview/OverviewUsage' import { OverviewLearnMore } from 'components/interfaces/Auth/Overview/OverviewLearnMore' +import { OverviewMonitoring } from 'components/interfaces/Auth/Overview/OverviewMonitoring' import { useRouter } from 'next/router' import { FeatureFlagContext, useFlag, useParams } from 'common' import { useContext, useEffect } from 'react' @@ -32,6 +33,7 @@ const AuthOverview: NextPageWithLayout = () => {
+