Ian Edwards пре 2 година
родитељ
комит
af36896d7a
27 измењених фајлова са 1275 додато и 210 уклоњено
  1. 126 0
      api/server/handlers/porter_app/templates_list.go
  2. 29 0
      api/server/router/porter_app.go
  3. 11 14
      dashboard/package-lock.json
  4. 1 1
      dashboard/package.json
  5. 1 0
      dashboard/src/assets/plus-square.svg
  6. 12 0
      dashboard/src/lib/environments/types.ts
  7. 87 0
      dashboard/src/lib/hooks/useTemplateEnvs.ts
  8. 4 1
      dashboard/src/lib/porter-apps/build.ts
  9. 6 0
      dashboard/src/lib/porter-apps/index.ts
  10. 12 2
      dashboard/src/main/home/app-dashboard/apps/AppGrid.tsx
  11. 43 6
      dashboard/src/main/home/app-dashboard/apps/AppMeta.tsx
  12. 6 1
      dashboard/src/main/home/app-dashboard/apps/SelectableAppList.tsx
  13. 94 40
      dashboard/src/main/home/cluster-dashboard/preview-environments/v2/ConfigurableAppList.tsx
  14. 69 61
      dashboard/src/main/home/cluster-dashboard/preview-environments/v2/ConfigurableAppRow.tsx
  15. 5 8
      dashboard/src/main/home/cluster-dashboard/preview-environments/v2/DeleteEnvModal.tsx
  16. 33 50
      dashboard/src/main/home/cluster-dashboard/preview-environments/v2/PreviewEnvGrid.tsx
  17. 6 4
      dashboard/src/main/home/cluster-dashboard/preview-environments/v2/PreviewEnvs.tsx
  18. 176 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/AppSelector.tsx
  19. 54 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/ConsolidatedServices.tsx
  20. 409 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/CreateTemplate.tsx
  21. 2 6
      dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/PreviewAppDataContainer.tsx
  22. 9 10
      dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/PreviewGHAModal.tsx
  23. 50 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/RevisionLoader.tsx
  24. 13 1
      dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/SetupApp.tsx
  25. 6 3
      dashboard/src/shared/DeploymentTargetContext.tsx
  26. 11 0
      dashboard/src/shared/api.tsx
  27. 0 2
      go.sum

+ 126 - 0
api/server/handlers/porter_app/templates_list.go

@@ -0,0 +1,126 @@
+package porter_app
+
+import (
+	"context"
+	"encoding/base64"
+	"net/http"
+
+	"connectrpc.com/connect"
+	"github.com/porter-dev/api-contracts/generated/go/helpers"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+	"google.golang.org/protobuf/reflect/protoreflect"
+)
+
+// ListEnvironmentTemplatesHandler handles requests to the /apps/templates endpoint
+type ListEnvironmentTemplatesHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+// NewListEnvironmentTemplatesHandler returns a new ListEnvironmentTemplatesHandler
+func NewListEnvironmentTemplatesHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ListEnvironmentTemplatesHandler {
+	return &ListEnvironmentTemplatesHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+// Environment is a partially encoded environment object
+type Environment struct {
+	// Name is the name of the environment
+	Name string `json:"name"`
+	// Base64 Apps is a list of apps that are deployed in the environment
+	Base64Apps []string `json:"base64_apps,omitempty"`
+	// Base64Addons is a list of encoded addons that are deployed in the environment
+	Base64Addons []string `json:"base64_addons,omitempty"`
+}
+
+// ListEnvironmentTemplatesResponse represents the response from the /apps/templates endpoint
+type ListEnvironmentTemplatesResponse struct {
+	EnvironmentTemplates []Environment `json:"environment_templates,omitempty"`
+}
+
+// ServeHTTP lists all environment templates
+func (c *ListEnvironmentTemplatesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-list-environment-templates")
+	defer span.End()
+
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	listTemplatesReq := connect.NewRequest(&porterv1.ListTemplatesRequest{
+		ProjectId: int64(project.ID),
+		ClusterId: int64(cluster.ID),
+	})
+
+	listTemplatesResp, err := c.Config().ClusterControlPlaneClient.ListTemplates(ctx, listTemplatesReq)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error listing templates")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	if listTemplatesResp == nil || listTemplatesResp.Msg == nil {
+		err = telemetry.Error(ctx, span, nil, "list templates response is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	// get all apps for each environment
+	var envTemplates []Environment
+
+	for _, env := range listTemplatesResp.Msg.EnvironmentTemplates {
+		var encodedApps []string
+		for _, app := range env.Apps {
+			encoded, err := base64EncodeContractObject(ctx, app)
+			if err != nil {
+				err = telemetry.Error(ctx, span, err, "error encoding app")
+				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+				return
+			}
+
+			encodedApps = append(encodedApps, encoded)
+		}
+
+		var encodedAddons []string
+		for _, addon := range env.Addons {
+			encoded, err := base64EncodeContractObject(ctx, addon)
+			if err != nil {
+				err = telemetry.Error(ctx, span, err, "error encoding addon")
+				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+				return
+			}
+
+			encodedAddons = append(encodedAddons, encoded)
+		}
+
+		envTemplates = append(envTemplates, Environment{
+			Name:         env.Name,
+			Base64Apps:   encodedApps,
+			Base64Addons: encodedAddons,
+		})
+	}
+
+	res := ListEnvironmentTemplatesResponse{
+		EnvironmentTemplates: envTemplates,
+	}
+
+	c.WriteResult(w, r, res)
+}
+
+func base64EncodeContractObject(ctx context.Context, pc protoreflect.ProtoMessage) (string, error) {
+	by, err := helpers.MarshalContractObject(ctx, pc)
+	if err != nil {
+		return "", err
+	}
+
+	return base64.StdEncoding.EncodeToString(by), nil
+}

+ 29 - 0
api/server/router/porter_app.go

@@ -1589,6 +1589,35 @@ func getPorterAppRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/apps/templates -> porter_app.NewListEnvironmentTemplatesHandler
+	listEnvironmentTemplatesEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbList,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/templates", relPathV2),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	listEnvironmentTemplatesHandler := porter_app.NewListEnvironmentTemplatesHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: listEnvironmentTemplatesEndpoint,
+		Handler:  listEnvironmentTemplatesHandler,
+		Router:   r,
+	})
+
 	// GET /api/projects/{project_id}/clusters/{cluster_id}/apps/{porter_app_name}/templates -> porter_app.NewGetAppTemplateHandler
 	getAppTemplateEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 11 - 14
dashboard/package-lock.json

@@ -100,7 +100,7 @@
         "@babel/preset-typescript": "^7.15.0",
         "@ianvs/prettier-plugin-sort-imports": "^4.1.1",
         "@pmmmwh/react-refresh-webpack-plugin": "^0.4.3",
-        "@porter-dev/api-contracts": "^0.2.155",
+        "@porter-dev/api-contracts": "file:../../api-contracts/generated/js",
         "@testing-library/jest-dom": "^4.2.4",
         "@testing-library/react": "^9.3.2",
         "@testing-library/user-event": "^7.1.2",
@@ -173,6 +173,14 @@
         "npm": "9.7.2"
       }
     },
+    "../../api-contracts/generated/js": {
+      "version": "0.2.7",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@bufbuild/protobuf": "^1.1.0"
+      }
+    },
     "node_modules/@aashutoshrathi/word-wrap": {
       "version": "1.2.6",
       "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz",
@@ -2076,12 +2084,6 @@
         "node": ">=6.9.0"
       }
     },
