Files
supabase/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureActivity.tsx
Jordi Enric 90d383f182 feat(studio): cross-link disk IO usage to observability charts (#45376)
## Problem

Users on the Infrastructure activity tab see a Disk IO Bandwidth chart
that plots burst budget percentage rather than real disk activity. The
more granular IOPS and throughput charts live in Observability, but
there is no path between the two pages — users either dismiss real
warnings or file support tickets.

Reported in [Linear
DEBUG-64](https://linear.app/supabase/issue/DEBUG-64).

## Fix

Two cross-links into Observability/Database:

1. **Below the Disk IO Bandwidth chart**
([InfrastructureActivity.tsx](apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureActivity.tsx))
an Admonition with a "View detailed IOPS and throughput" button. Only
shown for the disk IO section, not CPU/RAM.
2. **In the disk IO exhaustion banner's Troubleshoot dropdown**
([ResourceExhaustionWarningBanner.tsx](apps/studio/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner.tsx))
a "View metrics" item alongside Documentation and Ask AI Assistant.

The banner part is wired via a new optional `metricsHref` on
`ResourceWarningMessage`, so CPU/RAM warnings can opt in later by adding
their own observability path.

## Test plan

- [ ] Visit `/project/{ref}/settings/infrastructure` on a project that
does not have dedicated I/O resources. Confirm the new Admonition
appears under the Disk IO chart and the button navigates to
`/project/{ref}/observability/database`.
- [ ] On a project with dedicated I/O resources, confirm only the
existing "dedicated I/O" Admonition shows (no double-Admonition).
- [ ] Trigger (or mock) a `disk_io` resource warning; confirm the
banner's Troubleshoot dropdown shows "View metrics" first and links to
Observability.
- [ ] Trigger a `cpu` or `ram` warning; confirm the dropdown does NOT
show the new item.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* **New Features**
* Clarifies that the disk IO consumption chart shows remaining burst
budget, not real throughput.
* Adds a quick-access button to jump to the Database Observability page
for detailed read/write IOPS and throughput.
* Adds a "View metrics" link in resource exhaustion warnings for disk IO
issues (shown when a single warning is active).
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 16:08:14 +00:00

458 lines
20 KiB
TypeScript

import { useParams } from 'common'
import dayjs from 'dayjs'
import { capitalize } from 'lodash'
import { BarChart2, ChartLine, ExternalLink } from 'lucide-react'
import Link from 'next/link'
import { Fragment, useMemo, useState } from 'react'
import { Button } from 'ui'
import { Admonition } from 'ui-patterns/admonition'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { INFRA_ACTIVITY_METRICS } from './Infrastructure.constants'
import { getAddons } from '@/components/interfaces/Billing/Subscription/Subscription.utils'
import { CPUWarnings } from '@/components/interfaces/Billing/Usage/UsageWarningAlerts/CPUWarnings'
import { DiskIOBandwidthWarnings } from '@/components/interfaces/Billing/Usage/UsageWarningAlerts/DiskIOBandwidthWarnings'
import { RAMWarnings } from '@/components/interfaces/Billing/Usage/UsageWarningAlerts/RAMWarnings'
import UsageBarChart from '@/components/interfaces/Organization/Usage/UsageBarChart'
import {
ScaffoldContainer,
ScaffoldDivider,
ScaffoldSection,
ScaffoldSectionContent,
ScaffoldSectionDetail,
} from '@/components/layouts/Scaffold'
import { DatabaseSelector } from '@/components/ui/DatabaseSelector'
import { DateRangePicker } from '@/components/ui/DateRangePicker'
import { DocsButton } from '@/components/ui/DocsButton'
import Panel from '@/components/ui/Panel'
import { DataPoint } from '@/data/analytics/constants'
import { mapMultiResponseToAnalyticsData } from '@/data/analytics/infra-monitoring-queries'
import {
InfraMonitoringAttribute,
useInfraMonitoringAttributesQuery,
} from '@/data/analytics/infra-monitoring-query'
import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query'
import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query'
import { useResourceWarningsQuery } from '@/data/usage/resource-warnings-query'
import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { DOCS_URL, INSTANCE_MICRO_SPECS, INSTANCE_NANO_SPECS, InstanceSpecs } from '@/lib/constants'
import { TIME_PERIODS_BILLING, TIME_PERIODS_REPORTS } from '@/lib/constants/metrics'
import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector'
const NON_DEDICATED_IO_RESOURCES = [
'ci_micro',
'ci_small',
'ci_medium',
'ci_large',
'ci_xlarge',
'ci_2xlarge',
]
const INFRA_ATTRIBUTES: InfraMonitoringAttribute[] = [
'max_cpu_usage',
'ram_usage',
'disk_io_consumption',
]
export const InfrastructureActivity = () => {
const { ref: projectRef } = useParams()
const { data: project } = useSelectedProjectQuery()
const { data: organization } = useSelectedOrganizationQuery()
const state = useDatabaseSelectorStateSnapshot()
const [dateRange, setDateRange] = useState<any>()
const { data: subscription, isPending: isLoadingSubscription } = useOrgSubscriptionQuery({
orgSlug: organization?.slug,
})
const { hasAccess: hasAccessToComputeSizes } = useCheckEntitlements(
'instances.compute_update_available_sizes'
)
const { data: resourceWarnings } = useResourceWarningsQuery({ ref: projectRef })
// [Joshen Cleanup] JFYI this client side filtering can be cleaned up once BE changes are live which will only return the warnings based on the provided ref
const projectResourceWarnings = resourceWarnings?.find((x) => x.project === projectRef)
const { data: addons } = useProjectAddonsQuery({ projectRef })
const selectedAddons = addons?.selected_addons ?? []
const { computeInstance } = getAddons(selectedAddons)
const hasDedicatedIOResources =
computeInstance !== undefined &&
!NON_DEDICATED_IO_RESOURCES.includes(computeInstance.variant.identifier)
function getCurrentComputeInstanceSpecs() {
if (computeInstance?.variant.meta) {
// If user has a compute instance (called addons) return that
return computeInstance?.variant.meta as InstanceSpecs
} else {
// Otherwise, return the default specs
return project?.infra_compute_size === 'nano' ? INSTANCE_NANO_SPECS : INSTANCE_MICRO_SPECS
}
}
const currentComputeInstanceSpecs = getCurrentComputeInstanceSpecs()
const currentBillingCycleSelected = useMemo(() => {
// Selected by default
if (!dateRange?.period_start || !dateRange?.period_end || !subscription) return true
const { current_period_start, current_period_end } = subscription
return (
dayjs(dateRange.period_start.date).isSame(new Date(current_period_start * 1000)) &&
dayjs(dateRange.period_end.date).isSame(new Date(current_period_end * 1000))
)
}, [dateRange, subscription])
const upgradeUrl =
organization === undefined
? `/`
: hasAccessToComputeSizes
? `/org/${organization?.slug ?? '[slug]'}/billing#subscription`
: `/project/${projectRef}/settings/addons`
const categoryMeta = INFRA_ACTIVITY_METRICS.find((category) => category.key === 'infra')
const startDate = useMemo(() => {
if (dateRange?.period_start?.date === 'Invalid Date') return undefined
// If end date is in future, set end date to now
if (!dateRange?.period_start?.date) {
return undefined
} else {
// LF seems to have an issue with the milliseconds, causes infinite loading sometimes
return new Date(dateRange?.period_start?.date ?? 0).toISOString().slice(0, -5) + 'Z'
}
}, [dateRange])
const endDate = useMemo(() => {
if (dateRange?.period_end?.date === 'Invalid Date') return undefined
// If end date is in future, set end date to end of current day
if (dateRange?.period_end?.date && dayjs(dateRange.period_end.date).isAfter(dayjs())) {
// LF seems to have an issue with the milliseconds, causes infinite loading sometimes
// In order to have full days from Prometheus metrics when using 1d interval,
// the time needs to be greater or equal than the time of the start date
return dayjs().endOf('day').toISOString().slice(0, -5) + 'Z'
} else if (dateRange?.period_end?.date) {
// LF seems to have an issue with the milliseconds, causes infinite loading sometimes
return new Date(dateRange.period_end.date ?? 0).toISOString().slice(0, -5) + 'Z'
}
}, [dateRange])
// Switch to hourly interval, if the timeframe is <48 hours
let interval: '1d' | '1h' = '1d'
let dateFormat = 'DD MMM'
if (startDate && endDate) {
const diffInHours = dayjs(endDate).diff(startDate, 'hours')
if (diffInHours <= 48) {
interval = '1h'
dateFormat = 'h a'
}
}
const { data: infraMonitoringData, isPending: isLoadingInfraData } =
useInfraMonitoringAttributesQuery({
projectRef,
attributes: INFRA_ATTRIBUTES,
interval,
startDate,
endDate,
databaseIdentifier: state.selectedDatabaseId,
})
const transformedData = useMemo(() => {
if (!infraMonitoringData) return undefined
return mapMultiResponseToAnalyticsData(infraMonitoringData, INFRA_ATTRIBUTES, dateFormat)
}, [infraMonitoringData, dateFormat])
const cpuUsageData = transformedData?.max_cpu_usage
const memoryUsageData = transformedData?.ram_usage
const ioBudgetData = transformedData?.disk_io_consumption
const hasLatest = dayjs(endDate!).isAfter(dayjs().startOf('day'))
const latestIoBudgetConsumption =
hasLatest && ioBudgetData?.data?.slice(-1)?.[0]
? Number(ioBudgetData.data.slice(-1)[0].disk_io_consumption)
: 0
const highestIoBudgetConsumption = (ioBudgetData?.data || [])
.map((x) => Number(x.disk_io_consumption) ?? 0)
.reduce((a, b) => Math.max(a, b), 0)
const chartMeta: { [key: string]: { data: DataPoint[]; isLoading: boolean } } = {
max_cpu_usage: {
isLoading: isLoadingInfraData,
data: cpuUsageData?.data ?? [],
},
ram_usage: {
isLoading: isLoadingInfraData,
data: memoryUsageData?.data ?? [],
},
disk_io_consumption: {
isLoading: isLoadingInfraData,
data: ioBudgetData?.data ?? [],
},
}
return (
<>
<ScaffoldContainer id="infrastructure-activity">
<div className="mx-auto flex flex-col gap-10 pt-6">
<div>
<p className="text-xl">Infrastructure Activity</p>
<p className="text-sm text-foreground-light">
Activity statistics related to your server instance
</p>
</div>
</div>
</ScaffoldContainer>
<ScaffoldContainer className="sticky top-0 py-6 border-b bg-studio z-10">
<div className="flex items-center gap-x-4">
<DatabaseSelector />
{!isLoadingSubscription && (
<>
<DateRangePicker
onChange={setDateRange}
value={TIME_PERIODS_REPORTS[0].key}
options={[...TIME_PERIODS_BILLING, ...TIME_PERIODS_REPORTS]}
loading={isLoadingSubscription}
currentBillingPeriodStart={subscription?.current_period_start}
currentBillingPeriodEnd={subscription?.current_period_end}
/>
<p className="text-sm text-foreground-light">
{dayjs(startDate).format('DD MMM YYYY')} - {dayjs(endDate).format('DD MMM YYYY')}
</p>
</>
)}
</div>
</ScaffoldContainer>
{categoryMeta?.attributes.map((attribute) => {
const chartData = chartMeta[attribute.key]?.data ?? []
return (
<Fragment key={attribute.key}>
<ScaffoldContainer id={attribute.anchor}>
<ScaffoldSection>
<ScaffoldSectionDetail>
<div className="sticky top-32 space-y-6">
<div className="space-y-1">
<div className="flex items-center space-x-2">
<h4 className="text-base capitalize m-0">{attribute.name}</h4>
</div>
<div className="grid gap-4">
{attribute.description.split('\n').map((value, idx) => (
<p key={`desc-${idx}`} className="text-sm text-foreground-light pr-8 m-0">
{value}
</p>
))}
</div>
</div>
<div className="space-y-2">
<p className="text-sm text-foreground mb-2">More information</p>
{attribute.links.map((link) => (
<div key={link.url}>
<Link href={link.url} target="_blank" rel="noreferrer">
<div className="flex items-center space-x-2 opacity-50 hover:opacity-100 transition">
<p className="text-sm m-0">{link.name}</p>
<ExternalLink size={16} strokeWidth={1.5} />
</div>
</Link>
</div>
))}
</div>
</div>
</ScaffoldSectionDetail>
<ScaffoldSectionContent>
{attribute.key === 'disk_io_consumption' && (
<>
<DiskIOBandwidthWarnings
upgradeUrl={upgradeUrl}
hasAccessToComputeSizes={hasAccessToComputeSizes}
hasLatest={hasLatest}
currentBillingCycleSelected={currentBillingCycleSelected}
latestIoBudgetConsumption={latestIoBudgetConsumption}
highestIoBudgetConsumption={highestIoBudgetConsumption}
/>
<div className="space-y-1">
<p>Disk IO Bandwidth</p>
{currentComputeInstanceSpecs.baseline_disk_io_mbs ===
currentComputeInstanceSpecs.max_disk_io_mbs ? (
<p className="text-sm text-foreground-light">
Your current compute has a baseline and maximum disk throughput of{' '}
{currentComputeInstanceSpecs.max_disk_io_mbs?.toLocaleString()} Mbps.
</p>
) : (
<p className="text-sm text-foreground-light">
Your current compute can burst above the baseline disk throughput of{' '}
{currentComputeInstanceSpecs.baseline_disk_io_mbs?.toLocaleString()}{' '}
Mbps for short periods of time.
</p>
)}
</div>
<div>
<p className="text-sm mb-2">Overview</p>
<div className="flex items-center justify-between border-b py-1">
<p className="text-xs text-foreground-light">Current compute instance</p>
<p className="text-xs">
{computeInstance?.variant?.name ??
capitalize(project?.infra_compute_size) ??
'Micro'}
</p>
</div>
<div className="flex items-center justify-between border-b py-1">
<p className="text-xs text-foreground-light">Baseline IO Bandwidth</p>
<p className="text-xs">
{currentComputeInstanceSpecs.baseline_disk_io_mbs?.toLocaleString()}{' '}
Mbps
</p>
</div>
<div className="flex items-center justify-between border-b py-1">
<p className="text-xs text-foreground-light">
Maximum IO Bandwidth (burst limit)
</p>
<p className="text-xs">
{currentComputeInstanceSpecs.max_disk_io_mbs?.toLocaleString()} Mbps
</p>
</div>
{currentComputeInstanceSpecs.max_disk_io_mbs !==
currentComputeInstanceSpecs?.baseline_disk_io_mbs && (
<div className="flex items-center justify-between py-1">
<p className="text-xs text-foreground-light">Daily burst time limit</p>
<p className="text-xs">30 mins</p>
</div>
)}
</div>
</>
)}
{attribute.key === 'max_cpu_usage' && (
<CPUWarnings
upgradeUrl={upgradeUrl}
hasAccessToComputeSizes={hasAccessToComputeSizes}
severity={projectResourceWarnings?.cpu_exhaustion}
/>
)}
{attribute.key === 'ram_usage' && (
<RAMWarnings
upgradeUrl={upgradeUrl}
hasAccessToComputeSizes={hasAccessToComputeSizes}
severity={projectResourceWarnings?.memory_and_swap_exhaustion}
/>
)}
<div className="space-y-1">
<div className="flex flex-row justify-between">
{attribute.key === 'disk_io_consumption' ? (
<p>Disk IO consumed per {interval === '1d' ? 'day' : 'hour'}</p>
) : (
<p>
Max{' '}
<span className={attribute.key === 'ram_usage' ? 'lowercase' : ''}>
{attribute.name}
</span>{' '}
utilization per {interval === '1d' ? 'day' : 'hour'}
</p>
)}
</div>
{attribute.key === 'ram_usage' && (
<div className="text-sm text-foreground-light">
<p>
Your compute instance has {currentComputeInstanceSpecs.memory_gb} GB of
memory.
</p>
{currentComputeInstanceSpecs.memory_gb === 1 && (
<p>
As your project is running on the smallest compute instance, it is not
unusual for your project to have a base memory usage of ~50%.
</p>
)}
</div>
)}
{attribute.key === 'max_cpu_usage' && (
<p className="text-sm text-foreground-light">
Your compute instance has {currentComputeInstanceSpecs.cpu_cores} CPU cores.
</p>
)}
{attribute.chartDescription.split('\n').map((paragraph, idx) => (
<p key={`para-${idx}`} className="text-sm text-foreground-light">
{paragraph}
</p>
))}
</div>
{attribute.key === 'disk_io_consumption' && hasDedicatedIOResources ? (
<>
<Admonition
type="note"
title={`Your compute instance of ${computeInstance.variant.name} comes with dedicated I/O resources`}
description="Your project thus does not rely on I/O balance or burst capacity as larger
add-ons are designed for sustained, high performance with specific IOPS and
throughput limits without needing to burst."
>
<DocsButton
abbrev={false}
href={`${DOCS_URL}/guides/platform/compute-add-ons#disk-throughput-and-iops`}
/>
</Admonition>
</>
) : chartMeta[attribute.key].isLoading ? (
<div className="space-y-2">
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
<ShimmeringLoader className="w-1/2" />
</div>
) : chartData.length ? (
<UsageBarChart
name={`${attribute.chartPrefix || ''} ${attribute.name}`}
unit={attribute.unit}
attributes={attribute.attributes}
data={chartData}
yFormatter={(value) => `${Math.round(Number(value))}%`}
tooltipFormatter={(value) => `${value}%`}
yLimit={100}
/>
) : (
<Panel>
<Panel.Content>
<div className="flex flex-col items-center justify-center space-y-2">
<BarChart2 size={18} className="text-foreground-light mb-2" />
<p className="text-sm">No data in period</p>
<p className="text-sm text-foreground-light">
May take a few minutes to show
</p>
</div>
</Panel.Content>
</Panel>
)}
{attribute.key === 'disk_io_consumption' && !hasDedicatedIOResources && (
<Admonition
type="default"
title="Looking for actual disk activity?"
description="The chart above shows your remaining burst budget, not real disk throughput. For detailed read/write IOPS and throughput charts, head to the Database Observability page."
>
<Button asChild type="default" icon={<ChartLine size={14} />}>
<Link href={`/project/${projectRef}/observability/database`}>
View detailed IOPS and throughput
</Link>
</Button>
</Admonition>
)}
</ScaffoldSectionContent>
</ScaffoldSection>
</ScaffoldContainer>
<ScaffoldDivider />
</Fragment>
)
})}
</>
)
}