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 (
-
-
- }>
- {selectedInterval.label}
-
-
-
-
- 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
-
-
- }>
- {selectedInterval.label}
-
-
-
- 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
+
+
+
+
+ } onClick={handleRefresh}>
+ Refresh
+
+ 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
+
+
+
+
+
+
+
{warningCount.toLocaleString()}
+
+
+
+
{errorCount.toLocaleString()}
+
+
+
+
+
+
+
+ }
+ />
+
+
+
+
+ {reportUrl && (
+
+ View Report
+
+ )}
+
+ View Logs
+
+
+
+ )
+}
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}
+
+
+ e.preventDefault()}
+ className="text-foreground-lighter hover:text-foreground-light transition-colors"
+ >
+
+
+
+
+ {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 (
+
+
+ }>
+ {selectedInterval.label}
+
+
+
+
+ {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 && (
-
-
-
- More information
-
-
- )}
+ {href ? (
+
+
+
+
+
+ More information
+
+
+
+ {linkTooltip ? {linkTooltip} : null}
+
+ ) : null}
)
}
@@ -132,7 +147,7 @@ const MetricCardLabel = React.forwardRef(
- {tooltip}
+ {tooltip}
)}