ResourceExhaustionWarningBanner.tsx 12 KB

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