-    "node_modules/@bufbuild/protobuf": {
-      "version": "1.8.0",
-      "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.8.0.tgz",
-      "integrity": "sha512-qR9FwI8QKIveDnUYutvfzbC21UZJJryYrLuZGjeZ/VGz+vXelUkK+xgkOHsvPEdYEdxtgUUq4313N8QtOehJ1Q==",
-      "dev": true
-    },
     "node_modules/@discoveryjs/json-ext": {
       "version": "0.5.7",
       "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz",
@@ -2786,13 +2788,8 @@
       }
     },
     "node_modules/@porter-dev/api-contracts": {
-      "version": "0.2.155",
-      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.2.155.tgz",
-      "integrity": "sha512-Tar/IsKoUSmz8Q8Fw9ozflrAI+yAGzOIdx5WmZ5iCSCkvudSLnDp7xQ0po/traPzYLUldZjaNsw0KKXnOb1myQ==",
-      "dev": true,
-      "dependencies": {
-        "@bufbuild/protobuf": "^1.1.0"
-      }
+      "resolved": "../../api-contracts/generated/js",
+      "link": true
     },
     "node_modules/@react-spring/animated": {
       "version": "9.6.1",

+ 1 - 1
dashboard/package.json

@@ -107,7 +107,7 @@
     "@babel/preset-typescript": "^7.15.0",
     "@ianvs/prettier-plugin-sort-imports": "^4.1.1",
     "@pmmmwh/react-refresh-webpack-plugin": "^0.4.3",
-    "@porter-dev/api-contracts": "^0.2.155",
+    "@porter-dev/api-contracts": "file:../../api-contracts/generated/js",
     "@testing-library/jest-dom": "^4.2.4",
     "@testing-library/react": "^9.3.2",
     "@testing-library/user-event": "^7.1.2",

+ 1 - 0
dashboard/src/assets/plus-square.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-plus"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M8 12h8"/><path d="M12 8v8"/></svg>

+ 12 - 0
dashboard/src/lib/environments/types.ts

@@ -0,0 +1,12 @@
+import { z } from "zod";
+
+import { clientAddonValidator } from "lib/addons";
+import { clientAppValidator } from "lib/porter-apps";
+
+const environmentValidator = z.object({
+  name: z.string(),
+  apps: z.array(clientAppValidator),
+  addons: z.array(clientAddonValidator),
+});
+
+export type Environment = z.infer<typeof environmentValidator>;

+ 87 - 0
dashboard/src/lib/hooks/useTemplateEnvs.ts

@@ -0,0 +1,87 @@
+import { useContext, useMemo } from "react";
+import { Addon, PorterApp } from "@porter-dev/api-contracts";
+import { useQuery } from "@tanstack/react-query";
+import { z } from "zod";
+
+import { clientAddonFromProto } from "lib/addons";
+import { type Environment } from "lib/environments/types";
+import { clientAppFromProto } from "lib/porter-apps";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+
+type TUseTemplateEnvs = {
+  environments: Environment[];
+  status: "error" | "success" | "loading";
+};
+
+const listTemplateEnvsResValidator = z.object({
+  name: z.string(),
+  base64_apps: z.string().array().default([]),
+  base64_addons: z.string().array().default([]),
+});
+
+export const useTemplateEnvs = (): TUseTemplateEnvs => {
+  const { currentProject, currentCluster } = useContext(Context);
+
+  const { data: encodedEnvironments = [], status } = useQuery(
+    ["listTemplateEnvironments", currentProject?.id, currentCluster?.id],
+    async () => {
+      if (!currentProject || !currentCluster) {
+        return [];
+      }
+
+      const res = await api.listTemplateEnvironments(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      );
+
+      const data = await z
+        .object({
+          environment_templates: z
+            .array(listTemplateEnvsResValidator)
+            .default([]),
+        })
+        .parseAsync(res.data);
+      return data.environment_templates;
+    },
+    {
+      enabled: !!currentProject && !!currentCluster,
+    }
+  );
+
+  const environments = useMemo(() => {
+    return encodedEnvironments.map((env) => {
+      const apps = env.base64_apps.map((a) =>
+        clientAppFromProto({
+          proto: PorterApp.fromJsonString(atob(a), {
+            ignoreUnknownFields: true,
+          }),
+          overrides: null,
+        })
+      );
+      const addons = env.base64_addons.map((a) =>
+        clientAddonFromProto({
+          addon: Addon.fromJsonString(atob(a), {
+            ignoreUnknownFields: true,
+          }),
+        })
+      );
+
+      return {
+        name: env.name,
+        apps,
+        addons,
+      };
+    });
+  }, [encodedEnvironments]);
+
+  return {
+    environments,
+    status,
+  };
+};

+ 4 - 1
dashboard/src/lib/porter-apps/build.ts

@@ -1,6 +1,7 @@
-import { buildpackSchema } from "main/home/app-dashboard/types/buildpack";
 import { z } from "zod";
 
+import { buildpackSchema } from "main/home/app-dashboard/types/buildpack";
+
 // buildValidator is used to validate inputs for build setting fields
 export const buildValidator = z.discriminatedUnion("method", [
   z.object({
@@ -8,11 +9,13 @@ export const buildValidator = z.discriminatedUnion("method", [
     context: z.string().min(1).default("./").catch("./"),
     buildpacks: z.array(buildpackSchema).default([]),
     builder: z.string(),
+    repo: z.string().optional(),
   }),
   z.object({
     method: z.literal("docker"),
     context: z.string().min(1).default("./").catch("./"),
     dockerfile: z.string().min(1).default("./Dockerfile").catch("./Dockerfile"),
+    repo: z.string().optional(),
   }),
 ]);
 export type BuildOptions = z.infer<typeof buildValidator>;

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

@@ -350,6 +350,7 @@ const clientBuildToProto = (build: BuildOptions): Build => {
           context: b.context,
           buildpacks: b.buildpacks.map((b) => b.buildpack),
           builder: b.builder,
+          repo: b.repo,
         })
     )
     .with(
@@ -359,6 +360,7 @@ const clientBuildToProto = (build: BuildOptions): Build => {
           method: "docker",
           context: b.context,
           dockerfile: b.dockerfile,
+          repo: b.repo,
         })
     )
     .exhaustive();
@@ -479,11 +481,13 @@ const clientBuildFromProto = (proto?: Build): BuildOptions | undefined => {
         context: z.string(),
         buildpacks: z.array(z.string()).default([]),
         builder: z.string(),
+        repo: z.string().optional(),
       }),
       z.object({
         method: z.literal("docker"),
         context: z.string(),
         dockerfile: z.string(),
+        repo: z.string().optional(),
       }),
     ])
     .safeParse(proto);
@@ -504,6 +508,7 @@ const clientBuildFromProto = (proto?: Build): BuildOptions | undefined => {
           buildpack: b,
         })),
         builder: b.builder,
+        repo: b.repo,
       })
     )
     .with({ method: "docker" }, (b) =>
@@ -511,6 +516,7 @@ const clientBuildFromProto = (proto?: Build): BuildOptions | undefined => {
         method: b.method,
         context: b.context,
         dockerfile: b.dockerfile,
+        repo: b.repo,
       })
     )
     .exhaustive();

+ 12 - 2
dashboard/src/main/home/app-dashboard/apps/AppGrid.tsx

