diff --git a/apps/studio/components/ui/Charts/ChartHandler.tsx b/apps/studio/components/ui/Charts/ChartHandler.tsx index 1ffe98f9a9c..fdce29bb075 100644 --- a/apps/studio/components/ui/Charts/ChartHandler.tsx +++ b/apps/studio/components/ui/Charts/ChartHandler.tsx @@ -59,6 +59,7 @@ const ChartHandler = ({ isLoading, format, highlightedValue, + ...otherProps }: PropsWithChildren) => { const router = useRouter() const { ref } = router.query @@ -168,7 +169,6 @@ const ChartHandler = ({ {chartStyle === 'bar' ? ( ) : ( )} diff --git a/apps/studio/components/ui/Charts/ChartHeader.tsx b/apps/studio/components/ui/Charts/ChartHeader.tsx index 7eabf60887e..8b2fceca792 100644 --- a/apps/studio/components/ui/Charts/ChartHeader.tsx +++ b/apps/studio/components/ui/Charts/ChartHeader.tsx @@ -1,5 +1,6 @@ import { Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui' -import { Activity, BarChartIcon, GitCommitHorizontalIcon } from 'lucide-react' +import { Activity, BarChartIcon, GitCommitHorizontalIcon, InfoIcon } from 'lucide-react' +import Link from 'next/link' export interface ChartHeaderProps { title?: string @@ -9,17 +10,20 @@ export interface ChartHeaderProps { displayDateInUtc?: boolean highlightedLabel?: number | string | any | null highlightedValue?: number | string | any | null + hideHighlightedValue?: boolean hideChartType?: boolean chartStyle?: string onChartStyleChange?: (style: string) => void showMaxValue?: boolean setShowMaxValue?: (value: boolean) => void + docsUrl?: string } const ChartHeader = ({ format, highlightedValue, highlightedLabel, + hideHighlightedValue = false, title, minimalHeader = false, hideChartType = false, @@ -27,11 +31,23 @@ const ChartHeader = ({ onChartStyleChange, showMaxValue = false, setShowMaxValue, + docsUrl, }: ChartHeaderProps) => { const chartTitle = ( -

- {title} -

+
+

+ {title} +

+ {docsUrl && ( + + + + )} +
) const highlighted = ( @@ -52,7 +68,7 @@ const ChartHeader = ({
{title && chartTitle}
- {highlightedValue !== undefined && highlighted} + {highlightedValue !== undefined && !hideHighlightedValue && highlighted} {label}
@@ -63,7 +79,7 @@ const ChartHeader = ({
{title && chartTitle} - {highlightedValue !== undefined && highlighted} + {highlightedValue !== undefined && !hideHighlightedValue && highlighted} {label}
@@ -77,7 +93,7 @@ const ChartHeader = ({ onClick={() => onChartStyleChange(chartStyle === 'bar' ? 'line' : 'bar')} /> - + View as {chartStyle === 'bar' ? 'line chart' : 'bar chart'} @@ -90,13 +106,13 @@ const ChartHeader = ({ className="px-1.5" icon={ } onClick={() => setShowMaxValue(!showMaxValue)} /> - + {showMaxValue ? 'Hide' : 'Show'} limit diff --git a/apps/studio/components/ui/Charts/ChartHighlightActions.tsx b/apps/studio/components/ui/Charts/ChartHighlightActions.tsx index 83cac8af215..21633ec2dd7 100644 --- a/apps/studio/components/ui/Charts/ChartHighlightActions.tsx +++ b/apps/studio/components/ui/Charts/ChartHighlightActions.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react' +import { useRouter } from 'next/router' import dayjs from 'dayjs' -import { ArrowRight, SearchIcon } from 'lucide-react' +import { ArrowRight, LogsIcon, SearchIcon } from 'lucide-react' import { cn, DropdownMenu, @@ -20,6 +21,8 @@ const ChartHighlightActions = ({ chartHighlight?: ChartHighlight updateDateRange: UpdateDateRange }) => { + const router = useRouter() + const { ref } = router.query const { left: selectedRangeStart, right: selectedRangeEnd, clearHighlight } = chartHighlight ?? {} const [isOpen, setIsOpen] = useState(!!chartHighlight?.popoverPosition) @@ -34,6 +37,12 @@ const ChartHighlightActions = ({ clearHighlight && clearHighlight() } + const handleOpenLogsExplorer = () => { + const rangeQueryParams = `?its=${selectedRangeStart}&ite=${selectedRangeEnd}` + router.push(`/project/${ref}/logs/postgres-logs${rangeQueryParams}`) + clearHighlight && clearHighlight() + } + return ( Zoom in - {disableZoomIn && ( - 10 minutes min. - )} + {disableZoomIn && Min. 10 minutes} + + + + diff --git a/apps/studio/components/ui/Charts/Charts.constants.ts b/apps/studio/components/ui/Charts/Charts.constants.ts index 17139558ba0..d231b13b5e7 100644 --- a/apps/studio/components/ui/Charts/Charts.constants.ts +++ b/apps/studio/components/ui/Charts/Charts.constants.ts @@ -9,6 +9,8 @@ export const CHART_COLORS = { GREEN_2: 'hsl(var(--brand-500))', RED_1: 'hsl(var(--destructive-default))', RED_2: 'hsl(var(--destructive-500))', + REFERENCE_LINE: 'hsl(var(--foreground-muted))', + REFERENCE_LINE_TEXT: 'hsl(var(--foreground-muted))', } const LIGHT_STACKED_CHART_COLORS = [ diff --git a/apps/studio/components/ui/Charts/ComposedChart.tsx b/apps/studio/components/ui/Charts/ComposedChart.tsx index 1e6c62bbf1f..5a94ec5367a 100644 --- a/apps/studio/components/ui/Charts/ComposedChart.tsx +++ b/apps/studio/components/ui/Charts/ComposedChart.tsx @@ -10,9 +10,11 @@ import { Line, ComposedChart as RechartComposedChart, ReferenceArea, + ReferenceLine, Tooltip, XAxis, YAxis, + Label, } from 'recharts' import { CategoricalChartState } from 'recharts/types/chart/types' import { cn } from 'ui' @@ -26,17 +28,13 @@ import { } from './Charts.constants' import { CommonChartProps, Datum } from './Charts.types' import { numberFormatter, useChartSize } from './Charts.utils' -import { - calculateTotalChartAggregate, - CustomLabel, - CustomTooltip, - formatBytes, -} from './ComposedChart.utils' +import { calculateTotalChartAggregate, CustomLabel, CustomTooltip } from './ComposedChart.utils' import { MultiAttribute } from './ComposedChartHandler' import NoDataPlaceholder from './NoDataPlaceholder' import { ChartHighlight } from './useChartHighlight' +import { formatBytes } from 'lib/helpers' -export interface BarChartProps extends CommonChartProps { +export interface ComposedChartProps extends CommonChartProps { attributes: MultiAttribute[] yAxisKey: string xAxisKey: string @@ -56,6 +54,10 @@ export interface BarChartProps extends CommonChartProps { chartStyle?: string onChartStyleChange?: (style: string) => void updateDateRange: any + hideYAxis?: boolean + hideHighlightedValue?: boolean + syncId?: string + docsUrl?: string } export default function ComposedChart({ @@ -88,16 +90,22 @@ export default function ComposedChart({ chartStyle, onChartStyleChange, updateDateRange, -}: BarChartProps) { + hideYAxis, + hideHighlightedValue, + syncId, + docsUrl, +}: ComposedChartProps) { const { resolvedTheme } = useTheme() const [_activePayload, setActivePayload] = useState(null) const [_showMaxValue, setShowMaxValue] = useState(showMaxValue) const [focusDataIndex, setFocusDataIndex] = useState(null) const [hoveredLabel, setHoveredLabel] = useState(null) + const [isActiveHoveredChart, setIsActiveHoveredChart] = useState(false) + const isDarkMode = resolvedTheme?.includes('dark') // Update chart colors when theme changes useEffect(() => { - updateStackedChartColors(resolvedTheme?.includes('dark') ?? false) + updateStackedChartColors(isDarkMode ?? false) }, [resolvedTheme]) const { Container } = useChartSize(size) @@ -134,7 +142,7 @@ export default function ComposedChart({ const maxAttribute = attributes.find((a) => a.isMaxValue) const maxAttributeData = { name: maxAttribute?.attribute, - color: '#3ECF8E', + color: CHART_COLORS.REFERENCE_LINE, } const lastDataPoint = !!data[data.length - 1] @@ -145,6 +153,7 @@ export default function ComposedChart({ })) .filter((entry) => entry.dataKey !== 'timestamp') : undefined + const referenceLines = attributes.filter((attribute) => attribute?.provider === 'reference-line') const resolvedHighlightedLabel = getHeaderLabel() const resolvedHighlightedValue = @@ -152,13 +161,13 @@ export default function ComposedChart({ ? showTotal ? calculateTotalChartAggregate( _activePayload, - maxAttribute?.attribute ? [maxAttribute?.attribute] : [] + maxAttribute?.attribute ? referenceLines.map((a) => a.attribute) : [] ) : data[focusDataIndex]?.[yAxisKey] : showTotal && lastDataPoint ? calculateTotalChartAggregate( lastDataPoint, - maxAttribute?.attribute ? [maxAttribute?.attribute] : [] + maxAttribute?.attribute ? referenceLines.map((a) => a.attribute) : [] ) : highlightedValue @@ -175,13 +184,25 @@ export default function ComposedChart({ value: value, color: STACKED_CHART_COLORS[index - (1 % STACKED_CHART_COLORS.length)], })) - .filter((att) => att.name !== 'timestamp' && att.name !== maxAttribute?.attribute) + .filter( + (att) => + att.name !== 'timestamp' && + att.name !== maxAttribute?.attribute && + !referenceLines.map((a) => a.attribute).includes(att.name) + ) : [] const stackedAttributes = chartData.filter((att) => !att.name.includes('max')) const isPercentage = format === '%' const isRamChart = chartData?.some((att: any) => att.name.toLowerCase().includes('ram_')) - + const isDiskSpaceChart = chartData?.some((att: any) => + att.name.toLowerCase().includes('disk_space_') + ) + const isDBSizeChart = chartData?.some((att: any) => + att.name.toLowerCase().includes('pg_database_size') + ) + const isNetworkChart = chartData?.some((att: any) => att.name.toLowerCase().includes('network_')) + const shouldFormatBytes = isRamChart || isDiskSpaceChart || isDBSizeChart || isNetworkChart //* // Set the y-axis domain // to the highest value in the chart data for percentage charts @@ -213,8 +234,11 @@ export default function ComposedChart({ customDateFormat={customDateFormat} highlightedValue={ typeof resolvedHighlightedValue === 'number' - ? isRamChart - ? formatBytes(resolvedHighlightedValue, valuePrecision) + ? shouldFormatBytes + ? formatBytes( + isNetworkChart ? Math.abs(resolvedHighlightedValue) : resolvedHighlightedValue, + valuePrecision + ) : numberFormatter(resolvedHighlightedValue, valuePrecision) : resolvedHighlightedValue } @@ -225,11 +249,15 @@ export default function ComposedChart({ onChartStyleChange={onChartStyleChange} showMaxValue={_showMaxValue} setShowMaxValue={maxAttribute ? setShowMaxValue : undefined} + hideHighlightedValue={hideHighlightedValue} + docsUrl={docsUrl} /> { + setIsActiveHoveredChart(true) if (e.activeTooltipIndex !== focusDataIndex) { setFocusDataIndex(e.activeTooltipIndex) setActivePayload(e.activePayload) @@ -249,6 +277,7 @@ export default function ComposedChart({ }} onMouseUp={chartHighlight?.handleMouseUp} onMouseLeave={(e) => { + setIsActiveHoveredChart(false) setFocusDataIndex(null) setActivePayload(null) }} @@ -258,7 +287,14 @@ export default function ComposedChart({ }} > {showGrid && } - + ) : null } @@ -286,22 +323,23 @@ export default function ComposedChart({ a.attribute === attribute?.name)?.stackId ?? '1'} fill={attribute.color} - fillOpacity={hoveredLabel && hoveredLabel !== attribute.name ? 0.25 : 1} + fillOpacity={hoveredLabel && hoveredLabel !== attribute?.name ? 0.25 : 1} radius={0.75} - opacity={hoveredLabel && hoveredLabel !== attribute.name ? 0.5 : 1} + opacity={hoveredLabel && hoveredLabel !== attribute?.name ? 0.5 : 1} name={ - attributes?.find((a) => a.attribute === attribute.name)?.label || attribute.name + attributes?.find((a) => a.attribute === attribute?.name)?.label || + attribute?.name } /> )) - : stackedAttributes.map((attribute) => ( + : stackedAttributes.map((attribute, i) => ( a.attribute === attribute.name)?.stackId ?? '1'} fill={attribute.color} strokeOpacity={hoveredLabel && hoveredLabel !== attribute.name ? 0.4 : 1} stroke={attribute.color} @@ -317,6 +355,8 @@ export default function ComposedChart({ name={ attributes?.find((a) => a.attribute === attribute.name)?.label || attribute.name } + // Show dot for the first attribute when a point is focused + dot={false} /> ))} {/* Max value, if available */} @@ -325,13 +365,33 @@ export default function ComposedChart({ key={maxAttribute.attribute} type="stepAfter" dataKey={maxAttribute.attribute} - stroke="#3ECF8E" + stroke={CHART_COLORS.REFERENCE_LINE} strokeWidth={2} - strokeDasharray="3 3" + strokeDasharray={maxAttribute.strokeDasharray ?? '3 3'} dot={false} name={maxAttribute.label} /> )} + {referenceLines + .filter((line) => line.isReferenceLine) + .map((line) => ( + + + ))} {/* Selection highlight */} {showHighlightActions && ( {data && ( -
+
{xAxisIsDate ? day(data[0]?.timestamp).format(customDateFormat) : data[0]?.timestamp} diff --git a/apps/studio/components/ui/Charts/ComposedChart.utils.tsx b/apps/studio/components/ui/Charts/ComposedChart.utils.tsx index bacb440bf9b..b7325c94061 100644 --- a/apps/studio/components/ui/Charts/ComposedChart.utils.tsx +++ b/apps/studio/components/ui/Charts/ComposedChart.utils.tsx @@ -3,9 +3,10 @@ import dayjs from 'dayjs' import { useState } from 'react' import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' -import { DateTimeFormats } from './Charts.constants' +import { CHART_COLORS, DateTimeFormats } from './Charts.constants' import { numberFormatter } from './Charts.utils' import { MultiAttribute } from './ComposedChartHandler' +import { formatBytes } from 'lib/helpers' interface CustomIconProps { color: string @@ -17,9 +18,17 @@ const CustomIcon = ({ color }: CustomIconProps) => ( ) -const MaxConnectionsIcon = () => ( +const MaxConnectionsIcon = ({ color }: { color?: string }) => ( - + ) @@ -32,14 +41,7 @@ interface TooltipProps { valuePrecision?: number showMaxValue?: boolean showTotal?: boolean -} - -export const formatBytes = (bytes: number, precision: number = 1) => { - if (bytes === 0) return '0 Bytes' - const k = 1024 - const sizes = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'] - const i = Math.floor(Math.log(bytes) / Math.log(k)) - return `${(bytes / Math.pow(k, i)).toFixed(precision)} ${sizes[i]}` + isActiveHoveredChart?: boolean } const isMaxAttribute = (attributes?: MultiAttribute[]) => attributes?.find((a) => a.isMaxValue) @@ -64,6 +66,7 @@ const CustomTooltip = ({ isPercentage, valuePrecision, showTotal, + isActiveHoveredChart, }: TooltipProps) => { if (active && payload && payload.length) { const timestamp = payload[0].payload.timestamp @@ -72,6 +75,15 @@ const CustomTooltip = ({ maxValueAttribute && payload?.find((p: any) => p.dataKey === maxValueAttribute.attribute) const maxValue = maxValueData?.value const isRamChart = payload?.some((p: any) => p.dataKey.toLowerCase().includes('ram_')) + const isDiskSpaceChart = payload?.some((p: any) => + p.dataKey.toLowerCase().includes('disk_space_') + ) + const isDBSizeChart = payload?.some((p: any) => + p.dataKey.toLowerCase().includes('pg_database_size') + ) + const isNetworkChart = payload?.some((p: any) => p.dataKey.toLowerCase().includes('network_')) + const shouldFormatBytes = isRamChart || isDiskSpaceChart || isDBSizeChart || isNetworkChart + const total = showTotal && calculateTotalChartAggregate( @@ -79,29 +91,23 @@ const CustomTooltip = ({ maxValueAttribute?.attribute ? [maxValueAttribute.attribute] : [] ) - const getIcon = (name: string, color: string) => { - switch (name.toLowerCase().includes('max')) { - case false: - return - default: - return - } - } + const getIcon = (color: string, isMax: boolean) => + isMax ? : const LabelItem = ({ entry }: { entry: any }) => { - const attribute = attributes?.find((a: MultiAttribute) => a.attribute === entry.name) + const attribute = attributes?.find((a: MultiAttribute) => a?.attribute === entry.name) const percentage = ((entry.value / maxValue) * 100).toFixed(1) const isMax = entry.dataKey === maxValueAttribute?.attribute return (
- {getIcon(entry.name, entry.color)} + {getIcon(entry.color, isMax)} {attribute?.label || entry.name} - {isRamChart - ? formatBytes(entry.value, valuePrecision) + {shouldFormatBytes + ? formatBytes(isNetworkChart ? Math.abs(entry.value) : entry.value, valuePrecision) : numberFormatter(entry.value, valuePrecision)} {isPercentage ? '%' : ''} @@ -115,18 +121,28 @@ const CustomTooltip = ({ } return ( -
+

{dayjs(timestamp).format(DateTimeFormats.FULL_SECONDS)}

{payload.reverse().map((entry: any, index: number) => ( ))} {active && showTotal && ( -
+
Total
- {isRamChart ? formatBytes(total as number, 1) : numberFormatter(total as number)} + {shouldFormatBytes + ? formatBytes( + isDBSizeChart ? (total as number) * 1024 * 1024 : (total as number), + valuePrecision + ) + : numberFormatter(total as number, valuePrecision)} {isPercentage ? '%' : ''} {maxValueAttribute && @@ -184,11 +200,11 @@ const CustomLabel = ({ payload, attributes, showMaxValue, onLabelHover }: Custom const isHovered = hoveredLabel === entry.name const Label = () => ( -
+
{getIcon(entry.name, entry.color)} @@ -203,7 +219,7 @@ const CustomLabel = ({ payload, attributes, showMaxValue, onLabelHover }: Custom
handleMouseEnter(entry.name)} + onMouseOver={() => handleMouseEnter(entry.name)} onMouseOutCapture={handleMouseLeave} > {!!attribute?.tooltip ? ( diff --git a/apps/studio/components/ui/Charts/ComposedChartHandler.tsx b/apps/studio/components/ui/Charts/ComposedChartHandler.tsx index c88e56ca587..25bee033ebf 100644 --- a/apps/studio/components/ui/Charts/ComposedChartHandler.tsx +++ b/apps/studio/components/ui/Charts/ComposedChartHandler.tsx @@ -17,13 +17,14 @@ import { useChartHighlight } from './useChartHighlight' import type { ChartData } from './Charts.types' import type { UpdateDateRange } from 'pages/project/[ref]/reports/database' -type Provider = 'infra-monitoring' | 'daily-stats' +type Provider = 'infra-monitoring' | 'daily-stats' | 'reference-line' export type MultiAttribute = { attribute: string provider: Provider label?: string color?: string + stackId?: string format?: 'percent' | 'number' description?: string docsLink?: string @@ -31,6 +32,12 @@ export type MultiAttribute = { type?: 'line' | 'area-bar' omitFromTotal?: boolean tooltip?: string + customValue?: number + id?: string + value?: number + isReferenceLine?: boolean + strokeDasharray?: string + className?: string } interface ComposedChartHandlerProps { @@ -55,6 +62,7 @@ interface ComposedChartHandlerProps { updateDateRange: UpdateDateRange valuePrecision?: number isVisible?: boolean + docsUrl?: string } /** @@ -124,6 +132,8 @@ const ComposedChartHandler = ({ updateDateRange, valuePrecision, isVisible = true, + id, + ...otherProps }: PropsWithChildren) => { const router = useRouter() const { ref } = router.query @@ -160,20 +170,46 @@ const ComposedChartHandler = ({ const timestamps = new Set() attributeQueries.forEach((query: any) => { query.data?.data?.forEach((point: any) => { - timestamps.add(point.period_start) + if (point?.period_start) { + timestamps.add(point.period_start) + } }) }) + const referenceLineQueries = attributeQueries.filter( + (_, index) => attributes[index].provider === 'reference-line' + ) + // Combine data points for each timestamp const combined = Array.from(timestamps) .sort() .map((timestamp) => { const point: any = { timestamp } + + // Add regular attributes attributes.forEach((attr, index) => { - const queryData = attributeQueries[index].data?.data + if (!attr) return + // Handle custom value attributes (like disk size) + if (attr.customValue !== undefined) { + point[attr.attribute] = attr.customValue + return + } + + // Skip reference line attributes here, we'll add them below + if (attr.provider === 'reference-line') return + + const queryData = attributeQueries[index]?.data?.data const matchingPoint = queryData?.find((p: any) => p.period_start === timestamp) point[attr.attribute] = matchingPoint?.[attr.attribute] ?? 0 }) + + // Add reference line values for each timestamp + referenceLineQueries.forEach((query: any) => { + const attr = query.data.attribute + const value = query.data.total + point[attr] = value + }) + return point as DataPoint }) @@ -240,19 +276,14 @@ const ComposedChartHandler = ({ -
- {children} -
+
{children}
@@ -287,11 +319,12 @@ const useAttributeQueries = ( isVisible: boolean ) => { const infraAttributes = attributes - .filter((attr) => attr.provider === 'infra-monitoring') + .filter((attr) => attr?.provider === 'infra-monitoring') .map((attr) => attr.attribute as InfraMonitoringAttribute) const dailyStatsAttributes = attributes - .filter((attr) => attr.provider === 'daily-stats') + .filter((attr) => attr?.provider === 'daily-stats') .map((attr) => attr.attribute as ProjectDailyStatsAttribute) + const referenceLines = attributes.filter((attr) => attr?.provider === 'reference-line') const infraQueries = useInfraMonitoringQueries( infraAttributes, @@ -314,7 +347,23 @@ const useAttributeQueries = ( isVisible ) - return [...infraQueries, ...dailyStatsQueries] + const referenceLineQueries = referenceLines.map((line) => { + let value = line.value || 0 + + return { + data: { + data: [], // Will be populated in combinedData + attribute: line.attribute, + total: value, + maximum: value, + totalGrouped: { [line.attribute]: value }, + }, + isLoading: false, + isError: false, + } + }) + + return [...infraQueries, ...dailyStatsQueries, ...referenceLineQueries] } export default function LazyComposedChartHandler(props: ComposedChartHandlerProps) { diff --git a/apps/studio/components/ui/Panel.tsx b/apps/studio/components/ui/Panel.tsx index 12b2d88f0bc..616b368015f 100644 --- a/apps/studio/components/ui/Panel.tsx +++ b/apps/studio/components/ui/Panel.tsx @@ -5,6 +5,7 @@ import { Badge, Button, cn, Loading } from 'ui' interface PanelProps { className?: string + id?: string footer?: JSX.Element | false loading?: boolean noMargin?: boolean @@ -24,6 +25,7 @@ function Panel(props: PropsWithChildren) { props.noMargin ? '' : 'mb-4 md:mb-8', props.className )} + id={props.id} > {props.title && (
[ { id: 'ram_usage', label: 'Memory usage', hide: false }, { id: 'avg_cpu_usage', label: 'Average CPU usage', hide: false }, @@ -21,179 +26,426 @@ export const getReportAttributes = (isFreePlan: boolean) => [ }, ] -export const getReportAttributesV2 = (isFreePlan: boolean) => [ - { - id: 'ram-usage', - label: 'Memory usage', - hide: false, - showTooltip: true, - showLegend: true, - hideChartType: false, - defaultChartStyle: 'line', - showMaxValue: false, - attributes: [ - { - attribute: 'ram_usage_used', - provider: 'infra-monitoring', - label: 'Used', - tooltip: - 'Total RAM currently in use by the system and applications. High usage may indicate memory pressure.', - }, - { - attribute: 'ram_usage_cache_and_buffers', - provider: 'infra-monitoring', - label: 'Cache + buffers', - tooltip: - 'RAM used for caching and buffering disk operations. Higher values improve read performance.', - }, - { - attribute: 'ram_usage_free', - provider: 'infra-monitoring', - label: 'Free', - tooltip: - 'Available unused RAM. A small amount is always reserved. High unused RAM present with erratic Used RAM may indicate unoptimized queries disrupting cache.', - }, - { - attribute: 'ram_usage_swap', - provider: 'infra-monitoring', - label: 'Swap', - tooltip: - 'Memory swapped to disk when RAM is full. An instance has 1GB of SWAP. High swap with high disk I/O signals memory stress.', - }, - ], - }, - { - id: 'cpu-usage', - label: 'CPU usage', - format: '%', - valuePrecision: 2, - hide: false, - showTooltip: true, - showLegend: true, - showMaxValue: false, - hideChartType: false, - defaultChartStyle: 'line', - attributes: [ - { - attribute: 'cpu_usage_busy_system', - provider: 'infra-monitoring', - label: 'System', - format: '%', - tooltip: - 'CPU time spent on kernel operations (e.g., process scheduling, memory management). High values may indicate system overhead.', - }, - { - attribute: 'cpu_usage_busy_user', - provider: 'infra-monitoring', - label: 'User', - format: '%', - tooltip: - 'CPU time used by database queries and user-space processes. High values may suggest CPU-intensive queries.', - }, - { - attribute: 'cpu_usage_busy_iowait', - provider: 'infra-monitoring', - label: 'IOwait', - format: '%', - tooltip: - 'CPU time waiting for disk or network I/O. High values may indicate disk bottlenecks.', - }, - { - attribute: 'cpu_usage_busy_irqs', - provider: 'infra-monitoring', - label: 'IRQs', - format: '%', - tooltip: 'CPU time handling hardware interrupt requests (IRQ)', - }, - { - attribute: 'cpu_usage_busy_other', - provider: 'infra-monitoring', - label: 'Other', - format: '%', - tooltip: 'CPU time spent on other tasks (e.g., background processes, software interrupts).', - }, - ], - }, - { - id: 'disk-iops', - label: 'Disk IOps', - hide: false, - showTooltip: true, - showLegend: true, - hideChartType: false, - defaultChartStyle: 'line', - attributes: [ - { - attribute: 'disk_iops_write', - provider: 'infra-monitoring', - label: 'IOps write/s', - tooltip: - 'Number of write operations per second. High values indicate frequent data writes, logging, or transaction activity.', - }, - { - attribute: 'disk_iops_read', - provider: 'infra-monitoring', - label: 'IOps read/s', - tooltip: - 'Number of read operations per second. High values suggest frequent disk reads due to queries or poor caching.', - }, - ], - }, - { - id: 'client-connections', - label: 'Database client connections', - valuePrecision: 0, - hide: false, - showTooltip: false, - showLegend: false, - showMaxValue: false, - hideChartType: false, - defaultChartStyle: 'line', - attributes: [ - { - attribute: 'pg_stat_database_num_backends', - provider: 'infra-monitoring', - label: 'active connections', - tooltip: 'Active connections', - }, - ], - }, - { - id: 'pgbouncer-connections', - label: 'Pooler client connections', - valuePrecision: 0, - hide: false, - showTooltip: false, - showLegend: false, - showMaxValue: false, - hideChartType: false, - defaultChartStyle: 'line', - attributes: [ - { - attribute: 'client_connections_pgbouncer', - provider: 'infra-monitoring', - label: 'pgbouncer', - tooltip: 'PgBouncer connections', - }, - ], - }, - { - id: 'supavisor-connections-active', - label: 'Supavisor client connections', - valuePrecision: 0, - hide: isFreePlan, - showTooltip: false, - showLegend: false, - showMaxValue: false, - hideChartType: false, - defaultChartStyle: 'line', - attributes: [ - { - attribute: 'supavisor_connections_active', - provider: 'infra-monitoring', - label: 'supavisor', - tooltip: 'Supavisor connections', - }, - ], - }, -] +export const getReportAttributesV2 = (org: Organization, project: Project) => { + const isFreePlan = org?.plan?.id === 'free' + const computeSize = project?.infra_compute_size || 'medium' + const isSpendCapEnabled = + org?.plan.id !== 'free' && !org?.usage_billing_enabled && project?.cloud_provider !== 'FLY' + + return [ + { + id: 'ram-usage', + label: 'Memory usage', + hide: false, + showTooltip: true, + showLegend: true, + hideChartType: false, + defaultChartStyle: 'line', + showMaxValue: true, + showGrid: true, + syncId: 'database-reports', + YAxisProps: { width: 60, tickFormatter: (value: any) => formatBytes(value, 0) }, + attributes: [ + { + attribute: 'ram_usage_used', + provider: 'infra-monitoring', + label: 'Used', + tooltip: + 'Total RAM currently in use by the system and applications. High usage may indicate memory pressure.', + }, + { + attribute: 'ram_usage_cache_and_buffers', + provider: 'infra-monitoring', + label: 'Cache + buffers', + tooltip: + 'RAM used for caching and buffering disk operations. Higher values improve read performance.', + }, + { + attribute: 'ram_usage_free', + provider: 'infra-monitoring', + label: 'Free', + tooltip: + 'Available unused RAM. A small amount is always reserved. High unused RAM present with erratic Used RAM may indicate unoptimized queries disrupting cache.', + }, + { + attribute: 'ram_usage_swap', + provider: 'infra-monitoring', + label: 'Swap', + tooltip: + 'Memory swapped to disk when RAM is full. An instance has 1GB of SWAP. High swap with high disk I/O signals memory stress.', + }, + { + attribute: 'ram_usage_total', + provider: 'infra-monitoring', + label: 'Max', + isMaxValue: true, + tooltip: 'Total RAM', + }, + ], + }, + { + id: 'cpu-usage', + label: 'CPU usage', + syncId: 'database-reports', + format: '%', + valuePrecision: 2, + hide: false, + showTooltip: true, + showLegend: true, + showMaxValue: false, + showGrid: true, + YAxisProps: { width: 45, tickFormatter: (value: any) => `${numberFormatter(value, 2)}%` }, + hideChartType: false, + defaultChartStyle: 'line', + attributes: [ + { + attribute: 'cpu_usage_busy_system', + provider: 'infra-monitoring', + label: 'System', + format: '%', + tooltip: + 'CPU time spent on kernel operations (e.g., process scheduling, memory management). High values may indicate system overhead.', + }, + { + attribute: 'cpu_usage_busy_user', + provider: 'infra-monitoring', + label: 'User', + format: '%', + tooltip: + 'CPU time used by database queries and user-space processes. High values may suggest CPU-intensive queries.', + }, + { + attribute: 'cpu_usage_busy_iowait', + provider: 'infra-monitoring', + label: 'IOwait', + format: '%', + tooltip: + 'CPU time waiting for disk or network I/O. High values may indicate disk bottlenecks.', + }, + { + attribute: 'cpu_usage_busy_irqs', + provider: 'infra-monitoring', + label: 'IRQs', + format: '%', + tooltip: 'CPU time handling hardware interrupt requests (IRQ)', + }, + { + attribute: 'cpu_usage_busy_other', + provider: 'infra-monitoring', + label: 'Other', + format: '%', + tooltip: + 'CPU time spent on other tasks (e.g., background processes, software interrupts).', + }, + { + attribute: 'cpu_usage_max', + provider: 'reference-line', + label: 'Max', + value: 100, + tooltip: 'Max CPU usage', + isMaxValue: true, + }, + ], + }, + { + id: 'disk-iops', + label: 'Disk IOps', + syncId: 'database-reports', + hide: false, + showTooltip: true, + valuePrecision: 2, + showLegend: true, + hideChartType: false, + showGrid: true, + showMaxValue: true, + YAxisProps: { width: 35, tickFormatter: (value: any) => numberFormatter(value, 2) }, + defaultChartStyle: 'line', + docsUrl: 'https://supabase.com/docs/guides/platform/compute-and-disk#compute-size', + attributes: [ + { + attribute: 'disk_iops_write', + provider: 'infra-monitoring', + label: 'IOps write/s', + tooltip: + 'Number of write operations per second. High values indicate frequent data writes, logging, or transaction activity.', + }, + { + attribute: 'disk_iops_read', + provider: 'infra-monitoring', + label: 'IOps read/s', + tooltip: + 'Number of read operations per second. High values suggest frequent disk reads due to queries or poor caching.', + }, + { + attribute: 'disk_iops_max', + provider: 'reference-line', + label: 'Max IOPS', + value: getIOPSLimits(computeSize), + tooltip: + 'Maximum IOPS (Input/Output Operations Per Second) for your current compute size', + isMaxValue: true, + }, + ], + }, + { + id: 'disk-io-usage', + label: 'Disk IO Usage', + syncId: 'database-reports', + hide: false, + showTooltip: true, + format: '%', + valuePrecision: 6, + showLegend: false, + showMaxValue: false, + hideChartType: false, + showGrid: true, + YAxisProps: { width: 70, tickFormatter: (value: any) => `${numberFormatter(value, 6)}%` }, + defaultChartStyle: 'line', + attributes: [ + { + attribute: 'disk_io_usage', + provider: 'infra-monitoring', + label: 'IO Usage', + tooltip: + 'The actual number of IO operations per second that the database is currently using.', + }, + ], + }, + { + id: 'db-size', + label: 'Database Size', + syncId: 'database-reports', + valuePrecision: 2, + hide: false, + showTooltip: true, + showLegend: true, + showMaxValue: true, + showGrid: true, + YAxisProps: { width: 65, tickFormatter: (value: any) => formatBytes(value, 1) }, + hideChartType: false, + defaultChartStyle: 'line', + docsUrl: 'https://supabase.com/docs/guides/platform/database-size', + attributes: [ + { + attribute: 'pg_database_size', + provider: 'infra-monitoring', + label: 'Database', + tooltip: 'Total space on disk used by your database (tables, indexes, data, ...).', + }, + { + attribute: 'max_pg_database_size', + provider: 'reference-line', + label: 'Disk size', + value: (project?.volumeSizeGb || getRecommendedDbSize(computeSize)) * 1024 * 1024 * 1024, + tooltip: 'Disk Size refers to the total space your project occupies on disk', + isMaxValue: true, + }, + !isFreePlan && + (isSpendCapEnabled + ? { + attribute: 'pg_database_size_percent_paid_spendCap', + provider: 'reference-line', + isReferenceLine: true, + strokeDasharray: '4 2', + label: 'Spend cap enabled', + value: + (project?.volumeSizeGb || getRecommendedDbSize(computeSize)) * 1024 * 1024 * 1024, + className: '[&_line]:!stroke-yellow-800 [&_line]:!opacity-100', + opacity: 1, + } + : { + attribute: 'pg_database_size_percent_paid', + provider: 'reference-line', + isReferenceLine: true, + label: '90% - Disk resize threshold', + className: '[&_line]:!stroke-yellow-800', + value: + (project?.volumeSizeGb || getRecommendedDbSize(computeSize)) * + 1024 * + 1024 * + 1024 * + 0.9, // reaching 90% of the disk size will trigger a disk resize https://supabase.com/docs/guides/platform/database-size + }), + ], + }, + { + id: 'network-traffic', + label: 'Network Traffic', + syncId: 'database-reports', + valuePrecision: 1, + showTooltip: true, + showLegend: true, + showMaxValue: false, + showGrid: true, + YAxisProps: { width: 65, tickFormatter: (value: any) => formatBytes(value, 1) }, + hideChartType: false, + defaultChartStyle: 'line', + hideHighlightedValue: true, + showTotal: false, + attributes: [ + { + attribute: 'network_transmit_bytes', + provider: 'infra-monitoring', + label: 'Transmit', + tooltip: + 'Data sent from your database to clients. High values may indicate large query results or numerous outgoing connections.', + stackId: '2', + }, + { + attribute: 'network_receive_bytes', + provider: 'infra-monitoring', + label: 'Receive', + tooltip: + 'Data received by your database from clients. High values may indicate frequent queries, large data inserts, or many incoming connections.', + stackId: '1', + }, + ], + }, + { + id: 'client-connections', + label: 'Database client connections', + syncId: 'database-reports', + valuePrecision: 0, + hide: false, + showTooltip: true, + showLegend: true, + showMaxValue: true, + showGrid: true, + YAxisProps: { width: 30 }, + hideChartType: false, + defaultChartStyle: 'line', + docsUrl: 'https://supabase.com/docs/guides/platform/compute-and-disk#limits-and-constraints', + attributes: [ + { + attribute: 'pg_stat_database_num_backends', + provider: 'infra-monitoring', + label: 'Active connections', + }, + { + attribute: 'pg_database_max_connections', + provider: 'reference-line', + label: 'Max connections', + value: getConnectionLimits(computeSize).direct, + tooltip: 'Max available connections for your current compute size', + isMaxValue: true, + }, + ], + }, + { + id: 'pgbouncer-connections', + label: 'Pooler client connections', + syncId: 'database-reports', + valuePrecision: 0, + hide: false, + showTooltip: true, + showLegend: true, + showMaxValue: true, + showGrid: true, + YAxisProps: { width: 30 }, + hideChartType: false, + defaultChartStyle: 'line', + docsUrl: 'https://supabase.com/docs/guides/platform/compute-and-disk#limits-and-constraints', + attributes: [ + { + attribute: 'client_connections_pgbouncer', + provider: 'infra-monitoring', + label: 'pgbouncer', + tooltip: 'PgBouncer connections', + }, + { + attribute: 'pg_pooler_max_connections', + provider: 'reference-line', + label: 'Max pooler connections', + value: getConnectionLimits(computeSize).pooler, + tooltip: 'Maximum allowed pooler connections for your current compute size', + isMaxValue: true, + }, + ], + }, + { + id: 'supavisor-connections-active', + label: 'Supavisor client connections', + syncId: 'database-reports', + valuePrecision: 0, + hide: isFreePlan, + showTooltip: false, + showLegend: false, + showMaxValue: false, + showGrid: true, + YAxisProps: { width: 30 }, + hideChartType: false, + defaultChartStyle: 'line', + attributes: [ + { + attribute: 'supavisor_connections_active', + provider: 'infra-monitoring', + label: 'supavisor', + tooltip: 'Supavisor connections', + }, + ], + }, + ] +} + +// Helper function to get connection limits based on compute size +export const getConnectionLimits = (computeSize: string = 'medium') => { + const connectionLimits = { + nano: { direct: 60, pooler: 200 }, + micro: { direct: 60, pooler: 200 }, + small: { direct: 90, pooler: 400 }, + medium: { direct: 120, pooler: 600 }, + large: { direct: 160, pooler: 800 }, + xl: { direct: 240, pooler: 1000 }, + '2xl': { direct: 380, pooler: 1500 }, + '4xl': { direct: 480, pooler: 3000 }, + '8xl': { direct: 490, pooler: 6000 }, + '12xl': { direct: 500, pooler: 9000 }, + '16xl': { direct: 500, pooler: 12000 }, + } + + return ( + connectionLimits[computeSize?.toLowerCase() as keyof typeof connectionLimits] || + connectionLimits.medium + ) +} + +// Helper function to get IOPS limits based on compute size +export const getIOPSLimits = (computeSize: string = 'medium') => { + const iopsLimits = { + nano: 250, + micro: 500, + small: 1000, + medium: 2000, + large: 3600, + xl: 6000, + '2xl': 12000, + '4xl': 20000, + '8xl': 40000, + '12xl': 50000, + '16xl': 80000, + } + + return iopsLimits[computeSize?.toLowerCase() as keyof typeof iopsLimits] || iopsLimits.medium +} + +// Helper function to get recommended DB size based on compute size (in GB) +export const getRecommendedDbSize = (computeSize: string = 'medium') => { + const recommendedSizes = { + nano: 0.5, // 500 MB + micro: 10, + small: 50, + medium: 100, + large: 200, + xl: 500, + '2xl': 1024, // 1 TB + '4xl': 2048, // 2 TB + '8xl': 4096, // 4 TB + '12xl': 6144, // 6 TB + '16xl': 10240, // 10 TB + } + + return ( + recommendedSizes[computeSize?.toLowerCase() as keyof typeof recommendedSizes] || + recommendedSizes.medium + ) +} diff --git a/apps/studio/lib/helpers.ts b/apps/studio/lib/helpers.ts index a1386f4a844..e5baf72bfd8 100644 --- a/apps/studio/lib/helpers.ts +++ b/apps/studio/lib/helpers.ts @@ -130,8 +130,15 @@ export const formatBytes = ( const sizes = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] if (bytes === 0 || bytes === undefined) return size !== undefined ? `0 ${size}` : '0 bytes' - const i = size !== undefined ? sizes.indexOf(size) : Math.floor(Math.log(bytes) / Math.log(k)) - return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i] + + // Handle negative values + const isNegative = bytes < 0 + const absBytes = Math.abs(bytes) + + const i = size !== undefined ? sizes.indexOf(size) : Math.floor(Math.log(absBytes) / Math.log(k)) + const formattedValue = parseFloat((absBytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i] + + return isNegative ? '-' + formattedValue : formattedValue } export const snakeToCamel = (str: string) => diff --git a/apps/studio/pages/project/[ref]/reports/database.tsx b/apps/studio/pages/project/[ref]/reports/database.tsx index f443675d218..1c280a783c2 100644 --- a/apps/studio/pages/project/[ref]/reports/database.tsx +++ b/apps/studio/pages/project/[ref]/reports/database.tsx @@ -37,6 +37,8 @@ import { TIME_PERIODS_INFRA } from 'lib/constants/metrics' import { formatBytes } from 'lib/helpers' import type { NextPageWithLayout } from 'types' +import { useOrganizationQuery } from '../../../../data/organizations/organization-query' +import { useSelectedOrganization } from '../../../../hooks/misc/useSelectedOrganization' const DatabaseReport: NextPageWithLayout = () => { return ( @@ -59,12 +61,15 @@ const DatabaseUsage = () => { const { db, chart, ref } = useParams() const { project } = useProjectContext() const isReportsV2 = useFlag('reportsDatabaseV2') + const org = useSelectedOrganization() + const { plan: orgPlan, isLoading: isOrgPlanLoading } = useCurrentOrgPlan() + const isFreePlan = !isOrgPlanLoading && orgPlan?.id === 'free' const state = useDatabaseSelectorStateSnapshot() - const defaultStart = dayjs().subtract(7, 'day').toISOString() + const defaultStart = dayjs().subtract(1, 'day').toISOString() const defaultEnd = dayjs().toISOString() const [dateRange, setDateRange] = useState({ - period_start: { date: defaultStart, time_period: '7d' }, + period_start: { date: defaultStart, time_period: '1d' }, period_end: { date: defaultEnd, time_period: 'today' }, interval: '1h', }) @@ -80,7 +85,7 @@ const DatabaseUsage = () => { const { data: databaseSizeData } = useDatabaseSizeQuery({ projectRef: project?.ref, - connectionString: project?.connectionString, + connectionString: project?.connectionString || undefined, }) const databaseSizeBytes = databaseSizeData ?? 0 const currentDiskSize = project?.volumeSizeGb ?? 0 @@ -92,11 +97,8 @@ const DatabaseUsage = () => { }, }) - const { plan: orgPlan, isLoading: isOrgPlanLoading } = useCurrentOrgPlan() - const isFreePlan = !isOrgPlanLoading && orgPlan?.id === 'free' - const REPORT_ATTRIBUTES = getReportAttributes(isFreePlan) - const REPORT_ATTRIBUTES_V2 = getReportAttributesV2(isFreePlan) + const REPORT_ATTRIBUTES_V2 = getReportAttributesV2(org!, project!) const { isLoading: isUpdatingDiskSize } = useProjectDiskResizeMutation({ onSuccess: (_, variables) => { @@ -124,6 +126,21 @@ const DatabaseUsage = () => { }) ) }) + if (isReportsV2) { + REPORT_ATTRIBUTES_V2.forEach((chart: any) => { + chart.attributes.forEach((attr: any) => { + queryClient.invalidateQueries( + analyticsKeys.infraMonitoring(ref, { + attribute: attr.attribute, + startDate: period_start.date, + endDate: period_start.end, + interval, + databaseIdentifier: state.selectedDatabaseId, + }) + ) + }) + }) + } if (isReplicaSelected) { queryClient.invalidateQueries( analyticsKeys.infraMonitoring(ref, { @@ -179,7 +196,7 @@ const DatabaseUsage = () => { const updateDateRange: UpdateDateRange = (from: string, to: string) => { setDateRange({ - period_start: { date: from, time_period: '7d' }, + period_start: { date: from, time_period: '1d' }, period_end: { date: to, time_period: 'today' }, interval: handleIntervalGranularity(from, to), }) @@ -208,7 +225,7 @@ const DatabaseUsage = () => {
{ @@ -238,16 +255,21 @@ const DatabaseUsage = () => { {isReportsV2 ? (
{dateRange && - REPORT_ATTRIBUTES_V2.filter((attr) => !attr.hide).map((attr) => ( + REPORT_ATTRIBUTES_V2.filter((chart) => !chart.hide).map((chart) => ( ))}
@@ -258,6 +280,7 @@ const DatabaseUsage = () => { REPORT_ATTRIBUTES.filter((attr) => !attr.hide).map((attr) => ( { return (
-
+
Space used
{formatBytes(databaseSizeBytes, 2, 'GB')}
-
+
Provisioned disk size
{currentDiskSize} GB
-
+
{project?.cloud_provider === 'AWS' ? (