Chore/support checking replication lag in database reports (#26290)

* Init replication lag chart in database reports

* Navigate to replication lag chart via query params

* Add view replication lag CTA in map view

* Add replication lag metric to instance node view

* Prettier
This commit is contained in:
Joshen Lim
2024-05-14 20:00:20 +08:00
committed by GitHub
parent 448cf84e3d
commit 271734eec2
18 changed files with 489 additions and 530 deletions

View File

@@ -1,12 +1,32 @@
import { Button, IconRefreshCw } from 'ui'
import { useRouter } from 'next/router'
interface Props {
import { useParams } from 'common'
import DatabaseSelector from 'components/ui/DatabaseSelector'
interface ReportHeaderProps {
title: string
showDatabaseSelector?: boolean
}
const ReportHeader: React.FC<Props> = ({ title }) => (
<div className="flex flex-row justify-between gap-4 items-center">
<h1 className="text-2xl text-foreground">{title}</h1>
</div>
)
const ReportHeader = ({ title, showDatabaseSelector }: ReportHeaderProps) => {
const router = useRouter()
const { ref } = useParams()
const { db, chart, ...params } = router.query
return (
<div className="flex flex-row justify-between gap-4 items-center">
<h1 className="text-2xl text-foreground">{title}</h1>
{showDatabaseSelector && (
<DatabaseSelector
onSelectId={(db) => {
router.push({
pathname: router.pathname,
query: db !== ref ? { ...params, db } : params,
})
}}
/>
)}
</div>
)
}
export default ReportHeader

View File

@@ -1,7 +1,9 @@
import { PropsWithChildren } from 'react'
/**
* Standardized padding and width layout for non-custom reports
*/
const ReportPadding: React.FC<React.PropsWithChildren> = ({ children }) => (
const ReportPadding = ({ children }: PropsWithChildren<{}>) => (
<div className="flex flex-col gap-4 px-5 py-6 mx-auto 1xl:px-28 lg:px-16 xl:px-24 2xl:px-32">
{children}
</div>

View File

@@ -67,9 +67,7 @@ export function ChartConfig({ results = { rows: [] }, config, onConfigChange }:
<div className="p-2">
<NoDataPlaceholder
size="normal"
message="
Execute a query and configure the chart options.
"
description="Execute a query and configure the chart options."
/>
</div>
)

View File

@@ -169,7 +169,7 @@ export const DatabaseConnectionString = ({ appearance }: DatabaseConnectionStrin
<div id="connection-string" className="w-full">
<Panel
className={cn(
'!m-0 [&>div:nth-child(1)]:!border-0 [&>div:nth-child(1)>div]:!p-0',
'!m-0 [&>div:nth-child(1)]:!border-0 [&>div:nth-child(1)]:!p-0',
appearance === 'minimal' && 'border-0 shadow-none'
)}
bodyClassName={cn(appearance === 'minimal' && 'bg-transparent')}

View File

@@ -0,0 +1,77 @@
import { useParams } from 'common'
import { useReplicationLagQuery } from 'data/read-replicas/replica-lag-query'
import { formatDatabaseID } from 'data/read-replicas/replicas.utils'
import { Loader2 } from 'lucide-react'
import type { EdgeProps } from 'reactflow'
import { BaseEdge, EdgeLabelRenderer, getSmoothStepPath } from 'reactflow'
import { TooltipContent_Shadcn_, TooltipTrigger_Shadcn_, Tooltip_Shadcn_ } from 'ui'
export const SmoothstepEdge = ({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
style = {},
markerEnd,
data,
}: EdgeProps) => {
const { ref } = useParams()
// [Joshen] Only applicable for replicas
const { identifier, connectionString } = data || {}
const formattedId = formatDatabaseID(identifier ?? '')
const [edgePath, labelX, labelY] = getSmoothStepPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
})
const {
data: lagDuration,
isLoading,
isError,
} = useReplicationLagQuery({
id: identifier,
projectRef: ref,
connectionString,
})
const lagValue = Number(lagDuration?.toFixed(2) ?? 0).toLocaleString()
return (
<>
<BaseEdge path={edgePath} markerEnd={markerEnd} style={style} />
{data !== undefined && !isError && (
<EdgeLabelRenderer>
<Tooltip_Shadcn_>
<TooltipTrigger_Shadcn_ asChild>
<div
className="bg-surface-100 px-1.5 py-0.5 rounded absolute nodrag nopan"
style={{
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
pointerEvents: 'all',
}}
>
{isLoading ? (
<Loader2 size={12} className="animate-spin" />
) : (
<p className="font-mono text-xs">{lagValue}s</p>
)}
</div>
</TooltipTrigger_Shadcn_>
<TooltipContent_Shadcn_ side="bottom" align="center">
{isLoading
? `Checking replication lag for replica ID: ${formattedId}`
: `Replication lag (seconds) for replica ID: ${formattedId}`}
</TooltipContent_Shadcn_>
</Tooltip_Shadcn_>
</EdgeLabelRenderer>
)}
</>
)
}

View File

@@ -30,6 +30,7 @@ import { addRegionNodes, generateNodes, getDagreGraphLayout } from './InstanceCo
import { LoadBalancerNode, PrimaryNode, RegionNode, ReplicaNode } from './InstanceNode'
import MapView from './MapView'
import { RestartReplicaConfirmationModal } from './RestartReplicaConfirmationModal'
import { SmoothstepEdge } from './Edge'
// [Joshen] Just FYI, UI assumes single provider for primary + replicas
// [Joshen] Idea to visualize grouping based on region: https://reactflow.dev/examples/layout/sub-flows
@@ -142,6 +143,10 @@ const InstanceConfigurationUI = () => {
type: 'smoothstep',
animated: true,
className: '!cursor-default',
data: {
identifier: database.identifier,
connectionString: database.connectionString,
},
}
}),
]
@@ -159,6 +164,10 @@ const InstanceConfigurationUI = () => {
[]
)
const edgeTypes = {
smoothstep: SmoothstepEdge,
}
const setReactFlow = async () => {
const graph = getDagreGraphLayout(nodes, edges)
const { nodes: updatedNodes } = addRegionNodes(graph.nodes, graph.edges)
@@ -250,6 +259,7 @@ const InstanceConfigurationUI = () => {
defaultNodes={[]}
defaultEdges={[]}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
proOptions={{ hideAttribution: true }}
>
<Background color={backgroundPatternColor} />

View File

@@ -269,6 +269,14 @@ export const ReplicaNode = ({ data }: NodeProps<ReplicaNodeData>) => {
View connection string
</Link>
</DropdownMenuItem>
<DropdownMenuItem
disabled={status !== PROJECT_STATUS.ACTIVE_HEALTHY}
className="gap-x-2"
>
<Link href={`/project/${ref}/reports/database?db=${id}&chart=replication-lag`}>
View replication lag
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="gap-x-2" onClick={() => onSelectRestartReplica()}>
Restart replica

View File

@@ -287,7 +287,16 @@ const MapView = ({
View connection string
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="gap-x-2">
<Link
href={`/project/${ref}/reports/database?db=${database.identifier}&chart=replication-lag`}
>
View replication lag
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="gap-x-2"
onClick={() => onSelectRestartReplica(database)}

View File

@@ -1,10 +1,10 @@
import { isUndefined } from 'lodash'
import { useRouter } from 'next/router'
import { PropsWithChildren, useState } from 'react'
import { Button } from 'ui'
import { Button, TooltipContent_Shadcn_, TooltipTrigger_Shadcn_, Tooltip_Shadcn_ } from 'ui'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import AreaChart from 'components/ui/Charts/AreaChart'
import BarChart from 'components/ui/Charts/BarChart'
import { AnalyticsInterval } from 'data/analytics/constants'
import {
InfraMonitoringAttribute,
@@ -15,9 +15,9 @@ import { Activity, BarChartIcon, Loader2 } from 'lucide-react'
import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
import { WarningIcon } from 'ui-patterns/Icons/StatusIcons'
import type { ChartData } from './ChartHandler.types'
import { BarChart } from './ChartRenderer'
interface ChartHandlerProps {
id?: string
label: string
attribute: string
provider: 'infra-monitoring' | 'daily-stats'
@@ -139,7 +139,7 @@ const ChartHandler = ({
)
}
if (isUndefined(chartData)) {
if (chartData === undefined) {
return (
<div className="flex h-52 w-full flex-col items-center justify-center gap-y-2">
<WarningIcon />
@@ -152,30 +152,31 @@ const ChartHandler = ({
<div className="h-full w-full">
<div className="absolute right-6 z-50 flex justify-between">
{!hideChartType && (
<div>
<div className="flex w-full space-x-3">
<Tooltip_Shadcn_>
<TooltipTrigger_Shadcn_ asChild>
<Button
type="default"
className="px-1.5"
icon={chartStyle === 'bar' ? <Activity /> : <BarChartIcon />}
onClick={() => setChartStyle(chartStyle === 'bar' ? 'line' : 'bar')}
/>
</div>
</div>
</TooltipTrigger_Shadcn_>
<TooltipContent_Shadcn_ side="left" align="center">
View as {chartStyle === 'bar' ? 'line chart' : 'bar chart'}
</TooltipContent_Shadcn_>
</Tooltip_Shadcn_>
)}
{children}
</div>
{chartStyle === 'bar' ? (
<BarChart
data={chartData?.data ?? []}
attribute={attribute}
yAxisLimit={chartData?.yAxisLimit}
data={(chartData?.data ?? []) as any}
format={format || chartData?.format}
xAxisKey={'period_start'}
yAxisKey={attribute}
highlightedValue={_highlightedValue}
label={label}
title={label}
customDateFormat={customDateFormat}
onBarClick={onBarClick}
/>
) : (
<AreaChart

View File

@@ -1,270 +0,0 @@
import dayjs from 'dayjs'
import { useState } from 'react'
import {
Bar,
BarChart as RechartBarChart,
Cell,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import { Loading } from 'ui'
import { CHART_COLORS } from 'components/ui/Charts/Charts.constants'
import EmptyState from 'components/ui/Charts/EmptyState'
import { formatBytes } from 'lib/helpers'
function dataCheck(data: any, attribute: any) {
const hasData = data && data.find((record: any) => record[attribute])
return hasData ? true : false
}
const CustomTooltip = () => {
return null
}
const DATE_FORMAT__WITH_TIME = 'MMM D, YYYY, hh:mma'
const DATE_FORMAT__DATE_ONLY = 'MMM D, YYYY'
const Header = ({
attribute,
focus,
format,
highlightedValue,
data,
customDateFormat,
label,
minimalHeader = false,
displayDateInUtc = false,
}: any) => {
let FOCUS_FORMAT = customDateFormat
? customDateFormat
: format == '%'
? DATE_FORMAT__WITH_TIME
: DATE_FORMAT__DATE_ONLY
let title = ''
const isByteAttribute =
format === 'bytes' ||
attribute.includes('ingress') ||
attribute.includes('egress') ||
attribute.includes('bytes')
if (focus) {
if (!data) {
title = ''
} else if (format === '%') {
title = Number(data[focus]?.[attribute]).toFixed(2)
} else {
if (isByteAttribute) {
title = formatBytes(data[focus]?.[attribute])
} else {
title = data[focus]?.[attribute]?.toLocaleString()
}
}
} else {
if (format === '%' && highlightedValue) {
title = highlightedValue.toFixed(2)
} else {
if (isByteAttribute) {
title = formatBytes(highlightedValue)
} else {
title = highlightedValue?.toLocaleString()
}
}
}
const day = (value: number | string) => (displayDateInUtc ? dayjs(value).utc() : dayjs(value))
const chartTitle = (
<h3 className={'text-foreground-lighter ' + (minimalHeader ? 'text-xs' : 'text-sm')}>
{label ?? attribute}
</h3>
)
const highlighted = (
<h5
className={
'text-xl font-normal text-foreground ' + (minimalHeader ? 'text-base' : 'text-2xl')
}
>
{title}
{!isByteAttribute && <span className="text-lg">{format}</span>}
</h5>
)
const date = (
<h5 className="text-xs text-foreground-lighter">
{focus ? (
data && data[focus] && day(data[focus].period_start).format(FOCUS_FORMAT)
) : (
<span className="opacity-0">x</span>
)}
</h5>
)
if (minimalHeader) {
return (
<div className="flex flex-row items-center gap-x-4" style={{ minHeight: '1.8rem' }}>
{chartTitle}
<div className="flex flex-row items-baseline gap-x-2">
{highlighted}
{date}
</div>
</div>
)
}
return (
<>
{chartTitle}
{highlighted}
{date}
</>
)
}
/**
* @deprecated please use studio/components/ui/Charts/BarChart.tsx instead
*/
export function BarChart({
data,
attribute,
yAxisLimit,
format,
highlightedValue,
customDateFormat,
displayDateInUtc = false,
label,
onBarClick,
minimalHeader,
chartSize = 'normal',
className = '',
noDataTitle,
noDataMessage,
}: any) {
const hasData = data ? dataCheck(data, attribute) : true
const [focusBar, setFocusBar] = useState<any>(null)
const [mouseLeave, setMouseLeave] = useState<any>(true)
const onMouseMove = (state: any) => {
if (state?.activeTooltipIndex) {
setFocusBar(state.activeTooltipIndex)
setMouseLeave(false)
} else {
setFocusBar(null)
setMouseLeave(true)
}
}
const onMouseLeave = () => {
setFocusBar(false)
setMouseLeave(true)
}
const day = (value: number | string) => (displayDateInUtc ? dayjs(value).utc() : dayjs(value))
// For future reference: https://github.com/supabase/supabase/pull/5311#discussion_r800852828
const chartHeight = {
tiny: 76,
small: 96,
normal: 160,
}[chartSize as string] as number
return (
<Loading active={!data}>
<div className={className}>
<Header
minimalHeader={minimalHeader}
attribute={attribute}
focus={focusBar}
highlightedValue={highlightedValue}
data={data}
label={label}
format={format}
customDateFormat={customDateFormat}
displayDateInUtc={displayDateInUtc}
/>
<div style={{ width: '100%', height: `${chartHeight}px` }}>
{hasData ? (
<>
<ResponsiveContainer width="100%" height={chartHeight}>
<RechartBarChart
data={data}
margin={{
top: 0,
right: 0,
left: 0,
bottom: 0,
}}
className="cursor-pointer overflow-visible"
onMouseMove={onMouseMove}
onMouseLeave={onMouseLeave}
onClick={(tooltipData: any) => {
// receives tooltip data https://github.com/recharts/recharts/blob/2a3405ff64a0c050d2cf94c36f0beef738d9e9c2/src/chart/generateCategoricalChart.tsx
if (onBarClick) onBarClick(tooltipData)
}}
>
<XAxis
dataKey="period_start"
//interval={size === 'small' ? 5 : 1}
interval={data ? data.length - 2 : 0}
angle={0}
// stroke="#4B5563"
tick={false}
axisLine={{ stroke: CHART_COLORS.AXIS }}
tickLine={{ stroke: CHART_COLORS.AXIS }}
/>
<Tooltip content={<CustomTooltip />} />
{/* <YAxis dataKey={attribute} /> */}
{/* <YAxis type="number" domain={[(0, 100)]} /> */}
{yAxisLimit && <YAxis type="number" domain={[0, yAxisLimit]} hide />}
<Bar
dataKey={attribute}
fill={CHART_COLORS.GREEN_1}
// barSize={2}
animationDuration={300}
// max bar size required for LogEventChart, prevents bars from expanding to max width.
maxBarSize={48}
>
{data?.map((entry: any, index: any) => (
<Cell
key={`cell-${index}`}
className={`transition-all duration-300 ${
onBarClick ? 'cursor-pointer' : ''
}`}
fill={
focusBar === index || mouseLeave
? CHART_COLORS.GREEN_1
: CHART_COLORS.GREEN_2
}
enableBackground={12}
// for this, we make the hovered colour #2B5CE7, else its opacity decreases to 20%
/>
))}
</Bar>
</RechartBarChart>
</ResponsiveContainer>
{data && (
<div className="-mt-5 flex items-center justify-between text-xs text-foreground-lighter">
<span>
{day(data[0].period_start).format(
customDateFormat ? customDateFormat : DATE_FORMAT__WITH_TIME
)}
</span>
<span>
{day(data[data?.length - 1]?.period_start).format(
customDateFormat ? customDateFormat : DATE_FORMAT__WITH_TIME
)}
</span>
</div>
)}
</>
) : (
<EmptyState title={noDataTitle} message={noDataMessage} />
)}
</div>
</div>
</Loading>
)
}

View File

@@ -1,13 +1,12 @@
import { CHART_COLORS, DateTimeFormats } from 'components/ui/Charts/Charts.constants'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import { useState } from 'react'
import { Area, AreaChart as RechartAreaChart, Tooltip, XAxis } from 'recharts'
import { CHART_COLORS, DateTimeFormats } from 'components/ui/Charts/Charts.constants'
import ChartHeader from './ChartHeader'
import type { CommonChartProps, Datum } from './Charts.types'
import { numberFormatter, useChartSize } from './Charts.utils'
import ChartNoData from './NoDataPlaceholder'
dayjs.extend(utc)
import NoDataPlaceholder from './NoDataPlaceholder'
export interface AreaChartProps<D = Datum> extends CommonChartProps<D> {
yAxisKey: string
@@ -35,8 +34,6 @@ const AreaChart = ({
const { Container } = useChartSize(size)
const [focusDataIndex, setFocusDataIndex] = useState<number | null>(null)
if (data.length === 0) return <ChartNoData size={size} className={className} />
const day = (value: number | string) => (displayDateInUtc ? dayjs(value).utc() : dayjs(value))
const resolvedHighlightedLabel =
(focusDataIndex !== null &&
@@ -48,6 +45,18 @@ const AreaChart = ({
const resolvedHighlightedValue =
focusDataIndex !== null ? data[focusDataIndex]?.[yAxisKey] : highlightedValue
if (data.length === 0) {
return (
<NoDataPlaceholder
description="It may take up to 24 hours for data to show"
size={size}
className={className}
attribute={title}
format={format}
/>
)
}
return (
<div className={['flex flex-col gap-3', className].join(' ')}>
<ChartHeader

View File

@@ -1,14 +1,13 @@
import { CHART_COLORS, DateTimeFormats } from 'components/ui/Charts/Charts.constants'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import { useState } from 'react'
import { Bar, BarChart as RechartBarChart, Cell, Tooltip, XAxis, Legend } from 'recharts'
import { Bar, Cell, Legend, BarChart as RechartBarChart, Tooltip, XAxis } from 'recharts'
import { CHART_COLORS, DateTimeFormats } from 'components/ui/Charts/Charts.constants'
import type { CategoricalChartState } from 'recharts/types/chart/generateCategoricalChart'
import ChartHeader from './ChartHeader'
import type { CommonChartProps, Datum } from './Charts.types'
import { numberFormatter, useChartSize } from './Charts.utils'
import ChartNoData from './NoDataPlaceholder'
dayjs.extend(utc)
import NoDataPlaceholder from './NoDataPlaceholder'
export interface BarChartProps<D = Datum> extends CommonChartProps<D> {
yAxisKey: string
@@ -43,9 +42,6 @@ const BarChart = ({
const { Container } = useChartSize(size)
const [focusDataIndex, setFocusDataIndex] = useState<number | null>(null)
if (data.length === 0)
return <ChartNoData message={emptyStateMessage} size={size} className={className} />
const day = (value: number | string) => (displayDateInUtc ? dayjs(value).utc() : dayjs(value))
function getHeaderLabel() {
@@ -67,6 +63,19 @@ const BarChart = ({
const resolvedHighlightedValue =
focusDataIndex !== null ? data[focusDataIndex]?.[yAxisKey] : highlightedValue
if (data.length === 0) {
return (
<NoDataPlaceholder
message={emptyStateMessage}
description="It may take up to 24 hours for data to show"
size={size}
className={className}
attribute={title}
format={format}
/>
)
}
return (
<div className={['flex flex-col gap-3', className].join(' ')}>
<ChartHeader

View File

@@ -7,7 +7,8 @@ export interface ChartHeaderProps {
highlightedLabel?: number | string | null
highlightedValue?: number | string | null
}
const ChartHeader: React.FC<ChartHeaderProps> = ({
const ChartHeader = ({
format,
highlightedValue,
highlightedLabel,
@@ -25,6 +26,7 @@ const ChartHeader: React.FC<ChartHeaderProps> = ({
className={`text-foreground text-xl font-normal ${minimalHeader ? 'text-base' : 'text-2xl'}`}
>
{highlightedValue !== undefined && String(highlightedValue)}
{format === 'seconds' ? ' ' : ''}
<span className="text-lg">
{typeof format === 'function' ? format(highlightedValue) : format}
</span>

View File

@@ -1,32 +1,44 @@
import { BarChart2 } from 'lucide-react'
import { useChartSize } from './Charts.utils'
import ChartHeader from './ChartHeader'
interface Props {
interface NoDataPlaceholderProps {
title?: string
attribute?: string
format?: string | ((value: unknown) => string)
message?: string
description?: string
className?: string
size: Parameters<typeof useChartSize>[0]
}
const NoDataPlaceholder: React.FC<Props> = ({
title = 'No data to show',
message,
const NoDataPlaceholder = ({
attribute,
message = 'No data to show',
description,
format,
className = '',
size,
}) => {
}: NoDataPlaceholderProps) => {
const { minHeight } = useChartSize(size)
return (
<div
className={
'border-control flex flex-grow w-full flex-col items-center justify-center space-y-2 border border-dashed text-center ' +
className
}
// extra 20 px for the x ticks
style={{ minHeight: minHeight + 20 }}
>
<BarChart2 size={20} className="text-border-stronger" />
<div>
<p className="text-foreground-light text-xs">{title}</p>
{message && <p className="text-foreground-lighter text-xs">{message}</p>}
<div>
{attribute !== undefined && (
<ChartHeader title={attribute} format={format} highlightedValue={0} />
)}
<div
className={
'border-control flex flex-grow w-full flex-col items-center justify-center space-y-2 border border-dashed text-center ' +
className
}
// extra 20 px for the x ticks
style={{ minHeight: minHeight + 20 }}
>
<BarChart2 size={20} className="text-border-stronger" />
<div>
<p className="text-foreground-light text-xs">{message}</p>
{description && <p className="text-foreground-lighter text-xs">{description}</p>}
</div>
</div>
</div>
)

View File

@@ -1,5 +1,6 @@
import { useParams } from 'common'
import { noop } from 'lodash'
import { Check } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useState } from 'react'
@@ -23,12 +24,11 @@ import {
cn,
} from 'ui'
import { Markdown } from 'components/interfaces/Markdown'
import { REPLICA_STATUS } from 'components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.constants'
import { useReadReplicasQuery } from 'data/read-replicas/replicas-query'
import { formatDatabaseID, formatDatabaseRegion } from 'data/read-replicas/replicas.utils'
import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
import { Check } from 'lucide-react'
import { REPLICA_STATUS } from 'components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.constants'
import { Markdown } from 'components/interfaces/Markdown'
interface DatabaseSelectorProps {
variant?: 'regular' | 'connected-on-right' | 'connected-on-left' | 'connected-on-both'

View File

@@ -0,0 +1,44 @@
import { UseQueryOptions } from '@tanstack/react-query'
import { ExecuteSqlData, useExecuteSqlQuery } from '../sql/execute-sql-query'
export const replicationLagQuery = () => {
const sql = /* SQL */ `
select
case
when (select count(*) from pg_stat_wal_receiver) = 1 and pg_last_wal_receive_lsn() = pg_last_wal_replay_lsn()
then 0
else coalesce(extract(epoch from now() - pg_last_xact_replay_timestamp()),0)
end as physical_replica_lag_second
`
return sql
}
export type ReplicationLagVariables = {
id: string
projectRef?: string
connectionString?: string
}
export type ReplicationLagData = number
export type ReplicationLagError = unknown
export const useReplicationLagQuery = <TData extends ReplicationLagData = ReplicationLagData>(
{ projectRef, connectionString, id }: ReplicationLagVariables,
{ enabled, ...options }: UseQueryOptions<ExecuteSqlData, ReplicationLagError, TData> = {}
) =>
useExecuteSqlQuery(
{
projectRef,
connectionString,
sql: replicationLagQuery(),
queryKey: ['replica-lag', id],
},
{
select(data) {
return Number((data.result[0] ?? null)?.physical_replica_lag_second ?? 0) as TData
},
enabled: enabled && typeof projectRef !== 'undefined' && typeof id !== 'undefined',
...options,
}
)

View File

@@ -0,0 +1,31 @@
import { useParams } from 'common'
import { PRESET_CONFIG } from 'components/interfaces/Reports/Reports.constants'
import { queriesFactory } from 'components/interfaces/Reports/Reports.utils'
import { DbQueryHook } from 'hooks/analytics/useDbQuery'
export const useDatabaseReport = () => {
const { ref: projectRef } = useParams()
const queryHooks = queriesFactory<keyof typeof PRESET_CONFIG.database.queries>(
PRESET_CONFIG.database.queries,
projectRef ?? 'default'
)
const largeObjects = queryHooks.largeObjects() as DbQueryHook
const activeHooks = [largeObjects]
const isLoading = activeHooks.some((hook) => hook.isLoading)
return {
data: {
largeObjects: largeObjects.data,
},
errors: {
largeObjects: largeObjects.error,
},
params: {
largeObjects: largeObjects.params,
},
largeObjectsSql: largeObjects.resolvedSql,
isLoading,
}
}

View File

@@ -1,35 +1,31 @@
import { useParams } from 'common/hooks'
import dayjs from 'dayjs'
import { ArrowRight } from 'lucide-react'
import Link from 'next/link'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { AlertDescription_Shadcn_, Alert_Shadcn_, Button, IconExternalLink } from 'ui'
import { useParams } from 'common'
import ReportHeader from 'components/interfaces/Reports/ReportHeader'
import ReportPadding from 'components/interfaces/Reports/ReportPadding'
import ReportWidget from 'components/interfaces/Reports/ReportWidget'
import { PRESET_CONFIG } from 'components/interfaces/Reports/Reports.constants'
import { queriesFactory } from 'components/interfaces/Reports/Reports.utils'
import { ReportsLayout } from 'components/layouts'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import ChartHandler from 'components/to-be-cleaned/Charts/ChartHandler'
import DateRangePicker from 'components/to-be-cleaned/DateRangePicker'
import Table from 'components/to-be-cleaned/Table'
import DatabaseSelector from 'components/ui/DatabaseSelector'
import Panel from 'components/ui/Panel'
import { useDatabaseSizeQuery } from 'data/database/database-size-query'
import type { DbQueryHook } from 'hooks/analytics/useDbQuery'
import { useDatabaseReport } from 'data/reports/database-report-query'
import { TIME_PERIODS_INFRA } from 'lib/constants/metrics'
import { formatBytes } from 'lib/helpers'
import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
import type { NextPageWithLayout } from 'types'
const DatabaseReport: NextPageWithLayout = () => {
return (
<div className="1xl:px-28 mx-auto flex flex-col gap-4 px-5 py-6 lg:px-16 xl:px-24 2xl:px-32">
<div className="content h-full w-full overflow-y-auto">
<div className="w-full">
<DatabaseUsage />
</div>
</div>
</div>
<ReportPadding>
<DatabaseUsage />
</ReportPadding>
)
}
@@ -38,10 +34,13 @@ DatabaseReport.getLayout = (page) => <ReportsLayout title="Database">{page}</Rep
export default DatabaseReport
const DatabaseUsage = () => {
const { db, chart } = useParams()
const { project } = useProjectContext()
const [dateRange, setDateRange] = useState<any>(undefined)
const state = useDatabaseSelectorStateSnapshot()
const showReadReplicasUI = project?.is_read_replicas_enabled
const isReadReplicasEnabled = project?.is_read_replicas_enabled
const isReplicaSelected = state.selectedDatabaseId !== project?.ref
const report = useDatabaseReport()
const { data } = useDatabaseSizeQuery({
@@ -50,205 +49,203 @@ const DatabaseUsage = () => {
})
const databaseSizeBytes = data?.result[0].db_size ?? 0
// [Joshen] Empty dependency array as we only want this running once
useEffect(() => {
if (db !== undefined) {
setTimeout(() => {
// [Joshen] Adding a timeout here to support navigation from settings to reports
// Both are rendering different instances of ProjectLayout which is where the
// DatabaseSelectorContextProvider lies in (unless we reckon shifting the provider up one more level is better)
state.setSelectedDatabaseId(db)
}, 100)
}
if (chart !== undefined) {
setTimeout(() => {
const el = document.getElementById(chart)
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, 200)
}
}, [db, chart])
return (
<>
<div>
<section>
<Panel
title={
<div className="w-full flex items-center justify-between">
<h2>Database health</h2>
{showReadReplicasUI && <DatabaseSelector />}
</div>
}
>
<Panel.Content>
<div className="mb-4 flex items-center space-x-3">
<DateRangePicker
loading={false}
value={'7d'}
options={TIME_PERIODS_INFRA}
currentBillingPeriodStart={undefined}
onChange={setDateRange}
<ReportHeader title="Database" showDatabaseSelector={isReadReplicasEnabled} />
<section>
<Panel title={<h2>Database health</h2>}>
<Panel.Content>
<div className="mb-4 flex items-center space-x-3">
<DateRangePicker
loading={false}
value={'7d'}
options={TIME_PERIODS_INFRA}
currentBillingPeriodStart={undefined}
onChange={setDateRange}
/>
{dateRange && (
<div className="flex items-center gap-x-2">
<p className="text-foreground-light">
{dayjs(dateRange.period_start.date).format('MMMM D, hh:mma')}
</p>
<p className="text-foreground-light">
<ArrowRight size={12} />
</p>
<p className="text-foreground-light">
{dayjs(dateRange.period_end.date).format('MMMM D, hh:mma')}
</p>
</div>
)}
</div>
<div className="space-y-6">
{dateRange && (
<ChartHandler
startDate={dateRange?.period_start?.date}
endDate={dateRange?.period_end?.date}
attribute={'ram_usage'}
label={'Memory usage'}
interval={dateRange.interval}
provider={'infra-monitoring'}
/>
{dateRange && (
<div className="flex items-center gap-x-2">
<p className="text-foreground-light">
{dayjs(dateRange.period_start.date).format('MMMM D, hh:mma')}
</p>
<p className="text-foreground-light">
<ArrowRight size={12} />
</p>
<p className="text-foreground-light">
{dayjs(dateRange.period_end.date).format('MMMM D, hh:mma')}
</p>
</div>
)}
</div>
<div className="space-y-6">
{dateRange && (
<ChartHandler
startDate={dateRange?.period_start?.date}
endDate={dateRange?.period_end?.date}
attribute={'ram_usage'}
label={'Memory usage'}
interval={dateRange.interval}
provider={'infra-monitoring'}
/>
)}
)}
{dateRange && (
<ChartHandler
startDate={dateRange?.period_start?.date}
endDate={dateRange?.period_end?.date}
attribute={'swap_usage'}
label={'Swap usage'}
interval={dateRange.interval}
provider={'infra-monitoring'}
/>
)}
{dateRange && (
<ChartHandler
startDate={dateRange?.period_start?.date}
endDate={dateRange?.period_end?.date}
attribute={'swap_usage'}
label={'Swap usage'}
interval={dateRange.interval}
provider={'infra-monitoring'}
/>
)}
{dateRange && (
<ChartHandler
startDate={dateRange?.period_start?.date}
endDate={dateRange?.period_end?.date}
attribute={'avg_cpu_usage'}
label={'Average CPU usage'}
interval={dateRange.interval}
provider={'infra-monitoring'}
/>
)}
{dateRange && (
<ChartHandler
startDate={dateRange?.period_start?.date}
endDate={dateRange?.period_end?.date}
attribute={'avg_cpu_usage'}
label={'Average CPU usage'}
interval={dateRange.interval}
provider={'infra-monitoring'}
/>
)}
{dateRange && (
<ChartHandler
startDate={dateRange?.period_start?.date}
endDate={dateRange?.period_end?.date}
attribute={'max_cpu_usage'}
label={'Max CPU usage'}
interval={dateRange.interval}
provider={'infra-monitoring'}
/>
)}
{dateRange && (
<ChartHandler
startDate={dateRange?.period_start?.date}
endDate={dateRange?.period_end?.date}
attribute={'max_cpu_usage'}
label={'Max CPU usage'}
interval={dateRange.interval}
provider={'infra-monitoring'}
/>
)}
{dateRange && (
<ChartHandler
startDate={dateRange?.period_start?.date}
endDate={dateRange?.period_end?.date}
attribute={'disk_io_consumption'}
label={'Disk IO consumed'}
interval={dateRange.interval}
provider={'infra-monitoring'}
/>
)}
{dateRange && (
<ChartHandler
startDate={dateRange?.period_start?.date}
endDate={dateRange?.period_end?.date}
attribute={'disk_io_consumption'}
label={'Disk IO consumed'}
interval={dateRange.interval}
provider={'infra-monitoring'}
/>
)}
</div>
</Panel.Content>
</Panel>
{dateRange && isReplicaSelected && (
<Panel title="Replica Information">
<Panel.Content>
<div id="replication-lag">
<ChartHandler
startDate={dateRange?.period_start?.date}
endDate={dateRange?.period_end?.date}
attribute="physical_replication_lag_physical_replica_lag_seconds"
label="Replication lag"
interval={dateRange.interval}
provider="infra-monitoring"
/>
</div>
</Panel.Content>
</Panel>
)}
<ReportWidget
isLoading={report.isLoading}
params={report.params.largeObjects}
title="Database Size - Large Objects"
data={report.data.largeObjects || []}
queryType={'db'}
resolvedSql={report.largeObjectsSql}
renderer={(props) => {
return (
<div>
<header className="text-sm">Database Size used</header>
<p className="text-xl tracking-wide">{formatBytes(databaseSizeBytes)}</p>
<ReportWidget
isLoading={report.isLoading}
params={report.params.largeObjects}
title="Database Size - Large Objects"
data={report.data.largeObjects || []}
queryType={'db'}
resolvedSql={report.largeObjectsSql}
renderer={(props) => {
return (
<div>
<header className="text-sm">Database Size used</header>
<p className="text-xl tracking-wide">{formatBytes(databaseSizeBytes)}</p>
{!props.isLoading && props.data.length === 0 && (
<span>No large objects found</span>
)}
{!props.isLoading && props.data.length > 0 && (
<Table
className="space-y-3 mt-4"
head={[
<Table.th key="object" className="py-2">
Object
</Table.th>,
<Table.th key="size" className="py-2">
Size
</Table.th>,
]}
body={props.data?.map((object) => {
const percentage = (
((object.table_size as number) / databaseSizeBytes) *
100
).toFixed(2)
{!props.isLoading && props.data.length === 0 && <span>No large objects found</span>}
{!props.isLoading && props.data.length > 0 && (
<Table
className="space-y-3 mt-4"
head={[
<Table.th key="object" className="py-2">
Object
</Table.th>,
<Table.th key="size" className="py-2">
Size
</Table.th>,
]}
body={props.data?.map((object) => {
const percentage = (
((object.table_size as number) / databaseSizeBytes) *
100
).toFixed(2)
return (
<Table.tr key={`${object.schema_name}.${object.relname}`}>
<Table.td>
{object.schema_name}.{object.relname}
</Table.td>
<Table.td>
{formatBytes(object.table_size)} ({percentage}%)
</Table.td>
</Table.tr>
)
})}
/>
)}
</div>
)
}}
append={() => (
<div className="px-6 pb-2">
<Alert_Shadcn_ variant="default" className="mt-4">
<AlertDescription_Shadcn_>
<div className="space-y-2">
<p>
New Supabase projects have a database size of ~40-60mb. This space includes
pre-installed extensions, schemas, and default Postgres data. Additional
database size is used when installing extensions, even if those extensions
are inactive.
</p>
<Button asChild type="default" icon={<IconExternalLink />}>
<Link
href="https://supabase.com/docs/guides/platform/database-size#database-space-management"
target="_blank"
rel="noreferrer"
>
Read about database space
</Link>
</Button>
</div>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
return (
<Table.tr key={`${object.schema_name}.${object.relname}`}>
<Table.td>
{object.schema_name}.{object.relname}
</Table.td>
<Table.td>
{formatBytes(object.table_size)} ({percentage}%)
</Table.td>
</Table.tr>
)
})}
/>
)}
</div>
)}
/>
</section>
</div>
)
}}
append={() => (
<div className="px-6 pb-2">
<Alert_Shadcn_ variant="default" className="mt-4">
<AlertDescription_Shadcn_>
<div className="space-y-2">
<p>
New Supabase projects have a database size of ~40-60mb. This space includes
pre-installed extensions, schemas, and default Postgres data. Additional
database size is used when installing extensions, even if those extensions are
inactive.
</p>
<Button asChild type="default" icon={<IconExternalLink />}>
<Link
href="https://supabase.com/docs/guides/platform/database-size#database-space-management"
target="_blank"
rel="noreferrer"
>
Read about database space
</Link>
</Button>
</div>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
</div>
)}
/>
</section>
</>
)
}
const useDatabaseReport = () => {
const { ref: projectRef } = useParams()
const queryHooks = queriesFactory<keyof typeof PRESET_CONFIG.database.queries>(
PRESET_CONFIG.database.queries,
projectRef ?? 'default'
)
const largeObjects = queryHooks.largeObjects() as DbQueryHook
const activeHooks = [largeObjects]
const isLoading = activeHooks.some((hook) => hook.isLoading)
return {
data: {
largeObjects: largeObjects.data,
},
errors: {
largeObjects: largeObjects.error,
},
params: {
largeObjects: largeObjects.params,
},
largeObjectsSql: largeObjects.resolvedSql,
isLoading,
}
}