Преглед на файлове

scaffold form for enabling preview envs (#3711)

ianedwards преди 2 години
родител
ревизия
03d5b3370b

+ 46 - 10
dashboard/src/lib/hooks/usePorterYaml.ts

@@ -9,17 +9,17 @@ import { z } from "zod";
 
 type PorterYamlStatus =
   | {
-    loading: true;
-    detectedName: null;
-    detectedServices: null;
-    porterYamlFound: false;
-  }
+      loading: true;
+      detectedName: null;
+      detectedServices: null;
+      porterYamlFound: false;
+    }
   | {
-    detectedServices: DetectedServices | null;
-    detectedName: string | null;
-    loading: false;
-    porterYamlFound: boolean;
-  };
+      detectedServices: DetectedServices | null;
+      detectedName: string | null;
+      loading: false;
+      porterYamlFound: boolean;
+    };
 
 /*
  *
@@ -114,6 +114,15 @@ export const usePorterYaml = ({
         const data = await z
           .object({
             b64_app_proto: z.string(),
+            env_variables: z.record(z.string()).nullable(),
+            env_secrets: z.record(z.string()).nullable(),
+            preview_app: z
+              .object({
+                b64_app_proto: z.string(),
+                env_variables: z.record(z.string()).nullable(),
+                env_secrets: z.record(z.string()).nullable(),
+              })
+              .optional(),
           })
           .parseAsync(res.data);
         const proto = PorterApp.fromJsonString(atob(data.b64_app_proto));
@@ -131,6 +140,33 @@ export const usePorterYaml = ({
           });
         }
 
+        if (data.preview_app) {
+          const previewProto = PorterApp.fromJsonString(
+            atob(data.preview_app.b64_app_proto)
+          );
+          const {
+            services: previewServices,
+            predeploy: previewPredeploy,
+            build: previewBuild,
+          } = serviceOverrides({
+            overrides: previewProto,
+            useDefaults,
+          });
+
+          if (previewServices.length || previewPredeploy || previewBuild) {
+            setDetectedServices((prev) => ({
+              ...prev,
+              services: prev?.services ? prev.services : [],
+              previews: {
+                services: previewServices,
+                predeploy: previewPredeploy,
+                build: previewBuild,
+                variables: data.preview_app?.env_variables ?? {},
+              },
+            }));
+          }
+        }
+
         if (proto.name) {
           setDetectedName(proto.name);
         }

+ 81 - 0
dashboard/src/lib/porter-apps/index.ts

@@ -394,3 +394,84 @@ export function clientAppFromProto({
     },
   };
 }
+
+export function applyPreviewOverrides({
+  app,
+  overrides,
+}: {
+  app: ClientPorterApp;
+  overrides: DetectedServices["previews"];
+}): ClientPorterApp {
+  if (!overrides) {
+    return app;
+  }
+
+  const services = app.services.map((svc) => {
+    const override = overrides.services.find(
+      (s) => s.name.value === svc.name.value
+    );
+    if (override) {
+      const ds = deserializeService({
+        service: serializeService(svc),
+        override: serializeService(override),
+      });
+
+      if (ds.config.type == "web") {
+        ds.config.domains = [];
+      }
+      return ds;
+    }
+
+    if (svc.config.type == "web") {
+      svc.config.domains = [];
+    }
+    return svc;
+  });
+  const additionalServices = overrides.services
+    .filter((s) => !app.services.find((svc) => svc.name.value === s.name.value))
+    .map((svc) => deserializeService({ service: serializeService(svc) }));
+
+  app.services = [...services, ...additionalServices];
+
+  if (app.predeploy) {
+    const predeployOverride = overrides.predeploy;
+    if (predeployOverride) {
+      app.predeploy = [
+        deserializeService({
+          service: serializeService(app.predeploy[0]),
+          override: serializeService(predeployOverride),
+        }),
+      ];
+    }
+  }
+
+  const envOverrides = overrides.variables;
+  if (envOverrides) {
+    const env = app.env.map((e) => {
+      const override = envOverrides[e.key];
+      if (override) {
+        return {
+          ...e,
+          locked: true,
+          value: override,
+        };
+      }
+
+      return e;
+    });
+
+    const additionalEnv = Object.entries(envOverrides)
+      .filter(([key]) => !app.env.find((e) => e.key === key))
+      .map(([key, value]) => ({
+        key,
+        value,
+        hidden: false,
+        locked: true,
+        deleted: false,
+      }));
+
+    app.env = [...env, ...additionalEnv];
+  }
+
+  return app;
+}

+ 38 - 21
dashboard/src/lib/porter-apps/services.ts

@@ -22,9 +22,43 @@ export type DetectedServices = {
   services: ClientService[];
   predeploy?: ClientService;
   build?: BuildOptions;
+  previews?: {
+    services: ClientService[];
+    predeploy?: ClientService;
+    variables?: Record<string, string>;
+  };
 };
 type ClientServiceType = "web" | "worker" | "job" | "predeploy";
 
+const webConfigValidator = z.object({
+  type: z.literal("web"),
+  autoscaling: autoscalingValidator.optional(),
+  domains: domainsValidator,
+  healthCheck: healthcheckValidator.optional(),
+  private: serviceBooleanValidator.optional(),
+});
+export type ClientWebConfig = z.infer<typeof webConfigValidator>;
+
+const workerConfigValidator = z.object({
+  type: z.literal("worker"),
+  autoscaling: autoscalingValidator.optional(),
+});
+export type ClientWorkerConfig = z.infer<typeof workerConfigValidator>;
+
+const jobConfigValidator = z.object({
+  type: z.literal("job"),
+  allowConcurrent: serviceBooleanValidator.optional(),
+  cron: serviceStringValidator,
+  suspendCron: serviceBooleanValidator.optional(),
+  timeoutSeconds: serviceNumberValidator,
+});
+export type ClientJobConfig = z.infer<typeof jobConfigValidator>;
+
+const predeployConfigValidator = z.object({
+  type: z.literal("predeploy"),
+});
+export type ClientPredeployConfig = z.infer<typeof predeployConfigValidator>;
+
 // serviceValidator is the validator for a ClientService
 // This is used to validate a service when creating or updating an app
 export const serviceValidator = z.object({
@@ -37,27 +71,10 @@ export const serviceValidator = z.object({
   cpuCores: serviceNumberValidator,
   ramMegabytes: serviceNumberValidator,
   config: z.discriminatedUnion("type", [
-    z.object({
-      type: z.literal("web"),
-      autoscaling: autoscalingValidator.optional(),
-      domains: domainsValidator,
-      healthCheck: healthcheckValidator.optional(),
-      private: serviceBooleanValidator.optional(),
-    }),
-    z.object({
-      type: z.literal("worker"),
-      autoscaling: autoscalingValidator.optional(),
-    }),
-    z.object({
-      type: z.literal("job"),
-      allowConcurrent: serviceBooleanValidator.optional(),
-      cron: serviceStringValidator,
-      suspendCron: serviceBooleanValidator.optional(),
-      timeoutSeconds: serviceNumberValidator,
-    }),
-    z.object({
-      type: z.literal("predeploy"),
-    }),
+    webConfigValidator,
+    workerConfigValidator,
+    jobConfigValidator,
+    predeployConfigValidator,
   ]),
   domainDeletions: z
     .object({

+ 25 - 17
dashboard/src/main/home/Home.tsx

@@ -44,6 +44,7 @@ import AppView from "./app-dashboard/app-view/AppView";
 import Apps from "./app-dashboard/apps/Apps";
 import DeploymentTargetProvider from "shared/DeploymentTargetContext";
 import PreviewEnvs from "./cluster-dashboard/preview-environments/v2/PreviewEnvs";
+import SetupApp from "./cluster-dashboard/preview-environments/v2/setup-app/SetupApp";
 
 // Guarded components
 const GuardedProjectSettings = fakeGuardedRoute("settings", "", [
@@ -451,23 +452,7 @@ const Home: React.FC<Props> = (props) => {
                   <AppDashboard />
                 )}
               </Route>
-              {currentProject?.validate_apply_v2 &&
-              currentProject.preview_envs_enabled ? (
-                <>
-                  <Route path={`/preview-environments/apps/:appName/:tab`}>
-                    <AppView />
-                  </Route>
-                  <Route exact path="/preview-environments/apps/:appName">
-                    <AppView />
-                  </Route>
-                  <Route exact path={`/preview-environments/apps`}>
-                    <Apps />
-                  </Route>
-                  <Route exact path={`/preview-environments`}>
-                    <PreviewEnvs />
-                  </Route>
-                </>
-              ) : null}
+
               <Route path="/addons/new">
                 <NewAddOnFlow />
               </Route>
@@ -556,6 +541,29 @@ const Home: React.FC<Props> = (props) => {
                 path={"/project-settings"}
                 render={() => <GuardedProjectSettings />}
               />
+              {currentProject?.validate_apply_v2 &&
+              currentProject.preview_envs_enabled ? (
+                <>
+                  <Route exact path="/preview-environments/configure">
+                    <SetupApp />
+                  </Route>
+                  <Route
+                    exact
+                    path={`/preview-environments/apps/:appName/:tab`}
+                  >
+                    <AppView />
+                  </Route>
+                  <Route exact path="/preview-environments/apps/:appName">
+                    <AppView />
+                  </Route>
+                  <Route exact path={`/preview-environments/apps`}>
+                    <Apps />
+                  </Route>
+                  <Route exact path={`/preview-environments`}>
+                    <PreviewEnvs />
+                  </Route>
+                </>
+              ) : null}
               <Route path={"*"} render={() => <LaunchWrapper />} />
             </Switch>
           </ViewWrapper>

+ 45 - 6
dashboard/src/main/home/app-dashboard/app-view/tabs/Settings.tsx

@@ -1,4 +1,4 @@
-import React, { useCallback, useEffect, useState } from "react";
+import React, { useCallback, useContext, useEffect, useState } from "react";
 import styled from "styled-components";
 import { useHistory } from "react-router";
 
@@ -11,8 +11,11 @@ import { useLatestRevision } from "../LatestRevisionContext";
 import api from "shared/api";
 import { useAppAnalytics } from "lib/hooks/useAppAnalytics";
 import { useQueryClient } from "@tanstack/react-query";
+import { Link } from "react-router-dom";
+import { Context } from "shared/Context";
 
 const Settings: React.FC = () => {
+  const { currentProject } = useContext(Context);
   const queryClient = useQueryClient();
   const history = useHistory();
   const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
@@ -20,7 +23,9 @@ const Settings: React.FC = () => {
   const { updateAppStep } = useAppAnalytics();
   const [isDeleting, setIsDeleting] = useState(false);
 
-  const [githubWorkflowFilename, setGithubWorkflowFilename] = useState(`porter_stack_${porterApp.name}.yml`);
+  const [githubWorkflowFilename, setGithubWorkflowFilename] = useState(
+    `porter_stack_${porterApp.name}.yml`
+  );
 
   const workflowFileExists = useCallback(async () => {
     try {
@@ -109,12 +114,20 @@ const Settings: React.FC = () => {
             window.open(res.data.url, "_blank", "noreferrer");
           }
 
-          updateAppStep({ step: "stack-deletion", deleteWorkflow: true, appName: porterApp.name });
+          updateAppStep({
+            step: "stack-deletion",
+            deleteWorkflow: true,
+            appName: porterApp.name,
+          });
           history.push("/apps");
           return;
         }
 
-        updateAppStep({ step: "stack-deletion", deleteWorkflow: false, appName: porterApp.name });
+        updateAppStep({
+          step: "stack-deletion",
+          deleteWorkflow: false,
+          appName: porterApp.name,
+        });
         history.push("/apps");
       } catch (err) {
       } finally {
@@ -126,12 +139,38 @@ const Settings: React.FC = () => {
 
   return (
     <StyledSettingsTab>
+      {currentProject?.preview_envs_enabled && (
+        <>
+          <Text size={16}>
+            Enable preview environments for "{porterApp.name}"
+          </Text>
+          <Spacer y={0.5} />
+          <Text color="helper">
+            Setup your application to automatically create preview environments
+            for each pull request.
+          </Text>
+          <Spacer y={0.5} />
+          <Link
+            to={`/preview-environments/configure?app_name=${porterApp.name}`}
+          >
+            <Button
+              type="button"
+              onClick={() => {
+                setIsDeleteModalOpen(true);
+              }}
+            >
+              Enable
+            </Button>
+          </Link>
+          <Spacer y={1} />
+        </>
+      )}
       <Text size={16}>Delete "{porterApp.name}"</Text>
-      <Spacer y={1} />
+      <Spacer y={0.5} />
       <Text color="helper">
         Delete this application and all of its resources.
       </Text>
-      <Spacer y={1} />
+      <Spacer y={0.5} />
       <Button
         type="button"
         onClick={() => {

+ 2 - 11
dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx

@@ -31,7 +31,6 @@ import {
   defaultSerialized,
   deserializeService,
 } from "lib/porter-apps/services";
-import EnvVariables from "../validate-apply/app-settings/EnvVariables";
 import { usePorterYaml } from "lib/hooks/usePorterYaml";
 import { valueExists } from "shared/util";
 import api from "shared/api";
@@ -180,12 +179,7 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
   const image = watch("source.image");
   const services = watch("app.services");
 
-  const {
-    detectedServices: servicesFromYaml,
-    porterYamlFound,
-    detectedName,
-    loading: isLoadingPorterYaml,
-  } = usePorterYaml({
+  const { detectedServices: servicesFromYaml, detectedName } = usePorterYaml({
     source: source?.type === "github" ? source : null,
     appName: "", // only want to know if porter.yaml has name set, otherwise use name from input
   });
@@ -630,10 +624,7 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
                     <Text color="helper">
                       Specify environment variables shared among all services.
                     </Text>
-                    <EnvSettings
-                      baseEnvGroups={baseEnvGroups}
-                      servicesFromYaml={null}
-                    />
+                    <EnvSettings baseEnvGroups={baseEnvGroups} />
                   </>,
                   source.type === "github" && (
                     <>

+ 0 - 1
dashboard/src/main/home/app-dashboard/validate-apply/app-settings/EnvSettings.tsx

@@ -11,7 +11,6 @@ type Props = {
   appName?: string;
   revision?: AppRevision;
   baseEnvGroups?: PopulatedEnvGroup[];
-  servicesFromYaml: DetectedServices | null;
   latestSource?: SourceOptions;
   attachedEnvGroups?: PopulatedEnvGroup[];
 };

+ 1 - 6
dashboard/src/main/home/app-dashboard/validate-apply/revisions-list/RevisionTableContents.tsx

@@ -6,12 +6,7 @@ import { useLatestRevision } from "../../app-view/LatestRevisionContext";
 import styled from "styled-components";
 import { readableDate } from "shared/string_utils";
 import Text from "components/porter/Text";
-import { useFormContext } from "react-hook-form";
-import {
-  PorterAppFormData,
-  SourceOptions,
-  clientAppFromProto,
-} from "lib/porter-apps";
+import { SourceOptions } from "lib/porter-apps";
 
 type RevisionTableContentsProps = {
   latestRevisionNumber: number;

+ 224 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/AppTemplateForm.tsx

@@ -0,0 +1,224 @@
+import React, { useEffect, useMemo, useState } from "react";
+import { FormProvider, useForm } from "react-hook-form";
+
+import VerticalSteps from "components/porter/VerticalSteps";
+import {
+  PorterAppFormData,
+  SourceOptions,
+  applyPreviewOverrides,
+  clientAppFromProto,
+  porterAppFormValidator,
+} from "lib/porter-apps";
+import {
+  defaultSerialized,
+  deserializeService,
+} from "lib/porter-apps/services";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useLatestRevision } from "main/home/app-dashboard/app-view/LatestRevisionContext";
+import Spacer from "components/porter/Spacer";
+import ServiceList from "main/home/app-dashboard/validate-apply/services-settings/ServiceList";
+import Text from "components/porter/Text";
+import EnvSettings from "main/home/app-dashboard/validate-apply/app-settings/EnvSettings";
+import api from "shared/api";
+import { z } from "zod";
+import { populatedEnvGroup } from "main/home/app-dashboard/validate-apply/app-settings/types";
+import { useQuery } from "@tanstack/react-query";
+import { Redirect } from "react-router";
+import Button from "components/porter/Button";
+import { useAppValidation } from "lib/hooks/useAppValidation";
+import { PorterApp } from "@porter-dev/api-contracts";
+import axios from "axios";
+
+const AppTemplateForm: React.FC = () => {
+  const [step, setStep] = useState(0);
+  const [validatedAppProto, setValidatedAppProto] = useState<PorterApp | null>(
+    null
+  );
+  const [isCreating, setIsCreating] = useState(false);
+  const [createError, setCreateError] = useState("");
+  const [{ variables, secrets }, setFinalizedAppEnv] = useState<{
+    variables: Record<string, string>;
+    secrets: Record<string, string>;
+  }>({
+    variables: {},
+    secrets: {},
+  });
+
+  const {
+    porterApp,
+    appEnv,
+    latestProto,
+    servicesFromYaml,
+    clusterId,
+    projectId,
+    deploymentTarget,
+  } = useLatestRevision();
+  const { validateApp } = useAppValidation({
+    deploymentTargetID: deploymentTarget.id,
+    creating: true,
+  });
+
+  const { data: baseEnvGroups = [] } = useQuery(
+    ["getAllEnvGroups", projectId, clusterId],
+    async () => {
+      const res = await api.getAllEnvGroups(
+        "<token>",
+        {},
+        {
+          id: projectId,
+          cluster_id: clusterId,
+        }
+      );
+
+      const { environment_groups } = await z
+        .object({
+          environment_groups: z.array(populatedEnvGroup).default([]),
+        })
+        .parseAsync(res.data);
+
+      return environment_groups;
+    }
+  );
+
+  const latestSource: SourceOptions = useMemo(() => {
+    if (porterApp.image_repo_uri) {
+      const [repository, tag] = porterApp.image_repo_uri.split(":");
+      return {
+        type: "docker-registry",
+        image: {
+          repository,
+          tag,
+        },
+      };
+    }
+
+    return {
+      type: "github",
+      git_repo_id: porterApp.git_repo_id ?? 0,
+      git_repo_name: porterApp.repo_name ?? "",
+      git_branch: porterApp.git_branch ?? "",
+      porter_yaml_path: porterApp.porter_yaml_path ?? "./porter.yaml",
+    };
+  }, [porterApp]);
+
+  const withPreviewOverrides = useMemo(() => {
+    return applyPreviewOverrides({
+      app: clientAppFromProto({
+        proto: latestProto,
+        overrides: servicesFromYaml,
+        variables: appEnv?.variables,
+        secrets: appEnv?.secret_variables,
+      }),
+      overrides: servicesFromYaml?.previews,
+    });
+  }, [latestProto, appEnv, servicesFromYaml]);
+
+  const porterAppFormMethods = useForm<PorterAppFormData>({
+    reValidateMode: "onSubmit",
+    resolver: zodResolver(porterAppFormValidator),
+    defaultValues: {
+      app: withPreviewOverrides,
+      source: latestSource,
+      deletions: {
+        serviceNames: [],
+        envGroupNames: [],
+        predeploy: [],
+      },
+    },
+  });
+
+  const { reset, handleSubmit } = porterAppFormMethods;
+
+  const onSubmit = handleSubmit(async (data) => {
+    try {
+      setCreateError("");
+      const { validatedAppProto, variables, secrets } = await validateApp(data);
+      setValidatedAppProto(validatedAppProto);
+      setFinalizedAppEnv({ variables, secrets });
+
+      // todo(ianedwards): this is essentially a no-op for now
+      // follow up will be to actually create the template and commit the workflow
+    } catch (err) {
+      if (axios.isAxiosError(err) && err.response?.data?.error) {
+        setCreateError(err.response?.data?.error);
+        return;
+      }
+      setCreateError(
+        "An error occurred while validating your application. Please try again."
+      );
+    }
+  });
+
+  useEffect(() => {
+    reset({
+      app: withPreviewOverrides,
+      source: latestSource,
+      deletions: {
+        serviceNames: [],
+        envGroupNames: [],
+        predeploy: [],
+      },
+    });
+  }, [withPreviewOverrides, latestSource]);
+
+  if (latestSource.type !== "github") {
+    return <Redirect to={`/apps/${porterApp.name}`} />;
+  }
+
+  return (
+    <FormProvider {...porterAppFormMethods}>
+      <form onSubmit={onSubmit}>
+        <VerticalSteps
+          currentStep={step}
+          steps={[
+            <>
+              <Text size={16}>Application services</Text>
+              <Spacer y={0.5} />
+              <ServiceList
+                addNewText={"Add a new service"}
+                fieldArrayName={"app.services"}
+              />
+            </>,
+            <>
+              <Text size={16}>Environment variables (optional)</Text>
+              <Spacer y={0.5} />
+              <Text color="helper">
+                Specify environment variables shared among all services.
+              </Text>
+              <EnvSettings baseEnvGroups={baseEnvGroups} />
+            </>,
+            <>
+              <Text size={16}>Pre-deploy job (optional)</Text>
+              <Spacer y={0.5} />
+              <Text color="helper">
+                You may add a pre-deploy job to perform an operation before your
+                application services deploy each time, like a database
+                migration.
+              </Text>
+              <Spacer y={0.5} />
+              <ServiceList
+                addNewText={"Add a new pre-deploy job"}
+                prePopulateService={deserializeService({
+                  service: defaultSerialized({
+                    name: "pre-deploy",
+                    type: "predeploy",
+                  }),
+                })}
+                existingServiceNames={
+                  latestProto.predeploy ? ["pre-deploy"] : []
+                }
+                isPredeploy
+                fieldArrayName={"app.predeploy"}
+              />
+            </>,
+            <Button type="submit" loadingText={"Deploying..."} width={"150px"}>
+              Enable Previews
+            </Button>,
+          ].filter((x) => x)}
+        />
+      </form>
+    </FormProvider>
+  );
+};
+
+export default AppTemplateForm;

+ 92 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/SetupApp.tsx

@@ -0,0 +1,92 @@
+import React, { useContext, useMemo } from "react";
+import { RouteComponentProps, withRouter } from "react-router";
+import styled from "styled-components";
+
+import pull_request from "assets/pull_request_icon.svg";
+
+import Back from "components/porter/Back";
+import DashboardHeader from "main/home/cluster-dashboard/DashboardHeader";
+import Spacer from "components/porter/Spacer";
+import AppTemplateForm from "./AppTemplateForm";
+import { LatestRevisionProvider } from "main/home/app-dashboard/app-view/LatestRevisionContext";
+
+type Props = RouteComponentProps & {};
+
+const SetupApp: React.FC<Props> = ({ location }) => {
+  const params = useMemo(() => {
+    const queryParams = new URLSearchParams(location.search);
+    const appName = queryParams.get("app_name");
+
+    return {
+      appName,
+    };
+  }, [location.search]);
+
+  const appName = params.appName;
+
+  if (!appName) {
+    return null;
+  }
+
+  return (
+    <LatestRevisionProvider appName={appName}>
+      <CenterWrapper>
+        <Div>
+          <StyledConfigureTemplate>
+            <Back to="/preview-environments" />
+            <DashboardHeader
+              prefix={<Icon src={pull_request} />}
+              title={`Preview environments for ${appName}`}
+              description="Set preview environment specific configuration for this application below. Any newly created preview environments will use these settings."
+              capitalize={false}
+              disableLineBreak
+            />
+            <DarkMatter />
+            <AppTemplateForm />
+            <Spacer y={3} />
+          </StyledConfigureTemplate>
+        </Div>
+      </CenterWrapper>
+    </LatestRevisionProvider>
+  );
+};
+
+export default withRouter(SetupApp);
+
+const Div = styled.div`
+  width: 100%;
+  max-width: 900px;
+`;
+
+const CenterWrapper = styled.div`
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+`;
+
+const DarkMatter = styled.div`
+  width: 100%;
+  margin-top: -5px;
+`;
+
+const StyledConfigureTemplate = styled.div`
+  height: 100%;
+`;
+
+const Icon = styled.img`
+  margin-right: 15px;
+  height: 28px;
+  animation: floatIn 0.5s;
+  animation-fill-mode: forwards;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(20px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;

+ 2 - 2
dashboard/src/main/home/sidebar/Clusters.tsx

@@ -147,9 +147,9 @@ class Clusters extends Component<PropsType, StateType> {
     let { clusters } = this.state;
     let { currentCluster, setCurrentCluster, currentProject } = this.context;
 
-    if (currentProject?.simplified_view_enabled ) {
+    if (currentProject?.simplified_view_enabled) {
       const cluster = clusters[0];
-      return currentProject?.preview_envs_enabled && currentCluster?.preview_envs_enabled ? (
+      return currentProject?.preview_envs_enabled ? (
         <NavButton
           path="/preview-environments"
           targetClusterName={cluster?.name}