From 168c084f4ed9650281fb2f686efdac57224e6d9e Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Fri, 30 Jan 2026 15:00:01 +0100 Subject: [PATCH] feat(o11y): overview page (#42098) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary by CodeRabbit * **New Features** * Observability Dashboard: unified overview for service health and database infrastructure with interactive charts and metric cards (CPU, memory, disk I/O, connections, error rate, slow queries). * Service Health Monitoring: per-service health cards and a multi-service table with error/warning counts and drill-down links to reports/logs. * Interval Selector: new chart-interval dropdown with plan-aware retention messaging. * Menu & Reports: updated Observability menu with Overview entry and Custom Reports management. * **Documentation** * Added a footer link to troubleshooting guides. ✏️ Tip: You can customize this high-level summary in your review settings. --- .../interfaces/Home/ProjectUsage.tsx | 114 +------- .../HomeNew/ProjectUsageSection.tsx | 117 +------- .../DatabaseInfrastructureSection.tsx | 259 ++++++++++++++++++ ...atabaseInfrastructureSection.utils.test.ts | 256 +++++++++++++++++ .../DatabaseInfrastructureSection.utils.ts | 82 ++++++ .../Observability/ObservabilityOverview.tsx | 209 ++++++++++++++ .../ObservabilityOverview.utils.test.ts | 249 +++++++++++++++++ .../ObservabilityOverview.utils.ts | 83 ++++++ .../ObservabilityOverviewFooter.tsx | 19 ++ .../Observability/ServiceHealthCard.tsx | 110 ++++++++ .../Observability/ServiceHealthTable.tsx | 220 +++++++++++++++ .../usePostgrestOverviewMetrics.ts | 101 +++++++ .../Observability/useSlowQueriesCount.ts | 36 +++ .../ObservabilityLayout/ObservabilityMenu.tsx | 259 ++++++++++-------- .../ui/Logs/ChartIntervalDropdown.tsx | 98 +++++++ apps/studio/components/ui/Logs/logs.utils.ts | 36 +++ .../data/analytics/infra-monitoring-query.ts | 4 +- .../project/[ref]/observability/index.tsx | 12 +- packages/ui-patterns/src/MetricCard/index.tsx | 65 +++-- 19 files changed, 1974 insertions(+), 355 deletions(-) create mode 100644 apps/studio/components/interfaces/Observability/DatabaseInfrastructureSection.tsx create mode 100644 apps/studio/components/interfaces/Observability/DatabaseInfrastructureSection.utils.test.ts create mode 100644 apps/studio/components/interfaces/Observability/DatabaseInfrastructureSection.utils.ts create mode 100644 apps/studio/components/interfaces/Observability/ObservabilityOverview.tsx create mode 100644 apps/studio/components/interfaces/Observability/ObservabilityOverview.utils.test.ts create mode 100644 apps/studio/components/interfaces/Observability/ObservabilityOverview.utils.ts create mode 100644 apps/studio/components/interfaces/Observability/ObservabilityOverviewFooter.tsx create mode 100644 apps/studio/components/interfaces/Observability/ServiceHealthCard.tsx create mode 100644 apps/studio/components/interfaces/Observability/ServiceHealthTable.tsx create mode 100644 apps/studio/components/interfaces/Observability/usePostgrestOverviewMetrics.ts create mode 100644 apps/studio/components/interfaces/Observability/useSlowQueriesCount.ts create mode 100644 apps/studio/components/ui/Logs/ChartIntervalDropdown.tsx create mode 100644 apps/studio/components/ui/Logs/logs.utils.ts diff --git a/apps/studio/components/interfaces/Home/ProjectUsage.tsx b/apps/studio/components/interfaces/Home/ProjectUsage.tsx index 29d1645696d..3422b715957 100644 --- a/apps/studio/components/interfaces/Home/ProjectUsage.tsx +++ b/apps/studio/components/interfaces/Home/ProjectUsage.tsx @@ -1,6 +1,7 @@ import { useParams } from 'common' import BarChart from 'components/ui/Charts/BarChart' -import { InlineLink } from 'components/ui/InlineLink' +import { ChartIntervalDropdown } from 'components/ui/Logs/ChartIntervalDropdown' +import { CHART_INTERVALS } from 'components/ui/Logs/logs.utils' import Panel from 'components/ui/Panel' import { ProjectLogStatsVariables, @@ -14,55 +15,13 @@ import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { Auth, Database, Realtime, Storage } from 'icons' import sumBy from 'lodash/sumBy' -import { ChevronDown } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' import { useState } from 'react' -import type { ChartIntervals } from 'types' -import { - Button, - DropdownMenu, - DropdownMenuContent, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuTrigger, - Loading, - Tooltip, - TooltipContent, - TooltipTrigger, -} from 'ui' +import { Loading } from 'ui' type ChartIntervalKey = ProjectLogStatsVariables['interval'] -const LOG_RETENTION = { free: 1, pro: 7, team: 28, enterprise: 90, platform: 1 } - -const CHART_INTERVALS: ChartIntervals[] = [ - { - key: '1hr', - label: 'Last 60 minutes', - startValue: 1, - startUnit: 'hour', - format: 'MMM D, h:mma', - availableIn: ['free', 'pro', 'team', 'enterprise', 'platform'], - }, - { - key: '1day', - label: 'Last 24 hours', - startValue: 24, - startUnit: 'hour', - format: 'MMM D, ha', - availableIn: ['free', 'pro', 'team', 'enterprise', 'platform'], - }, - { - key: '7day', - label: 'Last 7 days', - startValue: 7, - startUnit: 'day', - format: 'MMM D', - availableIn: ['pro', 'team', 'enterprise'], - }, -] - const ProjectUsage = () => { const router = useRouter() const { ref: projectRef } = useParams() @@ -132,64 +91,15 @@ const ProjectUsage = () => { return (
- - - - - - - setInterval(interval as ProjectLogStatsVariables['interval']) - } - > - {CHART_INTERVALS.map((i) => { - const disabled = !i.availableIn?.includes(plan?.id || 'free') - - if (disabled) { - const retentionDuration = LOG_RETENTION[plan?.id ?? 'free'] - return ( - - - - {i.label} - - - -

- {plan?.name} plan only includes up to {retentionDuration} day - {retentionDuration > 1 ? 's' : ''} of log retention -

-

- - Upgrade your plan - {' '} - to increase log retention and view statistics for the{' '} - {i.label.toLowerCase()} -

-
-
- ) - } else { - return ( - - {i.label} - - ) - } - })} -
-
-
+ setInterval(interval as ProjectLogStatsVariables['interval'])} + planId={plan?.id} + planName={plan?.name} + organizationSlug={organization?.slug} + dropdownAlign="start" + tooltipSide="right" + /> Statistics for {selectedInterval.label.toLowerCase()} diff --git a/apps/studio/components/interfaces/HomeNew/ProjectUsageSection.tsx b/apps/studio/components/interfaces/HomeNew/ProjectUsageSection.tsx index 6600e507d9d..219108b4905 100644 --- a/apps/studio/components/interfaces/HomeNew/ProjectUsageSection.tsx +++ b/apps/studio/components/interfaces/HomeNew/ProjectUsageSection.tsx @@ -1,33 +1,16 @@ import { useParams } from 'common' import NoDataPlaceholder from 'components/ui/Charts/NoDataPlaceholder' -import { InlineLink } from 'components/ui/InlineLink' +import { ChartIntervalDropdown } from 'components/ui/Logs/ChartIntervalDropdown' +import { CHART_INTERVALS } from 'components/ui/Logs/logs.utils' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import dayjs from 'dayjs' import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' -import { ChevronDown } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' import { useMemo, useState } from 'react' -import type { ChartIntervals } from 'types' -import { - Button, - Card, - CardContent, - CardHeader, - CardTitle, - DropdownMenu, - DropdownMenuContent, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuTrigger, - Loading, - Tooltip, - TooltipContent, - TooltipTrigger, - cn, -} from 'ui' +import { Card, CardContent, CardHeader, CardTitle, Loading } from 'ui' import { Row } from 'ui-patterns' import { LogsBarChart } from 'ui-patterns/LogsBarChart' @@ -41,35 +24,6 @@ import { sumWarnings, } from './ProjectUsage.metrics' -const LOG_RETENTION = { free: 1, pro: 7, team: 28, enterprise: 90, platform: 1 } - -const CHART_INTERVALS: ChartIntervals[] = [ - { - key: '1hr', - label: 'Last 60 minutes', - startValue: 1, - startUnit: 'hour', - format: 'MMM D, h:mma', - availableIn: ['free', 'pro', 'team', 'enterprise', 'platform'], - }, - { - key: '1day', - label: 'Last 24 hours', - startValue: 24, - startUnit: 'hour', - format: 'MMM D, ha', - availableIn: ['free', 'pro', 'team', 'enterprise', 'platform'], - }, - { - key: '7day', - label: 'Last 7 days', - startValue: 7, - startUnit: 'day', - format: 'MMM D, ha', - availableIn: ['pro', 'team', 'enterprise'], - }, -] - type ChartIntervalKey = '1hr' | '1day' | '7day' type ServiceKey = 'db' | 'functions' | 'auth' | 'storage' | 'realtime' @@ -242,62 +196,15 @@ export const ProjectUsageSection = () => { Success Rate
- - - - - - setInterval(interval as ChartIntervalKey)} - > - {CHART_INTERVALS.map((i) => { - const disabled = !i.availableIn?.includes(plan?.id || 'free') - - if (disabled) { - const retentionDuration = LOG_RETENTION[plan?.id ?? 'free'] - return ( - - - - {i.label} - - - -

- {plan?.name} plan only includes up to {retentionDuration} day - {retentionDuration > 1 ? 's' : ''} of log retention -

-

- - Upgrade your plan - {' '} - to increase log retention and view statistics for the{' '} - {i.label.toLowerCase()} -

-
-
- ) - } else { - return ( - - {i.label} - - ) - } - })} -
-
-
+ setInterval(interval as ChartIntervalKey)} + planId={plan?.id} + planName={plan?.name} + organizationSlug={organization?.slug} + dropdownAlign="end" + tooltipSide="left" + /> {enabledServices.map((s) => ( diff --git a/apps/studio/components/interfaces/Observability/DatabaseInfrastructureSection.tsx b/apps/studio/components/interfaces/Observability/DatabaseInfrastructureSection.tsx new file mode 100644 index 00000000000..96f4aa2ba16 --- /dev/null +++ b/apps/studio/components/interfaces/Observability/DatabaseInfrastructureSection.tsx @@ -0,0 +1,259 @@ +import { useParams } from 'common' +import { useInfraMonitoringAttributesQuery } from 'data/analytics/infra-monitoring-query' +import { useMaxConnectionsQuery } from 'data/database/max-connections-query' +import dayjs from 'dayjs' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import Link from 'next/link' +import { useMemo } from 'react' +import { cn } from 'ui' +import { + MetricCard, + MetricCardContent, + MetricCardHeader, + MetricCardLabel, + MetricCardValue, +} from 'ui-patterns/MetricCard' + +import { + parseConnectionsData, + parseInfrastructureMetrics, +} from './DatabaseInfrastructureSection.utils' + +type DatabaseInfrastructureSectionProps = { + interval: '1hr' | '1day' | '7day' + refreshKey: number + dbErrorRate: number + isLoading: boolean + slowQueriesCount?: number + slowQueriesLoading?: boolean +} + +export const DatabaseInfrastructureSection = ({ + interval, + refreshKey, + dbErrorRate, + isLoading: dbLoading, + slowQueriesCount = 0, + slowQueriesLoading = false, +}: DatabaseInfrastructureSectionProps) => { + const { ref: projectRef } = useParams() + const { data: project } = useSelectedProjectQuery() + + // refreshKey forces date recalculation when user clicks refresh button + // eslint-disable-next-line react-hooks/exhaustive-deps + const { startDate, endDate, infraInterval } = useMemo(() => { + const now = dayjs() + const end = now.toISOString() + let start: string + let infraInterval: '1h' | '1d' + + switch (interval) { + case '1hr': + start = now.subtract(1, 'hour').toISOString() + infraInterval = '1h' + break + case '1day': + start = now.subtract(1, 'day').toISOString() + infraInterval = '1h' + break + case '7day': + start = now.subtract(7, 'day').toISOString() + infraInterval = '1d' + break + default: + start = now.subtract(1, 'hour').toISOString() + infraInterval = '1h' + } + + return { startDate: start, endDate: end, infraInterval } + }, [interval, refreshKey]) + + const { + data: infraData, + isLoading: infraLoading, + error: infraError, + } = useInfraMonitoringAttributesQuery({ + projectRef, + attributes: [ + 'avg_cpu_usage', + 'ram_usage', + 'disk_io_consumption', + 'pg_stat_database_num_backends', + ], + startDate, + endDate, + interval: infraInterval, + }) + + const { data: maxConnectionsData } = useMaxConnectionsQuery({ + projectRef, + connectionString: project?.connectionString, + }) + + const metrics = useMemo(() => parseInfrastructureMetrics(infraData), [infraData]) + + const connections = useMemo( + () => parseConnectionsData(infraData, maxConnectionsData), + [infraData, maxConnectionsData] + ) + + const errorMessage = + infraError && typeof infraError === 'object' && 'message' in infraError + ? String(infraError.message) + : 'Error loading data' + + // Generate database report URL with time range parameters + const getDatabaseReportUrl = () => { + const now = dayjs() + let its: string + let helperText: string + + switch (interval) { + case '1hr': + its = now.subtract(1, 'hour').toISOString() + helperText = 'Last 60 minutes' + break + case '1day': + its = now.subtract(24, 'hour').toISOString() + helperText = 'Last 24 hours' + break + case '7day': + its = now.subtract(7, 'day').toISOString() + helperText = 'Last 7 days' + break + default: + its = now.subtract(24, 'hour').toISOString() + helperText = 'Last 24 hours' + } + + const ite = now.toISOString() + const params = new URLSearchParams({ + its, + ite, + isHelper: 'true', + helperText, + }) + + return `/project/${projectRef}/observability/database?${params.toString()}` + } + + const databaseReportUrl = getDatabaseReportUrl() + + return ( +
+

Database

+ {/* First row: Metrics */} +
+ + + + + Error Rate + + + + {dbErrorRate.toFixed(2)}% + + + + + ', value: 1000 }))}`} + className="block group" + > + + ', value: 1000 }))}`} + linkTooltip="Go to query performance" + > + + Slow Queries + + + + {slowQueriesCount} + + + + + + + + + Connections + + + + {infraError ? ( +
{errorMessage}
+ ) : connections.max > 0 ? ( + + {connections.current}/{connections.max} + + ) : ( + -- + )} +
+
+ + + + + + + Disk + + + + {infraError ? ( +
{errorMessage}
+ ) : metrics ? ( + {metrics.disk.current.toFixed(0)}% + ) : ( + -- + )} +
+
+ + + + + + + Memory + + + + {infraError ? ( +
{errorMessage}
+ ) : metrics ? ( + {metrics.ram.current.toFixed(0)}% + ) : ( + -- + )} +
+
+ + + + + + + CPU + + + + {infraError ? ( +
{errorMessage}
+ ) : metrics ? ( + {metrics.cpu.current.toFixed(0)}% + ) : ( + -- + )} +
+
+ +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Observability/DatabaseInfrastructureSection.utils.test.ts b/apps/studio/components/interfaces/Observability/DatabaseInfrastructureSection.utils.test.ts new file mode 100644 index 00000000000..09388cc150f --- /dev/null +++ b/apps/studio/components/interfaces/Observability/DatabaseInfrastructureSection.utils.test.ts @@ -0,0 +1,256 @@ +import type { + InfraMonitoringMultiResponse, + InfraMonitoringSingleResponse, +} from 'data/analytics/infra-monitoring-query' +import { describe, expect, it } from 'vitest' + +import { + parseConnectionsData, + parseInfrastructureMetrics, + parseNumericValue, +} from './DatabaseInfrastructureSection.utils' + +describe('parseNumericValue', () => { + it('returns number value as-is', () => { + expect(parseNumericValue(42)).toBe(42) + expect(parseNumericValue(0)).toBe(0) + expect(parseNumericValue(3.14)).toBe(3.14) + }) + + it('parses valid string numbers', () => { + expect(parseNumericValue('42')).toBe(42) + expect(parseNumericValue('3.14')).toBe(3.14) + expect(parseNumericValue('0')).toBe(0) + }) + + it('returns 0 for invalid string values', () => { + expect(parseNumericValue('invalid')).toBe(0) + expect(parseNumericValue('')).toBe(0) + expect(parseNumericValue('NaN')).toBe(0) + }) + + it('returns 0 for undefined', () => { + expect(parseNumericValue(undefined)).toBe(0) + }) +}) + +describe('parseInfrastructureMetrics', () => { + it('returns null for undefined data', () => { + expect(parseInfrastructureMetrics(undefined)).toBe(null) + }) + + it('parses valid metrics from response', () => { + const mockResponse: InfraMonitoringMultiResponse = { + data: [], + series: { + avg_cpu_usage: { format: 'percent', total: 150, totalAverage: 50, yAxisLimit: 100 }, + ram_usage: { format: 'bytes', total: 180, totalAverage: 60, yAxisLimit: 100 }, + disk_io_consumption: { + format: 'percent', + total: 210, + totalAverage: 70, + yAxisLimit: 100, + }, + }, + } + + const result = parseInfrastructureMetrics(mockResponse) + + expect(result).toEqual({ + cpu: { current: 50, max: 100 }, + ram: { current: 60, max: 100 }, + disk: { current: 70, max: 100 }, + }) + }) + + it('handles string values in metrics', () => { + const mockResponse: InfraMonitoringMultiResponse = { + data: [], + series: { + avg_cpu_usage: { format: 'percent', total: 150, totalAverage: '50.5', yAxisLimit: 100 }, + ram_usage: { format: 'bytes', total: 180, totalAverage: '60.8', yAxisLimit: 100 }, + disk_io_consumption: { + format: 'percent', + total: 210, + totalAverage: '70.2', + yAxisLimit: 100, + }, + }, + } + + const result = parseInfrastructureMetrics(mockResponse) + + expect(result).toEqual({ + cpu: { current: 50.5, max: 100 }, + ram: { current: 60.8, max: 100 }, + disk: { current: 70.2, max: 100 }, + }) + }) + + it('returns 0 for missing metrics', () => { + const mockResponse: InfraMonitoringMultiResponse = { + data: [], + series: {}, + } + + const result = parseInfrastructureMetrics(mockResponse) + + expect(result).toEqual({ + cpu: { current: 0, max: 100 }, + ram: { current: 0, max: 100 }, + disk: { current: 0, max: 100 }, + }) + }) + + it('handles partial metrics data', () => { + const mockResponse: InfraMonitoringMultiResponse = { + data: [], + series: { + avg_cpu_usage: { format: 'percent', total: 150, totalAverage: 50, yAxisLimit: 100 }, + }, + } + + const result = parseInfrastructureMetrics(mockResponse) + + expect(result).toEqual({ + cpu: { current: 50, max: 100 }, + ram: { current: 0, max: 100 }, + disk: { current: 0, max: 100 }, + }) + }) + + it('handles single-response format (legacy)', () => { + const mockResponse: InfraMonitoringSingleResponse = { + data: [], + format: 'percent', + total: 150, + totalAverage: 50, + yAxisLimit: 100, + } + + const result = parseInfrastructureMetrics(mockResponse) + + expect(result).toEqual({ + cpu: { current: 0, max: 100 }, + ram: { current: 0, max: 100 }, + disk: { current: 0, max: 100 }, + }) + }) +}) + +describe('parseConnectionsData', () => { + it('returns zeros when data is undefined', () => { + expect(parseConnectionsData(undefined, undefined)).toEqual({ current: 0, max: 0 }) + expect(parseConnectionsData(undefined, { maxConnections: 100 })).toEqual({ + current: 0, + max: 0, + }) + }) + + it('parses connections data correctly', () => { + const mockInfraData: InfraMonitoringMultiResponse = { + data: [], + series: { + pg_stat_database_num_backends: { + format: 'number', + total: 150, + totalAverage: 25.5, + yAxisLimit: 100, + }, + }, + } + + const mockMaxData = { maxConnections: 100 } + + const result = parseConnectionsData(mockInfraData, mockMaxData) + + expect(result).toEqual({ current: 26, max: 100 }) // 25.5 rounded to 26 + }) + + it('handles string values for connections', () => { + const mockInfraData: InfraMonitoringMultiResponse = { + data: [], + series: { + pg_stat_database_num_backends: { + format: 'number', + total: 150, + totalAverage: '30.7', + yAxisLimit: 100, + }, + }, + } + + const mockMaxData = { maxConnections: 100 } + + const result = parseConnectionsData(mockInfraData, mockMaxData) + + expect(result).toEqual({ current: 31, max: 100 }) // 30.7 rounded to 31 + }) + + it('returns current connections even when maxConnectionsData is undefined', () => { + const mockInfraData: InfraMonitoringMultiResponse = { + data: [], + series: { + pg_stat_database_num_backends: { + format: 'number', + total: 150, + totalAverage: 25, + yAxisLimit: 100, + }, + }, + } + + const result = parseConnectionsData(mockInfraData, undefined) + + expect(result).toEqual({ current: 25, max: 0 }) + }) + + it('returns 0 max when maxConnections is missing from data object', () => { + const mockInfraData: InfraMonitoringMultiResponse = { + data: [], + series: { + pg_stat_database_num_backends: { + format: 'number', + total: 150, + totalAverage: 25, + yAxisLimit: 100, + }, + }, + } + + const mockMaxData = {} + + const result = parseConnectionsData(mockInfraData, mockMaxData) + + expect(result).toEqual({ current: 25, max: 0 }) + }) + + it('returns 0 current when connections metric is missing', () => { + const mockInfraData: InfraMonitoringMultiResponse = { + data: [], + series: {}, + } + + const mockMaxData = { maxConnections: 100 } + + const result = parseConnectionsData(mockInfraData, mockMaxData) + + expect(result).toEqual({ current: 0, max: 100 }) + }) + + it('handles single-response format (legacy)', () => { + const mockInfraData: InfraMonitoringSingleResponse = { + data: [], + format: 'number', + total: 150, + totalAverage: 25, + yAxisLimit: 100, + } + + const mockMaxData = { maxConnections: 100 } + + const result = parseConnectionsData(mockInfraData, mockMaxData) + + expect(result).toEqual({ current: 0, max: 100 }) + }) +}) diff --git a/apps/studio/components/interfaces/Observability/DatabaseInfrastructureSection.utils.ts b/apps/studio/components/interfaces/Observability/DatabaseInfrastructureSection.utils.ts new file mode 100644 index 00000000000..09d272a2584 --- /dev/null +++ b/apps/studio/components/interfaces/Observability/DatabaseInfrastructureSection.utils.ts @@ -0,0 +1,82 @@ +import type { + InfraMonitoringMultiResponse, + InfraMonitoringResponse, +} from 'data/analytics/infra-monitoring-query' + +type NumericValue = string | number | undefined + +/** + * Parses a numeric value that can be a number, string, or undefined. + * Returns 0 for invalid or missing values. + */ +export function parseNumericValue(val: NumericValue): number { + if (typeof val === 'number') return val + if (typeof val === 'string') return parseFloat(val) || 0 + return 0 +} + +type MetricData = { + current: number + max: number +} + +type InfrastructureMetrics = { + cpu: MetricData + ram: MetricData + disk: MetricData +} + +export function parseInfrastructureMetrics( + infraData: InfraMonitoringResponse | undefined +): InfrastructureMetrics | null { + if (!infraData) { + return null + } + + const series = 'series' in infraData ? infraData.series : {} + + const cpuValue = parseNumericValue(series.avg_cpu_usage?.totalAverage) + const ramValue = parseNumericValue(series.ram_usage?.totalAverage) + const diskValue = parseNumericValue(series.disk_io_consumption?.totalAverage) + + return { + cpu: { + current: cpuValue, + max: 100, + }, + ram: { + current: ramValue, + max: 100, + }, + disk: { + current: diskValue, + max: 100, + }, + } +} + +type ConnectionsData = { + current: number + max: number +} + +type MaxConnectionsData = { + maxConnections?: number +} + +export function parseConnectionsData( + infraData: InfraMonitoringResponse | undefined, + maxConnectionsData: MaxConnectionsData | undefined +): ConnectionsData { + if (!infraData) { + return { current: 0, max: 0 } + } + + const series = 'series' in infraData ? infraData.series : {} + + const currentVal = series.pg_stat_database_num_backends?.totalAverage + const current = Math.round(parseNumericValue(currentVal)) + const max = maxConnectionsData?.maxConnections || 0 + + return { current, max } +} diff --git a/apps/studio/components/interfaces/Observability/ObservabilityOverview.tsx b/apps/studio/components/interfaces/Observability/ObservabilityOverview.tsx new file mode 100644 index 00000000000..e4737bb84dc --- /dev/null +++ b/apps/studio/components/interfaces/Observability/ObservabilityOverview.tsx @@ -0,0 +1,209 @@ +import { useQueryClient } from '@tanstack/react-query' +import { useFlag, useParams } from 'common' +import ReportHeader from 'components/interfaces/Reports/ReportHeader' +import ReportPadding from 'components/interfaces/Reports/ReportPadding' +import { ChartIntervalDropdown } from 'components/ui/Logs/ChartIntervalDropdown' +import { CHART_INTERVALS } from 'components/ui/Logs/logs.utils' +import dayjs from 'dayjs' +import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan' +import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' +import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' +import { RefreshCw } from 'lucide-react' +import { useRouter } from 'next/router' +import { useCallback, useMemo, useState } from 'react' +import { Badge, Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui' + +import { DatabaseInfrastructureSection } from './DatabaseInfrastructureSection' +import { ObservabilityOverviewFooter } from './ObservabilityOverviewFooter' +import { useObservabilityOverviewData } from './ObservabilityOverview.utils' +import { ServiceHealthTable } from './ServiceHealthTable' +import { useSlowQueriesCount } from './useSlowQueriesCount' + +type ChartIntervalKey = '1hr' | '1day' | '7day' + +export const ObservabilityOverview = () => { + const router = useRouter() + const { ref: projectRef } = useParams() + const { data: organization } = useSelectedOrganizationQuery() + const { plan } = useCurrentOrgPlan() + const queryClient = useQueryClient() + + const authReportEnabled = useFlag('authreportv2') + const edgeFnReportEnabled = useFlag('edgefunctionreport') + const realtimeReportEnabled = useFlag('realtimeReport') + const storageReportEnabled = useFlag('storagereport') + const postgrestReportEnabled = useFlag('postgrestreport') + const { projectStorageAll: storageSupported } = useIsFeatureEnabled(['project_storage:all']) + + const DEFAULT_INTERVAL: ChartIntervalKey = '1day' + const [interval, setInterval] = useState(DEFAULT_INTERVAL) + const [refreshKey, setRefreshKey] = useState(0) + + const selectedInterval = CHART_INTERVALS.find((i) => i.key === interval) || CHART_INTERVALS[1] + + const { datetimeFormat } = useMemo(() => { + const format = selectedInterval.format || 'MMM D, ha' + return { datetimeFormat: format } + }, [selectedInterval]) + + const overviewData = useObservabilityOverviewData(projectRef!, interval, refreshKey) + + const { slowQueriesCount, isLoading: slowQueriesLoading } = useSlowQueriesCount( + projectRef, + refreshKey + ) + + const handleRefresh = useCallback(() => { + setRefreshKey((prev) => prev + 1) + queryClient.invalidateQueries({ queryKey: ['project-metrics'] }) + queryClient.invalidateQueries({ queryKey: ['postgrest-overview-metrics'] }) + queryClient.invalidateQueries({ queryKey: ['infra-monitoring'] }) + queryClient.invalidateQueries({ queryKey: ['max-connections'] }) + }, [queryClient]) + + const serviceBase = useMemo( + () => [ + { + key: 'db' as const, + name: 'Database', + reportUrl: `/project/${projectRef}/observability/database`, + logsUrl: `/project/${projectRef}/logs/postgres-logs`, + enabled: true, + hasReport: true, + }, + { + key: 'auth' as const, + name: 'Auth', + reportUrl: `/project/${projectRef}/observability/auth`, + logsUrl: `/project/${projectRef}/logs/auth-logs`, + enabled: true, + hasReport: authReportEnabled, + }, + { + key: 'functions' as const, + name: 'Edge Functions', + reportUrl: `/project/${projectRef}/observability/edge-functions`, + logsUrl: `/project/${projectRef}/logs/edge-functions-logs`, + enabled: true, + hasReport: edgeFnReportEnabled, + }, + { + key: 'realtime' as const, + name: 'Realtime', + reportUrl: `/project/${projectRef}/observability/realtime`, + logsUrl: `/project/${projectRef}/logs/realtime-logs`, + enabled: true, + hasReport: realtimeReportEnabled, + }, + { + key: 'storage' as const, + name: 'Storage', + reportUrl: `/project/${projectRef}/observability/storage`, + logsUrl: `/project/${projectRef}/logs/storage-logs`, + enabled: storageSupported, + hasReport: storageReportEnabled, + }, + { + key: 'postgrest' as const, + name: 'Data API', + reportUrl: `/project/${projectRef}/observability/postgrest`, + logsUrl: `/project/${projectRef}/logs/postgrest-logs`, + enabled: true, + hasReport: postgrestReportEnabled, + }, + ], + [ + projectRef, + authReportEnabled, + edgeFnReportEnabled, + realtimeReportEnabled, + storageReportEnabled, + storageSupported, + postgrestReportEnabled, + ] + ) + + const enabledServices = serviceBase.filter((s) => s.enabled) + + const dbServiceData = overviewData.services.db + + // Creates a 1-hour time window for the clicked bar for log filtering + const handleBarClick = useCallback( + (serviceKey: string, logsUrl: string) => (datum: any) => { + if (!datum?.timestamp) return + + const datumTimestamp = dayjs(datum.timestamp) + // Round down to the start of the hour + const start = datumTimestamp.startOf('hour').toISOString() + // Add 1 hour to get the end of the hour + const end = datumTimestamp.startOf('hour').add(1, 'hour').toISOString() + + const queryParams = new URLSearchParams({ + its: start, + ite: end, + }) + + router.push(`${logsUrl}?${queryParams.toString()}`) + }, + [router] + ) + + return ( + +
+
+ + + + Beta + + +

This page is subject to change

+
+
+
+
+ + setInterval(interval as ChartIntervalKey)} + planId={plan?.id} + planName={plan?.name} + organizationSlug={organization?.slug} + dropdownAlign="end" + tooltipSide="left" + /> +
+
+ +
+ + + ({ + key: service.key, + name: service.name, + description: '', + reportUrl: service.hasReport ? service.reportUrl : undefined, + logsUrl: service.logsUrl, + }))} + serviceData={overviewData.services} + onBarClick={handleBarClick} + interval={interval} + datetimeFormat={datetimeFormat} + /> +
+ + +
+ ) +} diff --git a/apps/studio/components/interfaces/Observability/ObservabilityOverview.utils.test.ts b/apps/studio/components/interfaces/Observability/ObservabilityOverview.utils.test.ts new file mode 100644 index 00000000000..c6b45882e84 --- /dev/null +++ b/apps/studio/components/interfaces/Observability/ObservabilityOverview.utils.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it } from 'vitest' + +import type { LogsBarChartDatum } from '../HomeNew/ProjectUsage.metrics' +import { + calculateErrorRate, + calculateSuccessRate, + getHealthStatus, +} from './ObservabilityOverview.utils' + +describe('calculateErrorRate', () => { + it('returns 0 when total is 0', () => { + const data: LogsBarChartDatum[] = [ + { timestamp: '2024-01-15T12:00:00Z', ok_count: 0, warning_count: 0, error_count: 0 }, + ] + + expect(calculateErrorRate(data)).toBe(0) + }) + + it('calculates correct error rate', () => { + const data: LogsBarChartDatum[] = [ + { timestamp: '2024-01-15T12:00:00Z', ok_count: 80, warning_count: 10, error_count: 10 }, + ] + + expect(calculateErrorRate(data)).toBe(10) + }) + + it('calculates error rate across multiple data points', () => { + const data: LogsBarChartDatum[] = [ + { timestamp: '2024-01-15T12:00:00Z', ok_count: 50, warning_count: 25, error_count: 25 }, + { timestamp: '2024-01-15T12:01:00Z', ok_count: 50, warning_count: 25, error_count: 25 }, + ] + + expect(calculateErrorRate(data)).toBe(25) + }) + + it('handles high error rates', () => { + const data: LogsBarChartDatum[] = [ + { timestamp: '2024-01-15T12:00:00Z', ok_count: 10, warning_count: 10, error_count: 80 }, + ] + + expect(calculateErrorRate(data)).toBe(80) + }) + + it('handles empty array', () => { + expect(calculateErrorRate([])).toBe(0) + }) +}) + +describe('calculateSuccessRate', () => { + it('returns 0 when total is 0', () => { + const data: LogsBarChartDatum[] = [ + { timestamp: '2024-01-15T12:00:00Z', ok_count: 0, warning_count: 0, error_count: 0 }, + ] + + expect(calculateSuccessRate(data)).toBe(0) + }) + + it('calculates correct success rate', () => { + const data: LogsBarChartDatum[] = [ + { timestamp: '2024-01-15T12:00:00Z', ok_count: 80, warning_count: 10, error_count: 10 }, + ] + + expect(calculateSuccessRate(data)).toBe(80) + }) + + it('returns 100% for all successful requests', () => { + const data: LogsBarChartDatum[] = [ + { timestamp: '2024-01-15T12:00:00Z', ok_count: 100, warning_count: 0, error_count: 0 }, + ] + + expect(calculateSuccessRate(data)).toBe(100) + }) + + it('calculates success rate across multiple data points', () => { + const data: LogsBarChartDatum[] = [ + { timestamp: '2024-01-15T12:00:00Z', ok_count: 70, warning_count: 15, error_count: 15 }, + { timestamp: '2024-01-15T12:01:00Z', ok_count: 70, warning_count: 15, error_count: 15 }, + ] + + expect(calculateSuccessRate(data)).toBe(70) + }) + + it('handles empty array', () => { + expect(calculateSuccessRate([])).toBe(0) + }) +}) + +describe('getHealthStatus', () => { + describe('unknown status', () => { + it('returns unknown when total is 0', () => { + const result = getHealthStatus(0, 0) + + expect(result).toEqual({ + status: 'unknown', + color: 'muted', + }) + }) + + it('returns unknown when total is below 100', () => { + const result = getHealthStatus(5, 50) + + expect(result).toEqual({ + status: 'unknown', + color: 'muted', + }) + }) + + it('returns unknown when total is exactly 99', () => { + const result = getHealthStatus(10, 99) + + expect(result).toEqual({ + status: 'unknown', + color: 'muted', + }) + }) + + it('returns unknown even with high error rate when total is low', () => { + const result = getHealthStatus(50, 20) + + expect(result).toEqual({ + status: 'unknown', + color: 'muted', + }) + }) + }) + + describe('error status', () => { + it('returns error when error rate is exactly 1%', () => { + const result = getHealthStatus(1, 100) + + expect(result).toEqual({ + status: 'error', + color: 'destructive', + }) + }) + + it('returns error when error rate is above 1%', () => { + const result = getHealthStatus(5, 100) + + expect(result).toEqual({ + status: 'error', + color: 'destructive', + }) + }) + + it('returns error when error rate is 10%', () => { + const result = getHealthStatus(10, 200) + + expect(result).toEqual({ + status: 'error', + color: 'destructive', + }) + }) + + it('returns error for very high error rates', () => { + const result = getHealthStatus(90, 1000) + + expect(result).toEqual({ + status: 'error', + color: 'destructive', + }) + }) + }) + + describe('healthy status', () => { + it('returns healthy when error rate is 0% with sufficient data', () => { + const result = getHealthStatus(0, 100) + + expect(result).toEqual({ + status: 'healthy', + color: 'brand', + }) + }) + + it('returns healthy when error rate is below 1%', () => { + const result = getHealthStatus(0.5, 100) + + expect(result).toEqual({ + status: 'healthy', + color: 'brand', + }) + }) + + it('returns healthy when error rate is just below 1%', () => { + const result = getHealthStatus(0.99, 100) + + expect(result).toEqual({ + status: 'healthy', + color: 'brand', + }) + }) + + it('returns healthy with 0% error rate and large total', () => { + const result = getHealthStatus(0, 10000) + + expect(result).toEqual({ + status: 'healthy', + color: 'brand', + }) + }) + }) + + describe('edge cases', () => { + it('handles fractional error rates correctly', () => { + const result = getHealthStatus(0.99, 1000) + + expect(result).toEqual({ + status: 'healthy', + color: 'brand', + }) + }) + + it('returns unknown for very small total values', () => { + const result = getHealthStatus(10, 1) + + expect(result).toEqual({ + status: 'unknown', + color: 'muted', + }) + }) + + it('handles very large total values', () => { + const result = getHealthStatus(0.5, 1000000) + + expect(result).toEqual({ + status: 'healthy', + color: 'brand', + }) + }) + + it('returns healthy when total is exactly 100 and error rate is 0', () => { + const result = getHealthStatus(0, 100) + + expect(result).toEqual({ + status: 'healthy', + color: 'brand', + }) + }) + + it('returns error when total is exactly 100 and error rate is 1%', () => { + const result = getHealthStatus(1, 100) + + expect(result).toEqual({ + status: 'error', + color: 'destructive', + }) + }) + }) +}) diff --git a/apps/studio/components/interfaces/Observability/ObservabilityOverview.utils.ts b/apps/studio/components/interfaces/Observability/ObservabilityOverview.utils.ts new file mode 100644 index 00000000000..fe929555f5c --- /dev/null +++ b/apps/studio/components/interfaces/Observability/ObservabilityOverview.utils.ts @@ -0,0 +1,83 @@ +import { + computeSuccessAndNonSuccessRates, + sumErrors, + sumTotal, + sumWarnings, +} from '../HomeNew/ProjectUsage.metrics' +import type { LogsBarChartDatum } from '../HomeNew/ProjectUsage.metrics' +import { useServiceHealthMetrics } from './useServiceHealthMetrics' + +export type ServiceKey = 'db' | 'functions' | 'auth' | 'storage' | 'realtime' | 'postgrest' + +export type HealthStatus = 'healthy' | 'error' | 'unknown' + +export type ServiceHealthData = { + total: number + errorRate: number + successRate: number + errorCount: number + warningCount: number + okCount: number + eventChartData: LogsBarChartDatum[] + isLoading: boolean + error: unknown | null + refresh: () => void +} + +export type OverviewData = { + services: Record + aggregated: { + totalRequests: number + totalErrors: number + totalWarnings: number + overallErrorRate: number + overallSuccessRate: number + } + isLoading: boolean +} + +export const calculateErrorRate = (data: LogsBarChartDatum[]): number => { + const total = sumTotal(data) + const errors = sumErrors(data) + return total > 0 ? (errors / total) * 100 : 0 +} + +export const calculateSuccessRate = (data: LogsBarChartDatum[]): number => { + const total = sumTotal(data) + const warnings = sumWarnings(data) + const errors = sumErrors(data) + const { successRate } = computeSuccessAndNonSuccessRates(total, warnings, errors) + return successRate +} + +/** + * Get health status and color based on error rate + * - Unknown: total_requests < 100 (insufficient data) + * - Healthy: error_rate < 1% + * - Unhealthy: error_rate ≥ 1% + */ +export const getHealthStatus = ( + errorRate: number, + total: number +): { status: HealthStatus; color: string } => { + if (total < 100) { + return { status: 'unknown', color: 'muted' } + } + if (errorRate >= 1) { + return { status: 'error', color: 'destructive' } + } + return { status: 'healthy', color: 'brand' } +} + +/** + * Hook to fetch and transform observability overview data for all services + * Uses the same reliable query logic as the logs pages + */ +export const useObservabilityOverviewData = ( + projectRef: string, + interval: '1hr' | '1day' | '7day', + refreshKey: number +): OverviewData => { + // The new hook handles all services using logs page logic + return useServiceHealthMetrics(projectRef, interval, refreshKey) +} diff --git a/apps/studio/components/interfaces/Observability/ObservabilityOverviewFooter.tsx b/apps/studio/components/interfaces/Observability/ObservabilityOverviewFooter.tsx new file mode 100644 index 00000000000..af9586e5144 --- /dev/null +++ b/apps/studio/components/interfaces/Observability/ObservabilityOverviewFooter.tsx @@ -0,0 +1,19 @@ +import Link from 'next/link' + +export const ObservabilityOverviewFooter = () => { + return ( +
+

+ + View our troubleshooting guides + {' '} + for solutions to common Supabase issues.{' '} +

+
+ ) +} diff --git a/apps/studio/components/interfaces/Observability/ServiceHealthCard.tsx b/apps/studio/components/interfaces/Observability/ServiceHealthCard.tsx new file mode 100644 index 00000000000..40a2cd5378a --- /dev/null +++ b/apps/studio/components/interfaces/Observability/ServiceHealthCard.tsx @@ -0,0 +1,110 @@ +import NoDataPlaceholder from 'components/ui/Charts/NoDataPlaceholder' +import Link from 'next/link' +import { Button, Card, CardContent, CardFooter, CardHeader, CardTitle, Loading, cn } from 'ui' +import { LogsBarChart } from 'ui-patterns/LogsBarChart' + +import type { LogsBarChartDatum } from '../HomeNew/ProjectUsage.metrics' +import { type ServiceKey, getHealthStatus } from './ObservabilityOverview.utils' + +const colorClassMap: Record = { + muted: 'bg-muted', + destructive: 'bg-destructive', + warning: 'bg-warning', + brand: 'bg-brand', +} + +export type ServiceHealthCardProps = { + serviceName: string + serviceKey: ServiceKey + total: number + errorRate: number + errorCount: number + warningCount: number + chartData: LogsBarChartDatum[] + reportUrl?: string // undefined if feature flag disabled or no report available + logsUrl: string + isLoading: boolean + onBarClick: (datum: any) => void + datetimeFormat: string +} + +export const ServiceHealthCard = ({ + serviceName, + serviceKey, + total, + errorRate, + errorCount, + warningCount, + chartData, + reportUrl, + logsUrl, + isLoading, + onBarClick, + datetimeFormat, +}: ServiceHealthCardProps) => { + const { status, color } = getHealthStatus(errorRate, total) + + return ( + + +
+ +
+ {serviceName} + +
+
+ {total.toLocaleString()} + requests +
+
+ {errorRate.toFixed(1)}% + error rate +
+
+
+
+
+
+
+ Warn +
+ {warningCount.toLocaleString()} +
+
+
+
+ Err +
+ {errorCount.toLocaleString()} +
+
+ + + + + + } + /> + + + + + {reportUrl && ( + + )} + + + + ) +} diff --git a/apps/studio/components/interfaces/Observability/ServiceHealthTable.tsx b/apps/studio/components/interfaces/Observability/ServiceHealthTable.tsx new file mode 100644 index 00000000000..e372be858c8 --- /dev/null +++ b/apps/studio/components/interfaces/Observability/ServiceHealthTable.tsx @@ -0,0 +1,220 @@ +import { HelpCircle } from 'lucide-react' +import { ChevronRight } from 'lucide-react' +import Link from 'next/link' +import { Card, CardContent } from 'ui' +import { Badge, Loading, Tooltip, TooltipContent, TooltipTrigger } from 'ui' +import { LogsBarChart } from 'ui-patterns/LogsBarChart' + +import { ButtonTooltip } from '../../ui/ButtonTooltip' +import type { LogsBarChartDatum } from '../HomeNew/ProjectUsage.metrics' +import { type ServiceKey, getHealthStatus } from './ObservabilityOverview.utils' + +type ServiceConfig = { + key: ServiceKey + name: string + description: string + reportUrl?: string + logsUrl: string +} + +type ServiceData = { + total: number + errorRate: number + errorCount: number + warningCount: number + eventChartData: LogsBarChartDatum[] + isLoading: boolean +} + +type IntervalKey = '1hr' | '1day' | '7day' + +export type ServiceHealthTableProps = { + services: ServiceConfig[] + serviceData: Record + onBarClick: (serviceKey: string, logsUrl: string) => (datum: LogsBarChartDatum) => void + interval: IntervalKey + datetimeFormat: string +} + +const SERVICE_DESCRIPTIONS: Record = { + db: 'PostgreSQL database health and performance', + auth: 'Authentication and user management', + functions: 'Serverless Edge Functions execution', + storage: 'Object storage for files and assets', + realtime: 'WebSocket connections and broadcasts', + postgrest: 'Auto-generated REST API for your database', +} + +const getStatusLabel = (status: 'healthy' | 'error' | 'unknown'): string => { + switch (status) { + case 'healthy': + return 'Healthy' + case 'error': + return 'Unhealthy' + case 'unknown': + return 'Unknown' + } +} + +const getStatusTooltip = (status: 'healthy' | 'error' | 'unknown'): string => { + switch (status) { + case 'healthy': + return 'Error rate is below 1%' + case 'error': + return 'Error rate is 1% or higher' + case 'unknown': + return 'Insufficient data (fewer than 100 requests)' + } +} + +const getStatusVariant = ( + status: 'healthy' | 'error' | 'unknown' +): 'success' | 'warning' | 'destructive' | 'default' => { + switch (status) { + case 'healthy': + return 'success' + case 'error': + return 'destructive' + case 'unknown': + return 'default' + } +} + +type ServiceRowProps = { + service: ServiceConfig + data: ServiceData + onBarClick: (datum: LogsBarChartDatum) => void + datetimeFormat: string +} + +const ServiceRow = ({ service, data, onBarClick, datetimeFormat }: ServiceRowProps) => { + const { status } = getHealthStatus(data.errorRate, data.total) + const statusLabel = getStatusLabel(status) + const statusVariant = getStatusVariant(status) + const statusTooltip = getStatusTooltip(status) + + const errorRate = data.total > 0 ? data.errorRate : 0 + const warningRate = data.total > 0 ? (data.warningCount / data.total) * 100 : 0 + + const reportUrl = service.reportUrl || service.logsUrl + + return ( + +
+
+ {service.name} + + + + + +

{SERVICE_DESCRIPTIONS[service.key as ServiceKey] || service.description}

+
+
+
+
+ + + {statusLabel} + + +

{statusTooltip}

+
+
+ + + +
+
+ +
e.preventDefault()}> + + {data.isLoading ? ( +
+ ) : ( + + No data +
+ } + /> + )} +
+
+ + {data.total > 0 && ( +
+ {errorRate > 0 && ( + +
+ {errorRate.toFixed(2)}% errors + + )} + {warningRate > 0 && ( + +
+ {warningRate.toFixed(2)}% warnings + + )} + {errorRate === 0 && warningRate === 0 && ( + +
+ 0% errors + + )} +
+ )} + + ) +} + +export const ServiceHealthTable = ({ + services, + serviceData, + onBarClick, + datetimeFormat, +}: ServiceHealthTableProps) => { + return ( +
+

Service Health

+ + + {services.map((service) => { + const data = serviceData[service.key] + if (!data) return null + + return ( + + ) + })} + + +
+ ) +} diff --git a/apps/studio/components/interfaces/Observability/usePostgrestOverviewMetrics.ts b/apps/studio/components/interfaces/Observability/usePostgrestOverviewMetrics.ts new file mode 100644 index 00000000000..4180fb3b3d5 --- /dev/null +++ b/apps/studio/components/interfaces/Observability/usePostgrestOverviewMetrics.ts @@ -0,0 +1,101 @@ +import { useQuery } from '@tanstack/react-query' +import { get } from 'data/fetchers' +import { generateRegexpWhere } from '../Reports/Reports.constants' +import type { LogsBarChartDatum } from '../HomeNew/ProjectUsage.metrics' + +type PostgrestMetricsVariables = { + projectRef: string + startDate: string + endDate: string + interval: '1hr' | '1day' | '7day' +} + +const getIntervalTrunc = (interval: '1hr' | '1day' | '7day') => { + switch (interval) { + case '1hr': + return 'minute' // 1-minute buckets for 1 hour + case '1day': + return 'hour' // 1-hour buckets for 1 day + case '7day': + return 'day' // 1-day buckets for 7 days + default: + return 'hour' + } +} + +const POSTGREST_METRICS_SQL = (interval: '1hr' | '1day' | '7day') => { + const truncInterval = getIntervalTrunc(interval) + + return ` + -- postgrest-overview-metrics + select + cast(timestamp_trunc(t.timestamp, ${truncInterval}) as datetime) as timestamp, + countif(response.status_code < 300) as ok_count, + countif(response.status_code >= 300 and response.status_code < 400) as warning_count, + countif(response.status_code >= 400) as error_count + FROM edge_logs t + cross join unnest(metadata) as m + cross join unnest(m.response) as response + cross join unnest(m.request) as request + WHERE + request.path like '/rest/%' + GROUP BY + timestamp + ORDER BY + timestamp ASC + ` +} + +type MetricsRow = { + timestamp: string + ok_count: number + warning_count: number + error_count: number +} + +async function fetchPostgrestMetrics( + { projectRef, startDate, endDate, interval }: PostgrestMetricsVariables, + signal?: AbortSignal +) { + const sql = POSTGREST_METRICS_SQL(interval) + + const { data, error } = await get(`/platform/projects/{ref}/analytics/endpoints/logs.all`, { + params: { + path: { ref: projectRef }, + query: { + sql, + iso_timestamp_start: startDate, + iso_timestamp_end: endDate, + }, + }, + signal, + }) + + if (error || data?.error) { + throw error || data?.error + } + + return (data?.result || []) as MetricsRow[] +} + +export const usePostgrestOverviewMetrics = ( + { projectRef, startDate, endDate, interval }: PostgrestMetricsVariables, + options?: { enabled?: boolean } +) => { + return useQuery({ + queryKey: ['postgrest-overview-metrics', projectRef, startDate, endDate, interval], + queryFn: ({ signal }) => + fetchPostgrestMetrics({ projectRef, startDate, endDate, interval }, signal), + enabled: (options?.enabled ?? true) && Boolean(projectRef), + staleTime: 1000 * 60, + }) +} + +export const transformPostgrestMetrics = (rows: MetricsRow[]): LogsBarChartDatum[] => { + return rows.map((row) => ({ + timestamp: row.timestamp, + ok_count: row.ok_count, + warning_count: row.warning_count, + error_count: row.error_count, + })) +} diff --git a/apps/studio/components/interfaces/Observability/useSlowQueriesCount.ts b/apps/studio/components/interfaces/Observability/useSlowQueriesCount.ts new file mode 100644 index 00000000000..9789a61b190 --- /dev/null +++ b/apps/studio/components/interfaces/Observability/useSlowQueriesCount.ts @@ -0,0 +1,36 @@ +import useDbQuery from 'hooks/analytics/useDbQuery' +import { useMemo } from 'react' + +export const useSlowQueriesCount = (projectRef?: string, refreshKey: number = 0) => { + // SQL to count queries with total execution time > 1000ms (1 second) + // refreshKey is used in useMemo to force recomputation when refresh is triggered + const sql = useMemo( + () => ` + -- observability-slow-queries-count + set search_path to public, extensions; + + SELECT + count(*)::int as slow_queries_count + FROM pg_stat_statements + WHERE total_exec_time + total_plan_time > 1000; + `, + [refreshKey] + ) + + const { data, isLoading, error } = useDbQuery({ + sql, + }) + + const slowQueriesCount = useMemo(() => { + if (!data || !Array.isArray(data) || data.length === 0) { + return 0 + } + return data[0]?.slow_queries_count ?? 0 + }, [data]) + + return { + slowQueriesCount, + isLoading, + error, + } +} diff --git a/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenu.tsx b/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenu.tsx index 3ce1b8b2565..b0e186cbb4e 100644 --- a/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenu.tsx +++ b/apps/studio/components/layouts/ObservabilityLayout/ObservabilityMenu.tsx @@ -1,32 +1,34 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { Plus } from 'lucide-react' -import Link from 'next/link' -import { useRouter } from 'next/router' -import { Fragment, useMemo, useState } from 'react' -import { toast } from 'sonner' - import { useFlag, useParams } from 'common' -import { IS_PLATFORM } from 'lib/constants' import { CreateReportModal } from 'components/interfaces/Reports/CreateReportModal' import { UpdateCustomReportModal } from 'components/interfaces/Reports/UpdateModal' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useContentDeleteMutation } from 'data/content/content-delete-mutation' -import { Content, useContentQuery } from 'data/content/content-query' +import { Content, ContentBase, useContentQuery } from 'data/content/content-query' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' +import { IS_PLATFORM } from 'lib/constants' import { useProfile } from 'lib/profile' +import { Plus } from 'lucide-react' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { parseAsBoolean, useQueryState } from 'nuqs' +import { Fragment, useMemo, useState } from 'react' +import { toast } from 'sonner' +import type { Dashboards } from 'types' import { Menu, cn } from 'ui' import { InnerSideBarEmptyPanel } from 'ui-patterns' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' + import { ObservabilityMenuItem } from './ObservabilityMenuItem' -import { useQueryState, parseAsBoolean } from 'nuqs' const ObservabilityMenu = () => { const router = useRouter() const { profile } = useProfile() const { ref, id } = useParams() - const pageKey = (id || router.pathname.split('/')[4]) as string + const pageKey = (id || router.pathname.split('/')[4] || 'observability') as string + const showOverview = useFlag('observabilityOverview') const authEnabled = useFlag('authreportv2') const edgeFnEnabled = useFlag('edgefunctionreport') const realtimeEnabled = useFlag('realtimeReport') @@ -89,10 +91,17 @@ const ObservabilityMenu = () => { deleteReport({ projectRef: ref, ids: [selectedReportToDelete.id] }) } + function isReportContent(c: Content): c is ContentBase & { + type: 'report' + content: Dashboards.Content + } { + return c.type === 'report' + } + function getReportMenuItems() { if (!content) return [] - const reports = content?.content.filter((c) => c.type === 'report') + const reports = content?.content.filter(isReportContent) const sortedReports = reports?.sort((a, b) => { if (a.name < b.name) { @@ -121,9 +130,23 @@ const ObservabilityMenu = () => { const menuItems = [ { - title: 'Performance Reports', - key: 'performance-reports', + title: 'GENERAL', + key: 'general-section', items: [ + ...(showOverview + ? [ + { + name: 'Overview', + key: 'observability', + url: `/project/${ref}/observability${preservedQueryParams}`, + }, + ] + : []), + { + name: 'Query Performance', + key: 'query-performance', + url: `/project/${ref}/observability/query-performance${preservedQueryParams}`, + }, ...(IS_PLATFORM ? [ { @@ -133,11 +156,21 @@ const ObservabilityMenu = () => { }, ] : []), - { - name: 'Query Performance', - key: 'query-performance', - url: `/project/${ref}/observability/query-performance${preservedQueryParams}`, - }, + ], + }, + { + title: 'PRODUCT', + key: 'product-section', + items: [ + ...(IS_PLATFORM + ? [ + { + name: 'Database', + key: 'database', + url: `/project/${ref}/observability/database${preservedQueryParams}`, + }, + ] + : []), ...(postgrestReportEnabled ? [ { @@ -147,12 +180,6 @@ const ObservabilityMenu = () => { }, ] : []), - ], - }, - { - title: 'Product Reports', - key: 'product-reports', - items: [ ...(authEnabled ? [ { @@ -162,15 +189,6 @@ const ObservabilityMenu = () => { }, ] : []), - ...(IS_PLATFORM - ? [ - { - name: 'Database', - key: 'database', - url: `/project/${ref}/observability/database${preservedQueryParams}`, - }, - ] - : []), ...(edgeFnEnabled ? [ { @@ -180,15 +198,6 @@ const ObservabilityMenu = () => { }, ] : []), - ...(realtimeEnabled - ? [ - { - name: 'Realtime', - key: 'realtime', - url: `/project/${ref}/observability/realtime${preservedQueryParams}`, - }, - ] - : []), ...(storageEnabled ? [ { @@ -198,6 +207,15 @@ const ObservabilityMenu = () => { }, ] : []), + ...(realtimeEnabled + ? [ + { + name: 'Realtime', + key: 'realtime', + url: `/project/${ref}/observability/realtime${preservedQueryParams}`, + }, + ] + : []), ], }, ] @@ -212,83 +230,6 @@ const ObservabilityMenu = () => {
) : (
- {IS_PLATFORM && ( -
- - Custom Reports - {reportMenuItems.length > 0 && ( - } - disabled={!canCreateCustomReport} - className="flex items-center justify-center h-6 w-6 absolute top-0 -right-1" - onClick={() => { - setShowNewReportModal(true) - }} - tooltip={{ - content: { - side: 'bottom', - text: !canCreateCustomReport - ? 'You need additional permissions to create custom reports' - : undefined, - }, - }} - /> - )} - - } - /> - {reportMenuItems.length === 0 ? ( -
- } - disabled={!canCreateCustomReport} - onClick={() => { - setShowNewReportModal(true) - }} - tooltip={{ - content: { - side: 'bottom', - text: !canCreateCustomReport - ? 'You need additional permissions to create custom reports' - : undefined, - }, - }} - > - New custom report - - } - /> -
- ) : ( - <> - {reportMenuItems.map((item) => ( - { - setSelectedReportToUpdate(item.report) - }} - onSelectDelete={() => { - setSelectedReportToDelete(item.report) - setDeleteModalOpen(true) - }} - /> - ))} - - )} -
- )} - {menuItems.map((item, idx) => (
@@ -323,6 +264,86 @@ const ObservabilityMenu = () => { ))} + {IS_PLATFORM && ( + +
+
+ + Custom Reports + {reportMenuItems.length > 0 && ( + } + disabled={!canCreateCustomReport} + className="flex items-center justify-center h-6 w-6 absolute top-0 -right-1" + onClick={() => { + setShowNewReportModal(true) + }} + tooltip={{ + content: { + side: 'bottom', + text: !canCreateCustomReport + ? 'You need additional permissions to create custom reports' + : undefined, + }, + }} + /> + )} + + } + /> + {reportMenuItems.length === 0 ? ( +
+ } + disabled={!canCreateCustomReport} + onClick={() => { + setShowNewReportModal(true) + }} + tooltip={{ + content: { + side: 'bottom', + text: !canCreateCustomReport + ? 'You need additional permissions to create custom reports' + : undefined, + }, + }} + > + New custom report + + } + /> +
+ ) : ( + <> + {reportMenuItems.map((item) => ( + { + setSelectedReportToUpdate(item.report) + }} + onSelectDelete={() => { + setSelectedReportToDelete(item.report) + setDeleteModalOpen(true) + }} + /> + ))} + + )} +
+ + )} + setSelectedReportToUpdate(undefined)} selectedReport={selectedReportToUpdate} diff --git a/apps/studio/components/ui/Logs/ChartIntervalDropdown.tsx b/apps/studio/components/ui/Logs/ChartIntervalDropdown.tsx new file mode 100644 index 00000000000..15728fc0f28 --- /dev/null +++ b/apps/studio/components/ui/Logs/ChartIntervalDropdown.tsx @@ -0,0 +1,98 @@ +import { InlineLink } from 'components/ui/InlineLink' +import { ChevronDown } from 'lucide-react' +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, + Tooltip, + TooltipContent, + TooltipTrigger, +} from 'ui' + +import { CHART_INTERVALS, LOG_RETENTION } from './logs.utils' + +type PlanId = keyof typeof LOG_RETENTION + +interface ChartIntervalDropdownProps { + value: string + onChange: (value: string) => void + planId?: string + planName?: string + organizationSlug?: string + dropdownAlign?: 'start' | 'center' | 'end' + tooltipSide?: 'left' | 'right' | 'top' | 'bottom' +} + +export const ChartIntervalDropdown = ({ + value, + onChange, + planId = 'free', + planName, + organizationSlug, + dropdownAlign = 'start', + tooltipSide = 'right', +}: ChartIntervalDropdownProps) => { + const selectedInterval = CHART_INTERVALS.find((i) => i.key === value) || CHART_INTERVALS[1] + const normalizedPlanId = (planId && planId in LOG_RETENTION ? planId : 'free') as PlanId + + return ( + + + + + + + {CHART_INTERVALS.map((i) => { + const disabled = !i.availableIn?.includes(normalizedPlanId) + + if (disabled) { + const retentionDuration = LOG_RETENTION[normalizedPlanId] + return ( + + + + {i.label} + + + +

+ {planName} plan only includes up to {retentionDuration} day + {retentionDuration > 1 ? 's' : ''} of log retention +

+

+ {organizationSlug ? ( + <> + + Upgrade your plan + {' '} + to increase log retention and view statistics for the{' '} + {i.label.toLowerCase()} + + ) : ( + `Upgrade your plan to increase log retention and view statistics for the ${i.label.toLowerCase()}` + )} +

+
+
+ ) + } else { + return ( + + {i.label} + + ) + } + })} +
+
+
+ ) +} diff --git a/apps/studio/components/ui/Logs/logs.utils.ts b/apps/studio/components/ui/Logs/logs.utils.ts new file mode 100644 index 00000000000..b6eed6c53a4 --- /dev/null +++ b/apps/studio/components/ui/Logs/logs.utils.ts @@ -0,0 +1,36 @@ +import type { ChartIntervals } from 'types' + +export const LOG_RETENTION = { + free: 1, + pro: 7, + team: 28, + enterprise: 90, + platform: 1, +} + +export const CHART_INTERVALS: ChartIntervals[] = [ + { + key: '1hr', + label: 'Last 60 minutes', + startValue: 1, + startUnit: 'hour', + format: 'MMM D, h:mma', + availableIn: ['free', 'pro', 'team', 'enterprise', 'platform'], + }, + { + key: '1day', + label: 'Last 24 hours', + startValue: 24, + startUnit: 'hour', + format: 'MMM D, ha', + availableIn: ['free', 'pro', 'team', 'enterprise', 'platform'], + }, + { + key: '7day', + label: 'Last 7 days', + startValue: 7, + startUnit: 'day', + format: 'MMM D', + availableIn: ['pro', 'team', 'enterprise'], + }, +] diff --git a/apps/studio/data/analytics/infra-monitoring-query.ts b/apps/studio/data/analytics/infra-monitoring-query.ts index 65f7faad282..322bc4bf3ac 100644 --- a/apps/studio/data/analytics/infra-monitoring-query.ts +++ b/apps/studio/data/analytics/infra-monitoring-query.ts @@ -1,8 +1,8 @@ import { useQuery } from '@tanstack/react-query' - import { paths } from 'api-types' import { get, handleError } from 'data/fetchers' import { UseCustomQueryOptions } from 'types' + import type { AnalyticsInterval } from './constants' import { analyticsKeys } from './keys' @@ -14,7 +14,7 @@ export type InfraMonitoringSeriesMetadata = { yAxisLimit: number format: string total: number - totalAverage: number + totalAverage: number | string } // TODO(raulb): Remove InfraMonitoringSingleResponse once API always returns multi-attribute format. diff --git a/apps/studio/pages/project/[ref]/observability/index.tsx b/apps/studio/pages/project/[ref]/observability/index.tsx index b911059e054..087c2791fb1 100644 --- a/apps/studio/pages/project/[ref]/observability/index.tsx +++ b/apps/studio/pages/project/[ref]/observability/index.tsx @@ -2,8 +2,9 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useRouter } from 'next/router' import { useEffect, useState } from 'react' -import { useParams } from 'common' +import { useParams, useFlag } from 'common' import { CreateReportModal } from 'components/interfaces/Reports/CreateReportModal' +import { ObservabilityOverview } from 'components/interfaces/Observability/ObservabilityOverview' import DefaultLayout from 'components/layouts/DefaultLayout' import ObservabilityLayout from 'components/layouts/ObservabilityLayout/ObservabilityLayout' import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState' @@ -19,6 +20,7 @@ export const UserReportPage: NextPageWithLayout = () => { const { ref } = useParams() const { profile } = useProfile() + const showOverview = useFlag('observabilityOverview') const [showCreateReportModal, setShowCreateReportModal] = useQueryState( 'newReport', parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true }) @@ -35,13 +37,14 @@ export const UserReportPage: NextPageWithLayout = () => { useEffect(() => { if (!isSuccess) return + if (showOverview) return // Don't redirect if overview is enabled const reports = data.content .filter((x) => x.type === 'report') .sort((a, b) => a.name.localeCompare(b.name)) if (reports.length >= 1) router.push(`/project/${ref}/observability/${reports[0].id}`) if (reports.length === 0) router.push(`/project/${ref}/observability/api-overview`) - }, [isSuccess, data, router, ref]) + }, [isSuccess, data, router, ref, showOverview]) const { can: canCreateReport } = useAsyncCheckPermissions( PermissionAction.CREATE, @@ -52,6 +55,11 @@ export const UserReportPage: NextPageWithLayout = () => { } ) + // Show overview page if feature flag is enabled + if (showOverview) { + return + } + return (
{isLoading ? ( diff --git a/packages/ui-patterns/src/MetricCard/index.tsx b/packages/ui-patterns/src/MetricCard/index.tsx index 653ec273a72..6d8b86cedc8 100644 --- a/packages/ui-patterns/src/MetricCard/index.tsx +++ b/packages/ui-patterns/src/MetricCard/index.tsx @@ -1,28 +1,28 @@ 'use client' +import dayjs from 'dayjs' +import { ChevronRight, HelpCircle } from 'lucide-react' +import Link from 'next/link' import * as React from 'react' import { useContext } from 'react' +import { + Area, + AreaChart, + Tooltip as RechartsTooltip, + TooltipProps as RechartsTooltipProps, + ResponsiveContainer, +} from 'recharts' import { Button, + Card, + CardContent, + CardTitle, + Skeleton, Tooltip, TooltipContent, TooltipTrigger, - Card, - CardTitle, cn, - CardContent, - Skeleton, } from 'ui' -import { ExternalLink, HelpCircle } from 'lucide-react' -import Link from 'next/link' -import { - AreaChart, - Area, - ResponsiveContainer, - Tooltip as RechartsTooltip, - TooltipProps as RechartsTooltipProps, -} from 'recharts' -import dayjs from 'dayjs' interface MetricCardContextValue { isLoading?: boolean @@ -46,7 +46,11 @@ interface MetricCardProps extends React.HTMLAttributes { const MetricCard = React.forwardRef( ({ isLoading = false, isDisabled = false, className, children, ...props }, ref) => { return ( - + {children} @@ -59,10 +63,11 @@ MetricCard.displayName = 'MetricCard' interface MetricCardHeaderProps extends React.HTMLAttributes { href?: string children: React.ReactNode + linkTooltip?: string } const MetricCardHeader = React.forwardRef( - ({ className, href, children, ...props }, ref) => { + ({ className, href, children, linkTooltip, ...props }, ref) => { return (
{...props} >
{children}
- {href && ( - - )} + {href ? ( + + + + + {linkTooltip ? {linkTooltip} : null} + + ) : null}
) } @@ -132,7 +147,7 @@ const MetricCardLabel = React.forwardRef( - {tooltip} + {tooltip} )}