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. */}
+
-