ResourceExhaustionWarningBanner.tsx309 lines · main
| 1 | import { useParams } from 'common' |
| 2 | import { AlertTriangle, BookOpen, ChartLine, ChevronDown, Sparkles, Wrench } from 'lucide-react' |
| 3 | import Link from 'next/link' |
| 4 | import { useRouter } from 'next/router' |
| 5 | import { COMPUTE_DISK } from 'shared-data' |
| 6 | import { |
| 7 | Alert, |
| 8 | AlertDescription, |
| 9 | AlertTitle, |
| 10 | Button, |
| 11 | cn, |
| 12 | DropdownMenu, |
| 13 | DropdownMenuContent, |
| 14 | DropdownMenuItem, |
| 15 | DropdownMenuTrigger, |
| 16 | } from 'ui' |
| 17 | |
| 18 | import { RESOURCE_WARNING_MESSAGES } from './ResourceExhaustionWarningBanner.constants' |
| 19 | import { getWarningContent } from './ResourceExhaustionWarningBanner.utils' |
| 20 | import { mapComputeSizeNameToAddonVariantId } from '@/components/interfaces/DiskManagement/DiskManagement.utils' |
| 21 | import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' |
| 22 | import { useResourceWarningsQuery } from '@/data/usage/resource-warnings-query' |
| 23 | import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' |
| 24 | import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' |
| 25 | import { useTrack } from '@/lib/telemetry/track' |
| 26 | import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state' |
| 27 | import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' |
| 28 | |
| 29 | const COMPUTE_UPGRADE_METRICS = ['disk_io', 'cpu', 'ram'] |
| 30 | const COMPUTE_UPGRADE_WARNING_TYPES = [ |
| 31 | 'disk_io_exhaustion', |
| 32 | 'cpu_exhaustion', |
| 33 | 'memory_and_swap_exhaustion', |
| 34 | ] |
| 35 | |
| 36 | export 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 | } |