mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
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>
|
||||
)
|
||||
}
|
||||
+256
@@ -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 })
|
||||
})
|
||||
})
|
||||
+82
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user