mirror of
https://github.com/supabase/supabase.git
synced 2026-06-14 23:25:16 +08:00
## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Adds a one time banner to the `<BannerStack />` to promote Unified Logs becoming available. This also fixes the `<BannerStack />` components issue with stacking varying height banners. https://github.com/user-attachments/assets/40f02709-0d67-43a9-ab95-750d9a4a582a <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added a dismissible "Unified Logs" banner with an animated sample-log carousel, CTA to Unified Logs, and a preview/enable flow for non-enabled users. Dismissal is persisted locally and telemetry is recorded for CTA and dismiss actions; banner appears only for eligible projects. * **Refactor** * Banner stack UI updated to display a single front banner with animated "peek" slivers and refined hover/animation behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
526 lines
19 KiB
TypeScript
526 lines
19 KiB
TypeScript
import { LOCAL_STORAGE_KEYS, mergeRefs, useParams } from 'common'
|
|
import { AnimatePresence, motion } from 'framer-motion'
|
|
import { XIcon } from 'lucide-react'
|
|
import Head from 'next/head'
|
|
import { useRouter } from 'next/router'
|
|
import {
|
|
forwardRef,
|
|
Fragment,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
type PropsWithChildren,
|
|
type ReactNode,
|
|
} from 'react'
|
|
import {
|
|
Alert,
|
|
AlertDescription,
|
|
AlertTitle,
|
|
cn,
|
|
LogoLoader,
|
|
ResizableHandle,
|
|
ResizablePanel,
|
|
ResizablePanelGroup,
|
|
useIsMobile,
|
|
usePanelRef,
|
|
} from 'ui'
|
|
|
|
import { useEditorType } from '../editors/EditorsLayout.hooks'
|
|
import { useMainScrollContainer, useSetMainScrollContainer } from '../MainScrollContainerContext'
|
|
import { useMobileSheet } from '../Navigation/NavigationBar/MobileSheetContext'
|
|
import ProductMenuBar from '../Navigation/ProductMenuBar'
|
|
import BuildingState from './BuildingState'
|
|
import ConnectingState from './ConnectingState'
|
|
import { getSectionKeyFromPathname, MobileMenuContent } from './LayoutHeader/MobileMenuContent'
|
|
import { LoadingState } from './LoadingState'
|
|
import { ProjectPausedState } from './PausedState/ProjectPausedState'
|
|
import { PauseFailedState } from './PauseFailedState'
|
|
import { PausingState } from './PausingState'
|
|
import { ResizingState } from './ResizingState'
|
|
import RestartingState from './RestartingState'
|
|
import { RestoreFailedState } from './RestoreFailedState'
|
|
import { RestoringState } from './RestoringState'
|
|
import { UnhealthyState } from './UnhealthyState'
|
|
import { UpgradingState } from './UpgradingState'
|
|
import { useUnifiedLogsPreview } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
|
import { CreateBranchModal } from '@/components/interfaces/BranchManagement/CreateBranchModal'
|
|
import { ProjectAPIDocs } from '@/components/interfaces/ProjectAPIDocs/ProjectAPIDocs'
|
|
import { BannerFreeMicroUpgrade } from '@/components/ui/BannerStack/Banners/BannerFreeMicroUpgrade'
|
|
import { BannerUnifiedLogs } from '@/components/ui/BannerStack/Banners/BannerUnifiedLogs'
|
|
import { BANNER_ID, useBannerStack } from '@/components/ui/BannerStack/BannerStackProvider'
|
|
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
|
import PartnerIcon from '@/components/ui/PartnerIcon'
|
|
import { ResourceExhaustionWarningBanner } from '@/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner'
|
|
import { useResourceWarningsQuery } from '@/data/usage/resource-warnings-query'
|
|
import { useCustomContent } from '@/hooks/custom-content/useCustomContent'
|
|
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
|
|
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
|
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
|
import { withAuth } from '@/hooks/misc/withAuth'
|
|
import { PROJECT_STATUS } from '@/lib/constants'
|
|
import { MANAGED_BY } from '@/lib/constants/infrastructure'
|
|
import { buildStudioPageTitle } from '@/lib/page-title'
|
|
import { getPathnameWithoutQuery } from '@/lib/pathname.utils'
|
|
import { useAppStateSnapshot } from '@/state/app-state'
|
|
import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector'
|
|
|
|
// [Joshen] This is temporary while we unblock users from managing their project
|
|
// if their project is not responding well for any reason. Eventually needs a bit of an overhaul
|
|
const routesToIgnoreProjectDetailsRequest = [
|
|
'/project/[ref]/settings/infrastructure',
|
|
'/project/[ref]/settings/addons',
|
|
'/project/[ref]/settings/general',
|
|
'/project/[ref]/database/settings',
|
|
'/project/[ref]/storage/settings',
|
|
]
|
|
|
|
const routesToIgnoreDBConnection = [
|
|
'/project/[ref]/branches',
|
|
'/project/[ref]/database/backups',
|
|
'/project/[ref]/settings',
|
|
'/project/[ref]/functions',
|
|
'/project/[ref]/logs',
|
|
]
|
|
|
|
const routesToIgnorePostgrestConnection = [
|
|
'/project/[ref]/settings/general',
|
|
'/project/[ref]/settings/infrastructure',
|
|
'/project/[ref]/settings/addons',
|
|
'/project/[ref]/database/settings',
|
|
'/project/[ref]/reports',
|
|
]
|
|
|
|
const DEFAULT_PROJECT_INTEGRATION_BANNER_DISMISS_KEY =
|
|
LOCAL_STORAGE_KEYS.PROJECT_INTEGRATION_BANNER_DISMISSED('unknown', 'unknown')
|
|
|
|
function getProjectIntegrationBannerDismissKey({
|
|
projectRef,
|
|
integrationSource,
|
|
}: {
|
|
projectRef?: string
|
|
integrationSource?: string | null
|
|
}) {
|
|
if (!projectRef || !integrationSource) return DEFAULT_PROJECT_INTEGRATION_BANNER_DISMISS_KEY
|
|
|
|
return LOCAL_STORAGE_KEYS.PROJECT_INTEGRATION_BANNER_DISMISSED(projectRef, integrationSource)
|
|
}
|
|
|
|
export interface ProjectLayoutProps {
|
|
isLoading?: boolean
|
|
isBlocking?: boolean
|
|
product?: string
|
|
productMenu?: ReactNode
|
|
browserTitle?: {
|
|
entity?: string
|
|
section?: string
|
|
override?: string
|
|
}
|
|
// Deprecated: use browserTitle.entity instead. Kept for backwards compatibility.
|
|
selectedTable?: string
|
|
resizableSidebar?: boolean
|
|
productMenuClassName?: string
|
|
}
|
|
|
|
export const ProjectLayout = forwardRef<HTMLDivElement, PropsWithChildren<ProjectLayoutProps>>(
|
|
(
|
|
{
|
|
isLoading = false,
|
|
isBlocking = true,
|
|
product = '',
|
|
productMenu,
|
|
browserTitle,
|
|
children,
|
|
selectedTable,
|
|
resizableSidebar = false,
|
|
|
|
productMenuClassName,
|
|
},
|
|
ref
|
|
) => {
|
|
const router = useRouter()
|
|
const { data: selectedOrganization } = useSelectedOrganizationQuery()
|
|
const { data: selectedProject } = useSelectedProjectQuery()
|
|
const { addBanner, dismissBanner } = useBannerStack()
|
|
const { data: resourceWarnings } = useResourceWarningsQuery({
|
|
slug: selectedOrganization?.slug,
|
|
})
|
|
const projectResourceWarnings = resourceWarnings?.find(
|
|
(w) => w.project === selectedProject?.ref
|
|
)
|
|
const isComputeNearExhaustion =
|
|
!!projectResourceWarnings?.cpu_exhaustion ||
|
|
!!projectResourceWarnings?.memory_and_swap_exhaustion ||
|
|
!!projectResourceWarnings?.disk_space_exhaustion ||
|
|
!!projectResourceWarnings?.disk_io_exhaustion
|
|
const isNanoCompute = selectedProject?.infra_compute_size === 'nano'
|
|
const showUpgradeBanner = isNanoCompute && isComputeNearExhaustion
|
|
const [isFreeMicroUpgradeBannerDismissed] = useLocalStorageQuery(
|
|
LOCAL_STORAGE_KEYS.FREE_MICRO_UPGRADE_BANNER_DISMISSED(selectedProject?.ref ?? ''),
|
|
false
|
|
)
|
|
const [isUnifiedLogsBannerDismissed] = useLocalStorageQuery(
|
|
LOCAL_STORAGE_KEYS.UNIFIED_LOGS_BANNER_DISMISSED,
|
|
false
|
|
)
|
|
const { isEligible: showUnifiedLogsBanner } = useUnifiedLogsPreview()
|
|
const [isProjectIntegrationBannerDismissed, setIsProjectIntegrationBannerDismissed] =
|
|
useLocalStorageQuery(
|
|
getProjectIntegrationBannerDismissKey({
|
|
projectRef: selectedProject?.ref,
|
|
integrationSource: selectedProject?.integration_source,
|
|
}),
|
|
false
|
|
)
|
|
const { showSidebar } = useAppStateSnapshot()
|
|
const { setContent: setMobileSheetContent, registerOpenMenu } = useMobileSheet()
|
|
|
|
const pathname = getPathnameWithoutQuery(router.asPath, router.pathname)
|
|
const currentSectionKey = getSectionKeyFromPathname(pathname)
|
|
|
|
const mainScrollContainer = useMainScrollContainer()
|
|
const setMainScrollContainer = useSetMainScrollContainer()
|
|
const combinedRef = mergeRefs(ref, setMainScrollContainer)
|
|
|
|
const { appTitle } = useCustomContent(['app:title'])
|
|
const brandTitle = appTitle || 'Supabase'
|
|
|
|
const isMobile = useIsMobile()
|
|
|
|
const editor = useEditorType()
|
|
const forceShowProductMenu = editor === undefined
|
|
const sideBarIsOpen = (forceShowProductMenu || showSidebar) && !isMobile
|
|
|
|
const panelRef = usePanelRef()
|
|
|
|
const projectName = selectedProject?.name
|
|
const organizationName = selectedOrganization?.name
|
|
const pageTitle =
|
|
browserTitle?.override ||
|
|
buildStudioPageTitle({
|
|
entity: browserTitle?.entity ?? selectedTable,
|
|
section: browserTitle?.section,
|
|
surface: product,
|
|
project: projectName,
|
|
org: organizationName,
|
|
brand: brandTitle,
|
|
}) ||
|
|
brandTitle
|
|
|
|
const isPaused = selectedProject?.status === PROJECT_STATUS.INACTIVE
|
|
|
|
const ignorePausedState =
|
|
router.pathname === '/project/[ref]' ||
|
|
router.pathname.includes('/project/[ref]/settings') ||
|
|
router.pathname.includes('/project/[ref]/functions') ||
|
|
router.pathname.includes('/project/[ref]/logs')
|
|
const showPausedState = isPaused && !ignorePausedState
|
|
const showStripeProjectBanner =
|
|
selectedProject?.integration_source === 'stripe_projects' &&
|
|
!isProjectIntegrationBannerDismissed
|
|
|
|
useEffect(() => {
|
|
if (!selectedProject?.ref) return
|
|
const isProjectHomepage = router.pathname === '/project/[ref]'
|
|
if (isProjectHomepage && showUpgradeBanner && !isFreeMicroUpgradeBannerDismissed) {
|
|
addBanner({
|
|
id: BANNER_ID.FREE_MICRO_UPGRADE,
|
|
isDismissed: false,
|
|
content: <BannerFreeMicroUpgrade />,
|
|
priority: 2,
|
|
})
|
|
} else {
|
|
dismissBanner(BANNER_ID.FREE_MICRO_UPGRADE)
|
|
}
|
|
}, [
|
|
router.pathname,
|
|
selectedProject?.ref,
|
|
showUpgradeBanner,
|
|
isFreeMicroUpgradeBannerDismissed,
|
|
addBanner,
|
|
dismissBanner,
|
|
])
|
|
|
|
useEffect(() => {
|
|
if (!selectedProject?.ref) return
|
|
if (showUnifiedLogsBanner && !isUnifiedLogsBannerDismissed) {
|
|
addBanner({
|
|
id: BANNER_ID.UNIFIED_LOGS,
|
|
isDismissed: false,
|
|
content: <BannerUnifiedLogs />,
|
|
priority: 1,
|
|
})
|
|
} else {
|
|
dismissBanner(BANNER_ID.UNIFIED_LOGS)
|
|
}
|
|
}, [
|
|
selectedProject?.ref,
|
|
showUnifiedLogsBanner,
|
|
isUnifiedLogsBannerDismissed,
|
|
addBanner,
|
|
dismissBanner,
|
|
])
|
|
|
|
useLayoutEffect(() => {
|
|
const unregister = registerOpenMenu(() => {
|
|
setMobileSheetContent(
|
|
<MobileMenuContent
|
|
currentProductMenu={productMenu ?? null}
|
|
currentProduct={product}
|
|
currentSectionKey={currentSectionKey}
|
|
onCloseSheet={() => setMobileSheetContent(null)}
|
|
/>
|
|
)
|
|
})
|
|
return unregister
|
|
}, [registerOpenMenu, productMenu, product, currentSectionKey, setMobileSheetContent])
|
|
|
|
useLayoutEffect(() => {
|
|
mainScrollContainer?.scrollTo({ top: 0, left: 0 })
|
|
}, [pathname, mainScrollContainer])
|
|
|
|
return (
|
|
<>
|
|
<Head>
|
|
<title>{pageTitle}</title>
|
|
<meta name="description" content="Supabase Studio" />
|
|
</Head>
|
|
<div className="flex flex-row h-full w-full">
|
|
<ResizablePanelGroup orientation="horizontal">
|
|
{productMenu && sideBarIsOpen && (
|
|
<ResizablePanel
|
|
panelRef={panelRef}
|
|
minSize={256}
|
|
maxSize={resizableSidebar ? 512 : 256}
|
|
defaultSize={256}
|
|
id="panel-left"
|
|
disabled={!resizableSidebar}
|
|
>
|
|
<AnimatePresence initial={false}>
|
|
<motion.div
|
|
initial={{ width: 0, opacity: 0, height: '100%' }}
|
|
animate={{ width: 'auto', opacity: 1, height: '100%' }}
|
|
exit={{ width: 0, opacity: 0, height: '100%' }}
|
|
className="h-full"
|
|
transition={{ duration: 0.12 }}
|
|
>
|
|
<MenuBarWrapper
|
|
isLoading={isLoading}
|
|
isBlocking={isBlocking}
|
|
productMenu={productMenu}
|
|
>
|
|
<ProductMenuBar title={product} className={productMenuClassName}>
|
|
{productMenu}
|
|
</ProductMenuBar>
|
|
</MenuBarWrapper>
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
</ResizablePanel>
|
|
)}
|
|
{productMenu && sideBarIsOpen && (
|
|
<ResizableHandle
|
|
withHandle
|
|
disabled={resizableSidebar ? false : true}
|
|
className="hidden md:flex"
|
|
/>
|
|
)}
|
|
<ResizablePanel
|
|
className={cn('h-full flex flex-col w-full xl:min-w-[600px] bg-dash-sidebar')}
|
|
id="panel-project-content"
|
|
>
|
|
<main
|
|
className="h-full flex flex-col flex-1 w-full overflow-y-auto overflow-x-hidden @container"
|
|
ref={combinedRef}
|
|
>
|
|
{showStripeProjectBanner && (
|
|
<Alert
|
|
variant="default"
|
|
className="flex items-center gap-4 border-t-0 border-x-0 rounded-none"
|
|
>
|
|
<PartnerIcon
|
|
organization={{ managed_by: MANAGED_BY.STRIPE_PROJECTS }}
|
|
showTooltip={false}
|
|
size="medium"
|
|
/>
|
|
<div className="flex-1">
|
|
<AlertTitle>This project is connected to Stripe</AlertTitle>
|
|
<AlertDescription>
|
|
Changes made here may affect your connected Stripe project.
|
|
</AlertDescription>
|
|
</div>
|
|
<ButtonTooltip
|
|
type="text"
|
|
icon={<XIcon size={14} />}
|
|
className="h-7 w-7 p-0"
|
|
onClick={() => setIsProjectIntegrationBannerDismissed(true)}
|
|
aria-label="Dismiss project integration banner"
|
|
tooltip={{ content: { text: 'Dismiss' } }}
|
|
/>
|
|
</Alert>
|
|
)}
|
|
{showPausedState ? (
|
|
<div className="mx-auto my-16 w-full h-full max-w-7xl flex items-center px-4">
|
|
<div className="w-full">
|
|
<ProjectPausedState product={product} />
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<ContentWrapper isLoading={isLoading} isBlocking={isBlocking}>
|
|
<ResourceExhaustionWarningBanner />
|
|
{children}
|
|
</ContentWrapper>
|
|
)}
|
|
</main>
|
|
</ResizablePanel>
|
|
</ResizablePanelGroup>
|
|
</div>
|
|
<CreateBranchModal />
|
|
<ProjectAPIDocs />
|
|
</>
|
|
)
|
|
}
|
|
)
|
|
|
|
ProjectLayout.displayName = 'ProjectLayout'
|
|
|
|
export const ProjectLayoutWithAuth = withAuth(ProjectLayout)
|
|
|
|
interface MenuBarWrapperProps {
|
|
isLoading: boolean
|
|
isBlocking?: boolean
|
|
productMenu?: ReactNode
|
|
children: ReactNode
|
|
}
|
|
|
|
const MenuBarWrapper = ({
|
|
isLoading,
|
|
isBlocking = true,
|
|
productMenu,
|
|
children,
|
|
}: MenuBarWrapperProps) => {
|
|
const router = useRouter()
|
|
const { data: selectedProject } = useSelectedProjectQuery()
|
|
const requiresProjectDetails = !routesToIgnoreProjectDetailsRequest.includes(router.pathname)
|
|
|
|
if (!isBlocking) {
|
|
return children
|
|
}
|
|
|
|
const showMenuBar =
|
|
!requiresProjectDetails || (requiresProjectDetails && selectedProject !== undefined)
|
|
|
|
return !isLoading && productMenu && showMenuBar ? children : null
|
|
}
|
|
|
|
interface ContentWrapperProps {
|
|
isLoading: boolean
|
|
isBlocking?: boolean
|
|
children: ReactNode
|
|
}
|
|
|
|
/**
|
|
* Check project.status to show building state or error state
|
|
*
|
|
* [Joshen] As of 210422: Current testing connection by pinging postgres
|
|
* Ideally we'd have a more specific monitoring of the project such as during restarts
|
|
* But that will come later: https://supabase.slack.com/archives/C01D6TWFFFW/p1650427619665549
|
|
*
|
|
* Just note that this logic does not differentiate between a "restarting" state and
|
|
* a "something is wrong and can't connect to project" state.
|
|
*
|
|
* [TODO] Next iteration should scrape long polling and just listen to the project's status
|
|
*/
|
|
const ContentWrapper = ({ isLoading, isBlocking = true, children }: ContentWrapperProps) => {
|
|
const router = useRouter()
|
|
const { ref } = useParams()
|
|
const state = useDatabaseSelectorStateSnapshot()
|
|
const { data: selectedProject } = useSelectedProjectQuery()
|
|
const isBackupsPage = router.pathname.includes('/project/[ref]/database/backups')
|
|
const isHomePage = router.pathname === '/project/[ref]'
|
|
|
|
const requiresDbConnection = !routesToIgnoreDBConnection.some((x) => router.pathname.includes(x))
|
|
const requiresPostgrestConnection = !routesToIgnorePostgrestConnection.includes(router.pathname)
|
|
const requiresProjectDetails = !routesToIgnoreProjectDetailsRequest.includes(router.pathname)
|
|
|
|
const isRestarting = selectedProject?.status === PROJECT_STATUS.RESTARTING
|
|
const isResizing = selectedProject?.status === PROJECT_STATUS.RESIZING
|
|
const isProjectUpgrading = selectedProject?.status === PROJECT_STATUS.UPGRADING
|
|
const isProjectRestoring = selectedProject?.status === PROJECT_STATUS.RESTORING
|
|
const isProjectRestoreFailed = selectedProject?.status === PROJECT_STATUS.RESTORE_FAILED
|
|
const isProjectBuilding =
|
|
selectedProject?.status === PROJECT_STATUS.COMING_UP ||
|
|
selectedProject?.status === PROJECT_STATUS.UNKNOWN
|
|
const isProjectPausing = selectedProject?.status === PROJECT_STATUS.PAUSING
|
|
const isProjectPauseFailed = selectedProject?.status === PROJECT_STATUS.PAUSE_FAILED
|
|
const isProjectUnhealthy = selectedProject?.status === PROJECT_STATUS.ACTIVE_UNHEALTHY
|
|
const isProjectOffline = selectedProject?.postgrestStatus === 'OFFLINE'
|
|
|
|
const ignoreUnhealthyState =
|
|
isHomePage ||
|
|
router.pathname.includes('/project/[ref]/settings') ||
|
|
router.pathname.includes('/project/[ref]/logs')
|
|
|
|
const shouldRedirectToHomeForBuilding = isProjectBuilding && requiresDbConnection && !isHomePage
|
|
|
|
// Don't show building state on the home page — it handles building state inline
|
|
const shouldShowBuildingState = isProjectBuilding && requiresDbConnection && !isHomePage
|
|
|
|
useEffect(() => {
|
|
if (shouldRedirectToHomeForBuilding && ref) {
|
|
router.replace(`/project/${ref}`)
|
|
}
|
|
}, [shouldRedirectToHomeForBuilding, ref, router])
|
|
|
|
useEffect(() => {
|
|
if (ref) state.setSelectedDatabaseId(ref)
|
|
}, [ref])
|
|
|
|
if (isBlocking && (isLoading || (requiresProjectDetails && selectedProject === undefined))) {
|
|
return router.pathname.endsWith('[ref]') ? <LoadingState /> : <LogoLoader />
|
|
}
|
|
|
|
if (isRestarting && !isBackupsPage) {
|
|
return <RestartingState />
|
|
}
|
|
|
|
if (isResizing && !isBackupsPage) {
|
|
return <ResizingState />
|
|
}
|
|
|
|
if (isProjectUpgrading && !isBackupsPage) {
|
|
return <UpgradingState />
|
|
}
|
|
|
|
if (isProjectPausing) {
|
|
return <PausingState project={selectedProject} />
|
|
}
|
|
|
|
if (isProjectPauseFailed) {
|
|
return <PauseFailedState />
|
|
}
|
|
|
|
if (isProjectUnhealthy && !ignoreUnhealthyState) {
|
|
return <UnhealthyState />
|
|
}
|
|
|
|
if (requiresPostgrestConnection && isProjectOffline) {
|
|
return <ConnectingState project={selectedProject} />
|
|
}
|
|
|
|
if (requiresDbConnection && isProjectRestoring) {
|
|
return <RestoringState />
|
|
}
|
|
|
|
if (requiresDbConnection && isProjectRestoreFailed) {
|
|
return <RestoreFailedState />
|
|
}
|
|
|
|
if (shouldRedirectToHomeForBuilding) {
|
|
return <LogoLoader />
|
|
}
|
|
|
|
if (shouldShowBuildingState) {
|
|
return <BuildingState />
|
|
}
|
|
|
|
return <Fragment key={selectedProject?.ref}>{children}</Fragment>
|
|
}
|