index.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493
  1. import { LOCAL_STORAGE_KEYS, mergeRefs, useParams } from 'common'
  2. import { AnimatePresence, motion } from 'framer-motion'
  3. import { XIcon } from 'lucide-react'
  4. import Head from 'next/head'
  5. import { useRouter } from 'next/router'
  6. import {
  7. forwardRef,
  8. Fragment,
  9. useEffect,
  10. useLayoutEffect,
  11. type PropsWithChildren,
  12. type ReactNode,
  13. } from 'react'
  14. import {
  15. Alert,
  16. AlertDescription,
  17. AlertTitle,
  18. cn,
  19. LogoLoader,
  20. ResizableHandle,
  21. ResizablePanel,
  22. ResizablePanelGroup,
  23. useIsMobile,
  24. usePanelRef,
  25. } from 'ui'
  26. import { useEditorType } from '../editors/EditorsLayout.hooks'
  27. import { useSetMainScrollContainer } from '../MainScrollContainerContext'
  28. import { useMobileSheet } from '../Navigation/NavigationBar/MobileSheetContext'
  29. import ProductMenuBar from '../Navigation/ProductMenuBar'
  30. import BuildingState from './BuildingState'
  31. import ConnectingState from './ConnectingState'
  32. import { getSectionKeyFromPathname, MobileMenuContent } from './LayoutHeader/MobileMenuContent'
  33. import { LoadingState } from './LoadingState'
  34. import { ProjectPausedState } from './PausedState/ProjectPausedState'
  35. import { PauseFailedState } from './PauseFailedState'
  36. import { PausingState } from './PausingState'
  37. import { ResizingState } from './ResizingState'
  38. import RestartingState from './RestartingState'
  39. import { RestoreFailedState } from './RestoreFailedState'
  40. import { RestoringState } from './RestoringState'
  41. import { UnhealthyState } from './UnhealthyState'
  42. import { UpgradingState } from './UpgradingState'
  43. import { CreateBranchModal } from '@/components/interfaces/BranchManagement/CreateBranchModal'
  44. import { ProjectAPIDocs } from '@/components/interfaces/ProjectAPIDocs/ProjectAPIDocs'
  45. import { BannerFreeMicroUpgrade } from '@/components/ui/BannerStack/Banners/BannerFreeMicroUpgrade'
  46. import { BANNER_ID, useBannerStack } from '@/components/ui/BannerStack/BannerStackProvider'
  47. import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
  48. import PartnerIcon from '@/components/ui/PartnerIcon'
  49. import { ResourceExhaustionWarningBanner } from '@/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner'
  50. import { useResourceWarningsQuery } from '@/data/usage/resource-warnings-query'
  51. import { useCustomContent } from '@/hooks/custom-content/useCustomContent'
  52. import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
  53. import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
  54. import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  55. import { withAuth } from '@/hooks/misc/withAuth'
  56. import { PROJECT_STATUS } from '@/lib/constants'
  57. import { MANAGED_BY } from '@/lib/constants/infrastructure'
  58. import { buildStudioPageTitle } from '@/lib/page-title'
  59. import { getPathnameWithoutQuery } from '@/lib/pathname.utils'
  60. import { useAppStateSnapshot } from '@/state/app-state'
  61. import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector'
  62. // [Joshen] This is temporary while we unblock users from managing their project
  63. // if their project is not responding well for any reason. Eventually needs a bit of an overhaul
  64. const routesToIgnoreProjectDetailsRequest = [
  65. '/project/[ref]/settings/infrastructure',
  66. '/project/[ref]/settings/addons',
  67. '/project/[ref]/settings/general',
  68. '/project/[ref]/database/settings',
  69. '/project/[ref]/storage/settings',
  70. ]
  71. const routesToIgnoreDBConnection = [
  72. '/project/[ref]/branches',
  73. '/project/[ref]/database/backups',
  74. '/project/[ref]/settings',
  75. '/project/[ref]/functions',
  76. '/project/[ref]/logs',
  77. ]
  78. const routesToIgnorePostgrestConnection = [
  79. '/project/[ref]/settings/general',
  80. '/project/[ref]/settings/infrastructure',
  81. '/project/[ref]/settings/addons',
  82. '/project/[ref]/database/settings',
  83. '/project/[ref]/reports',
  84. ]
  85. const DEFAULT_PROJECT_INTEGRATION_BANNER_DISMISS_KEY =
  86. LOCAL_STORAGE_KEYS.PROJECT_INTEGRATION_BANNER_DISMISSED('unknown', 'unknown')
  87. function getProjectIntegrationBannerDismissKey({
  88. projectRef,
  89. integrationSource,
  90. }: {
  91. projectRef?: string
  92. integrationSource?: string | null
  93. }) {
  94. if (!projectRef || !integrationSource) return DEFAULT_PROJECT_INTEGRATION_BANNER_DISMISS_KEY
  95. return LOCAL_STORAGE_KEYS.PROJECT_INTEGRATION_BANNER_DISMISSED(projectRef, integrationSource)
  96. }
  97. export interface ProjectLayoutProps {
  98. isLoading?: boolean
  99. isBlocking?: boolean
  100. product?: string
  101. productMenu?: ReactNode
  102. browserTitle?: {
  103. entity?: string
  104. section?: string
  105. override?: string
  106. }
  107. // Deprecated: use browserTitle.entity instead. Kept for backwards compatibility.
  108. selectedTable?: string
  109. resizableSidebar?: boolean
  110. productMenuClassName?: string
  111. }
  112. export const ProjectLayout = forwardRef<HTMLDivElement, PropsWithChildren<ProjectLayoutProps>>(
  113. (
  114. {
  115. isLoading = false,
  116. isBlocking = true,
  117. product = '',
  118. productMenu,
  119. browserTitle,
  120. children,
  121. selectedTable,
  122. resizableSidebar = false,
  123. productMenuClassName,
  124. },
  125. ref
  126. ) => {
  127. const router = useRouter()
  128. const { data: selectedOrganization } = useSelectedOrganizationQuery()
  129. const { data: selectedProject } = useSelectedProjectQuery()
  130. const { addBanner, dismissBanner } = useBannerStack()
  131. const { data: resourceWarnings } = useResourceWarningsQuery({
  132. slug: selectedOrganization?.slug,
  133. })
  134. const projectResourceWarnings = resourceWarnings?.find(
  135. (w) => w.project === selectedProject?.ref
  136. )
  137. const isComputeNearExhaustion =
  138. !!projectResourceWarnings?.cpu_exhaustion ||
  139. !!projectResourceWarnings?.memory_and_swap_exhaustion ||
  140. !!projectResourceWarnings?.disk_space_exhaustion ||
  141. !!projectResourceWarnings?.disk_io_exhaustion
  142. const isNanoCompute = selectedProject?.infra_compute_size === 'nano'
  143. const showUpgradeBanner = isNanoCompute && isComputeNearExhaustion
  144. const [isFreeMicroUpgradeBannerDismissed] = useLocalStorageQuery(
  145. LOCAL_STORAGE_KEYS.FREE_MICRO_UPGRADE_BANNER_DISMISSED(selectedProject?.ref ?? ''),
  146. false
  147. )
  148. const [isProjectIntegrationBannerDismissed, setIsProjectIntegrationBannerDismissed] =
  149. useLocalStorageQuery(
  150. getProjectIntegrationBannerDismissKey({
  151. projectRef: selectedProject?.ref,
  152. integrationSource: selectedProject?.integration_source,
  153. }),
  154. false
  155. )
  156. const { showSidebar } = useAppStateSnapshot()
  157. const { setContent: setMobileSheetContent, registerOpenMenu } = useMobileSheet()
  158. const pathname = getPathnameWithoutQuery(router.asPath, router.pathname)
  159. const currentSectionKey = getSectionKeyFromPathname(pathname)
  160. const setMainScrollContainer = useSetMainScrollContainer()
  161. const combinedRef = mergeRefs(ref, setMainScrollContainer)
  162. const { appTitle } = useCustomContent(['app:title'])
  163. const brandTitle = appTitle || 'Briven'
  164. const isMobile = useIsMobile()
  165. const editor = useEditorType()
  166. const forceShowProductMenu = editor === undefined
  167. const sideBarIsOpen = (forceShowProductMenu || showSidebar) && !isMobile
  168. const panelRef = usePanelRef()
  169. const projectName = selectedProject?.name
  170. const organizationName = selectedOrganization?.name
  171. const pageTitle =
  172. browserTitle?.override ||
  173. buildStudioPageTitle({
  174. entity: browserTitle?.entity ?? selectedTable,
  175. section: browserTitle?.section,
  176. surface: product,
  177. project: projectName,
  178. org: organizationName,
  179. brand: brandTitle,
  180. }) ||
  181. brandTitle
  182. const isPaused = selectedProject?.status === PROJECT_STATUS.INACTIVE
  183. const ignorePausedState =
  184. router.pathname === '/project/[ref]' ||
  185. router.pathname.includes('/project/[ref]/settings') ||
  186. router.pathname.includes('/project/[ref]/functions') ||
  187. router.pathname.includes('/project/[ref]/logs')
  188. const showPausedState = isPaused && !ignorePausedState
  189. const showStripeProjectBanner =
  190. selectedProject?.integration_source === 'stripe_projects' &&
  191. !isProjectIntegrationBannerDismissed
  192. useEffect(() => {
  193. if (!selectedProject?.ref) return
  194. const isProjectHomepage = router.pathname === '/project/[ref]'
  195. if (isProjectHomepage && showUpgradeBanner && !isFreeMicroUpgradeBannerDismissed) {
  196. addBanner({
  197. id: BANNER_ID.FREE_MICRO_UPGRADE,
  198. isDismissed: false,
  199. content: <BannerFreeMicroUpgrade />,
  200. priority: 2,
  201. })
  202. } else {
  203. dismissBanner(BANNER_ID.FREE_MICRO_UPGRADE)
  204. }
  205. }, [
  206. router.pathname,
  207. selectedProject?.ref,
  208. showUpgradeBanner,
  209. isFreeMicroUpgradeBannerDismissed,
  210. addBanner,
  211. dismissBanner,
  212. ])
  213. useLayoutEffect(() => {
  214. const unregister = registerOpenMenu(() => {
  215. setMobileSheetContent(
  216. <MobileMenuContent
  217. currentProductMenu={productMenu ?? null}
  218. currentProduct={product}
  219. currentSectionKey={currentSectionKey}
  220. onCloseSheet={() => setMobileSheetContent(null)}
  221. />
  222. )
  223. })
  224. return unregister
  225. }, [registerOpenMenu, productMenu, product, currentSectionKey, setMobileSheetContent])
  226. return (
  227. <>
  228. <Head>
  229. <title>{pageTitle}</title>
  230. <meta name="description" content="Briven Studio" />
  231. </Head>
  232. <div className="flex flex-row h-full w-full">
  233. <ResizablePanelGroup orientation="horizontal">
  234. {productMenu && sideBarIsOpen && (
  235. <ResizablePanel
  236. panelRef={panelRef}
  237. minSize={256}
  238. maxSize={resizableSidebar ? 512 : 256}
  239. defaultSize={256}
  240. id="panel-left"
  241. disabled={!resizableSidebar}
  242. >
  243. <AnimatePresence initial={false}>
  244. <motion.div
  245. initial={{ width: 0, opacity: 0, height: '100%' }}
  246. animate={{ width: 'auto', opacity: 1, height: '100%' }}
  247. exit={{ width: 0, opacity: 0, height: '100%' }}
  248. className="h-full"
  249. transition={{ duration: 0.12 }}
  250. >
  251. <MenuBarWrapper
  252. isLoading={isLoading}
  253. isBlocking={isBlocking}
  254. productMenu={productMenu}
  255. >
  256. <ProductMenuBar title={product} className={productMenuClassName}>
  257. {productMenu}
  258. </ProductMenuBar>
  259. </MenuBarWrapper>
  260. </motion.div>
  261. </AnimatePresence>
  262. </ResizablePanel>
  263. )}
  264. {productMenu && sideBarIsOpen && (
  265. <ResizableHandle
  266. withHandle
  267. disabled={resizableSidebar ? false : true}
  268. className="hidden md:flex"
  269. />
  270. )}
  271. <ResizablePanel
  272. className={cn('h-full flex flex-col w-full xl:min-w-[600px] bg-dash-sidebar')}
  273. id="panel-project-content"
  274. >
  275. <main
  276. className="h-full flex flex-col flex-1 w-full overflow-y-auto overflow-x-hidden @container"
  277. ref={combinedRef}
  278. >
  279. {showStripeProjectBanner && (
  280. <Alert
  281. variant="default"
  282. className="flex items-center gap-4 border-t-0 border-x-0 rounded-none"
  283. >
  284. <PartnerIcon
  285. organization={{ managed_by: MANAGED_BY.STRIPE_PROJECTS }}
  286. showTooltip={false}
  287. size="medium"
  288. />
  289. <div className="flex-1">
  290. <AlertTitle>This project is connected to Stripe</AlertTitle>
  291. <AlertDescription>
  292. Changes made here may affect your connected Stripe project.
  293. </AlertDescription>
  294. </div>
  295. <ButtonTooltip
  296. type="text"
  297. icon={<XIcon size={14} />}
  298. className="h-7 w-7 p-0"
  299. onClick={() => setIsProjectIntegrationBannerDismissed(true)}
  300. aria-label="Dismiss project integration banner"
  301. tooltip={{ content: { text: 'Dismiss' } }}
  302. />
  303. </Alert>
  304. )}
  305. {showPausedState ? (
  306. <div className="mx-auto my-16 w-full h-full max-w-7xl flex items-center px-4">
  307. <div className="w-full">
  308. <ProjectPausedState product={product} />
  309. </div>
  310. </div>
  311. ) : (
  312. <ContentWrapper isLoading={isLoading} isBlocking={isBlocking}>
  313. <ResourceExhaustionWarningBanner />
  314. {children}
  315. </ContentWrapper>
  316. )}
  317. </main>
  318. </ResizablePanel>
  319. </ResizablePanelGroup>
  320. </div>
  321. <CreateBranchModal />
  322. <ProjectAPIDocs />
  323. </>
  324. )
  325. }
  326. )
  327. ProjectLayout.displayName = 'ProjectLayout'
  328. export const ProjectLayoutWithAuth = withAuth(ProjectLayout)
  329. interface MenuBarWrapperProps {
  330. isLoading: boolean
  331. isBlocking?: boolean
  332. productMenu?: ReactNode
  333. children: ReactNode
  334. }
  335. const MenuBarWrapper = ({
  336. isLoading,
  337. isBlocking = true,
  338. productMenu,
  339. children,
  340. }: MenuBarWrapperProps) => {
  341. const router = useRouter()
  342. const { data: selectedProject } = useSelectedProjectQuery()
  343. const requiresProjectDetails = !routesToIgnoreProjectDetailsRequest.includes(router.pathname)
  344. if (!isBlocking) {
  345. return children
  346. }
  347. const showMenuBar =
  348. !requiresProjectDetails || (requiresProjectDetails && selectedProject !== undefined)
  349. return !isLoading && productMenu && showMenuBar ? children : null
  350. }
  351. interface ContentWrapperProps {
  352. isLoading: boolean
  353. isBlocking?: boolean
  354. children: ReactNode
  355. }
  356. /**
  357. * Check project.status to show building state or error state
  358. *
  359. * [Joshen] As of 210422: Current testing connection by pinging postgres
  360. * Ideally we'd have a more specific monitoring of the project such as during restarts
  361. * But that will come later: https://briven.slack.com/archives/C01D6TWFFFW/p1650427619665549
  362. *
  363. * Just note that this logic does not differentiate between a "restarting" state and
  364. * a "something is wrong and can't connect to project" state.
  365. *
  366. * [TODO] Next iteration should scrape long polling and just listen to the project's status
  367. */
  368. const ContentWrapper = ({ isLoading, isBlocking = true, children }: ContentWrapperProps) => {
  369. const router = useRouter()
  370. const { ref } = useParams()
  371. const state = useDatabaseSelectorStateSnapshot()
  372. const { data: selectedProject } = useSelectedProjectQuery()
  373. const isBackupsPage = router.pathname.includes('/project/[ref]/database/backups')
  374. const isHomePage = router.pathname === '/project/[ref]'
  375. const requiresDbConnection = !routesToIgnoreDBConnection.some((x) => router.pathname.includes(x))
  376. const requiresPostgrestConnection = !routesToIgnorePostgrestConnection.includes(router.pathname)
  377. const requiresProjectDetails = !routesToIgnoreProjectDetailsRequest.includes(router.pathname)
  378. const isRestarting = selectedProject?.status === PROJECT_STATUS.RESTARTING
  379. const isResizing = selectedProject?.status === PROJECT_STATUS.RESIZING
  380. const isProjectUpgrading = selectedProject?.status === PROJECT_STATUS.UPGRADING
  381. const isProjectRestoring = selectedProject?.status === PROJECT_STATUS.RESTORING
  382. const isProjectRestoreFailed = selectedProject?.status === PROJECT_STATUS.RESTORE_FAILED
  383. const isProjectBuilding =
  384. selectedProject?.status === PROJECT_STATUS.COMING_UP ||
  385. selectedProject?.status === PROJECT_STATUS.UNKNOWN
  386. const isProjectPausing = selectedProject?.status === PROJECT_STATUS.PAUSING
  387. const isProjectPauseFailed = selectedProject?.status === PROJECT_STATUS.PAUSE_FAILED
  388. const isProjectUnhealthy = selectedProject?.status === PROJECT_STATUS.ACTIVE_UNHEALTHY
  389. const isProjectOffline = selectedProject?.postgrestStatus === 'OFFLINE'
  390. const ignoreUnhealthyState =
  391. isHomePage ||
  392. router.pathname.includes('/project/[ref]/settings') ||
  393. router.pathname.includes('/project/[ref]/logs')
  394. const shouldRedirectToHomeForBuilding = isProjectBuilding && requiresDbConnection && !isHomePage
  395. // Don't show building state on the home page — it handles building state inline
  396. const shouldShowBuildingState = isProjectBuilding && requiresDbConnection && !isHomePage
  397. useEffect(() => {
  398. if (shouldRedirectToHomeForBuilding && ref) {
  399. router.replace(`/project/${ref}`)
  400. }
  401. }, [shouldRedirectToHomeForBuilding, ref, router])
  402. useEffect(() => {
  403. if (ref) state.setSelectedDatabaseId(ref)
  404. }, [ref])
  405. if (isBlocking && (isLoading || (requiresProjectDetails && selectedProject === undefined))) {
  406. return router.pathname.endsWith('[ref]') ? <LoadingState /> : <LogoLoader />
  407. }
  408. if (isRestarting && !isBackupsPage) {
  409. return <RestartingState />
  410. }
  411. if (isResizing && !isBackupsPage) {
  412. return <ResizingState />
  413. }
  414. if (isProjectUpgrading && !isBackupsPage) {
  415. return <UpgradingState />
  416. }
  417. if (isProjectPausing) {
  418. return <PausingState project={selectedProject} />
  419. }
  420. if (isProjectPauseFailed) {
  421. return <PauseFailedState />
  422. }
  423. if (isProjectUnhealthy && !ignoreUnhealthyState) {
  424. return <UnhealthyState />
  425. }
  426. if (requiresPostgrestConnection && isProjectOffline) {
  427. return <ConnectingState project={selectedProject} />
  428. }
  429. if (requiresDbConnection && isProjectRestoring) {
  430. return <RestoringState />
  431. }
  432. if (requiresDbConnection && isProjectRestoreFailed) {
  433. return <RestoreFailedState />
  434. }
  435. if (shouldRedirectToHomeForBuilding) {
  436. return <LogoLoader />
  437. }
  438. if (shouldShowBuildingState) {
  439. return <BuildingState />
  440. }
  441. return <Fragment key={selectedProject?.ref}>{children}</Fragment>
  442. }