feat(studio): compute metrics on project diagram Primary Database card (#45274)

## Problem

The Primary Database card in the project homepage diagram showed region
and instance size, but no live health data. Users had no quick way to
spot a high-disk or high-CPU situation without navigating to the
database report.

## Fix

Added a clickable metrics row at the bottom of the Primary Database card
showing CPU, Disk, and RAM as percentages, plus active/max connections
when available. Each metric is color-coded (warning at 80%, destructive
at 90%). Clicking the row navigates to the database observability
report.

The metrics are powered by a new \`useComputeMetrics\` hook that wraps
the existing \`useInfraMonitoringAttributesQuery\` and
\`useMaxConnectionsQuery\`, reusing the parse utilities already used by
the database infrastructure section. The \`metricColor\` threshold logic
is extracted into a separate util with unit tests.

## How to test

- Open the project homepage for a running project
- The Primary Database card should show a new bottom row: "CPU X% · Disk
X% · RAM X% · Y/Z conns"
- Values above 80% should appear in amber, above 90% in red
- Click the metrics row and confirm it navigates to
\`/project/<ref>/observability/database\`
- While metrics are loading, a spinner should appear in the row
- If the infra monitoring API is unavailable, the row should show
"Metrics unavailable" instead of zeroes

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

## Release Notes

* **New Features**
* Infrastructure configuration page now displays real-time compute
metrics (CPU, disk, memory usage) with color-coded usage indicators
based on thresholds.
  * Connection information is displayed when available.
  * Includes loading states and error handling for metric retrieval.

* **Tests**
  * Added test coverage for metric color-coding logic.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jordi EnricandClaude Sonnet 4.6 authored and GitHub committed 2026-04-27 14:15:11 +02:00
1 parent 67deabf67e
commit dfd3eec8e9
4 files changed
+161

No files matched your search

@@ -29,6 +29,7 @@ import {
ReplicaNodeData,
} from './InstanceConfiguration.constants'
import { formatSeconds } from './InstanceConfiguration.utils'
import { metricColor } from './InstanceNode.utils'
import SparkBar from '@/components/ui/SparkBar'
import {
DatabaseInitEstimations,
@@ -36,6 +37,7 @@ import {
useReadReplicasStatusesQuery,
} from '@/data/read-replicas/replicas-status-query'
import { formatDatabaseID } from '@/data/read-replicas/replicas.utils'
import { useComputeMetrics } from '@/hooks/analytics/useComputeMetrics'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
import { BASE_PATH } from '@/lib/constants'
import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector'
@@ -85,11 +87,25 @@ export const LoadBalancerNode = ({ data }: NodeProps<Node<LoadBalancerData>>) =>
export const PrimaryNode = ({ data }: NodeProps<Node<PrimaryNodeData>>) => {
// [Joshen] Just FYI Handles cannot be conditionally rendered
const { region, computeSize, numReplicas, numRegions, hasLoadBalancer } = data
const { ref } = useParams()
const { projectHomepageShowInstanceSize } = useIsFeatureEnabled([
'project_homepage:show_instance_size',
])
const {
cpu,
disk,
memory,
connections,
isLoading: metricsLoading,
isError: metricsError,
} = useComputeMetrics({
projectRef: ref,
})
const observabilityUrl = `/project/${ref}/observability/database`
return (
<>
<Handle
@@ -142,6 +158,43 @@ export const PrimaryNode = ({ data }: NodeProps<Node<PrimaryNodeData>>) => {
</p>
</div>
)}
<Tooltip>
<TooltipTrigger asChild>
<Link
href={observabilityUrl}
className="border-t px-3 py-2 hover:bg-surface-200 transition flex items-center gap-x-3 text-xs"
>
{metricsLoading ? (
<div className="h-3 w-44 rounded bg-surface-300 animate-pulse" />
) : metricsError ? (
<span className="text-foreground-lighter">Metrics unavailable</span>
) : (
<>
<span>
CPU <span className={metricColor(cpu)}>{cpu.toFixed(0)}%</span>
</span>
<span className="text-foreground-lighter">·</span>
<span>
Disk <span className={metricColor(disk)}>{disk.toFixed(0)}%</span>
</span>
<span className="text-foreground-lighter">·</span>
<span>
RAM <span className={metricColor(memory)}>{memory.toFixed(0)}%</span>
</span>
{connections.max > 0 && (
<>
<span className="text-foreground-lighter">·</span>
<span className="text-foreground-light">
{connections.current}/{connections.max} conns
</span>
</>
)}
</>
)}
</Link>
</TooltipTrigger>
<TooltipContent side="bottom">Go to Database Report</TooltipContent>
</Tooltip>
</div>
<Handle
type="source"
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import { metricColor } from './InstanceNode.utils'
describe('metricColor', () => {
it('returns warning color at 80%', () => {
expect(metricColor(80)).toBe('text-warning')
})
it('returns warning color between 80% and 90%', () => {
expect(metricColor(85)).toBe('text-warning')
expect(metricColor(89)).toBe('text-warning')
})
it('returns destructive color at 90%', () => {
expect(metricColor(90)).toBe('text-destructive')
})
it('returns destructive color above 90%', () => {
expect(metricColor(95)).toBe('text-destructive')
expect(metricColor(100)).toBe('text-destructive')
})
it('returns light foreground color below 80%', () => {
expect(metricColor(0)).toBe('text-foreground-light')
expect(metricColor(50)).toBe('text-foreground-light')
expect(metricColor(79)).toBe('text-foreground-light')
})
})
@@ -0,0 +1,5 @@
export function metricColor(value: number): string {
if (value >= 90) return 'text-destructive'
if (value >= 80) return 'text-warning'
return 'text-foreground-light'
}
@@ -0,0 +1,74 @@
import dayjs from 'dayjs'
import { useMemo } from 'react'
import {
parseConnectionsData,
parseInfrastructureMetrics,
} from '@/components/interfaces/Observability/DatabaseInfrastructureSection.utils'
import { useInfraMonitoringAttributesQuery } from '@/data/analytics/infra-monitoring-query'
import { useMaxConnectionsQuery } from '@/data/database/max-connections-query'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
export type ComputeMetrics = {
cpu: number
disk: number
memory: number
connections: { current: number; max: number }
isLoading: boolean
isError: boolean
}
export function useComputeMetrics({ projectRef }: { projectRef?: string }): ComputeMetrics {
const { data: project } = useSelectedProjectQuery()
// Intentionally anchored to mount time so the query key stays stable across re-renders.
// React Query's staleTime handles background refresh without shifting the window.
// eslint-disable-next-line react-hooks/exhaustive-deps
const { startDate, endDate } = useMemo(() => {
const now = dayjs()
return {
startDate: now.subtract(1, 'hour').toISOString(),
endDate: now.toISOString(),
}
}, [])
const {
data: infraData,
isLoading: infraLoading,
isError,
} = useInfraMonitoringAttributesQuery({
projectRef,
attributes: [
'avg_cpu_usage',
'ram_usage',
'disk_fs_used_system',
'disk_fs_used_wal',
'pg_database_size',
'disk_fs_size',
'pg_stat_database_num_backends',
],
startDate,
endDate,
interval: '1h',
})
const { data: maxConnectionsData, isLoading: connectionsLoading } = useMaxConnectionsQuery({
projectRef,
connectionString: project?.connectionString,
})
const metrics = useMemo(() => parseInfrastructureMetrics(infraData), [infraData])
const connections = useMemo(
() => parseConnectionsData(infraData, maxConnectionsData),
[infraData, maxConnectionsData]
)
return {
cpu: metrics?.cpu.current ?? 0,
disk: metrics?.disk.current ?? 0,
memory: metrics?.ram.current ?? 0,
connections,
isLoading: infraLoading || connectionsLoading,
isError,
}
}