mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
feat(studio): exhaustion banner links to metrics (#50276)
This commit is contained in:
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>
|
||||
)
|
||||
|
||||
+24
-10
@@ -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)
|
||||
+261
@@ -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()
|
||||
})
|
||||
})
|
||||
+144
-74
@@ -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
|
||||
|
||||
+145
@@ -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()
|
||||
})
|
||||
})
|
||||
+111
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user