feat(o11y): overview page (#42098)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Jordi Enric authored and GitHub committed 2026-01-30 14:00:01 +00:00
1 parent 6063652a23
commit 168c084f4e
19 files changed
+1974 -355

No files matched your search

@@ -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 (
<div className="space-y-6">
<div className="flex flex-row items-center gap-x-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" iconRight={<ChevronDown size={14} />}>
<span>{selectedInterval.label}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="start" className="w-40">
<DropdownMenuRadioGroup
value={interval}
onValueChange={(interval) =>
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 (
<Tooltip key={i.key}>
<TooltipTrigger asChild>
<DropdownMenuRadioItem
disabled
value={i.key}
className="!pointer-events-auto"
>
{i.label}
</DropdownMenuRadioItem>
</TooltipTrigger>
<TooltipContent side="right">
<p>
{plan?.name} plan only includes up to {retentionDuration} day
{retentionDuration > 1 ? 's' : ''} of log retention
</p>
<p className="text-foreground-light">
<InlineLink
className="text-foreground-light hover:text-foreground"
href={`/org/${organization?.slug}/billing?panel=subscriptionPlan`}
>
Upgrade your plan
</InlineLink>{' '}
to increase log retention and view statistics for the{' '}
{i.label.toLowerCase()}
</p>
</TooltipContent>
</Tooltip>
)
} else {
return (
<DropdownMenuRadioItem key={i.key} value={i.key}>
{i.label}
</DropdownMenuRadioItem>
)
}
})}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<ChartIntervalDropdown
value={interval || '1day'}
onChange={(interval) => setInterval(interval as ProjectLogStatsVariables['interval'])}
planId={plan?.id}
planName={plan?.name}
organizationSlug={organization?.slug}
dropdownAlign="start"
tooltipSide="right"
/>
<span className="text-xs text-foreground-light">
Statistics for {selectedInterval.label.toLowerCase()}
</span>
@@ -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 = () => {
<span>Success Rate</span>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" iconRight={<ChevronDown size={14} />}>
<span>{selectedInterval.label}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end" className="w-40">
<DropdownMenuRadioGroup
value={interval}
onValueChange={(interval) => 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 (
<Tooltip key={i.key}>
<TooltipTrigger asChild>
<DropdownMenuRadioItem
disabled
value={i.key}
className="!pointer-events-auto"
>
{i.label}
</DropdownMenuRadioItem>
</TooltipTrigger>
<TooltipContent side="left">
<p>
{plan?.name} plan only includes up to {retentionDuration} day
{retentionDuration > 1 ? 's' : ''} of log retention
</p>
<p className="text-foreground-light">
<InlineLink
className="text-foreground-light hover:text-foreground"
href={`/org/${organization?.slug}/billing?panel=subscriptionPlan`}
>
Upgrade your plan
</InlineLink>{' '}
to increase log retention and view statistics for the{' '}
{i.label.toLowerCase()}
</p>
</TooltipContent>
</Tooltip>
)
} else {
return (
<DropdownMenuRadioItem key={i.key} value={i.key}>
{i.label}
</DropdownMenuRadioItem>
)
}
})}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<ChartIntervalDropdown
value={interval}
onChange={(interval) => setInterval(interval as ChartIntervalKey)}
planId={plan?.id}
planName={plan?.name}
organizationSlug={organization?.slug}
dropdownAlign="end"
tooltipSide="left"
/>
</div>
<Row columns={[3, 2, 1]}>
{enabledServices.map((s) => (
@@ -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 (
<div>
<h2 className="mb-4">Database</h2>
{/* First row: Metrics */}
<div className="grid grid-cols-3 gap-2">
<Link href={databaseReportUrl} className="block group">
<MetricCard isLoading={dbLoading}>
<MetricCardHeader href={databaseReportUrl} linkTooltip="Go to database report">
<MetricCardLabel tooltip="Percentage of database operations resulting in errors or warnings">
Error Rate
</MetricCardLabel>
</MetricCardHeader>
<MetricCardContent>
<MetricCardValue>{dbErrorRate.toFixed(2)}%</MetricCardValue>
</MetricCardContent>
</MetricCard>
</Link>
<Link
href={`/project/${projectRef}/observability/query-performance?totalTimeFilter=${encodeURIComponent(JSON.stringify({ operator: '>', value: 1000 }))}`}
className="block group"
>
<MetricCard isLoading={slowQueriesLoading}>
<MetricCardHeader
href={`/project/${projectRef}/observability/query-performance?totalTimeFilter=${encodeURIComponent(JSON.stringify({ operator: '>', value: 1000 }))}`}
linkTooltip="Go to query performance"
>
<MetricCardLabel tooltip="Queries with total execution time (execution time + planning time) greater than 1000ms. High values may indicate query optimization opportunities">
Slow Queries
</MetricCardLabel>
</MetricCardHeader>
<MetricCardContent>
<MetricCardValue>{slowQueriesCount}</MetricCardValue>
</MetricCardContent>
</MetricCard>
</Link>
<Link href={databaseReportUrl} className="block group">
<MetricCard isLoading={infraLoading}>
<MetricCardHeader href={databaseReportUrl} linkTooltip="Go to database report">
<MetricCardLabel tooltip="Active database connections (current/max). Monitor to avoid connection exhaustion">
Connections
</MetricCardLabel>
</MetricCardHeader>
<MetricCardContent>
{infraError ? (
<div className="text-xs text-destructive break-words">{errorMessage}</div>
) : connections.max > 0 ? (
<MetricCardValue>
{connections.current}/{connections.max}
</MetricCardValue>
) : (
<MetricCardValue>--</MetricCardValue>
)}
</MetricCardContent>
</MetricCard>
</Link>
<Link href={databaseReportUrl} className="block group">
<MetricCard isLoading={infraLoading}>
<MetricCardHeader href={databaseReportUrl} linkTooltip="Go to database report">
<MetricCardLabel tooltip="Disk I/O consumption percentage. High values may indicate disk bottlenecks">
Disk
</MetricCardLabel>
</MetricCardHeader>
<MetricCardContent>
{infraError ? (
<div className="text-xs text-destructive break-words">{errorMessage}</div>
) : metrics ? (
<MetricCardValue>{metrics.disk.current.toFixed(0)}%</MetricCardValue>
) : (
<MetricCardValue>--</MetricCardValue>
)}
</MetricCardContent>
</MetricCard>
</Link>
<Link href={databaseReportUrl} className="block group">
<MetricCard isLoading={infraLoading}>
<MetricCardHeader href={databaseReportUrl} linkTooltip="Go to database report">
<MetricCardLabel tooltip="RAM usage percentage. Sustained high usage may indicate memory pressure">
Memory
</MetricCardLabel>
</MetricCardHeader>
<MetricCardContent>
{infraError ? (
<div className="text-xs text-destructive break-words">{errorMessage}</div>
) : metrics ? (
<MetricCardValue>{metrics.ram.current.toFixed(0)}%</MetricCardValue>
) : (
<MetricCardValue>--</MetricCardValue>
)}
</MetricCardContent>
</MetricCard>
</Link>
<Link href={databaseReportUrl} className="block group">
<MetricCard isLoading={infraLoading}>
<MetricCardHeader href={databaseReportUrl} linkTooltip="Go to database report">
<MetricCardLabel tooltip="CPU usage percentage. High values may suggest CPU-intensive queries or workloads">
CPU
</MetricCardLabel>
</MetricCardHeader>
<MetricCardContent>
{infraError ? (
<div className="text-xs text-destructive break-words">{errorMessage}</div>
) : metrics ? (
<MetricCardValue>{metrics.cpu.current.toFixed(0)}%</MetricCardValue>
) : (
<MetricCardValue>--</MetricCardValue>
)}
</MetricCardContent>
</MetricCard>
</Link>
</div>
</div>
)
}
@@ -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 })
})
})
@@ -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 }
}
@@ -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<ChartIntervalKey>(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 (
<ReportPadding>
<div className="flex flex-row justify-between items-center">
<div className="flex items-center gap-3">
<ReportHeader title="Overview" />
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="warning">Beta</Badge>
</TooltipTrigger>
<TooltipContent>
<p>This page is subject to change</p>
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-3">
<Button type="outline" icon={<RefreshCw size={14} />} onClick={handleRefresh}>
Refresh
</Button>
<ChartIntervalDropdown
value={interval}
onChange={(interval) => setInterval(interval as ChartIntervalKey)}
planId={plan?.id}
planName={plan?.name}
organizationSlug={organization?.slug}
dropdownAlign="end"
tooltipSide="left"
/>
</div>
</div>
<div className="space-y-12 mt-8">
<DatabaseInfrastructureSection
interval={interval}
refreshKey={refreshKey}
dbErrorRate={dbServiceData.errorRate}
isLoading={dbServiceData.isLoading}
slowQueriesCount={slowQueriesCount}
slowQueriesLoading={slowQueriesLoading}
/>
<ServiceHealthTable
services={enabledServices.map((service) => ({
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}
/>
</div>
<ObservabilityOverviewFooter />
</ReportPadding>
)
}
@@ -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',
})
})
})
})
@@ -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<ServiceKey, ServiceHealthData>
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)
}
@@ -0,0 +1,19 @@
import Link from 'next/link'
export const ObservabilityOverviewFooter = () => {
return (
<div className="py-12 flex items-center justify-center">
<p className="text-sm text-foreground-light">
<Link
href="https://supabase.com/docs/guides/troubleshooting"
target="_blank"
rel="noopener noreferrer"
className="text-foreground underline underline-offset-2 decoration-foreground-muted hover:decoration-foreground transition-all"
>
View our troubleshooting guides
</Link>{' '}
for solutions to common Supabase issues.{' '}
</p>
</div>
)
}
@@ -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<string, string> = {
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 (
<Card className="mb-0 md:mb-0 h-full flex flex-col">
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0 pb-0 border-b-0">
<div className="flex flex-col">
<CardTitle className="text-foreground-light flex items-center gap-2 text-xs font-medium">
<div className={cn('w-1.5 h-1.5 rounded-full', colorClassMap[color] || 'bg-muted')} />
{serviceName}
</CardTitle>
<div className="flex items-start gap-6 mt-2">
<div className="flex flex-col">
<span className="text-foreground text-xl">{total.toLocaleString()}</span>
<span className="text-foreground-light text-xs">requests</span>
</div>
<div className="flex flex-col">
<span className="text-foreground text-xl">{errorRate.toFixed(1)}%</span>
<span className="text-foreground-light text-xs">error rate</span>
</div>
</div>
</div>
<div className="flex items-end gap-4 text-foreground-light">
<div className="flex flex-col items-end">
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 bg-warning rounded-full" />
<span className="heading-meta">Warn</span>
</div>
<span className="text-foreground text-xl">{warningCount.toLocaleString()}</span>
</div>
<div className="flex flex-col items-end">
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 bg-destructive rounded-full" />
<span className="heading-meta">Err</span>
</div>
<span className="text-foreground text-xl">{errorCount.toLocaleString()}</span>
</div>
</div>
</CardHeader>
<CardContent className="p-6 pt-4 flex-1 overflow-hidden max-h-[200px]">
<Loading isFullHeight active={isLoading}>
<LogsBarChart
isFullHeight
data={chartData}
DateTimeFormat={datetimeFormat}
onBarClick={onBarClick}
EmptyState={
<NoDataPlaceholder size="small" message="No data for selected period" isFullHeight />
}
/>
</Loading>
</CardContent>
<CardFooter className="border-t pt-3 pb-4 px-4 flex gap-2">
{reportUrl && (
<Button type="default" size="tiny" asChild className="flex-1">
<Link href={reportUrl}>View Report</Link>
</Button>
)}
<Button type="default" size="tiny" asChild className="flex-1">
<Link href={logsUrl}>View Logs</Link>
</Button>
</CardFooter>
</Card>
)
}
@@ -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<string, ServiceData>
onBarClick: (serviceKey: string, logsUrl: string) => (datum: LogsBarChartDatum) => void
interval: IntervalKey
datetimeFormat: string
}
const SERVICE_DESCRIPTIONS: Record<ServiceKey, string> = {
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 (
<Link
href={reportUrl}
className="block group p-4 border-b border-default last:border-b-0 hover:bg-surface-200 transition-colors cursor-pointer"
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="text-foreground font-medium">{service.name}</span>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={(e) => e.preventDefault()}
className="text-foreground-lighter hover:text-foreground-light transition-colors"
>
<HelpCircle size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs">
<p>{SERVICE_DESCRIPTIONS[service.key as ServiceKey] || service.description}</p>
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Badge variant={statusVariant}>{statusLabel}</Badge>
</TooltipTrigger>
<TooltipContent side="top">
<p>{statusTooltip}</p>
</TooltipContent>
</Tooltip>
<ButtonTooltip
type="text"
size="tiny"
className="p-1.5"
tooltip={{ content: { text: `Go to ${service.name} report` } }}
>
<ChevronRight
size={14}
className="text-foreground-lighter group-hover:text-foreground"
/>
</ButtonTooltip>
</div>
</div>
<div className="h-16" onClick={(e) => e.preventDefault()}>
<Loading active={data.isLoading}>
{data.isLoading ? (
<div />
) : (
<LogsBarChart
data={data.eventChartData}
DateTimeFormat={datetimeFormat}
onBarClick={onBarClick}
EmptyState={
<div className="flex items-center justify-center h-full text-xs text-foreground-lighter">
No data
</div>
}
/>
)}
</Loading>
</div>
{data.total > 0 && (
<div className="flex items-center justify-center mt-2 text-xs text-foreground-lighter gap-4 font-mono tabular-nums tracking-tight">
{errorRate > 0 && (
<span className="flex items-center gap-1.5">
<div className="w-1.5 h-1.5 rounded-full bg-destructive" />
{errorRate.toFixed(2)}% errors
</span>
)}
{warningRate > 0 && (
<span className="flex items-center gap-1.5">
<div className="w-1.5 h-1.5 rounded-full bg-warning" />
{warningRate.toFixed(2)}% warnings
</span>
)}
{errorRate === 0 && warningRate === 0 && (
<span className="flex items-center gap-1.5">
<div className="w-1.5 h-1.5 rounded-full bg-brand" />
0% errors
</span>
)}
</div>
)}
</Link>
)
}
export const ServiceHealthTable = ({
services,
serviceData,
onBarClick,
datetimeFormat,
}: ServiceHealthTableProps) => {
return (
<div>
<h2 className="heading-section mb-4">Service Health</h2>
<Card>
<CardContent className="p-0">
{services.map((service) => {
const data = serviceData[service.key]
if (!data) return null
return (
<ServiceRow
key={service.key}
service={service}
data={data}
onBarClick={onBarClick(service.key, service.logsUrl)}
datetimeFormat={datetimeFormat}
/>
)
})}
</CardContent>
</Card>
</div>
)
}
@@ -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,
}))
}
@@ -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,
}
}
@@ -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 = () => {
</div>
) : (
<div className="flex flex-col gap-y-6">
{IS_PLATFORM && (
<div className="mx-2">
<Menu.Group
title={
<span className="flex w-full items-center justify-between relative h-6">
<span className="uppercase font-mono">Custom Reports</span>
{reportMenuItems.length > 0 && (
<ButtonTooltip
type="default"
size="tiny"
icon={<Plus />}
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,
},
}}
/>
)}
</span>
}
/>
{reportMenuItems.length === 0 ? (
<div className="px-2">
<InnerSideBarEmptyPanel
title="No custom reports yet"
description="Create and save custom reports to track your project metrics"
actions={
<ButtonTooltip
type="default"
icon={<Plus />}
disabled={!canCreateCustomReport}
onClick={() => {
setShowNewReportModal(true)
}}
tooltip={{
content: {
side: 'bottom',
text: !canCreateCustomReport
? 'You need additional permissions to create custom reports'
: undefined,
},
}}
>
New custom report
</ButtonTooltip>
}
/>
</div>
) : (
<>
{reportMenuItems.map((item) => (
<ObservabilityMenuItem
key={item.id}
item={item as any}
pageKey={pageKey}
onSelectEdit={() => {
setSelectedReportToUpdate(item.report)
}}
onSelectDelete={() => {
setSelectedReportToDelete(item.report)
setDeleteModalOpen(true)
}}
/>
))}
</>
)}
</div>
)}
{menuItems.map((item, idx) => (
<Fragment key={idx}>
<div className="h-px w-full bg-border-overlay first:hidden" />
@@ -323,6 +264,86 @@ const ObservabilityMenu = () => {
</Fragment>
))}
{IS_PLATFORM && (
<Fragment>
<div className="h-px w-full bg-border-overlay" />
<div className="mx-2">
<Menu.Group
title={
<span className="flex w-full items-center justify-between relative h-6">
<span className="uppercase font-mono">Custom Reports</span>
{reportMenuItems.length > 0 && (
<ButtonTooltip
type="default"
size="tiny"
icon={<Plus />}
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,
},
}}
/>
)}
</span>
}
/>
{reportMenuItems.length === 0 ? (
<div className="px-2">
<InnerSideBarEmptyPanel
title="No custom reports yet"
description="Create and save custom reports to track your project metrics"
actions={
<ButtonTooltip
type="default"
icon={<Plus />}
disabled={!canCreateCustomReport}
onClick={() => {
setShowNewReportModal(true)
}}
tooltip={{
content: {
side: 'bottom',
text: !canCreateCustomReport
? 'You need additional permissions to create custom reports'
: undefined,
},
}}
>
New custom report
</ButtonTooltip>
}
/>
</div>
) : (
<>
{reportMenuItems.map((item) => (
<ObservabilityMenuItem
key={item.id}
item={item}
pageKey={pageKey}
onSelectEdit={() => {
setSelectedReportToUpdate(item.report)
}}
onSelectDelete={() => {
setSelectedReportToDelete(item.report)
setDeleteModalOpen(true)
}}
/>
))}
</>
)}
</div>
</Fragment>
)}
<UpdateCustomReportModal
onCancel={() => setSelectedReportToUpdate(undefined)}
selectedReport={selectedReportToUpdate}
@@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="default" iconRight={<ChevronDown size={14} />}>
<span>{selectedInterval.label}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align={dropdownAlign} className="w-40">
<DropdownMenuRadioGroup value={value} onValueChange={onChange}>
{CHART_INTERVALS.map((i) => {
const disabled = !i.availableIn?.includes(normalizedPlanId)
if (disabled) {
const retentionDuration = LOG_RETENTION[normalizedPlanId]
return (
<Tooltip key={i.key}>
<TooltipTrigger asChild>
<DropdownMenuRadioItem disabled value={i.key} className="!pointer-events-auto">
{i.label}
</DropdownMenuRadioItem>
</TooltipTrigger>
<TooltipContent side={tooltipSide}>
<p>
{planName} plan only includes up to {retentionDuration} day
{retentionDuration > 1 ? 's' : ''} of log retention
</p>
<p className="text-foreground-light">
{organizationSlug ? (
<>
<InlineLink
className="text-foreground-light hover:text-foreground"
href={`/org/${organizationSlug}/billing?panel=subscriptionPlan`}
>
Upgrade your plan
</InlineLink>{' '}
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()}`
)}
</p>
</TooltipContent>
</Tooltip>
)
} else {
return (
<DropdownMenuRadioItem key={i.key} value={i.key}>
{i.label}
</DropdownMenuRadioItem>
)
}
})}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -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'],
},
]
@@ -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.
@@ -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 <ObservabilityOverview />
}
return (
<div className="h-full w-full">
{isLoading ? (
+40 -25
View File
@@ -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<HTMLDivElement> {
const MetricCard = React.forwardRef<HTMLDivElement, MetricCardProps>(
({ isLoading = false, isDisabled = false, className, children, ...props }, ref) => {
return (
<Card ref={ref} className={cn(className)} {...props}>
<Card
ref={ref}
className={cn('group-hover:bg-surface-200 transition-colors', className)}
{...props}
>
<MetricCardContext.Provider value={{ isLoading, isDisabled }}>
{children}
</MetricCardContext.Provider>
@@ -59,10 +63,11 @@ MetricCard.displayName = 'MetricCard'
interface MetricCardHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
href?: string
children: React.ReactNode
linkTooltip?: string
}
const MetricCardHeader = React.forwardRef<HTMLDivElement, MetricCardHeaderProps>(
({ className, href, children, ...props }, ref) => {
({ className, href, children, linkTooltip, ...props }, ref) => {
return (
<div
ref={ref}
@@ -73,14 +78,24 @@ const MetricCardHeader = React.forwardRef<HTMLDivElement, MetricCardHeaderProps>
{...props}
>
<div className="flex flex-row items-center gap-2">{children}</div>
{href && (
<Button type="text" size="tiny" className="px-1 text-foreground-lighter" asChild>
<Link href={href}>
<ExternalLink aria-disabled={true} size={14} strokeWidth={1.5} />
<span className="sr-only">More information</span>
</Link>
</Button>
)}
{href ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="text"
size="tiny"
className="px-1 text-foreground-lighter group-hover:text-foreground absolute right-3 transition-colors"
asChild
>
<Link href={href}>
<ChevronRight aria-disabled={true} size={14} strokeWidth={1.5} />
<span className="sr-only">More information</span>
</Link>
</Button>
</TooltipTrigger>
{linkTooltip ? <TooltipContent>{linkTooltip}</TooltipContent> : null}
</Tooltip>
) : null}
</div>
)
}
@@ -132,7 +147,7 @@ const MetricCardLabel = React.forwardRef<HTMLDivElement, MetricCardLabelProps>(
<TooltipTrigger asChild>
<HelpCircle size={14} strokeWidth={1.5} />
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
<TooltipContent className="max-w-xs">{tooltip}</TooltipContent>
</Tooltip>
)}
</CardTitle>