mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 01:45:10 +03:00
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:
1 parent
67deabf67e
commit
dfd3eec8e9
4 files changed
+161
No files matched your search
+53
@@ -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"
|
||||
|
||||
+29
@@ -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')
|
||||
})
|
||||
})
|
||||
+5
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user