diff --git a/console/src/layouts/BaseLayout.tsx b/console/src/layouts/BaseLayout.tsx index abb13f5409e17..1c520d8870c89 100644 --- a/console/src/layouts/BaseLayout.tsx +++ b/console/src/layouts/BaseLayout.tsx @@ -54,6 +54,14 @@ import { FIXED_TOP_BAR_Z_INDEX, MAIN_CONTENT_Z_INDEX } from "./zIndex"; export interface BaseLayoutProps { children?: React.ReactNode; containerProps?: FlexProps; + /** + * Suppresses the welcome dialog that pops when a region first becomes + * healthy. Used by flows with their own region-ready affordances, so the + * dialog doesn't cover them. + */ + hideWelcomeDialog?: boolean; + /** Renders only account-scoped navigation. See NavBarProps.accountOnly. */ + accountOnlyNav?: boolean; navBarOverride?: React.FunctionComponent; sectionNav?: React.ReactNode; } @@ -98,7 +106,7 @@ export const BaseLayout = (props: BaseLayoutProps) => { data-testid="page-layout" {...props.containerProps} > - + {!props.hideWelcomeDialog && } { flexGrow="1" minHeight="0" > - + { + afterEach(() => { + server.resetHandlers(); + vi.clearAllMocks(); + }); + + it("renders the Admin group after the region-scoped items", async () => { + await renderComponent(, { + initializeState: ({ set }) => + setFakeEnvironment(set, "aws/us-east-1", healthyEnvironment), + }); + const clusters = await screen.findByText("Clusters"); + const admin = screen.getByText("Admin"); + // Admin is pinned to the bottom of the menu, so it must come after the + // region-scoped items in document order. + expect( + clusters.compareDocumentPosition(admin) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect(screen.getByText("Create New")).toBeInTheDocument(); + }); + + it("hides the create button and region items without an enabled environment", async () => { + await renderComponent(, { + initializeState: ({ set }) => + setFakeEnvironment(set, "aws/us-east-1", disabledEnvironment), + }); + expect(await screen.findByText("Admin")).toBeInTheDocument(); + expect(screen.queryByText("Create New")).not.toBeInTheDocument(); + expect(screen.queryByText("Clusters")).not.toBeInTheDocument(); + expect(screen.queryByText("SQL Shell")).not.toBeInTheDocument(); + }); +}); diff --git a/console/src/layouts/NavBar.tsx b/console/src/layouts/NavBar.tsx index 2fd17f015cdf6..cad1e7b16ad8a 100644 --- a/console/src/layouts/NavBar.tsx +++ b/console/src/layouts/NavBar.tsx @@ -149,9 +149,15 @@ export const NavBarEnvironmentSelect = () => { export interface NavBarProps { isCollapsed: boolean; + /** + * Renders only account-scoped navigation (the Admin group), hiding + * region-scoped items and object creation. Used by flows where no + * environment is usable yet, such as environment-not-ready. + */ + accountOnly?: boolean; } -export const NavBar = ({ isCollapsed }: NavBarProps) => { +export const NavBar = ({ isCollapsed, accountOnly }: NavBarProps) => { const { isOpen: isConnectModalOpen, onClose: onCloseConnectModal, @@ -224,6 +230,7 @@ export const NavBar = ({ isCollapsed }: NavBarProps) => { offsetY={navBarContainerHeight} runtimeConfig={runtimeConfig} isMobile={true} + accountOnly={accountOnly} /> )} selfManagedConfigElement={ @@ -231,25 +238,34 @@ export const NavBar = ({ isCollapsed }: NavBarProps) => { closeMenu={closeMobileNav} offsetY={navBarContainerHeight} isMobile={true} + accountOnly={accountOnly} /> } /> )} - + {!accountOnly && ( + + + + )} ( )} selfManagedConfigElement={ - + } /> - {!isMobile && } {!isMobile && !isCollapsed && ( )} diff --git a/console/src/layouts/NavBar/NavMenu.tsx b/console/src/layouts/NavBar/NavMenu.tsx index 6a2ea3c63615f..7044ce6b0d066 100644 --- a/console/src/layouts/NavBar/NavMenu.tsx +++ b/console/src/layouts/NavBar/NavMenu.tsx @@ -50,6 +50,8 @@ export type NavItemType = { navItems?: NavItemType[]; onClick?: () => void; forceShow?: boolean; + /** Renders the item at the bottom of the nav menu, below a spacer. */ + pinToBottom?: boolean; }; const getNavItems = ({ @@ -144,6 +146,7 @@ const getNavItems = ({ icon: , label: "Admin", forceShow: true, + pinToBottom: true, navItems: [ ...(canViewAppPasswords ? [ @@ -253,9 +256,17 @@ const useSelfManagedNavMenuItems = () => { }; const NavMenu = (props: { isCollapsed?: boolean; items: NavItemType[] }) => { + const topItems = props.items.filter((item) => !item.pinToBottom); + const bottomItems = props.items.filter((item) => item.pinToBottom); return ( - {props.items.map((item) => ( + {topItems.map((item) => ( + + + + ))} + + {bottomItems.map((item) => ( @@ -285,16 +296,30 @@ const NavMenuMobile = (props: { overflowY="auto" > - {props.items.map((item) => ( - - - - ))} + {props.items + .filter((item) => !item.pinToBottom) + .map((item) => ( + + + + ))} + + {props.items + .filter((item) => item.pinToBottom) + .map((item) => ( + + + + ))} + @@ -347,6 +372,8 @@ const NavMenuMobile = (props: { type BaseCloudNavMenuProps = { runtimeConfig: CloudRuntimeConfig; + /** Renders only account-scoped (forceShow) items. */ + accountOnly?: boolean; }; type MobileNavMenuProps = { @@ -365,9 +392,12 @@ export const CloudNavMenu = ( | (BaseCloudNavMenuProps & DesktopNavMenuProps) | (BaseCloudNavMenuProps & MobileNavMenuProps), ) => { - const items = useCloudNavMenuItems({ + const allItems = useCloudNavMenuItems({ runtimeConfig: props.runtimeConfig, }); + const items = props.accountOnly + ? allItems.filter((item) => item.forceShow) + : allItems; if (props.isMobile) { return ; @@ -377,9 +407,15 @@ export const CloudNavMenu = ( }; export const SelfManagedNavMenu = ( - props: DesktopNavMenuProps | MobileNavMenuProps, + props: (DesktopNavMenuProps | MobileNavMenuProps) & { + /** Renders only account-scoped (forceShow) items. */ + accountOnly?: boolean; + }, ) => { - const items = useSelfManagedNavMenuItems(); + const allItems = useSelfManagedNavMenuItems(); + const items = props.accountOnly + ? allItems.filter((item) => item.forceShow) + : allItems; if (props.isMobile) { return ; diff --git a/console/src/platform/environment-not-ready/EnvironmentNotReadyRoutes.test.tsx b/console/src/platform/environment-not-ready/EnvironmentNotReadyRoutes.test.tsx index 02ca23bdfeea7..5c1aa6a422c0c 100644 --- a/console/src/platform/environment-not-ready/EnvironmentNotReadyRoutes.test.tsx +++ b/console/src/platform/environment-not-ready/EnvironmentNotReadyRoutes.test.tsx @@ -14,6 +14,7 @@ import server from "~/api/mocks/server"; import { dummyValidUser } from "~/external-library-wrappers/__mocks__/frontegg"; import { disabledEnvironment, + healthyEnvironment, InitializeStateFn, renderComponent, RenderWithPathname, @@ -91,4 +92,45 @@ describe("EnvironmentNotReadyRoutes", () => { ); }); }); + + it("shows account-level navigation without an enabled environment", async () => { + await renderRoutes(["/enable-region"]); + // Admin items are account-scoped and must stay reachable while no + // environment is enabled. + expect(await screen.findByText("Admin")).toBeInTheDocument(); + expect(screen.getByText("App Passwords")).toBeInTheDocument(); + expect(screen.getByText("Usage & Billing")).toBeInTheDocument(); + // Region-scoped items and object creation are hidden in this flow. + expect(screen.queryByText("Clusters")).not.toBeInTheDocument(); + expect(screen.queryByText("SQL Shell")).not.toBeInTheDocument(); + expect(screen.queryByText("Create New")).not.toBeInTheDocument(); + }); + + it("keeps the nav account-only in this flow regardless of environment health", async () => { + // The nav in this flow is gated on the route, not on health state, so a + // transient "crashed" reading during boot cannot flash the full sidebar. + await renderRoutes(["/creating-environment"], ({ set }) => + setFakeEnvironment(set, "aws/us-east-1", { + ...healthyEnvironment, + status: { ...healthyEnvironment.status, health: "crashed" }, + }), + ); + expect(await screen.findByText("Admin")).toBeInTheDocument(); + expect(screen.queryByText("Clusters")).not.toBeInTheDocument(); + expect(screen.queryByText("Create New")).not.toBeInTheDocument(); + }); + + it("does not show the welcome dialog when a region becomes ready", async () => { + // The dialog would cover the flow's own region-ready affordances (the + // toast and the tutorial's "Open console" button). + await renderRoutes(["/creating-environment"], ({ set }) => + setFakeEnvironment(set, "aws/us-east-1", healthyEnvironment), + ); + expect( + await screen.findByText("We’re creating your environment"), + ).toBeVisible(); + expect( + screen.queryByTestId("welcome-dialog-close-button"), + ).not.toBeInTheDocument(); + }); }); diff --git a/console/src/platform/environment-not-ready/EnvironmentNotReadyRoutes.tsx b/console/src/platform/environment-not-ready/EnvironmentNotReadyRoutes.tsx index 34c8214aec1c5..7ba378f4c9083 100644 --- a/console/src/platform/environment-not-ready/EnvironmentNotReadyRoutes.tsx +++ b/console/src/platform/environment-not-ready/EnvironmentNotReadyRoutes.tsx @@ -7,24 +7,103 @@ // the Business Source License, use of this software will be governed // by the Apache License, Version 2.0. +import { Text, VStack } from "@chakra-ui/react"; +import { useAtomValue } from "jotai"; import React from "react"; -import { Navigate, Route } from "react-router-dom"; +import { Link, Navigate, Route } from "react-router-dom"; +import TextLink from "~/components/TextLink"; import { User } from "~/external-library-wrappers/frontegg"; +import { useToast } from "~/hooks/useToast"; +import { BaseLayout } from "~/layouts/BaseLayout"; +import { regionPath } from "~/platform/routeHelpers"; import { SentryRoutes } from "~/sentry"; +import { + currentRegionIdAtom, + useEnvironmentsWithHealth, + useRegionSlug, +} from "~/store/environments"; import EnableRegion from "./EnableRegion"; -import { EnvironmentNotReadyLayout } from "./Layout"; import { OnboardingSteps } from "./OnboardingSteps"; +const REGION_READY_TOAST_ID = "region-ready-toast"; + +export const RegionReadyToastBody = (props: { + currentRegionId: string; + regionPath: string; +}) => { + return ( + + {props.currentRegionId} is ready! + + Go to Materialize Console → + + + ); +}; + +/** + * Pops a toast when the current region becomes healthy while the user is + * still in the environment-not-ready flow. Renders nothing. + */ +const RegionReadyToast = () => { + const toast = useToast(); + const regionSlug = useRegionSlug(); + const environments = useEnvironmentsWithHealth(); + const currentRegionId = useAtomValue(currentRegionIdAtom); + const currentEnvironment = environments.get(currentRegionId); + + // The toast reference isn't stable, so the unmount cleanup reads it through + // a ref instead of depending on it directly. + const toastRef = React.useRef(toast); + React.useEffect(() => { + toastRef.current = toast; + }, [toast]); + + React.useEffect(() => { + if ( + currentEnvironment && + currentEnvironment.state === "enabled" && + currentEnvironment.status.health === "healthy" && + !toast.isActive(REGION_READY_TOAST_ID) + ) { + toast({ + id: REGION_READY_TOAST_ID, + duration: null, // keep it open + position: "top-right", + description: ( + + ), + }); + } + }, [currentEnvironment, currentRegionId, regionSlug, toast]); + + React.useEffect(() => { + return () => { + // Close the toast when this component unmounts + toastRef.current.close(REGION_READY_TOAST_ID); + }; + }, []); + + return null; +}; + export const EnvironmentNotReadyRoutes = ({ user }: { user: User }) => { return ( - + // The welcome dialog is suppressed here because this flow has its own + // region-ready affordances (the toast and the tutorial's "Open console" + // button), which the dialog would cover. + + } /> } /> } /> - + ); }; diff --git a/console/src/platform/environment-not-ready/Layout.tsx b/console/src/platform/environment-not-ready/Layout.tsx deleted file mode 100644 index efc4956c23d5a..0000000000000 --- a/console/src/platform/environment-not-ready/Layout.tsx +++ /dev/null @@ -1,172 +0,0 @@ -// Copyright Materialize, Inc. and contributors. All rights reserved. -// -// Use of this software is governed by the Business Source License -// included in the LICENSE file. -// -// As of the Change Date specified in that file, in accordance with -// the Business Source License, use of this software will be governed -// by the Apache License, Version 2.0. - -import { Flex, HStack, Text, useTheme, VStack } from "@chakra-ui/react"; -import { useAtomValue } from "jotai"; -import React from "react"; -import { Link } from "react-router-dom"; - -import { MaterializeLogo } from "~/components/MaterializeLogo"; -import TextLink from "~/components/TextLink"; -import { User } from "~/external-library-wrappers/frontegg"; -import { useToast } from "~/hooks/useToast"; -import EnvironmentSelect from "~/layouts/EnvironmentSelect"; -import PageFooter from "~/layouts/PageFooter"; -import ProfileDropdown from "~/layouts/ProfileDropdown"; -import { NAVBAR_Z_INDEX } from "~/layouts/zIndex"; -import { regionPath } from "~/platform/routeHelpers"; -import { - currentRegionIdAtom, - useEnvironmentsWithHealth, - useRegionSlug, -} from "~/store/environments"; -import { MaterializeTheme } from "~/theme"; - -const REGION_READY_TOAST_ID = "region-ready-toast"; - -export const NAVBAR_HEIGHT = "16"; - -export const RegionReadyToastBody = (props: { - currentRegionId: string; - regionPath: string; -}) => { - return ( - - {props.currentRegionId} is ready! - - Go to Materialize Console → - - - ); -}; - -export const EnvironmentNotReadyStatus = ({ user }: { user: User }) => { - const toast = useToast(); - const regionSlug = useRegionSlug(); - const environments = useEnvironmentsWithHealth(); - const currentRegionId = useAtomValue(currentRegionIdAtom); - const currentEnvironment = environments.get(currentRegionId); - const currentRegionReady = - currentEnvironment && - "status" in currentEnvironment && - currentEnvironment.status.health === "healthy"; - - const anyOtherRegionReady = React.useMemo( - () => - Array.from(environments.entries()).some(([regionId, env]) => { - if (regionId === currentRegionId) return false; - return ( - env && - env.state === "enabled" && - (env.status.health === "healthy" || env.status.health === "blocked") - ); - }), - [currentRegionId, environments], - ); - - React.useEffect(() => { - if ( - currentEnvironment && - currentEnvironment.state === "enabled" && - currentEnvironment.status.health === "healthy" && - !toast.isActive(REGION_READY_TOAST_ID) - ) { - toast({ - id: REGION_READY_TOAST_ID, - duration: null, // keep it open - position: "top-right", - description: ( - - ), - }); - } - }, [currentEnvironment, currentRegionId, regionSlug, toast]); - - React.useEffect(() => { - return () => { - // Close to toast when this component unmounts - toast.close(REGION_READY_TOAST_ID); - }; - // The toast reference isn't stable, including it in the dependency array leads to - // edge cases where the toast never shows up. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - if (!currentEnvironment) return; - - return ( - - - - - Materialize - - - {!currentRegionReady && anyOtherRegionReady && ( - - )} - - ); -}; - -export const EnvironmentNotReadyLayout = (props: { - children: React.ReactNode; - user: User; -}) => { - const { colors } = useTheme(); - - return ( - - - - - - - {props.children} - - - - ); -}; diff --git a/console/src/platform/environment-not-ready/OnboardingSteps.tsx b/console/src/platform/environment-not-ready/OnboardingSteps.tsx index 56d91b61d908a..7f4753614c8bb 100644 --- a/console/src/platform/environment-not-ready/OnboardingSteps.tsx +++ b/console/src/platform/environment-not-ready/OnboardingSteps.tsx @@ -58,7 +58,13 @@ export const OnboardingSteps = ({ user }: { user: User }) => { const environmentReady = environment?.state === "enabled" && environment.status.health === "healthy"; return ( - + { base: "center", xl: "space-between", }} - ml={{ base: "0", xl: "144px" }} - position="absolute" - top={{ base: "auto", xl: "50%" }} - right={{ base: "auto", xl: "0" }} - left={{ base: "auto", xl: "0" }} - mt={{ base: "auto", xl: "-300px" }} + px={{ base: 8, xl: 16 }} width="100%" + maxWidth="1500px" > { src={slide.image.src} alt={slide.image.src} height="auto" - maxHeight={{ base: "auto", lg: "400px", xl: "600px" }} - mr={{ base: undefined, xl: "-44px" }} + maxHeight={{ base: "auto", lg: "400px", xl: "540px" }} px={{ base: "8", xl: "0" }} width={{ base: "100%", lg: "auto", xl: "auto" }} + maxWidth={{ xl: "55%" }} /> - + ); }; diff --git a/console/src/store/environments.test.ts b/console/src/store/environments.test.ts index 0a5e2bec5692a..2d251c7054745 100644 --- a/console/src/store/environments.test.ts +++ b/console/src/store/environments.test.ts @@ -66,9 +66,19 @@ describe("store/environments", () => { expect(result.health).toEqual("healthy"); }); - it("should return crashed when there is an error", async () => { + it("should return booting for a SQL error while still within the boot window", async () => { server.use(badRequestHandler); const result = await fetchEnvironmentHealth(enabledEnvironment); + expect(result.health).toEqual("booting"); + }); + + it("should return crashed for a SQL error after the boot window", async () => { + server.use(badRequestHandler); + const result = await fetchEnvironmentHealth( + enabledEnvironment, + 10_000, + { seconds: 0.001 }, // 1ms max boot time (already exceeded) + ); expect(result.health).toEqual("crashed"); const { errors } = result as { errors?: EnvironmentError[] }; expect(errors).toEqual([ diff --git a/console/src/store/environments.ts b/console/src/store/environments.ts index ab0b652e81648..614bb68c9f07b 100644 --- a/console/src/store/environments.ts +++ b/console/src/store/environments.ts @@ -423,6 +423,14 @@ export const useEnvironmentsWithHealth = () => { const defaultTimeout = 10_000; // 10 seconds const maxBootDuration = { minutes: 15 }; + +/** Whether an environment enabled at `enabledAt` is still within its boot grace window. */ +function withinBootWindow( + enabledAt: EnabledEnvironment["enabledAt"], + maxBoot: Duration, +) { + return new Date() <= add(new Date(enabledAt), maxBoot); +} // A made up enabled at time only used during impersonation and self-managed, // since we don't know when the environment was enabled. const fakeEnabledAt = sub(new Date(), { @@ -508,6 +516,14 @@ export const fetchEnvironmentHealth = async ( errors, }; } + // A fresh environmentd can respond with errors before it has fully + // initialized, so a SQL-level error gets the same boot-window grace + // as an unreachable environment. Reporting "crashed" here would count + // as ready (isEnvironmentReady) and route users into a console that + // cannot serve queries yet. + if (withinBootWindow(environment.enabledAt, maxBoot)) { + return { health: "booting", errors: [] }; + } errors.push({ message: "Environmentd health check failed", }); @@ -521,9 +537,7 @@ export const fetchEnvironmentHealth = async ( return { health: "healthy", version, errors: [] }; } } catch (e) { - const enabledAt = new Date(environment.enabledAt); - const cutoff = add(enabledAt, maxBoot); - if (new Date() > cutoff) { + if (!withinBootWindow(environment.enabledAt, maxBoot)) { const errors: EnvironmentError[] = []; errors.push({ message: `Environment not resolvable for more than ${formatDuration( diff --git a/console/src/test/utils.tsx b/console/src/test/utils.tsx index b050ee5d43be6..2a4e8e38d18aa 100644 --- a/console/src/test/utils.tsx +++ b/console/src/test/utils.tsx @@ -206,10 +206,12 @@ export const createProviderWrapper = async ({ + {/* Inside the router to match the app's provider nesting, + so toasts can render router Links. */} + -