ResourceExhaustionWarningBanner.tsx309 lines · main
1import { useParams } from 'common'
2import { AlertTriangle, BookOpen, ChartLine, ChevronDown, Sparkles, Wrench } from 'lucide-react'
3import Link from 'next/link'
4import { useRouter } from 'next/router'
5import { COMPUTE_DISK } from 'shared-data'
6import {
7 Alert,
8 AlertDescription,
9 AlertTitle,
10 Button,
11 cn,
12 DropdownMenu,
13 DropdownMenuContent,
14 DropdownMenuItem,
15 DropdownMenuTrigger,
16} from 'ui'
17
18import { RESOURCE_WARNING_MESSAGES } from './ResourceExhaustionWarningBanner.constants'
19import { getWarningContent } from './ResourceExhaustionWarningBanner.utils'
20import { mapComputeSizeNameToAddonVariantId } from '@/components/interfaces/DiskManagement/DiskManagement.utils'
21import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
22import { useResourceWarningsQuery } from '@/data/usage/resource-warnings-query'
23import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
24import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
25import { useTrack } from '@/lib/telemetry/track'
26import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state'
27import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
28
29const COMPUTE_UPGRADE_METRICS = ['disk_io', 'cpu', 'ram']
30const COMPUTE_UPGRADE_WARNING_TYPES = [
31 'disk_io_exhaustion',
32 'cpu_exhaustion',
33 'memory_and_swap_exhaustion',
34]
35
36export const ResourceExhaustionWarningBanner = () => {
37 const { ref } = useParams()
38 const router = useRouter()
39 const { data: organization, isLoading: isOrgLoading } = useSelectedOrganizationQuery()
40 const { data: project } = useSelectedProjectQuery()
41 const diskIoBaselineLabel = (() => {
42 const variant = mapComputeSizeNameToAddonVariantId(project?.infra_compute_size)
43 const baseline = COMPUTE_DISK[variant]?.baselineThroughputMBps
44 return typeof baseline === 'number' ? `${baseline} MB/s` : 'its baseline'
45 })()
46 const applyDiskIoBaseline = (text?: string) =>
47 text ? text.replace(/\{baseline\}/g, diskIoBaselineLabel) : text
48 const { openSidebar } = useSidebarManagerSnapshot()
49 const aiSnap = useAiAssistantStateSnapshot()
50 const track = useTrack()
51 const { data: resourceWarnings } = useResourceWarningsQuery({ ref: ref })
52 // [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
53 const projectResourceWarnings = (resourceWarnings ?? [])?.find(
54 (warning) => warning.project === ref
55 )
56
57 // [Joshen] Read only takes higher precedence over multiple resource warnings
58 const activeWarnings =
59 projectResourceWarnings !== undefined
60 ? projectResourceWarnings.is_readonly_mode_enabled
61 ? ['is_readonly_mode_enabled']
62 : Object.keys(projectResourceWarnings).filter(
63 (property) =>
64 property !== 'project' &&
65 property !== 'is_readonly_mode_enabled' &&
66 projectResourceWarnings[property as keyof typeof projectResourceWarnings] !== null
67 )
68 : []
69
70 const hasCriticalWarning =
71 projectResourceWarnings !== undefined
72 ? activeWarnings.some(
73 (x) => projectResourceWarnings[x as keyof typeof projectResourceWarnings] === 'critical'
74 )
75 : false
76 const isCritical = activeWarnings.includes('is_readonly_mode_enabled') || hasCriticalWarning
77
78 const warningContent =
79 projectResourceWarnings !== undefined
80 ? getWarningContent(projectResourceWarnings, activeWarnings[0], 'bannerContent')
81 : undefined
82
83 const title = applyDiskIoBaseline(
84 activeWarnings.length > 1
85 ? RESOURCE_WARNING_MESSAGES.multiple_resource_warnings.bannerContent[
86 hasCriticalWarning ? 'critical' : 'warning'
87 ].title
88 : warningContent?.title
89 )
90
91 const description = applyDiskIoBaseline(
92 activeWarnings.length > 1
93 ? RESOURCE_WARNING_MESSAGES.multiple_resource_warnings.bannerContent[
94 hasCriticalWarning ? 'critical' : 'warning'
95 ].description
96 : warningContent?.description
97 )
98
99 const learnMoreUrl =
100 activeWarnings.length > 1
101 ? RESOURCE_WARNING_MESSAGES.multiple_resource_warnings.docsUrl
102 : RESOURCE_WARNING_MESSAGES[activeWarnings[0] as keyof typeof RESOURCE_WARNING_MESSAGES]
103 ?.docsUrl
104
105 const singleWarningMessage =
106 activeWarnings.length === 1 ? RESOURCE_WARNING_MESSAGES[activeWarnings[0]] : undefined
107 const metricsHref = singleWarningMessage?.metricsHref?.replace('[ref]', ref ?? 'default')
108
109 const metric =
110 activeWarnings.length > 1
111 ? RESOURCE_WARNING_MESSAGES.multiple_resource_warnings.metric
112 : RESOURCE_WARNING_MESSAGES[activeWarnings[0] as keyof typeof RESOURCE_WARNING_MESSAGES]
113 ?.metric
114
115 const correctionUrlVariants = {
116 undefined: undefined,
117 null: '/project/[ref]/settings/[infra-path]',
118 disk_space: '/project/[ref]/settings/compute-and-disk',
119 read_only: '/project/[ref]/settings/compute-and-disk',
120 disk_io: '/project/[ref]/settings/compute-and-disk',
121 cpu: '/project/[ref]/settings/compute-and-disk',
122 ram: '/project/[ref]/settings/compute-and-disk',
123 auth_email_rate_limit: '/project/[ref]/auth/rate-limits',
124 auth_restricted_email_sending: '/project/[ref]/auth/smtp',
125 default: (metric: string) => `/project/[ref]/settings/[infra-path]#${metric}`,
126 }
127
128 const getCorrectionUrl = (metric: string | undefined | null) => {
129 const variant = metric === undefined ? 'undefined' : metric === null ? 'null' : metric
130 const url =
131 correctionUrlVariants[variant as keyof typeof correctionUrlVariants] ||
132 correctionUrlVariants.default(metric as string)
133 return typeof url === 'function' ? url(metric as string) : url
134 }
135
136 const isFreePlan = organization?.plan?.id === 'free'
137
138 // True for a single compute warning, or when all active warnings are compute-related
139 const isComputeUpgradeMetric =
140 (metric !== null && metric !== undefined && COMPUTE_UPGRADE_METRICS.includes(metric)) ||
141 (activeWarnings.length > 1 &&
142 activeWarnings.every((w) => COMPUTE_UPGRADE_WARNING_TYPES.includes(w)))
143
144 const correctionUrl = (() => {
145 if (isComputeUpgradeMetric && isFreePlan) {
146 return `/org/${organization?.slug ?? '_'}/billing?panel=subscriptionPlan&source=resource_exhaustion_banner`
147 }
148 if (isComputeUpgradeMetric && activeWarnings.length > 1) {
149 return `/project/${ref ?? 'default'}/settings/compute-and-disk`
150 }
151 return getCorrectionUrl(metric)
152 ?.replace('[ref]', ref ?? 'default')
153 ?.replace('[infra-path]', 'infrastructure')
154 })()
155
156 const buttonText = (() => {
157 if (isComputeUpgradeMetric) return 'Upgrade compute'
158 return activeWarnings.length > 1
159 ? RESOURCE_WARNING_MESSAGES.multiple_resource_warnings.buttonText
160 : RESOURCE_WARNING_MESSAGES[activeWarnings[0] as keyof typeof RESOURCE_WARNING_MESSAGES]
161 ?.buttonText
162 })()
163
164 const aiPrompt =
165 activeWarnings.length > 1
166 ? isComputeUpgradeMetric
167 ? RESOURCE_WARNING_MESSAGES.multiple_resource_warnings.aiPrompt
168 : undefined
169 : RESOURCE_WARNING_MESSAGES[activeWarnings[0] as keyof typeof RESOURCE_WARNING_MESSAGES]
170 ?.aiPrompt
171
172 const handleAskAI = () => {
173 track('resource_exhaustion_banner_ai_assistant_clicked', {
174 warningTypes: activeWarnings,
175 })
176 openSidebar(SIDEBAR_KEYS.AI_ASSISTANT)
177 aiSnap.newChat({ initialInput: aiPrompt })
178 }
179
180 const hasNoWarnings = activeWarnings.length === 0
181 const hasNoWarningContent =
182 warningContent === undefined || (!warningContent?.title && !warningContent?.description)
183 const isUsageOrInfraPage =
184 router.pathname.endsWith('/usage') || router.pathname.endsWith('/infrastructure')
185 // Compute warnings now link to compute-and-disk, so they should remain visible on infrastructure
186 const onUsageOrInfraAndNotInReadOnlyMode =
187 isUsageOrInfraPage &&
188 !activeWarnings.includes('is_readonly_mode_enabled') &&
189 !isComputeUpgradeMetric
190 // Suppress when already on the target page (no-op CTA). Paid-plan compute warnings link to
191 // compute-and-disk; free-plan links to billing instead, so we keep the banner visible for them.
192 const onDatabaseSettingsAndInReadOnlyMode =
193 router.pathname.endsWith('settings/compute-and-disk') &&
194 (activeWarnings.includes('is_readonly_mode_enabled') || (isComputeUpgradeMetric && !isFreePlan))
195
196 // these take precedence over each other, so there's only one active warning to check
197 const activeWarning =
198 RESOURCE_WARNING_MESSAGES[activeWarnings[0] as keyof typeof RESOURCE_WARNING_MESSAGES]
199 const restrictToRoutes = activeWarning?.restrictToRoutes
200
201 const isVisible =
202 restrictToRoutes === undefined ||
203 restrictToRoutes.some((route: string) => {
204 // check for exact match with /project/[ref] (project home) first
205 // doing this let's us avoid checking with regex, keeping it simple
206 if (route === '/project/[ref]') {
207 const isExactMatch = router.pathname === '/project/[ref]'
208 return isExactMatch
209 }
210
211 // For other routes, use the original startsWith logic
212 const isMatch = router.pathname.startsWith(route)
213 return isMatch
214 })
215
216 if (
217 hasNoWarnings ||
218 hasNoWarningContent ||
219 onUsageOrInfraAndNotInReadOnlyMode ||
220 onDatabaseSettingsAndInReadOnlyMode ||
221 !isVisible
222 ) {
223 return null
224 }
225
226 return (
227 <Alert
228 variant={isCritical ? 'destructive' : 'warning'}
229 className={cn(
230 'flex items-center justify-between',
231 'border-0 border-r-0 rounded-none [&>svg]:left-6 px-6 [&>svg]:w-[26px] [&>svg]:h-[26px]'
232 )}
233 >
234 <AlertTriangle />
235 <div className="">
236 <AlertTitle>{title}</AlertTitle>
237 <AlertDescription>{description}</AlertDescription>
238 </div>
239 <div className="flex items-center gap-x-2">
240 {learnMoreUrl !== undefined && aiPrompt !== undefined ? (
241 <DropdownMenu>
242 <DropdownMenuTrigger asChild>
243 <Button
244 type="default"
245 icon={<Wrench size={14} />}
246 iconRight={<ChevronDown size={14} />}
247 >
248 Troubleshoot
249 </Button>
250 </DropdownMenuTrigger>
251 <DropdownMenuContent align="end">
252 {metricsHref !== undefined && (
253 <DropdownMenuItem asChild>
254 <Link href={metricsHref} className="flex items-center gap-x-2 cursor-pointer">
255 <ChartLine size={14} />
256 View metrics
257 </Link>
258 </DropdownMenuItem>
259 )}
260 <DropdownMenuItem asChild>
261 <a
262 href={learnMoreUrl}
263 target="_blank"
264 rel="noreferrer"
265 className="flex items-center gap-x-2 cursor-pointer"
266 >
267 <BookOpen size={14} />
268 Documentation
269 </a>
270 </DropdownMenuItem>
271 <DropdownMenuItem
272 className="flex items-center gap-x-2 cursor-pointer"
273 onClick={handleAskAI}
274 >
275 <Sparkles size={14} />
276 Ask AI Assistant
277 </DropdownMenuItem>
278 </DropdownMenuContent>
279 </DropdownMenu>
280 ) : learnMoreUrl !== undefined ? (
281 <Button asChild type="default" icon={<BookOpen size={14} />}>
282 <a href={learnMoreUrl} target="_blank" rel="noreferrer">
283 Learn more
284 </a>
285 </Button>
286 ) : aiPrompt !== undefined ? (
287 <Button type="default" onClick={handleAskAI}>
288 Ask AI Assistant
289 </Button>
290 ) : null}
291 {correctionUrl !== undefined && (
292 <Button
293 asChild
294 type="primary"
295 disabled={isComputeUpgradeMetric && isOrgLoading}
296 onClick={() =>
297 track('resource_exhaustion_banner_upgrade_clicked', {
298 warningTypes: activeWarnings,
299 destination: correctionUrl,
300 })
301 }
302 >
303 <Link href={correctionUrl}>{buttonText ?? 'Check'}</Link>
304 </Button>
305 )}
306 </div>
307 </Alert>
308 )
309}