@@ -134,7 +134,12 @@ const AppGrid: React.FC<AppGridProps> = ({
                   </Container>
                   {/** TODO: make the status icon dynamic */}
                   {/* <StatusIcon src={healthy} /> */}
-                  <AppSource source={source} />
+                  <AppSource
+                    source={{
+                      from: "porter_apps",
+                      details: source,
+                    }}
+                  />
                   {currentProject?.managed_deployment_targets_enabled &&
                     !currentDeploymentTarget?.is_preview && (
                       <Container row>
@@ -188,7 +193,12 @@ const AppGrid: React.FC<AppGridProps> = ({
                   </Container>
                   <Spacer height="15px" />
                   <Container row>
-                    <AppSource source={source} />
+                    <AppSource
+                      source={{
+                        from: "porter_apps",
+                        details: source,
+                      }}
+                    />
                     <Spacer inline x={1} />
                     <SmallIcon opacity="0.4" src={time} />
                     <Text size={13} color="#ffffff44">

+ 43 - 6
dashboard/src/main/home/app-dashboard/apps/AppMeta.tsx

@@ -4,6 +4,7 @@ import styled from "styled-components";
 import Container from "components/porter/Container";
 import Icon from "components/porter/Icon";
 import Text from "components/porter/Text";
+import { type ClientPorterApp } from "lib/porter-apps";
 
 import box from "assets/box.png";
 import git_scm from "assets/git-scm.svg";
@@ -26,13 +27,49 @@ type IconProps = {
 };
 
 type SourceProps = {
-  source: AppRevisionWithSource["source"];
+  source:
+    | {
+        from: "porter_apps";
+        details: AppRevisionWithSource["source"];
+      }
+    | {
+        from: "app_contract";
+        details: ClientPorterApp;
+      };
 };
 
 export const AppSource: React.FC<SourceProps> = ({ source }) => {
+  if (source.from === "app_contract") {
+    const build = source.details.build;
+    const repoFullName = build.repo
+      ? new URL(build.repo).pathname.substring(1)
+      : "";
+    return (
+      <>
+        {build.repo ? (
+          <Container row>
+            <SmallIcon opacity="0.6" src={github} />
+            <Text truncate={true} size={13} color="#ffffff44">
+              {repoFullName.endsWith(".git")
+                ? repoFullName.slice(0, -4)
+                : repoFullName}
+            </Text>
+          </Container>
+        ) : (
+          <Container row>
+            <SmallIcon opacity="0.6" height="18px" src={box} />
+            <Text truncate={true} size={13} color="#ffffff44">
+              {source.details.name.value}
+            </Text>
+          </Container>
+        )}
+      </>
+    );
+  }
+
   return (
     <>
-      {source.image_repo_uri ? (
+      {source.details.image_repo_uri ? (
         <Container row>
           <SmallIcon
             opacity="0.7"
@@ -40,21 +77,21 @@ export const AppSource: React.FC<SourceProps> = ({ source }) => {
             src="https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/97_Docker_logo_logos-512.png"
           />
           <Text truncate={true} size={13} color="#ffffff44">
-            {source.image_repo_uri}
+            {source.details.image_repo_uri}
           </Text>
         </Container>
-      ) : source.repo_name ? (
+      ) : source.details.repo_name ? (
         <Container row>
           <SmallIcon opacity="0.6" src={github} />
           <Text truncate={true} size={13} color="#ffffff44">
-            {source.repo_name}
+            {source.details.repo_name}
           </Text>
         </Container>
       ) : (
         <Container row>
           <SmallIcon src={git_scm} />
           <Text truncate={true} size={13} color="#ffffff44">
-            {source.name}
+            {source.details.name}
           </Text>
         </Container>
       )}

+ 6 - 1
dashboard/src/main/home/app-dashboard/apps/SelectableAppList.tsx

@@ -49,7 +49,12 @@ const SelectableAppList: React.FC<AppListProps> = ({ appListItems }) => {
               </Container>
               <Spacer height="15px" />
               <Container row>
-                <AppSource source={ali.app.source} />
+                <AppSource
+                  source={{
+                    from: "porter_apps",
+                    details: ali.app.source,
+                  }}
+                />
                 <Spacer inline x={1} />
               </Container>
             </>

+ 94 - 40
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/ConfigurableAppList.tsx

@@ -1,77 +1,131 @@
-import React, { useContext } from "react";
+import React, { useContext, useMemo, useState } from "react";
 import { useHistory } from "react-router";
 import styled from "styled-components";
 
 import Loading from "components/Loading";
 import Button from "components/porter/Button";
-import Fieldset from "components/porter/Fieldset";
+import Container from "components/porter/Container";
+import DashboardPlaceholder from "components/porter/DashboardPlaceholder";
+import Image from "components/porter/Image";
+import SearchBar from "components/porter/SearchBar";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
-import { useLatestAppRevisions } from "lib/hooks/useLatestAppRevisions";
+import { useAppInstances } from "lib/hooks/useLatestAppRevisions";
+import { useTemplateEnvs } from "lib/hooks/useTemplateEnvs";
 
 import { Context } from "shared/Context";
+import add from "assets/plus-square.svg";
 
 import { ConfigurableAppRow } from "./ConfigurableAppRow";
 
 export const ConfigurableAppList: React.FC = () => {
   const history = useHistory();
   const queryParams = new URLSearchParams(window.location.search);
+  const [searchValue, setSearchValue] = useState("");
 
   const { currentProject, currentCluster } = useContext(Context);
 
-  const { revisions: apps } = useLatestAppRevisions({
+  const { instances: appInstances } = useAppInstances({
     projectId: currentProject?.id ?? 0,
     clusterId: currentCluster?.id ?? 0,
   });
 
-  if (apps.length === 0) {
-    return (
-      <Fieldset>
-        <CentralContainer>
-          <Text size={16}>No apps have been deployed yet.</Text>
-          <Spacer y={1} />
+  const { environments, status } = useTemplateEnvs();
 
-          <Text color={"helper"}>Get started by creating a new app.</Text>
-          <Spacer y={1} />
-          <Button
-            onClick={() => {
-              history.push("/apps/new/app");
-            }}
-          >
-            Create App
-          </Button>
-        </CentralContainer>
-      </Fieldset>
+  const envsWithExistingAppInstance = useMemo(() => {
+    return environments
+      .map((env) => {
+        const existingAppInstance = appInstances.find(
+          (inst) => inst.name === env.name
+        );
+
+        return {
+          ...env,
+          existingAppInstance,
+        };
+      })
+      .filter((ev) => ev.name.includes(searchValue));
+  }, [environments, appInstances, searchValue]);
+
+  if (status === "loading") {
+    return <Loading offset="-150px" />;
+  }
+
+  if (appInstances.length === 0) {
+    return (
+      <DashboardPlaceholder>
+        <Text size={16}>No apps have been deployed yet.</Text>
+        <Spacer y={0.5} />
+        <Text color={"helper"}>Get started by creating a new app.</Text>
+        <Spacer y={1} />
+        <Button
+          alt
+          height="35px"
+          onClick={() => {
+            history.push("/apps/new/app");
+          }}
+        >
+          Create App
+        </Button>
+      </DashboardPlaceholder>
     );
   }
 
   return (
-    <List>
-      {apps.map((a) => (
-        <ConfigurableAppRow
-          key={a.source.id}
-          setEditingApp={() => {
-            queryParams.set("target", a.app_revision.deployment_target.id);
-            queryParams.set("app_name", a.source.name);
+    <>
+      <Container row spaced>
+        <SearchBar
+          value={searchValue}
+          setValue={(x) => {
+            setSearchValue(x);
+          }}
+          placeholder="Search environment templates . . ."
+          width="100%"
+        />
+        <Spacer inline x={1} />
+        <Button
+          onClick={() => {
             history.push({
               pathname: "/preview-environments/configure",
-              search: queryParams.toString(),
             });
           }}
-          app={a}
-        />
-      ))}
-    </List>
+          height="30px"
+          width="140px"
+        >
+          <Container row>
+            <Image src={add} size={12} />
+            <Spacer inline x={0.5} />
+            <Text>New Template</Text>
+          </Container>
+        </Button>
+      </Container>
+      <Spacer y={1} />
+      <List>
+        {envsWithExistingAppInstance.map((ev) => (
+          <ConfigurableAppRow
+            key={ev.name}
+            setEditingApp={() => {
+              queryParams.set("app_name", ev.name);
+              if (ev.existingAppInstance?.deployment_target.id) {
+                queryParams.set(
+                  "target",
+                  ev.existingAppInstance.deployment_target.id.toString()
+                );
+              }
+
+              history.push({
+                pathname: "/preview-environments/configure",
+                search: queryParams.toString(),
+              });
+            }}
+            env={ev}
+          />
+        ))}
+      </List>
+    </>
   );
 };
 
-const CentralContainer = styled.div`
-  display: flex;
-  flex-direction: column;
-  justify-content: left;
-  align-items: left;
-`;
-
 const List = styled.div`
   overflow: hidden;
 `;

+ 69 - 61
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/ConfigurableAppRow.tsx

@@ -1,88 +1,96 @@
-import React, { useMemo } from "react";
-import { PorterApp } from "@porter-dev/api-contracts";
+import React from "react";
+import pluralize from "pluralize";
 import styled from "styled-components";
 
 import Container from "components/porter/Container";
+import Icon from "components/porter/Icon";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
-import { AppIcon, AppSource } from "main/home/app-dashboard/apps/AppMeta";
-import { type AppRevisionWithSource } from "main/home/app-dashboard/apps/types";
+import { AppSource } from "main/home/app-dashboard/apps/AppMeta";
+import { type Environment } from "lib/environments/types";
 
-import settings from "assets/settings.svg";
+import addOns from "assets/add-ons.svg";
+import database from "assets/database.svg";
 
 type Props = {
-  app: AppRevisionWithSource;
+  env: Environment;
   setEditingApp: () => void;
 };
 
-export const ConfigurableAppRow: React.FC<Props> = ({ app, setEditingApp }) => {
-  const proto = useMemo(() => {
-    return PorterApp.fromJsonString(atob(app.app_revision.b64_app_proto), {
-      ignoreUnknownFields: true,
-    });
-  }, [app.app_revision.b64_app_proto]);
+export const ConfigurableAppRow: React.FC<Props> = ({ env, setEditingApp }) => {
+  const firstApp = env.apps?.[0];
+
+  if (!firstApp) {
+    return null;
+  }
 
   return (
-    <Row>
-      <div>
-        <Container row>
-          <Spacer inline width="1px" />
-          <AppIcon buildpacks={proto.build?.buildpacks ?? []} />
-          <Spacer inline width="12px" />
-          <Text size={14}>{proto.name}</Text>
-          <Spacer inline x={1} />
-        </Container>
-        <Spacer height="15px" />
+    <Row
+      onClick={() => {
+        setEditingApp();
+      }}
+    >
+      <Container row>
+        <Spacer inline width="1px" />
+        <Icon src={addOns} height="18px" />
+        <Spacer inline width="12px" />
+        <Text size={14}>{env.name}</Text>
+        <Spacer inline x={1} />
+      </Container>
+      <Spacer height="15px" />
+      <Container row>
+        <AppSource
+          source={{
+            from: "app_contract",
+            details: firstApp,
+          }}
+        />
+        <Spacer inline x={1} />
         <Container row>
-          <AppSource source={app.source} />
-          <Spacer inline x={1} />
+          <DBIcon opacity="0.6" src={database} />
+          <Text truncate={true} size={13} color="#ffffff44">
+            {`${env.addons.length > 0 ? env.addons.length : "No"} ${pluralize(
+              "datastore",
+              env.addons.length
+            )} included`}
+          </Text>
         </Container>
-      </div>
-      <div
-        style={{
-          display: "flex",
-          alignItems: "center",
-        }}
-      >
-        <SettingsButton
-          onClick={() => {
-            setEditingApp();
-          }}
-        >
-          <img src={settings} />
-          <Spacer inline x={0.5} />
-          Update Previews
-        </SettingsButton>
-      </div>
+      </Container>
     </Row>
   );
 };
 
-const SettingsButton = styled.button`
-  background: ${(props) => props.theme.fg};
-  padding: 8px 12px;
-  border-radius: 5px;
-  border: 1px solid #494b4f;
+export const Row = styled.div`
   cursor: pointer;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-
-  :hover {
-    filter: brightness(120%);
-  }
-`;
-
-const Row = styled.div<{ isAtBottom?: boolean }>`
   padding: 15px;
-  border-bottom: ${(props) =>
-    props.isAtBottom ? "none" : "1px solid #494b4f"};
-  background: ${({ theme }) => theme.fg};
+  border-bottom: 1px solid #494b4f;
+  background: ${(props) => props.theme.clickable.bg};
   position: relative;
   border: 1px solid #494b4f;
   border-radius: 5px;
   margin-bottom: 15px;
+
+  transition: all 0.2s;
+
+  :hover {
+    border: 1px solid #7a7b80;
+  }
+
   animation: fadeIn 0.3s 0s;
-  display: flex;
-  justify-content: space-between;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const DBIcon = styled.img<{ opacity?: string; height?: string }>`
+  margin-left: 2px;
+  height: ${(props) => props.height || "14px"};
+  opacity: ${(props) => props.opacity || 1};
+  filter: grayscale(100%);
+  margin-right: 10px;
 `;

+ 5 - 8
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/DeleteEnvModal.tsx

@@ -1,11 +1,10 @@
+import React from "react";
+
 import Button from "components/porter/Button";
 import Modal from "components/porter/Modal";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 
-import React from "react";
-import styled from "styled-components";
-
 type Props = {
   closeModal: () => void;
   deleteEnv: () => Promise<void>;
@@ -32,7 +31,9 @@ const DeleteEnvModal: React.FC<Props> = ({
       </Text>
       <Spacer y={1} />
       <Button
-        onClick={() => deleteEnv()}
+        onClick={() => {
+          void deleteEnv();
+        }}
         color="#b91133"
         status={loading ? "loading" : ""}
         loadingText="Deleting..."
@@ -45,7 +46,3 @@ const DeleteEnvModal: React.FC<Props> = ({
 };
 
 export default DeleteEnvModal;
-
-const Code = styled.span`
-  font-family: monospace;
-`;

+ 33 - 50
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/PreviewEnvGrid.tsx

@@ -11,7 +11,7 @@ import { match } from "ts-pattern";
 
 import Button from "components/porter/Button";
 import Container from "components/porter/Container";
-import Fieldset from "components/porter/Fieldset";
+import DashboardPlaceholder from "components/porter/DashboardPlaceholder";
 import Icon from "components/porter/Icon";
 import SearchBar from "components/porter/SearchBar";
 import Spacer from "components/porter/Spacer";
@@ -22,7 +22,6 @@ import type { DeploymentTarget } from "lib/hooks/useDeploymentTarget";
 import { search } from "shared/search";
 import { readableDate } from "shared/string_utils";
 import calendar from "assets/calendar-number.svg";
-import notFound from "assets/not-found.png";
 import pull_request from "assets/pull_request_icon.svg";
 import healthy from "assets/status-healthy.png";
 import time from "assets/time.png";
@@ -58,40 +57,23 @@ const PreviewEnvGrid: React.FC<PreviewEnvGridProps> = ({
 
   if (deploymentTargets.length === 0) {
     return (
-      <Fieldset>
-        <CentralContainer>
-          <Text size={16}>No preview environments have been deployed yet.</Text>
-          <Spacer y={1} />
-
-          <Text color={"helper"}>
-            Get started by enabling preview envs for your apps.
-          </Text>
-          <Spacer y={1} />
-          <Button
-            onClick={() => {
-              setTab("config");
-            }}
-          >
-            Configure Preview Environments
-          </Button>
-        </CentralContainer>
-      </Fieldset>
-    );
-  }
-
-  if (filteredEnvs.length === 0) {
-    let copy =
-      "No preview environments exist. To get started with preview environments, enable them in the Settings tab of an existing application.";
-    if (searchValue !== "") {
-      copy = "No matching environments were found.";
-    }
-    return (
-      <Fieldset>
-        <Container row>
-          <PlaceholderIcon src={notFound} />
-          <Text color="helper">{copy}</Text>
-        </Container>
-      </Fieldset>
+      <DashboardPlaceholder>
+        <Text size={16}>No preview environments have been deployed yet.</Text>
+        <Spacer y={0.5} />
+        <Text color={"helper"}>
+          Get started by enabling preview envs for your apps.
+        </Text>
+        <Spacer y={1} />
+        <Button
+          alt
+          height="35px"
+          onClick={() => {
+            setTab("config");
+          }}
+        >
+          Set up
+        </Button>
+      </DashboardPlaceholder>
     );
   }
 
@@ -106,7 +88,7 @@ const PreviewEnvGrid: React.FC<PreviewEnvGridProps> = ({
           placeholder="Search environments . . ."
           width="100%"
         />
-        <Spacer inline x={2} />
+        <Spacer inline x={1} />
         <Toggle
           items={[
             { label: <ToggleIcon src={calendar} />, value: "calendar" },
@@ -157,12 +139,6 @@ const PreviewEnvGrid: React.FC<PreviewEnvGridProps> = ({
 
 export default PreviewEnvGrid;
 
-const PlaceholderIcon = styled.img`
-  height: 13px;
-  margin-right: 12px;
-  opacity: 0.65;
-`;
-
 const List = styled.div`
   overflow: hidden;
 `;
@@ -177,7 +153,21 @@ const Row = styled.div<{ isAtBottom?: boolean }>`
   border: 1px solid #494b4f;
   border-radius: 5px;
   margin-bottom: 15px;
+  transition: all 0.2s;
+
+  :hover {
+    border: 1px solid #7a7b80;
+  }
+
   animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
 `;
 
 const SmallIcon = styled.img<{ opacity?: string; height?: string }>`
@@ -193,10 +183,3 @@ const ToggleIcon = styled.img`
   margin: 0 5px;
   min-width: 12px;
 `;
-
-const CentralContainer = styled.div`
-  display: flex;
-  flex-direction: column;
-  justify-content: left;
-  align-items: left;
-`;

+ 6 - 4
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/PreviewEnvs.tsx

@@ -55,7 +55,9 @@ const PreviewEnvs: React.FC = () => {
     if (currentProject?.sandbox_enabled) {
       return (
         <DashboardPlaceholder>
-          <Text size={16}>Preview apps are coming soon to the Porter Cloud</Text>
+          <Text size={16}>
+            Preview apps are coming soon to the Porter Cloud
+          </Text>
           <Spacer y={0.5} />
           <Text color={"helper"}>
             You can also eject to your own cloud account to start using preview
@@ -64,7 +66,7 @@ const PreviewEnvs: React.FC = () => {
           <Spacer y={1} />
           <PorterLink to="https://docs.porter.run/other/eject">
             <Button alt height="35px">
-             Eject to AWS, Azure, or GCP
+              Eject to AWS, Azure, or GCP
             </Button>
           </PorterLink>
         </DashboardPlaceholder>
@@ -96,8 +98,8 @@ const PreviewEnvs: React.FC = () => {
         <TabSelector
           noBuffer
           options={[
-            { label: "Existing Previews", value: "environments" },
-            { label: "Preview Templates", value: "config" },
+            { label: "Current Previews", value: "environments" },
+            { label: "Setup", value: "config" },
           ]}
           currentTab={tab}
           setCurrentTab={(tab: string) => {

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

@@ -0,0 +1,176 @@
+import React, {
+  useContext,
+  useMemo,
+  useState,
+  type Dispatch,
+  type SetStateAction,
+} from "react";
+import { PorterApp } from "@porter-dev/api-contracts";
+import styled from "styled-components";
+
+import SearchBar from "components/porter/SearchBar";
+import { AppIcon } from "main/home/app-dashboard/apps/AppMeta";
+import { type AppInstance } from "main/home/app-dashboard/apps/types";
+import { useLatestAppRevisions } from "lib/hooks/useLatestAppRevisions";
+
+import { Context } from "shared/Context";
+import { search } from "shared/search";
+
+type Props = {
+  selectedApp: AppInstance | null;
+  setSelectedApp: Dispatch<SetStateAction<AppInstance | null>>;
+};
+
+export const AppSelector: React.FC<Props> = ({
+  selectedApp,
+  setSelectedApp,
+}) => {
+  const { currentProject, currentCluster } = useContext(Context);
+  const [searchValue, setSearchValue] = useState("");
+
+  const { revisions: apps } = useLatestAppRevisions({
+    projectId: currentProject?.id ?? 0,
+    clusterId: currentCluster?.id ?? 0,
+  });
+
+  const filteredApps = useMemo(() => {
+    const withProto = apps.map((app) => {
+      return {
+        ...app,
+        app_revision: {
+          ...app.app_revision,
+          proto: PorterApp.fromJsonString(
+            atob(app.app_revision.b64_app_proto),
+            {
+              ignoreUnknownFields: true,
+            }
+          ),
+        },
+      };
+    });
+
+    return search(withProto, searchValue, {
+      keys: ["app_revision.proto.name"],
+      isCaseSensitive: false,
+    });
+  }, [apps, searchValue]);
+
+  return (
+    <ExpandedWrapper>
+      <div style={{ display: "flex", marginBottom: "10px" }}>
+        <SearchBar
+          value={searchValue}
+          setValue={setSearchValue}
+          placeholder={"Search apps . . ."}
+          width="100%"
+        />
+      </div>
+      <ListContainer>
+        <ListWrapper>
+          {filteredApps.map((app, i) => {
+            return (
+              <AppItem
+                key={i}
+                onClick={() => {
+                  setSelectedApp({
+                    id: app.app_revision.app_instance_id,
+                    name: app.source.name,
+                    deployment_target: {
+                      id: app.app_revision.deployment_target.id,
+                      name: app.app_revision.deployment_target.name,
+                    },
+                  });
+                }}
+                isSelected={selectedApp?.name === app.app_revision.proto.name}
+              >
+                <AppIcon
+                  buildpacks={app.app_revision.proto.build?.buildpacks ?? []}
+                />
+                {app.source.name}
+              </AppItem>
+            );
+          })}
+        </ListWrapper>
+      </ListContainer>
+    </ExpandedWrapper>
+  );
+};
+
+const ExpandedWrapper = styled.div`
+  margin-top: 10px;
+  width: 100%;
+  border-radius: 3px;
+  max-height: 275px;
+`;
+
+const ListContainer = styled.div`
+  border: 1px solid #ffffff55;
+  border-radius: 3px;
+  overflow-y: auto;
+`;
+
+const ListWrapper = styled.div`
+  width: 100%;
+  border-radius: 3px;
+  border: 0px solid #ffffff44;
+  max-height: 221px;
+  top: 40px;
+
+  > i {
+    font-size: 18px;
+    display: block;
+    position: absolute;
+    left: 10px;
+    top: 10px;
+  }
+`;
+
+const AppItem = styled.div<{
+  isSelected?: boolean;
+  readOnly?: boolean;
+  disabled?: boolean;
+  lastItem?: boolean;
+}>`
+  display: flex;
+  width: 100%;
+  font-size: 13px;
+  border-bottom: 1px solid
+    ${(props) => (props.lastItem ? "#00000000" : "#606166")};
+  color: ${(props) => (props.disabled ? "#ffffff88" : "#ffffff")};
+  user-select: none;
+  align-items: center;
+  padding: 10px 0px;
+  cursor: ${(props) =>
+    props.readOnly || props.disabled ? "default" : "pointer"};
+  pointer-events: ${(props) =>
+    props.readOnly || props.disabled ? "none" : "auto"};
+
+  ${(props) => {
+    if (props.disabled) {
+      return "";
+    }
+
+    if (props.isSelected) {
+      return `background: #ffffff22;`;
+    }
+
+    return `background: #ffffff11;`;
+  }}
+
+  :hover {
+    background: #ffffff22;
+
+    > i {
+      background: #ffffff22;
+    }
+  }
+
+  > img,
+  i {
+    width: 18px;
+    height: 18px;
+    margin-left: 12px;
+    margin-right: 12px;
+    font-size: 20px;
+  }
+`;

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

@@ -0,0 +1,54 @@
+import React from "react";
+import { useFormContext } from "react-hook-form";
+
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import ServiceList from "main/home/app-dashboard/validate-apply/services-settings/ServiceList";
+
+import { useDeploymentTarget } from "shared/DeploymentTargetContext";
+
+import { type AppTemplateFormData } from "./PreviewAppDataContainer";
+
+export const ConsolidatedServices: React.FC = () => {
+  const { watch } = useFormContext<AppTemplateFormData>();
+  const { currentDeploymentTarget } = useDeploymentTarget();
+
+  const initialDeploy = watch("app.initialDeploy");
+  const predeploy = watch("app.predeploy");
+  const name = watch("app.name");
+
+  return (
+    <>
+      <Text size={16}>Initial deploy job</Text>
+      <Spacer y={0.5} />
+      <ServiceList
+        addNewText={"Add a new initial deploy job"}
+        existingServiceNames={initialDeploy ? ["initdeploy"] : []}
+        lifecycleJobType="initdeploy"
+        fieldArrayName={"app.initialDeploy"}
+      />
+      <Spacer y={0.5} />
+      <Text size={16}>Pre-deploy job</Text>
+      <Spacer y={0.5} />
+      <ServiceList
+        addNewText={"Add a new pre-deploy job"}
+        existingServiceNames={predeploy ? ["pre-deploy"] : []}
+        lifecycleJobType="predeploy"
+        fieldArrayName={"app.predeploy"}
+      />
+      <Spacer y={0.5} />
+      <Text size={16}>Application services</Text>
+      <Spacer y={0.5} />
+      <ServiceList
+        addNewText={"Add a new service"}
+        fieldArrayName={"app.services"}
+        internalNetworkingDetails={{
+          namespace: currentDeploymentTarget?.namespace || "default",
+          appName: name.value,
+        }}
+        allowAddServices={false}
+      />
+      <Spacer y={0.75} />
+    </>
+  );
+};

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

@@ -0,0 +1,409 @@
+import React, {
+  useCallback,
+  useContext,
+  useEffect,
+  useMemo,
+  useState,
+} from "react";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { type PorterApp } from "@porter-dev/api-contracts";
+import axios from "axios";
+import _ from "lodash";
+import { FormProvider, useForm } from "react-hook-form";
+import { useHistory } from "react-router";
+import styled from "styled-components";
+import { match } from "ts-pattern";
+
+import Back from "components/porter/Back";
+import Button from "components/porter/Button";
+import Error from "components/porter/Error";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import VerticalSteps from "components/porter/VerticalSteps";
+import { LatestRevisionProvider } from "main/home/app-dashboard/app-view/LatestRevisionContext";
+import { type AppInstance } from "main/home/app-dashboard/apps/types";
+import EnvSettings from "main/home/app-dashboard/validate-apply/app-settings/EnvSettings";
+import DashboardHeader from "main/home/cluster-dashboard/DashboardHeader";
+import { AddonsList } from "main/home/managed-addons/AddonsList";
+import { clientAddonToProto } from "lib/addons";
+import { usePorterYaml } from "lib/hooks/usePorterYaml";
+import { clientAppToProto } from "lib/porter-apps";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { valueExists } from "shared/util";
+import addOns from "assets/add-ons.svg";
+
+import { AppSelector } from "./AppSelector";
+import { ConsolidatedServices } from "./ConsolidatedServices";
+import {
+  appTemplateClientValidator,
+  type AppTemplateFormData,
+  type EncodedAddonWithEnv,
+} from "./PreviewAppDataContainer";
+import { PreviewGHAModal } from "./PreviewGHAModal";
+import { RevisionLoader } from "./RevisionLoader";
+
+export const CreateTemplate: React.FC = () => {
+  const { currentProject, currentCluster } = useContext(Context);
+  const history = useHistory();
+
+  const [step, setStep] = useState(0);
+  const [selectedApp, setSelectedApp] = useState<AppInstance | null>(null);
+  const [validatedAppProto, setValidatedAppProto] = useState<PorterApp | null>(
+    null
+  );
+  const [createError, setCreateError] = useState("");
+  const [showGHAModal, setShowGHAModal] = useState(false);
+  const [{ variables, secrets }, setFinalizedAppEnv] = useState<{
+    variables: Record<string, string>;
+    secrets: Record<string, string>;
+  }>({
+    variables: {},
+    secrets: {},
+  });
+  const [encodedAddons, setEncodedAddons] = useState<EncodedAddonWithEnv[]>([]);
+  const [detectedServices, setDetectedServices] = useState<{
+    detected: boolean;
+    count: number;
+  }>({ detected: false, count: 0 });
+
+  const envTemplateFormMethods = useForm<AppTemplateFormData>({
+    reValidateMode: "onSubmit",
+    resolver: zodResolver(appTemplateClientValidator),
+    defaultValues: {
+      app: {
+        name: {
+          value: "",
+          readOnly: false,
+        },
+        build: {
+          method: "pack",
+          context: "./",
+          builder: "",
+          buildpacks: [],
+        },
+        env: [],
+        efsStorage: {
+          enabled: false,
+        },
+      },
+      source: {
+        git_repo_name: "",
+        git_branch: "",
+        porter_yaml_path: "",
+      },
+      deletions: {
+        serviceNames: [],
+        envGroupNames: [],
+        predeploy: [],
+        initialDeploy: [],
+      },
+      addons: [],
+    },
+  });
+
+  const {
+    handleSubmit,
+    formState: { errors, isSubmitting, isSubmitSuccessful },
+    setValue,
+    watch,
+  } = envTemplateFormMethods;
+
+  const source = watch("source");
+
+  const { detectedServices: servicesFromYaml, detectedName } = usePorterYaml({
+    source: source.type === "github" ? source : null,
+    appName: "",
+  });
+
+  const errorMessagesDeep = useMemo(() => {
+    return Object.values(_.mapValues(errors, (error) => error?.message));
+  }, [errors]);
+
+  const buttonStatus = useMemo(() => {
+    if (isSubmitting) {
+      return "loading";
+    }
+
+    if (errorMessagesDeep.length > 0) {
+      return <Error message={`App update failed. ${errorMessagesDeep[0]}`} />;
+    }
+
+    if (isSubmitSuccessful) {
+      return "success";
+    }
+
+    return "";
+  }, [isSubmitting, errorMessagesDeep]);
+
+  const onSubmit = handleSubmit((data) => {
+    setCreateError("");
+
+    const proto = clientAppToProto(data);
+    setValidatedAppProto(proto);
+
+    const addons = data.addons.map((addon) => {
+      const variables = match(addon.config)
+        .returnType<Record<string, string>>()
+        .with({ type: "postgres" }, (conf) => ({
+          POSTGRESQL_USERNAME: conf.username,
+        }))
+        .with({ type: "redis" }, (conf) => ({
+          REDIS_PASSWORD: conf.password,
+        }))
+        .otherwise(() => ({}));
+      const secrets = match(addon.config)
+        .returnType<Record<string, string>>()
+        .with({ type: "postgres" }, (conf) => ({
+          POSTGRESQL_PASSWORD: conf.password,
+        }))
+        .with({ type: "redis" }, (conf) => ({
+          REDIS_PASSWORD: conf.password,
+        }))
+        .otherwise(() => ({}));
+
+      const proto = clientAddonToProto(addon);
+
+      return {
+        base64_addon: btoa(proto.toJsonString()),
+        variables,
+        secrets,
+      };
+    });
+    setEncodedAddons(addons);
+
+    const { env } = data.app;
+    const variables = env
+      .filter((e) => !e.hidden && !e.deleted)
+      .reduce((acc: Record<string, string>, item) => {
+        acc[item.key] = item.value;
+        return acc;
+      }, {});
+    const secrets = env
+      .filter((e) => !e.deleted)
+      .reduce((acc: Record<string, string>, item) => {
+        if (item.hidden) {
+          acc[item.key] = item.value;
+        }
+        return acc;
+      }, {});
+    setFinalizedAppEnv({ variables, secrets });
+
+    setShowGHAModal(true);
+  });
+
+  const createTemplateAndWorkflow = useCallback(
+    async ({
+      app,
+      variables,
+      secrets,
+      addons = [],
+    }: {
+      app: PorterApp | null;
+      variables: Record<string, string>;
+      secrets: Record<string, string>;
+      addons?: EncodedAddonWithEnv[];
+    }) => {
+      try {
+        if (!app || !selectedApp || !currentCluster || !currentProject) {
+          return false;
+        }
+
+        await api.createAppTemplate(
+          "<token>",
+          {
+            b64_app_proto: btoa(app.toJsonString()),
+            variables,
+            secrets,
+            base_deployment_target_id: selectedApp.deployment_target.id ?? "",
+            addons,
+          },
+          {
+            project_id: currentProject.id,
+            cluster_id: currentCluster.id,
+            porter_app_name: app.name,
+          }
+        );
+
+        return true;
+      } catch (err) {
+        if (axios.isAxiosError(err) && err.response?.data?.error) {
+          setCreateError(err.response?.data?.error);
+          return false;
+        }
+
+        setCreateError(
+          "An error occurred while creating the CI workflow. Please try again."
+        );
+        return false;
+      }
+    },
+    [selectedApp, currentCluster?.id, currentProject?.id]
+  );
+
+  useEffect(() => {
+    if (servicesFromYaml && !detectedServices.detected) {
+      const { services, predeploy, build: detectedBuild } = servicesFromYaml;
+      setValue("app.services", services);
+      setValue("app.predeploy", [predeploy].filter(valueExists));
+
+      if (detectedBuild) {
+        setValue("app.build", detectedBuild);
+      }
+      setDetectedServices({
+        detected: true,
+        count: services.length,
+      });
+    }
+
+    if (!servicesFromYaml && detectedServices.detected) {
+      setValue("app.services", []);
+      setValue("app.predeploy", []);
+      setDetectedServices({
+        detected: false,
+        count: 0,
+      });
+    }
+  }, [servicesFromYaml, detectedName, detectedServices.detected]);
+
+  useEffect(() => {
+    if (selectedApp?.deployment_target.id) {
+      const queryParams = new URLSearchParams(location.search);
+      queryParams.set("target", selectedApp.deployment_target.id.toString());
+      history.push({
+        search: queryParams.toString(),
+      });
+    }
+  }, [selectedApp]);
+
+  useEffect(() => {
+    if (selectedApp) {
+      setStep(3);
+    }
+  }, [selectedApp?.id]);
+
+  return (
+    <>
+      <Back to="/apps" />
+      <DashboardHeader
+        prefix={<Icon src={addOns} />}
+        title="Create a new preview template"
+        capitalize={false}
+        disableLineBreak
+      />
+      <DarkMatter />
+      <FormProvider {...envTemplateFormMethods}>
+        <form onSubmit={onSubmit}>
+          <VerticalSteps
+            currentStep={step}
+            steps={[
+              <>
+                <Text size={16}>Choose an existing app</Text>
+                <Spacer y={0.5} />
+                <AppSelector
+                  selectedApp={selectedApp}
+                  setSelectedApp={setSelectedApp}
+                />
+                <Spacer y={0.5} />
+              </>,
+              <>
+                <Text size={16}>Datastore Addons</Text>
+                <Spacer y={0.5} />
+                <Text color="helper">
+                  Ephemeral datastores will be created for each preview app
+                </Text>
+                <Spacer y={0.5} />
+                <AddonsList />
+                <Spacer y={0.5} />
+              </>,
+              !selectedApp?.name ? (
+                <>
+                  <Text size={16}>Service overrides</Text>
+                  <Spacer y={0.5} />
+                  <ConsolidatedServices />
+                  <Text size={16}>Env variable overrides</Text>
+                  <Spacer y={0.5} />
+                  <Text color="helper">
+                    Change environment variables to test keys or values suitable
+                    for previews
+                  </Text>
+                  <EnvSettings baseEnvGroups={[]} />
+                </>
+              ) : (
+                <LatestRevisionProvider
+                  key={selectedApp?.id}
+                  appName={selectedApp?.name}
+                >
+                  <>
+                    <RevisionLoader>
+                      <Text size={16}>Service overrides</Text>
+                      <Spacer y={0.5} />
+                      <ConsolidatedServices />
+                      <Text size={16}>Env variable overrides</Text>
+                      <Spacer y={0.5} />
+                      <Text color="helper">
+                        Change environment variables to test keys or values
+                        suitable for previews
+                      </Text>
+                      <EnvSettings baseEnvGroups={[]} />
+                    </RevisionLoader>
+                    {showGHAModal && (
+                      <PreviewGHAModal
+                        onClose={() => {
+                          setShowGHAModal(false);
+                        }}
+                        savePreviewConfig={async () =>
+                          await createTemplateAndWorkflow({
+                            app: validatedAppProto,
+                            variables,
+                            secrets,
+                            addons: encodedAddons,
+                          })
+                        }
+                        error={createError}
+                      />
+                    )}
+                  </>
+                </LatestRevisionProvider>
+              ),
+              <>
+                <Button
+                  type="submit"
+                  status={buttonStatus}
+                  loadingText={"Creating..."}
+                  width={"120px"}
+                  disabled={isSubmitting}
+                >
+                  Create
+                </Button>
+              </>,
+            ].filter((x) => x)}
+          />
+        </form>
+      </FormProvider>
+    </>
+  );
+};
+
+const DarkMatter = styled.div`
+  width: 100%;
+  margin-top: -5px;
+`;
+
+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 - 6
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/PreviewAppDataContainer.tsx

@@ -42,12 +42,12 @@ const previewEnvSettingsTabs = [
 
 type PreviewEnvSettingsTab = (typeof previewEnvSettingsTabs)[number];
 
-const appTemplateClientValidator = basePorterAppFormValidator.extend({
+export const appTemplateClientValidator = basePorterAppFormValidator.extend({
   addons: z.array(clientAddonValidator).default([]),
 });
 export type AppTemplateFormData = z.infer<typeof appTemplateClientValidator>;
 
-type EncodedAddonWithEnv = {
+export type EncodedAddonWithEnv = {
   base64_addon: string;
   variables: Record<string, string>;
   secrets: Record<string, string>;
@@ -341,13 +341,9 @@ export const PreviewAppDataContainer: React.FC<Props> = ({
       </form>
       {showGHAModal && (
         <PreviewGHAModal
-          projectId={projectId}
-          clusterId={clusterId}
           onClose={() => {
             setShowGHAModal(false);
           }}
-          latestSource={latestSource}
-          appName={porterApp.name}
           savePreviewConfig={async ({ repo }: { repo?: RepoOverrides }) =>
             await createTemplateAndWorkflow({
               app: validatedAppProto,

+ 9 - 10
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/setup-app/PreviewGHAModal.tsx

@@ -16,21 +16,17 @@ import Select from "components/porter/Select";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import YamlEditor from "components/YamlEditor";
+import { useLatestRevision } from "main/home/app-dashboard/app-view/LatestRevisionContext";
 import RepositorySelector from "main/home/app-dashboard/build-settings/RepositorySelector";
 import { getPreviewGithubAction } from "main/home/app-dashboard/new-app-flow/utils";
 import FileSelector from "main/home/app-dashboard/validate-apply/build-settings/FileSelector";
 import { Code } from "main/home/managed-addons/tabs/shared";
-import { type SourceOptions } from "lib/porter-apps";
 
 import api from "shared/api";
 
 import { type RepoOverrides } from "./PreviewAppDataContainer";
 
 type PreviewGHAModalProps = {
-  projectId: number;
-  clusterId: number;
-  appName: string;
-  latestSource: SourceOptions;
   onClose: () => void;
   savePreviewConfig: ({ repo }: { repo?: RepoOverrides }) => Promise<boolean>;
   error: string;
@@ -46,21 +42,24 @@ const previewActionFormValidator = z.object({
 type PreviewActionForm = z.infer<typeof previewActionFormValidator>;
 
 export const PreviewGHAModal: React.FC<PreviewGHAModalProps> = ({
-  projectId,
-  clusterId,
-  appName,
-  latestSource,
   onClose,
   savePreviewConfig,
   error,
 }) => {
+  const history = useHistory();
+  const {
+    projectId,
+    clusterId,
+    latestSource,
+    porterApp: { name: appName },
+  } = useLatestRevision();
+
   const [step, setStep] = useState<"repo" | "confirm">(
     latestSource.type === "github" ? "confirm" : "repo"
   );
   const [showFileSelector, setShowFileSelector] = useState<boolean>(false);
   const [changePorterYamlPath, setChangePorterYamlPath] = useState(false);
 
-  const history = useHistory();
   const queryClient = useQueryClient();
   const {
     watch,

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

@@ -0,0 +1,50 @@
+import React, { useEffect } from "react";
+import { useFormContext } from "react-hook-form";
+
+import { useLatestRevision } from "main/home/app-dashboard/app-view/LatestRevisionContext";
+import { useAppWithPreviewOverrides } from "lib/hooks/useAppWithPreviewOverrides";
+
+import { type AppTemplateFormData } from "./PreviewAppDataContainer";
+
+export const RevisionLoader: React.FC<{
+  children: React.ReactNode;
+}> = ({ children }) => {
+  const { latestProto, porterApp, latestSource, servicesFromYaml, appEnv } =
+    useLatestRevision();
+  const { reset } = useFormContext<AppTemplateFormData>();
+
+  const withPreviewOverrides = useAppWithPreviewOverrides({
+    latestApp: latestProto,
+    detectedServices: servicesFromYaml,
+    appEnv,
+  });
+
+  useEffect(() => {
+    // we don't store versions of build settings because they are stored in the db, so we just have to use the latest version
+    // however, for image settings, we can pull image repo and tag from the proto
+    const newSource =
+      porterApp.image_repo_uri && latestProto.image
+        ? {
+            type: "docker-registry" as const,
+            image: {
+              repository: latestProto.image.repository,
+              tag: latestProto.image.tag,
+            },
+          }
+        : latestSource;
+
+    reset({
+      app: withPreviewOverrides,
+      source: newSource,
+      deletions: {
+        envGroupNames: [],
+        serviceNames: [],
+        predeploy: [],
+        initialDeploy: [],
+      },
+      redeployOnSave: false,
+    });
+  }, [withPreviewOverrides]);
+
+  return <>{children}</>;
+};

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

@@ -16,6 +16,7 @@ import { Context } from "shared/Context";
 import pull_request from "assets/pull_request_icon.svg";
 
 import { existingTemplateWithEnvValidator } from "../types";
+import { CreateTemplate } from "./CreateTemplate";
 import { PreviewAppDataContainer } from "./PreviewAppDataContainer";
 
 type Props = RouteComponentProps;
@@ -73,7 +74,18 @@ const SetupApp: React.FC<Props> = ({ location }) => {
   );
 
   if (!appName) {
-    return null;
+    return (
+      <ClusterContextProvider
+        clusterId={currentCluster?.id}
+        refetchInterval={0}
+      >
+        <CenterWrapper>
+          <Div>
+            <CreateTemplate />
+          </Div>
+        </CenterWrapper>
+      </ClusterContextProvider>
+    );
   }
 
   return (

+ 6 - 3
dashboard/src/shared/DeploymentTargetContext.tsx

@@ -34,12 +34,15 @@ const DeploymentTargetProvider = ({
 }: {
   children: JSX.Element;
 }): JSX.Element => {
-  const { search } = useLocation();
+  const location = useLocation();
   const { currentProject, currentCluster, setCurrentCluster } =
     useContext(Context);
-  const queryParams = new URLSearchParams(search);
 
-  const deploymentTargetID = queryParams.get("target");
+  const deploymentTargetID = useMemo(() => {
+    const queryParams = new URLSearchParams(location.search);
+    return queryParams.get("target");
+  }, [location.search]);
+
   const { defaultDeploymentTarget, isDefaultDeploymentTargetLoading } =
     useDefaultDeploymentTarget();
 

+ 11 - 0
dashboard/src/shared/api.tsx

@@ -1075,6 +1075,16 @@ const createAppTemplate = baseApi<
   return `/api/projects/${project_id}/clusters/${cluster_id}/apps/${porter_app_name}/templates`;
 });
 
+const listTemplateEnvironments = baseApi<
+  Record<string, unknown>,
+  {
+    project_id: number;
+    cluster_id: number;
+  }
+>("GET", (pathParams) => {
+  return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}/apps/templates`;
+});
+
 const updateApp = baseApi<
   {
     deployment_target_id: string;
@@ -3880,6 +3890,7 @@ export default {
   getBranchHead,
   createApp,
   createAppTemplate,
+  listTemplateEnvironments,
   updateApp,
   appRun,
   updateBuildSettings,

+ 0 - 2
go.sum

@@ -1563,8 +1563,6 @@ github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/polyfloyd/go-errorlint v0.0.0-20210722154253-910bb7978349/go.mod h1:wi9BfjxjF/bwiZ701TzmfKu6UKC357IOAtNr0Td0Lvw=
-github.com/porter-dev/api-contracts v0.2.159 h1:Ze4K0rm8p6sRMxaFW4Nb3dJuzz4NEMQ+UMXMtOKKRQ4=
-github.com/porter-dev/api-contracts v0.2.159/go.mod h1:VV5BzXd02ZdbWIPLVP+PX3GKawJSGQnxorVT2sUZALU=
 github.com/porter-dev/api-contracts v0.2.161 h1:kf1ZcS1032eLabBzjwDs9SVcecXwUxJ2mJUkRl9C8jk=
 github.com/porter-dev/api-contracts v0.2.161/go.mod h1:VV5BzXd02ZdbWIPLVP+PX3GKawJSGQnxorVT2sUZALU=
 github.com/porter-dev/switchboard v0.0.3 h1:dBuYkiVLa5Ce7059d6qTe9a1C2XEORFEanhbtV92R+M=