feat(studio): exhaustion banner links to metrics (#50276)

This commit is contained in:
Pamela Chia authored and GitHub committed 2026-09-17 22:23:10 +02:00
1 parent 57197ad800
commit 64ab76262e
14 files changed
+1078 -127

No files matched your search

@@ -60,7 +60,7 @@ export interface ComposedChartHandlerProps {
/**
* Wrapper component that handles intersection observer logic for lazy loading
*/
const LazyChartWrapper = ({ children }: PropsWithChildren) => {
const LazyChartWrapper = ({ id, children }: PropsWithChildren<{ id: string }>) => {
const [isVisible, setIsVisible] = useState(false)
const ref = useRef<HTMLDivElement>(null)
@@ -91,7 +91,7 @@ const LazyChartWrapper = ({ children }: PropsWithChildren) => {
}, [])
return (
<div ref={ref}>
<div ref={ref} id={id} className="scroll-mt-16">
{React.cloneElement(children as React.ReactElement<{ isVisible: boolean }>, { isVisible })}
</div>
)
@@ -291,9 +291,8 @@ const ComposedChartHandler = ({
<Panel
noMargin
noHideOverflow
className={cn('relative w-full scroll-mt-16', className)}
className={cn('relative w-full', className)}
wrapWithLoading={false}
id={id ?? label.toLowerCase().replaceAll(' ', '-')}
>
<Panel.Content className="flex flex-col gap-4">
<div className="absolute right-6 z-50 flex justify-between scroll-mt-16">{children}</div>
@@ -385,8 +384,9 @@ const useAttributeQueries = (
export function LazyComposedChartHandler(props: ComposedChartHandlerProps) {
if (props.hide) return null
const anchorId = props.id ?? props.label.toLowerCase().replaceAll(' ', '-')
return (
<LazyChartWrapper>
<LazyChartWrapper id={anchorId}>
<ComposedChartHandler {...props} />
</LazyChartWrapper>
)
@@ -13,9 +13,8 @@ interface ResourceWarningMessage {
critical: { title?: string; description?: string }
}
docsUrl?: string
// In-app destination for inspecting the metric (e.g. observability charts).
// [ref] is replaced with the current project ref at render time.
metricsHref?: string
resourceLabel?: string
metricsChartId?: string
buttonText?: string
aiPrompt?: string
metric: string | null
@@ -23,7 +22,7 @@ interface ResourceWarningMessage {
type ResourceWarningMessages = Record<string, ResourceWarningMessage>
export const RESOURCE_WARNING_MESSAGES: ResourceWarningMessages = {
const RESOURCE_WARNING_MESSAGE_CONFIG = {
is_readonly_mode_enabled: {
bannerContent: {
warning: {
@@ -78,10 +77,11 @@ export const RESOURCE_WARNING_MESSAGES: ResourceWarningMessages = {
},
},
docsUrl: `${DOCS_URL}/guides/troubleshooting/exhaust-disk-io`,
metricsHref: '/project/[ref]/observability/database',
buttonText: 'Upgrade compute',
aiPrompt:
'My database is running out of Disk IO budget. Can you query pg_stat_statements to find the top queries by shared blocks read and written, identify which are causing the most disk I/O, and suggest specific optimizations to reduce disk usage?',
resourceLabel: 'Disk IO',
metricsChartId: 'disk-throughput',
metric: 'disk_io',
},
disk_space_exhaustion: {
@@ -110,6 +110,8 @@ export const RESOURCE_WARNING_MESSAGES: ResourceWarningMessages = {
},
docsUrl: `${DOCS_URL}/guides/platform/database-size#disk-management`,
buttonText: undefined,
resourceLabel: 'Disk space',
metricsChartId: 'disk-size',
metric: 'disk_space',
},
cpu_exhaustion: {
@@ -139,6 +141,8 @@ export const RESOURCE_WARNING_MESSAGES: ResourceWarningMessages = {
buttonText: 'Upgrade compute',
aiPrompt:
'My database is experiencing high CPU usage. Can you query pg_stat_statements to find the top queries by total execution time and mean execution time, identify which are most CPU-intensive, and suggest specific optimizations such as missing indexes or query rewrites to reduce CPU load?',
resourceLabel: 'CPU',
metricsChartId: 'cpu-usage',
metric: 'cpu',
},
memory_and_swap_exhaustion: {
@@ -169,6 +173,8 @@ export const RESOURCE_WARNING_MESSAGES: ResourceWarningMessages = {
buttonText: 'Upgrade compute',
aiPrompt:
'My database is experiencing high memory and swap usage. Can you query pg_stat_statements to find the top queries by shared buffer hits and rows returned, identify which queries are putting the most pressure on memory, and suggest optimizations to reduce memory consumption?',
resourceLabel: 'Memory',
metricsChartId: 'ram-usage',
metric: 'ram',
},
auth_rate_limit_exhaustion: {
@@ -197,18 +203,18 @@ export const RESOURCE_WARNING_MESSAGES: ResourceWarningMessages = {
},
docsUrl: `${DOCS_URL}/guides/platform/going-into-prod#auth-rate-limits`,
buttonText: 'Enable custom SMTP',
resourceLabel: 'Auth rate limits',
metric: 'auth_email_rate_limit',
},
multiple_resource_warnings: {
bannerContent: {
warning: {
title:
'Your project is currently exhausting multiple resources, and its performance is affected',
title: 'Your project is exhausting {resources}, which is affecting its performance',
description:
'Upgrade your compute or use the AI Assistant to identify and optimize the most expensive queries.',
},
critical: {
title: 'Your project has exhausted multiple resources, and its performance is affected',
title: 'Your project has exhausted {resources}, which is affecting its performance',
description:
'Upgrade your compute or use the AI Assistant to identify and optimize the most expensive queries.',
},
@@ -226,7 +232,15 @@ export const RESOURCE_WARNING_MESSAGES: ResourceWarningMessages = {
docsUrl: undefined,
buttonText: 'Check usage',
aiPrompt:
'My database is exhausting multiple resources (CPU, memory, and/or disk IO). Can you query pg_stat_statements to identify the most expensive queries overall, and suggest which optimizations would have the biggest impact on reducing resource consumption?',
'My database is exhausting multiple resources ({resources}). Can you query pg_stat_statements to identify the most expensive queries overall, and suggest which optimizations would have the biggest impact on reducing resource consumption?',
metric: null,
},
}
} satisfies ResourceWarningMessages
export type ResourceWarningMessageKey = keyof typeof RESOURCE_WARNING_MESSAGE_CONFIG
export const RESOURCE_WARNING_MESSAGES: ResourceWarningMessages = RESOURCE_WARNING_MESSAGE_CONFIG
export const isResourceWarningMessageKey = (
property: string
): property is ResourceWarningMessageKey => Object.hasOwn(RESOURCE_WARNING_MESSAGE_CONFIG, property)
@@ -0,0 +1,261 @@
import { screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { platformComponents as components } from 'api-types'
import { mockAnimationsApi } from 'jsdom-testing-mocks'
import { HttpResponse } from 'msw'
import { afterEach, describe, expect, test, vi } from 'vitest'
import { ResourceExhaustionWarningBanner } from './ResourceExhaustionWarningBanner'
import type { ProfileContextType } from '@/lib/profile'
import { createMockOrganizationResponse } from '@/tests/helpers'
import { customRender } from '@/tests/lib/custom-render'
import { addAPIMock } from '@/tests/lib/msw'
type ProjectDetailResponse = components['schemas']['ProjectDetailResponse_Output']
type ProjectResourceWarningsResponse =
components['schemas']['ProjectResourceWarningsResponse_Output']
const PROJECT_REF = 'project-ref'
const { trackSpy, flags } = vi.hoisted(() => ({
trackSpy: vi.fn(),
flags: { showDiskIOBurstBalanceChart: false },
}))
vi.mock('common', async (importOriginal) => {
const actual = await importOriginal<typeof import('common')>()
return {
...actual,
IS_PLATFORM: true,
useIsLoggedIn: () => true,
useParams: () => ({ ref: PROJECT_REF }),
useFlag: () => flags.showDiskIOBurstBalanceChart,
}
})
vi.mock('@/lib/telemetry/track', () => ({ useTrack: () => trackSpy }))
mockAnimationsApi()
afterEach(() => {
flags.showDiskIOBurstBalanceChart = false
})
const PROFILE_CONTEXT: ProfileContextType = {
profile: {
id: 1,
auth0_id: 'auth0|test',
gotrue_id: 'gotrue-test',
username: 'testuser',
primary_email: 'test@example.com',
first_name: null,
last_name: null,
mobile: null,
is_alpha_user: false,
is_sso_user: false,
disabled_features: [],
free_project_limit: null,
},
error: null,
isLoading: false,
isError: false,
isSuccess: true,
}
const PROJECT: ProjectDetailResponse = {
cloud_provider: 'AWS',
connectionString: 'postgresql://postgres:password@db.project-ref.supabase.co:5432/postgres',
db_host: 'db.project-ref.supabase.co',
dbVersion: 'supabase-postgres-15.1.0',
high_availability: false,
id: 1,
infra_compute_size: 'micro',
inserted_at: '2026-01-01T00:00:00.000Z',
integration_source: null,
is_branch_enabled: false,
is_physical_backups_enabled: false,
name: 'Production',
organization_id: 1,
ref: PROJECT_REF,
region: 'us-east-1',
restUrl: `https://${PROJECT_REF}.supabase.co`,
status: 'ACTIVE_HEALTHY',
subscription_id: 'subscription-1',
updated_at: '2026-01-01T00:00:00.000Z',
}
const NO_WARNINGS: ProjectResourceWarningsResponse = {
project: PROJECT_REF,
is_readonly_mode_enabled: false,
auth_email_offender: null,
auth_rate_limit_exhaustion: null,
auth_restricted_email_sending: null,
cpu_exhaustion: null,
disk_io_exhaustion: null,
disk_space_exhaustion: null,
memory_and_swap_exhaustion: null,
need_pitr: null,
}
const metricsHref = (chartId: string) =>
`/project/${PROJECT_REF}/observability/database?chart=${chartId}&db=${PROJECT_REF}&isHelper=true&helperText=Last+3+hours#${chartId}`
const renderBanner = (warnings: Partial<ProjectResourceWarningsResponse>) => {
addAPIMock({ method: 'get', path: '/platform/projects/:ref', response: PROJECT })
addAPIMock({
method: 'get',
path: '/platform/organizations',
response: [
createMockOrganizationResponse({
id: 1,
slug: 'acme',
plan: { id: 'pro', name: 'Pro' },
usage_billing_enabled: true,
}),
],
})
addAPIMock({
method: 'get',
path: '/platform/projects-resource-warnings',
response: () =>
HttpResponse.json<ProjectResourceWarningsResponse[]>([{ ...NO_WARNINGS, ...warnings }]),
})
return customRender(<ResourceExhaustionWarningBanner />, { profileContext: PROFILE_CONTEXT })
}
const openTroubleshootMenu = async () => {
await userEvent.click(await screen.findByRole('button', { name: 'Troubleshoot' }))
return within(await screen.findByRole('menu'))
}
describe('ResourceExhaustionWarningBanner', () => {
test('multi-resource banner names each resource and links each to its chart', async () => {
renderBanner({ cpu_exhaustion: 'warning', disk_space_exhaustion: 'warning' })
expect(
await screen.findByText(
'Your project is exhausting CPU and Disk space, which is affecting its performance'
)
).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Check usage' })).toHaveAttribute(
'href',
`/project/${PROJECT_REF}/settings/infrastructure`
)
const menu = await openTroubleshootMenu()
const itemLabels = menu.getAllByRole('menuitem').map((item) => item.textContent)
expect(itemLabels).toEqual([
'View CPU metrics',
'View Disk space metrics',
'CPU documentation',
'Disk space documentation',
'Ask AI Assistant',
])
const cpuMetrics = menu.getByRole('menuitem', { name: 'View CPU metrics' })
expect(cpuMetrics).toHaveAttribute('href', metricsHref('cpu-usage'))
await userEvent.click(cpuMetrics)
expect(trackSpy).toHaveBeenCalledWith('resource_exhaustion_banner_troubleshoot_clicked', {
troubleshootAction: 'metrics',
warningType: 'cpu_exhaustion',
warningTypes: ['cpu_exhaustion', 'disk_space_exhaustion'],
destination: metricsHref('cpu-usage'),
})
})
test('mixed-severity multi-resource banner does not describe every resource as exhausted', async () => {
renderBanner({ cpu_exhaustion: 'critical', disk_space_exhaustion: 'warning' })
expect(
await screen.findByText(
"Resource warnings for CPU and Disk space are affecting your project's performance"
)
).toBeInTheDocument()
expect(screen.queryByText(/has exhausted CPU and Disk space/)).not.toBeInTheDocument()
})
test('single-resource banner keeps its title and adds View metrics', async () => {
renderBanner({ cpu_exhaustion: 'warning' })
expect(
await screen.findByText(
'Your project is currently facing high CPU usage, and its performance is affected'
)
).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Upgrade compute' })).toBeInTheDocument()
const menu = await openTroubleshootMenu()
const itemLabels = menu.getAllByRole('menuitem').map((item) => item.textContent)
expect(itemLabels).toEqual(['View metrics', 'Documentation', 'Ask AI Assistant'])
expect(menu.getByRole('menuitem', { name: 'View metrics' })).toHaveAttribute(
'href',
metricsHref('cpu-usage')
)
})
test('filters unconfigured warning fields before rendering', async () => {
renderBanner({
auth_email_offender: 'warning',
auth_restricted_email_sending: 'critical',
cpu_exhaustion: 'warning',
need_pitr: 'warning',
})
expect(
await screen.findByText(
'Your project is currently facing high CPU usage, and its performance is affected'
)
).toBeInTheDocument()
const menu = await openTroubleshootMenu()
const metricsItem = menu.getByRole('menuitem', { name: 'View metrics' })
await userEvent.click(metricsItem)
expect(trackSpy).toHaveBeenCalledWith('resource_exhaustion_banner_troubleshoot_clicked', {
troubleshootAction: 'metrics',
warningType: 'cpu_exhaustion',
warningTypes: ['cpu_exhaustion'],
destination: metricsHref('cpu-usage'),
})
})
test('all-compute multi banner keeps Upgrade compute and falls back to the throughput chart', async () => {
renderBanner({ cpu_exhaustion: 'warning', disk_io_exhaustion: 'warning' })
expect(await screen.findByRole('link', { name: 'Upgrade compute' })).toBeInTheDocument()
const menu = await openTroubleshootMenu()
expect(menu.getByRole('menuitem', { name: 'Ask AI Assistant' })).toBeInTheDocument()
expect(menu.getByRole('menuitem', { name: 'View Disk IO metrics' })).toHaveAttribute(
'href',
metricsHref('disk-throughput')
)
})
test('disk IO links to the burst balance chart when the report renders it', async () => {
flags.showDiskIOBurstBalanceChart = true
renderBanner({ disk_io_exhaustion: 'warning' })
const menu = await openTroubleshootMenu()
expect(menu.getByRole('menuitem', { name: 'View metrics' })).toHaveAttribute(
'href',
metricsHref('disk-io-burst-balance')
)
})
test('non-compute multi-resource warning points users to the available actions', async () => {
renderBanner({ disk_space_exhaustion: 'warning', auth_rate_limit_exhaustion: 'warning' })
expect(
await screen.findByText(
'Use the Troubleshoot menu to review the affected resources and resolve these warnings.'
)
).toBeInTheDocument()
expect(screen.queryByText(/AI Assistant/)).not.toBeInTheDocument()
const menu = await openTroubleshootMenu()
expect(menu.queryByRole('menuitem', { name: 'Ask AI Assistant' })).not.toBeInTheDocument()
})
})
@@ -15,21 +15,83 @@ import {
DropdownMenuTrigger,
} from 'ui'
import { RESOURCE_WARNING_MESSAGES } from './ResourceExhaustionWarningBanner.constants'
import {
isResourceWarningMessageKey,
RESOURCE_WARNING_MESSAGES,
type ResourceWarningMessageKey,
} from './ResourceExhaustionWarningBanner.constants'
import {
applyResourceList,
getResourceWarningAiPrompt,
getResourceWarningCorrectionUrl,
getTroubleshootItems,
getWarningContent,
isComputeUpgradeWarning,
type TroubleshootItem,
} from './ResourceExhaustionWarningBanner.utils'
import { mapComputeSizeNameToAddonVariantId } from '@/components/interfaces/DiskManagement/DiskManagement.utils'
import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
import { useResourceWarningsQuery } from '@/data/usage/resource-warnings-query'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { useShowDiskIOBurstBalanceChart } from '@/hooks/misc/useShowDiskIOBurstBalanceChart'
import { useTrack } from '@/lib/telemetry/track'
import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state'
import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
type LinkedTroubleshootItem = Extract<TroubleshootItem, { kind: 'metrics' | 'docs' }>
type ConfiguredResourceWarning = Exclude<ResourceWarningMessageKey, 'multiple_resource_warnings'>
const isConfiguredResourceWarning = (property: string): property is ConfiguredResourceWarning =>
isResourceWarningMessageKey(property) && property !== 'multiple_resource_warnings'
const MULTIPLE_RESOURCE_WARNING_NO_AI_DESCRIPTION =
'Use the Troubleshoot menu to review the affected resources and resolve these warnings.'
const MULTIPLE_RESOURCE_MIXED_SEVERITY_TITLE =
"Resource warnings for {resources} are affecting your project's performance"
const TroubleshootMenuItem = ({
item,
onTroubleshootClick,
onAskAI,
}: {
item: TroubleshootItem
onTroubleshootClick: (item: LinkedTroubleshootItem) => void
onAskAI: () => void
}) => {
if (item.kind === 'ai') {
return (
<DropdownMenuItem className="flex items-center gap-x-2 cursor-pointer" onClick={onAskAI}>
<Sparkles size={14} />
{item.menuLabel}
</DropdownMenuItem>
)
}
if (item.kind === 'metrics') {
return (
<DropdownMenuItem asChild onClick={() => onTroubleshootClick(item)}>
<Link href={item.href} className="flex items-center gap-x-2 cursor-pointer">
<ChartLine size={14} />
{item.menuLabel}
</Link>
</DropdownMenuItem>
)
}
return (
<DropdownMenuItem asChild onClick={() => onTroubleshootClick(item)}>
<a
href={item.href}
target="_blank"
rel="noreferrer"
className="flex items-center gap-x-2 cursor-pointer"
>
<BookOpen size={14} />
{item.menuLabel}
</a>
</DropdownMenuItem>
)
}
export const ResourceExhaustionWarningBanner = () => {
const { ref } = useParams()
const router = useRouter()
@@ -45,6 +107,7 @@ export const ResourceExhaustionWarningBanner = () => {
const { openSidebar } = useSidebarManagerSnapshot()
const aiSnap = useAiAssistantStateSnapshot()
const track = useTrack()
const showBurstBalanceChart = useShowDiskIOBurstBalanceChart()
const { data: resourceWarnings } = useResourceWarningsQuery({ ref: ref })
// [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(
@@ -52,62 +115,65 @@ export const ResourceExhaustionWarningBanner = () => {
)
// [Joshen] Read only takes higher precedence over multiple resource warnings
const activeWarnings =
const activeWarnings: ConfiguredResourceWarning[] =
projectResourceWarnings !== undefined
? projectResourceWarnings.is_readonly_mode_enabled
? ['is_readonly_mode_enabled']
: Object.keys(projectResourceWarnings).filter(
(property) =>
(property): property is ConfiguredResourceWarning =>
property !== 'project' &&
property !== 'is_readonly_mode_enabled' &&
projectResourceWarnings[property as keyof typeof projectResourceWarnings] !== null
isConfiguredResourceWarning(property) &&
projectResourceWarnings[property] !== null
)
: []
const hasCriticalWarning =
projectResourceWarnings !== undefined
? activeWarnings.some(
(x) => projectResourceWarnings[x as keyof typeof projectResourceWarnings] === 'critical'
)
? activeWarnings.some((warningType) => projectResourceWarnings[warningType] === 'critical')
: false
const hasWarningSeverity =
projectResourceWarnings !== undefined
? activeWarnings.some((warningType) => projectResourceWarnings[warningType] === 'warning')
: false
const hasMixedWarningSeverities = hasCriticalWarning && hasWarningSeverity
const isCritical = activeWarnings.includes('is_readonly_mode_enabled') || hasCriticalWarning
const warningContent =
projectResourceWarnings !== undefined
? getWarningContent(projectResourceWarnings, activeWarnings[0], 'bannerContent')
: undefined
const aiPrompt = getResourceWarningAiPrompt(activeWarnings)
const multipleWarningDescription =
aiPrompt === undefined
? MULTIPLE_RESOURCE_WARNING_NO_AI_DESCRIPTION
: RESOURCE_WARNING_MESSAGES.multiple_resource_warnings.bannerContent[
hasCriticalWarning ? 'critical' : 'warning'
].description
const multipleWarningTitle = hasMixedWarningSeverities
? MULTIPLE_RESOURCE_MIXED_SEVERITY_TITLE
: RESOURCE_WARNING_MESSAGES.multiple_resource_warnings.bannerContent[
hasCriticalWarning ? 'critical' : 'warning'
].title
const title = applyDiskIoBaseline(
activeWarnings.length > 1
? RESOURCE_WARNING_MESSAGES.multiple_resource_warnings.bannerContent[
hasCriticalWarning ? 'critical' : 'warning'
].title
: warningContent?.title
applyResourceList(
activeWarnings.length > 1 ? multipleWarningTitle : warningContent?.title,
activeWarnings
)
)
const description = applyDiskIoBaseline(
activeWarnings.length > 1
? RESOURCE_WARNING_MESSAGES.multiple_resource_warnings.bannerContent[
hasCriticalWarning ? 'critical' : 'warning'
].description
: warningContent?.description
applyResourceList(
activeWarnings.length > 1 ? multipleWarningDescription : warningContent?.description,
activeWarnings
)
)
const learnMoreUrl =
activeWarnings.length > 1
? RESOURCE_WARNING_MESSAGES.multiple_resource_warnings.docsUrl
: RESOURCE_WARNING_MESSAGES[activeWarnings[0] as keyof typeof RESOURCE_WARNING_MESSAGES]
?.docsUrl
const singleWarningMessage =
activeWarnings.length === 1 ? RESOURCE_WARNING_MESSAGES[activeWarnings[0]] : undefined
const metricsHref = singleWarningMessage?.metricsHref?.replace('[ref]', ref ?? 'default')
const metric =
activeWarnings.length > 1
? RESOURCE_WARNING_MESSAGES.multiple_resource_warnings.metric
: RESOURCE_WARNING_MESSAGES[activeWarnings[0] as keyof typeof RESOURCE_WARNING_MESSAGES]
?.metric
: RESOURCE_WARNING_MESSAGES[activeWarnings[0]]?.metric
const isFreePlan = organization?.plan?.id === 'free'
@@ -126,17 +192,16 @@ export const ResourceExhaustionWarningBanner = () => {
if (isComputeUpgradeMetric) return 'Upgrade compute'
return activeWarnings.length > 1
? RESOURCE_WARNING_MESSAGES.multiple_resource_warnings.buttonText
: RESOURCE_WARNING_MESSAGES[activeWarnings[0] as keyof typeof RESOURCE_WARNING_MESSAGES]
?.buttonText
: RESOURCE_WARNING_MESSAGES[activeWarnings[0]]?.buttonText
})()
const aiPrompt =
activeWarnings.length > 1
? isComputeUpgradeMetric
? RESOURCE_WARNING_MESSAGES.multiple_resource_warnings.aiPrompt
: undefined
: RESOURCE_WARNING_MESSAGES[activeWarnings[0] as keyof typeof RESOURCE_WARNING_MESSAGES]
?.aiPrompt
const troubleshootItems = getTroubleshootItems({
activeWarnings,
projectRef: ref ?? 'default',
aiPrompt,
showBurstBalanceChart,
})
const soleTroubleshootItem = troubleshootItems.length === 1 ? troubleshootItems[0] : undefined
const handleAskAI = () => {
track('resource_exhaustion_banner_ai_assistant_clicked', {
@@ -146,6 +211,15 @@ export const ResourceExhaustionWarningBanner = () => {
aiSnap.newChat({ initialInput: aiPrompt })
}
const handleTroubleshootClick = (item: LinkedTroubleshootItem) => {
track('resource_exhaustion_banner_troubleshoot_clicked', {
troubleshootAction: item.kind,
warningType: item.warningType,
warningTypes: activeWarnings,
destination: item.href,
})
}
const hasNoWarnings = activeWarnings.length === 0
const hasNoWarningContent =
warningContent === undefined || (!warningContent?.title && !warningContent?.description)
@@ -163,8 +237,7 @@ export const ResourceExhaustionWarningBanner = () => {
(activeWarnings.includes('is_readonly_mode_enabled') || (isComputeUpgradeMetric && !isFreePlan))
// these take precedence over each other, so there's only one active warning to check
const activeWarning =
RESOURCE_WARNING_MESSAGES[activeWarnings[0] as keyof typeof RESOURCE_WARNING_MESSAGES]
const activeWarning = RESOURCE_WARNING_MESSAGES[activeWarnings[0]]
const restrictToRoutes = activeWarning?.restrictToRoutes
const isVisible =
@@ -206,7 +279,7 @@ export const ResourceExhaustionWarningBanner = () => {
<AlertDescription>{description}</AlertDescription>
</div>
<div className="flex items-center gap-x-2">
{learnMoreUrl !== undefined && aiPrompt !== undefined ? (
{troubleshootItems.length >= 2 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button icon={<Wrench size={14} />} iconRight={<ChevronDown size={14} />}>
@@ -214,43 +287,40 @@ export const ResourceExhaustionWarningBanner = () => {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{metricsHref !== undefined && (
<DropdownMenuItem asChild>
<Link href={metricsHref} className="flex items-center gap-x-2 cursor-pointer">
<ChartLine size={14} />
View metrics
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem asChild>
<a
href={learnMoreUrl}
target="_blank"
rel="noreferrer"
className="flex items-center gap-x-2 cursor-pointer"
>
<BookOpen size={14} />
Documentation
</a>
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-x-2 cursor-pointer"
onClick={handleAskAI}
>
<Sparkles size={14} />
Ask AI Assistant
</DropdownMenuItem>
{troubleshootItems.map((item) => (
<TroubleshootMenuItem
key={item.kind === 'ai' ? 'ai' : `${item.kind}-${item.warningType}`}
item={item}
onTroubleshootClick={handleTroubleshootClick}
onAskAI={handleAskAI}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
) : learnMoreUrl !== undefined ? (
<Button asChild icon={<BookOpen size={14} />}>
<a href={learnMoreUrl} target="_blank" rel="noreferrer">
Learn more
)}
{soleTroubleshootItem?.kind === 'metrics' && (
<Button
asChild
icon={<ChartLine size={14} />}
onClick={() => handleTroubleshootClick(soleTroubleshootItem)}
>
<Link href={soleTroubleshootItem.href}>{soleTroubleshootItem.buttonLabel}</Link>
</Button>
)}
{soleTroubleshootItem?.kind === 'docs' && (
<Button
asChild
icon={<BookOpen size={14} />}
onClick={() => handleTroubleshootClick(soleTroubleshootItem)}
>
<a href={soleTroubleshootItem.href} target="_blank" rel="noreferrer">
{soleTroubleshootItem.buttonLabel}
</a>
</Button>
) : aiPrompt !== undefined ? (
<Button onClick={handleAskAI}>Ask AI Assistant</Button>
) : null}
)}
{soleTroubleshootItem?.kind === 'ai' && (
<Button onClick={handleAskAI}>{soleTroubleshootItem.buttonLabel}</Button>
)}
{correctionUrl !== undefined && (
<Button
asChild
@@ -1,7 +1,14 @@
import { describe, expect, it } from 'vitest'
import { RESOURCE_WARNING_MESSAGES } from './ResourceExhaustionWarningBanner.constants'
import {
applyResourceList,
formatResourceList,
getResourceWarningAiPrompt,
getResourceWarningCorrectionUrl,
getResourceWarningLabels,
getResourceWarningMetricsHref,
getTroubleshootItems,
isComputeUpgradeWarning,
} from './ResourceExhaustionWarningBanner.utils'
@@ -77,3 +84,141 @@ describe('resource warning correction routes', () => {
).toBe('/project/project-ref/settings/infrastructure#custom_metric')
})
})
describe('formatResourceList', () => {
it('returns a single label as is', () => {
expect(formatResourceList(['CPU'])).toBe('CPU')
})
it('joins two labels with and', () => {
expect(formatResourceList(['CPU', 'Disk IO'])).toBe('CPU and Disk IO')
})
it('joins three labels with an Oxford comma', () => {
expect(formatResourceList(['CPU', 'Disk IO', 'Memory'])).toBe('CPU, Disk IO, and Memory')
})
})
describe('getResourceWarningLabels', () => {
it('drops response keys that have no config entry', () => {
expect(getResourceWarningLabels(['cpu_exhaustion', 'need_pitr'])).toEqual(['CPU'])
})
})
describe('getResourceWarningMetricsHref', () => {
it('links a warning to its primary database chart over the last 3 hours', () => {
expect(getResourceWarningMetricsHref('cpu_exhaustion', 'abc')).toBe(
'/project/abc/observability/database?chart=cpu-usage&db=abc&isHelper=true&helperText=Last+3+hours#cpu-usage'
)
})
it('defaults disk IO to the always-rendered throughput chart', () => {
expect(getResourceWarningMetricsHref('disk_io_exhaustion', 'abc')).toContain(
'chart=disk-throughput'
)
})
it('targets the burst balance chart for disk IO when that chart is shown', () => {
expect(getResourceWarningMetricsHref('disk_io_exhaustion', 'abc', true)).toContain(
'chart=disk-io-burst-balance'
)
})
it('leaves other warnings alone when the burst balance chart is shown', () => {
expect(getResourceWarningMetricsHref('cpu_exhaustion', 'abc', true)).toContain(
'chart=cpu-usage'
)
})
it.each(['auth_rate_limit_exhaustion', 'is_readonly_mode_enabled'])(
'returns undefined for %s, which has no chart',
(warningType) => {
expect(getResourceWarningMetricsHref(warningType, 'abc')).toBeUndefined()
}
)
})
describe('applyResourceList', () => {
it('fills the multi-resource title with the tripped resources', () => {
expect(
applyResourceList(
RESOURCE_WARNING_MESSAGES.multiple_resource_warnings.bannerContent.warning.title,
['cpu_exhaustion', 'disk_space_exhaustion']
)
).toBe('Your project is exhausting CPU and Disk space, which is affecting its performance')
})
})
describe('getTroubleshootItems', () => {
const kindsOf = (items: ReturnType<typeof getTroubleshootItems>) => items.map((item) => item.kind)
it('lists metrics, then docs, then AI for a multi-resource banner', () => {
const items = getTroubleshootItems({
activeWarnings: ['cpu_exhaustion', 'disk_space_exhaustion'],
projectRef: 'abc',
aiPrompt: 'prompt',
})
expect(kindsOf(items)).toEqual(['metrics', 'metrics', 'docs', 'docs', 'ai'])
expect(items[0].menuLabel).toBe('View CPU metrics')
expect(items[2].menuLabel).toBe('CPU documentation')
expect(items[3].menuLabel).toBe('Disk space documentation')
})
it('keeps the generic labels for a single-resource banner', () => {
const items = getTroubleshootItems({
activeWarnings: ['cpu_exhaustion'],
projectRef: 'abc',
aiPrompt: 'prompt',
})
expect(kindsOf(items)).toEqual(['metrics', 'docs', 'ai'])
expect(items[0].menuLabel).toBe('View metrics')
})
it('skips the metrics item for warnings without a chart', () => {
const items = getTroubleshootItems({
activeWarnings: ['auth_rate_limit_exhaustion'],
projectRef: 'abc',
aiPrompt: 'prompt',
})
expect(kindsOf(items)).toEqual(['docs', 'ai'])
})
it('leaves read-only mode with a single Learn more button', () => {
const items = getTroubleshootItems({
activeWarnings: ['is_readonly_mode_enabled'],
projectRef: 'abc',
aiPrompt: undefined,
})
expect(kindsOf(items)).toEqual(['docs'])
expect(items[0].buttonLabel).toBe('Learn more')
})
it('gives disk space a metrics and a docs item without an AI prompt', () => {
const items = getTroubleshootItems({
activeWarnings: ['disk_space_exhaustion'],
projectRef: 'abc',
aiPrompt: undefined,
})
expect(kindsOf(items)).toEqual(['metrics', 'docs'])
})
})
describe('getResourceWarningAiPrompt', () => {
it('uses the entry prompt for a single warning', () => {
expect(getResourceWarningAiPrompt(['cpu_exhaustion'])).toBe(
RESOURCE_WARNING_MESSAGES.cpu_exhaustion.aiPrompt
)
})
it('names the tripped resources for multiple warnings', () => {
expect(getResourceWarningAiPrompt(['cpu_exhaustion', 'disk_space_exhaustion'])).toContain(
'(CPU and Disk space)'
)
})
it('offers no prompt when none of the tripped resources is a compute resource', () => {
expect(
getResourceWarningAiPrompt(['disk_space_exhaustion', 'auth_rate_limit_exhaustion'])
).toBeUndefined()
})
})
@@ -1,4 +1,5 @@
import { RESOURCE_WARNING_MESSAGES } from './ResourceExhaustionWarningBanner.constants'
import { REPORT_DATERANGE_HELPER_LABELS } from '@/components/interfaces/Reports/Reports.constants'
import { getInfrastructurePath } from '@/components/interfaces/Settings/Infrastructure/Infrastructure.utils'
import type { ResourceWarning } from '@/data/usage/resource-warnings-query'
@@ -74,3 +75,113 @@ export const getResourceWarningCorrectionUrl = ({
if (metric === null) return infrastructurePath
return correctionUrlVariants[metric] ?? `${infrastructurePath}#${metric}`
}
export type TroubleshootItem =
| { kind: 'metrics'; warningType: string; menuLabel: string; buttonLabel: string; href: string }
| { kind: 'docs'; warningType: string; menuLabel: string; buttonLabel: string; href: string }
| { kind: 'ai'; menuLabel: string; buttonLabel: string }
const resourceListFormatter = new Intl.ListFormat('en-US', { style: 'long', type: 'conjunction' })
export const formatResourceList = (labels: string[]): string => resourceListFormatter.format(labels)
export const getResourceWarningLabels = (activeWarnings: string[]): string[] =>
activeWarnings.flatMap((warningType) => {
const label = RESOURCE_WARNING_MESSAGES[warningType]?.resourceLabel
return label === undefined ? [] : [label]
})
export const applyResourceList = (
text: string | undefined,
activeWarnings: string[]
): string | undefined => {
if (text === undefined) return undefined
return text.replaceAll(
'{resources}',
formatResourceList(getResourceWarningLabels(activeWarnings))
)
}
export const getResourceWarningMetricsHref = (
warningType: string,
projectRef: string,
showBurstBalanceChart = false
): string | undefined => {
const chartId =
warningType === 'disk_io_exhaustion' && showBurstBalanceChart
? 'disk-io-burst-balance'
: RESOURCE_WARNING_MESSAGES[warningType]?.metricsChartId
if (chartId === undefined) return undefined
const params = new URLSearchParams({
chart: chartId,
db: projectRef,
isHelper: 'true',
helperText: REPORT_DATERANGE_HELPER_LABELS.LAST_3_HOURS,
})
return `/project/${projectRef}/observability/database?${params}#${chartId}`
}
export const getResourceWarningAiPrompt = (activeWarnings: string[]): string | undefined => {
if (activeWarnings.length === 1) return RESOURCE_WARNING_MESSAGES[activeWarnings[0]]?.aiPrompt
const hasComputeWarning = activeWarnings.some((warningType) =>
COMPUTE_UPGRADE_WARNING_TYPES.includes(warningType)
)
if (!hasComputeWarning) return undefined
return applyResourceList(
RESOURCE_WARNING_MESSAGES.multiple_resource_warnings.aiPrompt,
activeWarnings
)
}
export const getTroubleshootItems = ({
activeWarnings,
projectRef,
aiPrompt,
showBurstBalanceChart = false,
}: {
activeWarnings: string[]
projectRef: string
aiPrompt: string | undefined
showBurstBalanceChart?: boolean
}): TroubleshootItem[] => {
const isSingleWarning = activeWarnings.length === 1
const metricsItems = activeWarnings.flatMap((warningType): TroubleshootItem[] => {
const href = getResourceWarningMetricsHref(warningType, projectRef, showBurstBalanceChart)
const label = RESOURCE_WARNING_MESSAGES[warningType]?.resourceLabel
if (href === undefined || (!isSingleWarning && label === undefined)) return []
return [
{
kind: 'metrics',
warningType,
menuLabel: isSingleWarning ? 'View metrics' : `View ${label} metrics`,
buttonLabel: 'View metrics',
href,
},
]
})
const docsItems = activeWarnings.flatMap((warningType): TroubleshootItem[] => {
const href = RESOURCE_WARNING_MESSAGES[warningType]?.docsUrl
const label = RESOURCE_WARNING_MESSAGES[warningType]?.resourceLabel
if (href === undefined || (!isSingleWarning && label === undefined)) return []
return [
{
kind: 'docs',
warningType,
menuLabel: isSingleWarning ? 'Documentation' : `${label} documentation`,
buttonLabel: 'Learn more',
href,
},
]
})
const aiItems: TroubleshootItem[] =
aiPrompt === undefined
? []
: [{ kind: 'ai', menuLabel: 'Ask AI Assistant', buttonLabel: 'Ask AI Assistant' }]
return [...metricsItems, ...docsItems, ...aiItems]
}
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import { getReportAttributesV2 } from './database-charts'
import { getReportAttributesV2, shouldShowDiskIOBurstBalanceChart } from './database-charts'
import { RESOURCE_WARNING_MESSAGES } from '@/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner.constants'
import { Project } from '@/data/projects/project-detail-query'
const PROJECT: Project = {
@@ -117,3 +118,47 @@ describe('getReportAttributesV2 disk-io-burst-balance chart', () => {
expect(getBurstBalanceChart(buildProject({ infra_compute_size: '16xlarge' }))?.hide).toBe(true)
})
})
describe('shouldShowDiskIOBurstBalanceChart', () => {
it('shows the chart for burstable non high availability projects when the flag is on', () => {
expect(shouldShowDiskIOBurstBalanceChart(buildProject(), true)).toBe(true)
})
it('hides the chart when the flag is off', () => {
expect(shouldShowDiskIOBurstBalanceChart(buildProject(), false)).toBe(false)
})
it('hides the chart for non burstable compute sizes', () => {
expect(
shouldShowDiskIOBurstBalanceChart(buildProject({ infra_compute_size: '4xlarge' }), true)
).toBe(false)
})
it('hides the chart for high availability projects', () => {
expect(
shouldShowDiskIOBurstBalanceChart(
buildProject({ high_availability: true, infra_compute_size: 'large' }),
true
)
).toBe(false)
})
it('hides the chart when the project is unknown', () => {
expect(shouldShowDiskIOBurstBalanceChart(undefined, true)).toBe(false)
})
})
describe('resource warning metrics chart IDs', () => {
it('keeps every configured chart target visible in the database report', () => {
const visibleChartIds = new Set(
getReportAttributesV2(ENTITLED_FEATURES, buildProject())
.filter((chart) => !chart.hide)
.map((chart) => chart.id)
)
const missingChartIds = Object.values(RESOURCE_WARNING_MESSAGES)
.flatMap((message) => message.metricsChartId ?? [])
.filter((chartId) => !visibleChartIds.has(chartId))
expect(missingChartIds).toEqual([])
})
})
+12 -6
View File
@@ -13,6 +13,14 @@ import { resolveHighAvailability } from '@/hooks/misc/useHighAvailability.consta
import { DOCS_URL } from '@/lib/constants'
import { formatBytes, formatBytesMinMB } from '@/lib/helpers'
// High Availability projects run on volumes without a burst credit pool, so the
// Disk IO Burst Balance chart has no data to show for them.
export const shouldShowDiskIOBurstBalanceChart = (
project: Project | undefined,
isFlagEnabled: boolean
): boolean =>
isFlagEnabled && hasBurstableIO(project?.infra_compute_size) && !resolveHighAvailability(project)
export const getReportAttributesV2: (
entitledFeatures: string[],
project: Project,
@@ -42,12 +50,10 @@ export const getReportAttributesV2: (
typeof provisionedDiskIops === 'number' && typeof computeIopsLimit === 'number'
? Math.min(provisionedDiskIops, computeIopsLimit)
: provisionedDiskIops
// High Availability projects run on volumes without a burst credit pool, so the
// Disk IO Burst Balance chart has no data to show for them.
const showBurstBalanceChart =
!!showDiskIOBurstBalanceChart &&
hasBurstableIO(project?.infra_compute_size) &&
!isHighAvailability
const showBurstBalanceChart = shouldShowDiskIOBurstBalanceChart(
project,
!!showDiskIOBurstBalanceChart
)
const baselineThroughputMBps = COMPUTE_DISK[computeVariantId]?.baselineThroughputMBps
const baselineThroughputLabel =
typeof baselineThroughputMBps === 'number' ? `${baselineThroughputMBps} MB/s` : 'its baseline'
@@ -1,8 +1,9 @@
import dayjs from 'dayjs'
import { describe, expect, test } from 'vitest'
import { REPORT_DATERANGE_HELPER_LABELS } from '@/components/interfaces/Reports/Reports.constants'
import { analyticsIntervalToGranularity } from '@/data/reports/report.utils'
import { getIntervalGranularity } from '@/hooks/misc/useReportDateRange'
import { getIntervalGranularity, resolveHelperFromUrl } from '@/hooks/misc/useReportDateRange'
const rangeOf = (value: number, unit: dayjs.ManipulateType) => {
const to = dayjs()
@@ -40,3 +41,19 @@ describe('getIntervalGranularity', () => {
expect(getIntervalGranularity(from, to)).toBe(expected)
})
})
describe('resolveHelperFromUrl', () => {
test('returns the helper named by the URL when the helper flag is set', () => {
expect(resolveHelperFromUrl(true, 'Last 3 hours')?.text).toBe(
REPORT_DATERANGE_HELPER_LABELS.LAST_3_HOURS
)
})
test('ignores the label when the helper flag is off', () => {
expect(resolveHelperFromUrl(false, 'Last 3 hours')).toBeUndefined()
})
test('ignores labels that are not a known helper', () => {
expect(resolveHelperFromUrl(true, 'Last 3 fortnights')).toBeUndefined()
})
})
+24 -10
View File
@@ -23,6 +23,26 @@ export const getIntervalGranularity = (from: string, to: string): AnalyticsInter
return '1d'
}
export const resolveHelperFromUrl = (
isHelper: boolean,
helperText: string
): ReportsDatetimeHelper | undefined => {
if (!isHelper || helperText === '') return undefined
return REPORTS_DATEPICKER_HELPERS.find((helper) => helper.text === helperText)
}
const resolveHelperFromArgument = (
defaultHelper: REPORT_DATERANGE_HELPER_LABELS | string | ReportsDatetimeHelper
): ReportsDatetimeHelper | undefined => {
if (typeof defaultHelper === 'string') {
return REPORTS_DATEPICKER_HELPERS.find((helper) => helper.text === defaultHelper)
}
if (defaultHelper && typeof defaultHelper === 'object' && 'text' in defaultHelper) {
return defaultHelper
}
return undefined
}
export const DATERANGE_LIMITS: { [key: string]: number } = {
free: 1,
pro: 7,
@@ -91,15 +111,9 @@ export const useReportDateRange = (
)
const getDefaultHelper = useCallback(() => {
let targetHelper: ReportsDatetimeHelper | undefined
if (typeof defaultHelper === 'string') {
// Find helper by text (supports both enum values and direct strings)
targetHelper = REPORTS_DATEPICKER_HELPERS.find((helper) => helper.text === defaultHelper)
} else if (defaultHelper && typeof defaultHelper === 'object' && 'text' in defaultHelper) {
// Use the provided helper object directly
targetHelper = defaultHelper
}
const targetHelper =
resolveHelperFromUrl(Boolean(isHelperValue), helperTextValue ?? '') ??
resolveHelperFromArgument(defaultHelper)
// Check if the target helper is available for the current entitlement
if (targetHelper && hasAccessToHelper(targetHelper)) {
@@ -142,7 +156,7 @@ export const useReportDateRange = (
end: defaultEnd,
helper: { isHelper: false },
}
}, [defaultHelper, hasAccessToHelper])
}, [defaultHelper, hasAccessToHelper, helperTextValue, isHelperValue])
// Get current effective values (from URL or defaults, but don't set URL)
const timestampStart = useMemo(() => {
@@ -0,0 +1,10 @@
import { useFlag } from 'common'
import { shouldShowDiskIOBurstBalanceChart } from '@/data/reports/database-charts'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
export const useShowDiskIOBurstBalanceChart = (): boolean => {
const isFlagEnabled = useFlag('showDiskIOBurstBalanceChart')
const { data: project } = useSelectedProjectQuery()
return shouldShowDiskIOBurstBalanceChart(project, !!isFlagEnabled)
}
@@ -4,7 +4,7 @@ import { useFlag, useParams } from 'common'
import dayjs from 'dayjs'
import { ArrowRight, ExternalLink, RefreshCw } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useRef, useState } from 'react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Alert, AlertDescription, Button } from 'ui'
@@ -69,6 +69,84 @@ export type UpdateDateRange = (from: string, to: string) => void
export default DatabaseReport
const REPORT_TITLE = 'Database'
const CHART_LAYOUT_OBSERVATION_DURATION = 10_000
const CHART_LAYOUT_OBSERVATION_CANCEL_EVENTS = [
'keydown',
'pointerdown',
'touchstart',
'wheel',
] as const
const isChartFullyVisible = (target: HTMLElement) => {
const targetBounds = target.getBoundingClientRect()
const scrollContainerBounds = target.closest('main')?.getBoundingClientRect()
const viewportTop = scrollContainerBounds?.top ?? 0
const viewportBottom = scrollContainerBounds?.bottom ?? window.innerHeight
return targetBounds.top >= viewportTop && targetBounds.bottom <= viewportBottom
}
export const useDatabaseChartDeepLink = (chart?: string) => {
useEffect(() => {
if (chart === undefined) return
let chartTargetObserver: MutationObserver | undefined
let resizeObserver: ResizeObserver | undefined
let stopObservingTimer: number | undefined
const stopDeepLink = () => {
if (stopObservingTimer !== undefined) window.clearTimeout(stopObservingTimer)
chartTargetObserver?.disconnect()
resizeObserver?.disconnect()
CHART_LAYOUT_OBSERVATION_CANCEL_EVENTS.forEach((eventName) =>
window.removeEventListener(eventName, stopDeepLink, true)
)
}
CHART_LAYOUT_OBSERVATION_CANCEL_EVENTS.forEach((eventName) =>
window.addEventListener(eventName, stopDeepLink, true)
)
const scrollToChart = () => {
const target = document.getElementById(chart)
if (target === null) return false
chartTargetObserver?.disconnect()
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
const chartList = target.parentElement
if (chartList === null) return true
resizeObserver = new ResizeObserver(() => {
if (!isChartFullyVisible(target)) {
target.scrollIntoView({ behavior: 'auto', block: 'center' })
}
})
resizeObserver.observe(chartList)
stopObservingTimer = window.setTimeout(stopDeepLink, CHART_LAYOUT_OBSERVATION_DURATION)
return true
}
if (!scrollToChart()) {
chartTargetObserver = new MutationObserver(scrollToChart)
chartTargetObserver.observe(document.body, { childList: true, subtree: true })
}
return stopDeepLink
}, [chart])
}
export const useDatabaseSelectionFromUrl = (
db: string | undefined,
setSelectedDatabaseId: (databaseId: string) => void
) => {
useEffect(() => {
if (db === undefined) return
const timeout = window.setTimeout(() => setSelectedDatabaseId(db), 100)
return () => window.clearTimeout(timeout)
}, [db, setSelectedDatabaseId])
}
const DatabaseUsage = () => {
const { db, chart, ref } = useParams()
@@ -207,26 +285,10 @@ const DatabaseUsage = () => {
setShowDatePicker((open) => !open)
})
const stateSyncedFromUrlRef = useRef(false)
useEffect(() => {
if (stateSyncedFromUrlRef.current) return
stateSyncedFromUrlRef.current = true
useDatabaseSelectionFromUrl(db, state.setSelectedDatabaseId)
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, state])
// Loading charts above the target resize after the first scroll and can push it out of view.
useDatabaseChartDeepLink(chart)
return (
<>
@@ -0,0 +1,178 @@
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import {
useDatabaseChartDeepLink,
useDatabaseSelectionFromUrl,
} from '@/pages/project/[ref]/observability/database'
class MockResizeObserver implements ResizeObserver {
static instances: MockResizeObserver[] = []
callback: ResizeObserverCallback
disconnect = vi.fn()
observe = vi.fn()
takeRecords = vi.fn(() => [])
unobserve = vi.fn()
constructor(callback: ResizeObserverCallback) {
this.callback = callback
MockResizeObserver.instances.push(this)
}
}
class MockMutationObserver implements MutationObserver {
static instances: MockMutationObserver[] = []
callback: MutationCallback
isDisconnected = false
disconnect = vi.fn(() => {
this.isDisconnected = true
})
observe = vi.fn()
takeRecords = vi.fn(() => [])
constructor(callback: MutationCallback) {
this.callback = callback
MockMutationObserver.instances.push(this)
}
notify() {
if (!this.isDisconnected) this.callback([], this)
}
}
const bounds = (top: number, bottom: number): DOMRect =>
({
bottom,
height: bottom - top,
left: 0,
right: 800,
top,
width: 800,
x: 0,
y: top,
toJSON: () => ({}),
}) as DOMRect
const addChart = (id: string) => {
const target = document.createElement('div')
target.id = id
target.scrollIntoView = vi.fn()
document.querySelector('section')?.appendChild(target)
return target
}
beforeEach(() => {
vi.useFakeTimers()
vi.stubGlobal('MutationObserver', MockMutationObserver)
vi.stubGlobal('ResizeObserver', MockResizeObserver)
MockMutationObserver.instances = []
MockResizeObserver.instances = []
const scrollContainer = document.createElement('main')
const chartList = document.createElement('section')
vi.spyOn(scrollContainer, 'getBoundingClientRect').mockReturnValue(bounds(50, 800))
scrollContainer.appendChild(chartList)
document.body.appendChild(scrollContainer)
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
document.body.replaceChildren()
})
describe('useDatabaseChartDeepLink', () => {
test('re-centers a chart when lazy loading pushes it outside the report viewport', () => {
const target = addChart('disk-size')
const targetBounds = vi.spyOn(target, 'getBoundingClientRect').mockReturnValue(bounds(300, 600))
const { unmount } = renderHook(() => useDatabaseChartDeepLink('disk-size'))
act(() => vi.advanceTimersByTime(200))
expect(target.scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'center' })
act(() => MockResizeObserver.instances[0].callback([], MockResizeObserver.instances[0]))
expect(target.scrollIntoView).toHaveBeenCalledTimes(1)
targetBounds.mockReturnValue(bounds(850, 1_150))
act(() => MockResizeObserver.instances[0].callback([], MockResizeObserver.instances[0]))
expect(target.scrollIntoView).toHaveBeenLastCalledWith({ behavior: 'auto', block: 'center' })
unmount()
expect(MockResizeObserver.instances[0].disconnect).toHaveBeenCalled()
})
test('stops re-centering after the user interacts with the page', () => {
const target = addChart('disk-size')
vi.spyOn(target, 'getBoundingClientRect').mockReturnValue(bounds(300, 600))
renderHook(() => useDatabaseChartDeepLink('disk-size'))
act(() => vi.advanceTimersByTime(200))
act(() => window.dispatchEvent(new Event('wheel')))
expect(MockResizeObserver.instances[0].disconnect).toHaveBeenCalled()
})
test('stops waiting after the user interacts before the chart mounts', () => {
renderHook(() => useDatabaseChartDeepLink('disk-size'))
act(() => window.dispatchEvent(new Event('wheel')))
const target = addChart('disk-size')
act(() => MockMutationObserver.instances[0].notify())
expect(target.scrollIntoView).not.toHaveBeenCalled()
expect(MockResizeObserver.instances).toHaveLength(0)
})
test('scrolls when a conditional chart mounts after the previous polling window', () => {
renderHook(() => useDatabaseChartDeepLink('disk-io-burst-balance'))
act(() => vi.advanceTimersByTime(6_000))
const target = addChart('disk-io-burst-balance')
act(() => MockMutationObserver.instances[0].notify())
expect(target.scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'center' })
expect(MockMutationObserver.instances[0].disconnect).toHaveBeenCalled()
expect(MockResizeObserver.instances[0].observe).toHaveBeenCalledWith(target.parentElement)
})
test('starts a fresh observer when the chart query parameter changes', () => {
const cpuTarget = addChart('cpu-usage')
const diskTarget = addChart('disk-size')
vi.spyOn(cpuTarget, 'getBoundingClientRect').mockReturnValue(bounds(200, 500))
vi.spyOn(diskTarget, 'getBoundingClientRect').mockReturnValue(bounds(300, 600))
const { rerender } = renderHook(({ chart }) => useDatabaseChartDeepLink(chart), {
initialProps: { chart: 'cpu-usage' },
})
act(() => vi.advanceTimersByTime(200))
rerender({ chart: 'disk-size' })
act(() => vi.advanceTimersByTime(200))
expect(cpuTarget.scrollIntoView).toHaveBeenCalledTimes(1)
expect(diskTarget.scrollIntoView).toHaveBeenCalledTimes(1)
expect(MockResizeObserver.instances[0].disconnect).toHaveBeenCalled()
expect(MockResizeObserver.instances[1].observe).toHaveBeenCalledWith(diskTarget.parentElement)
})
})
describe('useDatabaseSelectionFromUrl', () => {
test('selects the primary database when a deep link updates the database parameter', () => {
const setSelectedDatabaseId = vi.fn()
const { rerender } = renderHook(
({ db }) => useDatabaseSelectionFromUrl(db, setSelectedDatabaseId),
{ initialProps: { db: 'read-replica' } }
)
act(() => vi.advanceTimersByTime(100))
expect(setSelectedDatabaseId).toHaveBeenLastCalledWith('read-replica')
rerender({ db: 'project-ref' })
act(() => vi.advanceTimersByTime(100))
expect(setSelectedDatabaseId).toHaveBeenLastCalledWith('project-ref')
})
})
+18
View File
@@ -3656,6 +3656,23 @@ export interface ResourceExhaustionBannerAiAssistantClickedEvent {
}
}
/**
* User clicked a metrics or documentation link on a resource exhaustion warning banner (Troubleshoot menu item or single-action button).
*
* @group Events
* @source studio
*/
export interface ResourceExhaustionBannerTroubleshootClickedEvent {
action: 'resource_exhaustion_banner_troubleshoot_clicked'
groups: TelemetryGroups
properties: {
troubleshootAction: 'metrics' | 'docs'
warningType: string
warningTypes: string[]
destination: string
}
}
/**
* User clicked a row in the Unified Logs interface.
*
@@ -4092,6 +4109,7 @@ export type TelemetryEvent =
| AccessTokenDoneButtonClickedEvent
| ResourceExhaustionBannerUpgradeClickedEvent
| ResourceExhaustionBannerAiAssistantClickedEvent
| ResourceExhaustionBannerTroubleshootClickedEvent
| UnifiedLogsRowClickedEvent
| HeaderHomeLogoClickedEvent
| HeaderBackToDashboardClickedEvent