瀏覽代碼

POR-1354 scope dashboard to a given deployment target (#3649)

ianedwards 2 年之前
父節點
當前提交
6c9c760a2c
共有 26 個文件被更改,包括 992 次插入252 次删除
  1. 81 0
      api/server/handlers/deployment_target/list.go
  2. 17 9
      api/server/handlers/porter_app/latest_app_revisions.go
  3. 29 0
      api/server/router/deployment_target.go
  4. 19 0
      api/types/deployment_target.go
  5. 1 0
      dashboard/src/lib/revisions/types.ts
  6. 192 166
      dashboard/src/main/home/Home.tsx
  7. 22 7
      dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx
  8. 16 8
      dashboard/src/main/home/app-dashboard/app-view/LatestRevisionContext.tsx
  9. 16 11
      dashboard/src/main/home/app-dashboard/app-view/tabs/Activity.tsx
  10. 2 2
      dashboard/src/main/home/app-dashboard/app-view/tabs/JobsTab.tsx
  11. 2 2
      dashboard/src/main/home/app-dashboard/app-view/tabs/LogsTab.tsx
  12. 2 2
      dashboard/src/main/home/app-dashboard/app-view/tabs/MetricsTab.tsx
  13. 2 2
      dashboard/src/main/home/app-dashboard/app-view/tabs/Overview.tsx
  14. 1 1
      dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/focus-views/EventFocusView.tsx
  15. 2 2
      dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/focus-views/PredeployEventFocusView.tsx
  16. 18 2
      dashboard/src/main/home/app-dashboard/apps/AppGrid.tsx
  17. 40 25
      dashboard/src/main/home/app-dashboard/apps/Apps.tsx
  18. 2 2
      dashboard/src/main/home/app-dashboard/validate-apply/jobs/JobRunDetails.tsx
  19. 193 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/v2/PreviewEnvGrid.tsx
  20. 229 0
      dashboard/src/main/home/cluster-dashboard/preview-environments/v2/PreviewEnvs.tsx
  21. 71 0
      dashboard/src/shared/DeploymentTargetContext.tsx
  22. 17 2
      dashboard/src/shared/api.tsx
  23. 14 0
      internal/models/deployment_target.go
  24. 1 1
      internal/repository/deployment_target.go
  25. 2 7
      internal/repository/gorm/deployment_target.go
  26. 1 1
      internal/repository/test/deployment_target.go

+ 81 - 0
api/server/handlers/deployment_target/list.go

@@ -0,0 +1,81 @@
+package deployment_target
+
+import (
+	"net/http"
+
+	"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"
+)
+
+// ListDeploymentTargetsHandler is the handler for the /deployment-targets endpoint
+type ListDeploymentTargetsHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+// NewListDeploymentTargetsHandler handles GET requests to the endpoint /deployment-targets
+func NewListDeploymentTargetsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ListDeploymentTargetsHandler {
+	return &ListDeploymentTargetsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+// ListDeploymentTargetsRequest is the request object for the /deployment-targets GET endpoint
+type ListDeploymentTargetsRequest struct {
+	Preview bool `json:"preview"`
+}
+
+// ListDeploymentTargetsResponse is the response object for the /deployment-targets GET endpoint
+type ListDeploymentTargetsResponse struct {
+	DeploymentTargets []types.DeploymentTarget `json:"deployment_targets"`
+}
+
+func (c *ListDeploymentTargetsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-list-deployment-targets")
+	defer span.End()
+
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	if !project.GetFeatureFlag(models.ValidateApplyV2, c.Config().LaunchDarklyClient) {
+		err := telemetry.Error(ctx, span, nil, "project does not have validate apply v2 enabled")
+		c.HandleAPIError(w, r, apierrors.NewErrForbidden(err))
+		return
+	}
+
+	request := &ListDeploymentTargetsRequest{}
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		err := telemetry.Error(ctx, span, nil, "error decoding request")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	deploymentTargets, err := c.Repo().DeploymentTarget().List(project.ID, cluster.ID, request.Preview)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error retrieving deployment targets")
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	response := ListDeploymentTargetsResponse{
+		DeploymentTargets: make([]types.DeploymentTarget, 0),
+	}
+
+	for _, dt := range deploymentTargets {
+		if dt == nil {
+			continue
+		}
+
+		response.DeploymentTargets = append(response.DeploymentTargets, *dt.ToDeploymentTargetType())
+	}
+
+	c.WriteResult(w, r, response)
+}

+ 17 - 9
api/server/handlers/porter_app/latest_app_revisions.go

@@ -32,8 +32,10 @@ func NewLatestAppRevisionsHandler(
 	}
 }
 
-// LatestAppRevisionsRequest represents the response from the /apps/revisions endpoint
-type LatestAppRevisionsRequest struct{}
+// LatestAppRevisionsRequest represents the request for the /apps/revisions endpoint
+type LatestAppRevisionsRequest struct {
+	DeploymentTargetID string `schema:"deployment_target_id"`
+}
 
 // LatestRevisionWithSource is an app revision and its source porter app
 type LatestRevisionWithSource struct {
@@ -53,22 +55,28 @@ func (c *LatestAppRevisionsHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
 
-	// todo(ianedwards): once we have a way to select a deployment target, we can add it to the request
-	defaultDeploymentTarget, err := c.Repo().DeploymentTarget().DeploymentTargetBySelectorAndSelectorType(project.ID, cluster.ID, DeploymentTargetSelector_Default, DeploymentTargetSelectorType_Default)
+	request := &LatestAppRevisionsRequest{}
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		err := telemetry.Error(ctx, span, nil, "error decoding request")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	deploymentTargetID, err := uuid.Parse(request.DeploymentTargetID)
 	if err != nil {
-		err := telemetry.Error(ctx, span, err, "error getting default deployment target from repo")
+		err := telemetry.Error(ctx, span, err, "error parsing deployment target id")
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
 		return
 	}
-	if defaultDeploymentTarget.ID == uuid.Nil {
-		err := telemetry.Error(ctx, span, err, "default deployment target not found")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+	if deploymentTargetID == uuid.Nil {
+		err := telemetry.Error(ctx, span, err, "deployment target id is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
 		return
 	}
 
 	listAppRevisionsReq := connect.NewRequest(&porterv1.LatestAppRevisionsRequest{
 		ProjectId:          int64(project.ID),
-		DeploymentTargetId: defaultDeploymentTarget.ID.String(),
+		DeploymentTargetId: deploymentTargetID.String(),
 	})
 
 	latestAppRevisionsResp, err := c.Config().ClusterControlPlaneClient.LatestAppRevisions(ctx, listAppRevisionsReq)

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

@@ -85,5 +85,34 @@ func getDeploymentTargetRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/deployment-targets -> deployment_target.ListDeploymentTargetsHandler
+	listDeploymentTargetsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbList,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath,
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	listDeploymentTargetsHandler := deployment_target.NewListDeploymentTargetsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: listDeploymentTargetsEndpoint,
+		Handler:  listDeploymentTargetsHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 19 - 0
api/types/deployment_target.go

@@ -0,0 +1,19 @@
+package types
+
+import (
+	"time"
+
+	"github.com/google/uuid"
+)
+
+// DeploymentTarget is a struct that represents a unique cluster, namespace pair that a Porter app is deployed to.
+type DeploymentTarget struct {
+	ID        uuid.UUID `json:"id"`
+	ProjectID uint      `json:"project_id"`
+	ClusterID uint      `json:"cluster_id"`
+
+	Selector     string    `json:"selector"`
+	SelectorType string    `json:"selector_type"`
+	CreatedAt    time.Time `json:"created_at"`
+	UpdatedAt    time.Time `json:"updated_at"`
+}

+ 1 - 0
dashboard/src/lib/revisions/types.ts

@@ -13,6 +13,7 @@ export const appRevisionValidator = z.object({
   ]),
   b64_app_proto: z.string(),
   revision_number: z.number(),
+  deployment_target_id: z.string(),
   id: z.string(),
   created_at: z.string(),
   updated_at: z.string(),

+ 192 - 166
dashboard/src/main/home/Home.tsx

@@ -42,6 +42,8 @@ import ExpandedApp from "./app-dashboard/expanded-app/ExpandedApp";
 import CreateApp from "./app-dashboard/create-app/CreateApp";
 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";
 
 // Guarded components
 const GuardedProjectSettings = fakeGuardedRoute("settings", "", [
@@ -385,187 +387,211 @@ const Home: React.FC<Props> = (props) => {
     <ThemeProvider
       theme={currentProject?.simplified_view_enabled ? midnight : standard}
     >
-      <StyledHome>
-        <ModalHandler setRefreshClusters={setForceRefreshClusters} />
-        {currentOverlay &&
-          createPortal(
-            <ConfirmOverlay
-              show={true}
-              message={currentOverlay.message}
-              onYes={currentOverlay.onYes}
-              onNo={currentOverlay.onNo}
-            />,
-            document.body
+      <DeploymentTargetProvider>
+        <StyledHome>
+          <ModalHandler setRefreshClusters={setForceRefreshClusters} />
+          {currentOverlay &&
+            createPortal(
+              <ConfirmOverlay
+                show={true}
+                message={currentOverlay.message}
+                onYes={currentOverlay.onYes}
+                onNo={currentOverlay.onNo}
+              />,
+              document.body
+            )}
+          {/* Render sidebar when there's at least one project */}
+          {projects?.length > 0 && baseRoute !== "new-project" ? (
+            <Sidebar
+              key="sidebar"
+              forceSidebar={forceSidebar}
+              setWelcome={setShowWelcome}
+              currentView={props.currentRoute}
+              forceRefreshClusters={forceRefreshClusters}
+              setRefreshClusters={setForceRefreshClusters}
+            />
+          ) : (
+            <DiscordButton href="https://discord.gg/34n7NN7FJ7" target="_blank">
+              <Icon src={discordLogo} />
+              Join Our Discord
+            </DiscordButton>
           )}
-        {/* Render sidebar when there's at least one project */}
-        {projects?.length > 0 && baseRoute !== "new-project" ? (
-          <Sidebar
-            key="sidebar"
-            forceSidebar={forceSidebar}
-            setWelcome={setShowWelcome}
-            currentView={props.currentRoute}
-            forceRefreshClusters={forceRefreshClusters}
-            setRefreshClusters={setForceRefreshClusters}
-          />
-        ) : (
-          <DiscordButton href="https://discord.gg/34n7NN7FJ7" target="_blank">
-            <Icon src={discordLogo} />
-            Join Our Discord
-          </DiscordButton>
-        )}
-        <ViewWrapper id="HomeViewWrapper">
-          <Navbar
-            logOut={props.logOut}
-            currentView={props.currentRoute} // For form feedback
-          />
-
-          <Switch>
-            <Route path="/apps/new/app">
-              {currentProject?.validate_apply_v2 ? (
-                <CreateApp />
-              ) : (
-                <NewAppFlow />
-              )}
-            </Route>
-            <Route path="/apps/:appName/:tab">
-              {currentProject?.validate_apply_v2 ? (
-                <AppView />
-              ) : (
-                <ExpandedApp />
-              )}
-            </Route>
-            <Route path="/apps/:appName">
+          <ViewWrapper id="HomeViewWrapper">
+            <Navbar
+              logOut={props.logOut}
+              currentView={props.currentRoute} // For form feedback
+            />
+
+            <Switch>
+              <Route path="/apps/new/app">
+                {currentProject?.validate_apply_v2 ? (
+                  <CreateApp />
+                ) : (
+                  <NewAppFlow />
+                )}
+              </Route>
+              <Route path="/apps/:appName/:tab">
+                {currentProject?.validate_apply_v2 ? (
+                  <AppView />
+                ) : (
+                  <ExpandedApp />
+                )}
+              </Route>
+              <Route path="/apps/:appName">
+                {currentProject?.validate_apply_v2 ? (
+                  <AppView />
+                ) : (
+                  <ExpandedApp />
+                )}
+              </Route>
+              <Route path="/apps">
+                {currentProject?.validate_apply_v2 ? (
+                  <Apps />
+                ) : (
+                  <AppDashboard />
+                )}
+              </Route>
               {currentProject?.validate_apply_v2 ? (
-                <AppView />
-              ) : (
-                <ExpandedApp />
+                <>
+                  <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>
+              <Route path="/addons">
+                <AddOnDashboard />
+              </Route>
+              <Route
+                path="/new-project"
+                render={() => {
+                  return <NewProjectFC />;
+                }}
+              ></Route>
+              <Route
+                path="/onboarding"
+                render={() => {
+                  return <Onboarding />;
+                }}
+              />
+              {(user?.isPorterUser ||
+                overrideInfraTabEnabled({
+                  projectID: currentProject?.id,
+                })) && (
+                <Route
+                  path="/infrastructure"
+                  render={() => {
+                    return (
+                      <DashboardWrapper>
+                        <InfrastructureRouter />
+                      </DashboardWrapper>
+                    );
+                  }}
+                />
               )}
-            </Route>
-            <Route path="/apps">
-              {currentProject?.validate_apply_v2 ? <Apps /> : <AppDashboard />}
-            </Route>
-            <Route path="/addons/new">
-              <NewAddOnFlow />
-            </Route>
-            <Route path="/addons">
-              <AddOnDashboard />
-            </Route>
-            <Route
-              path="/new-project"
-              render={() => {
-                return <NewProjectFC />;
-              }}
-            ></Route>
-            <Route
-              path="/onboarding"
-              render={() => {
-                return <Onboarding />;
-              }}
-            />
-            {(user?.isPorterUser ||
-              overrideInfraTabEnabled({
-                projectID: currentProject?.id,
-              })) && (
               <Route
-                path="/infrastructure"
+                path="/dashboard"
                 render={() => {
                   return (
                     <DashboardWrapper>
-                      <InfrastructureRouter />
+                      <Dashboard
+                        projectId={currentProject?.id}
+                        setRefreshClusters={setForceRefreshClusters}
+                      />
                     </DashboardWrapper>
                   );
                 }}
               />
-            )}
-            <Route
-              path="/dashboard"
-              render={() => {
-                return (
-                  <DashboardWrapper>
-                    <Dashboard
-                      projectId={currentProject?.id}
-                      setRefreshClusters={setForceRefreshClusters}
-                    />
-                  </DashboardWrapper>
-                );
-              }}
-            />
-            <Route
-              path={[
-                "/cluster-dashboard",
-                "/applications",
-                "/jobs",
-                "/env-groups",
-                "/databases",
-                "/preview-environments",
-                "/stacks",
-              ]}
-              render={() => {
-                if (currentCluster?.id === -1) {
-                  return <Loading />;
-                } else if (!currentCluster || !currentCluster.name) {
+              <Route
+                path={[
+                  "/cluster-dashboard",
+                  "/applications",
+                  "/jobs",
+                  "/env-groups",
+                  "/databases",
+                  ...(!currentProject?.validate_apply_v2
+                    ? ["/preview-environments"]
+                    : []),
+                  "/stacks",
+                ]}
+                render={() => {
+                  if (currentCluster?.id === -1) {
+                    return <Loading />;
+                  } else if (!currentCluster || !currentCluster.name) {
+                    return (
+                      <DashboardWrapper>
+                        <NoClusterPlaceHolder></NoClusterPlaceHolder>
+                      </DashboardWrapper>
+                    );
+                  }
                   return (
                     <DashboardWrapper>
-                      <NoClusterPlaceHolder></NoClusterPlaceHolder>
+                      <DashboardRouter
+                        currentCluster={currentCluster}
+                        setSidebar={setForceSidebar}
+                        currentView={props.currentRoute}
+                      />
                     </DashboardWrapper>
                   );
-                }
-                return (
-                  <DashboardWrapper>
-                    <DashboardRouter
-                      currentCluster={currentCluster}
-                      setSidebar={setForceSidebar}
-                      currentView={props.currentRoute}
-                    />
-                  </DashboardWrapper>
-                );
-              }}
-            />
-            <Route
-              path={"/integrations"}
-              render={() => <GuardedIntegrations />}
-            />
-            <Route
-              path={"/project-settings"}
-              render={() => <GuardedProjectSettings />}
-            />
-            <Route path={"*"} render={() => <LaunchWrapper />} />
-          </Switch>
-        </ViewWrapper>
-        {createPortal(
-          <ConfirmOverlay
-            show={currentModal === "UpdateProjectModal"}
-            message={
-              currentProject
-                ? `Are you sure you want to delete ${currentProject.name}?`
-                : ""
-            }
-            onYes={handleDelete}
-            onNo={() => setCurrentModal(null, null)}
-          />,
-          document.body
-        )}
-        {showWrongEmailModal && (
-          <Modal>
-            <Text size={16}>
-              Oops! This invite link wasn't for {user?.email}
-            </Text>
-            <Spacer y={1} />
-            <Text color="helper">
-              Your account email does not match the email associated with this
-              project invite. Please log out and sign up again with the correct
-              email using the invite link.
-            </Text>
-            <Spacer y={1} />
-            <Text color="helper">
-              You should reach out to the person who sent you the invite link to
-              get the correct email.
-            </Text>
-            <Spacer y={1} />
-            <Button onClick={props.logOut}>Log out</Button>
-          </Modal>
-        )}
-      </StyledHome>
+                }}
+              />
+              <Route
+                path={"/integrations"}
+                render={() => <GuardedIntegrations />}
+              />
+              <Route
+                path={"/project-settings"}
+                render={() => <GuardedProjectSettings />}
+              />
+              <Route path={"*"} render={() => <LaunchWrapper />} />
+            </Switch>
+          </ViewWrapper>
+          {createPortal(
+            <ConfirmOverlay
+              show={currentModal === "UpdateProjectModal"}
+              message={
+                currentProject
+                  ? `Are you sure you want to delete ${currentProject.name}?`
+                  : ""
+              }
+              onYes={handleDelete}
+              onNo={() => setCurrentModal(null, null)}
+            />,
+            document.body
+          )}
+          {showWrongEmailModal && (
+            <Modal>
+              <Text size={16}>
+                Oops! This invite link wasn't for {user?.email}
+              </Text>
+              <Spacer y={1} />
+              <Text color="helper">
+                Your account email does not match the email associated with this
+                project invite. Please log out and sign up again with the
+                correct email using the invite link.
+              </Text>
+              <Spacer y={1} />
+              <Text color="helper">
+                You should reach out to the person who sent you the invite link
+                to get the correct email.
+              </Text>
+              <Spacer y={1} />
+              <Button onClick={props.logOut}>Log out</Button>
+            </Modal>
+          )}
+        </StyledHome>
+      </DeploymentTargetProvider>
     </ThemeProvider>
   );
 };

+ 22 - 7
dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx

@@ -66,12 +66,12 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
     latestRevision,
     projectId,
     clusterId,
-    deploymentTargetId,
+    deploymentTarget,
     servicesFromYaml,
     setPreviewRevision,
   } = useLatestRevision();
   const { validateApp } = useAppValidation({
-    deploymentTargetID: deploymentTargetId,
+    deploymentTargetID: deploymentTarget.id,
   });
 
   const currentTab = useMemo(() => {
@@ -167,7 +167,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
       const res = await api.updateEnvironmentGroupV2(
         "<token>",
         {
-          deployment_target_id: deploymentTargetId,
+          deployment_target_id: deploymentTarget.id,
           variables,
           secrets,
           b64_app_proto: btoa(validatedAppProto.toJsonString()),
@@ -203,7 +203,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
         "<token>",
         {
           b64_app_proto: btoa(protoWithUpdatedEnv.toJsonString()),
-          deployment_target_id: deploymentTargetId,
+          deployment_target_id: deploymentTarget.id,
         },
         {
           project_id: projectId,
@@ -241,11 +241,18 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
         "getLatestRevision",
         projectId,
         clusterId,
-        deploymentTargetId,
+        deploymentTarget.id,
         porterApp.name,
       ]);
       setPreviewRevision(null);
 
+      if (deploymentTarget.preview) {
+        history.push(
+          `/preview-environments/apps/${porterApp.name}/${DEFAULT_TAB}?target=${deploymentTarget.id}`
+        );
+        return;
+      }
+
       // redirect to the default tab after save
       history.push(`/apps/${porterApp.name}/${DEFAULT_TAB}`);
     } catch (err) {}
@@ -270,7 +277,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
       <form onSubmit={onSubmit}>
         <RevisionsList
           latestRevisionNumber={latestRevision.revision_number}
-          deploymentTargetId={deploymentTargetId}
+          deploymentTargetId={deploymentTarget.id}
           projectId={projectId}
           clusterId={clusterId}
           appName={porterApp.name}
@@ -321,6 +328,12 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
           ]}
           currentTab={currentTab}
           setCurrentTab={(tab) => {
+            if (deploymentTarget.preview) {
+              history.push(
+                `/preview-environments/apps/${porterApp.name}/${tab}?target=${deploymentTarget.id}`
+              );
+              return;
+            }
             history.push(`/apps/${porterApp.name}/${tab}`);
           }}
         />
@@ -334,7 +347,9 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
               setRedeployOnSave={setRedeployOnSave}
             />
           ))
-          .with("environment", () => <Environment latestSource={latestSource} />)
+          .with("environment", () => (
+            <Environment latestSource={latestSource} />
+          ))
           .with("settings", () => <Settings />)
           .with("logs", () => <LogsTab />)
           .with("metrics", () => <MetricsTab />)

+ 16 - 8
dashboard/src/main/home/app-dashboard/app-view/LatestRevisionContext.tsx

@@ -1,7 +1,6 @@
 import React, { Dispatch, SetStateAction, useMemo, useState } from "react";
 import { PorterApp } from "@porter-dev/api-contracts";
 import { useQuery } from "@tanstack/react-query";
-import { useDefaultDeploymentTarget } from "lib/hooks/useDeploymentTarget";
 import { createContext, useContext } from "react";
 import { Context } from "shared/Context";
 import api from "shared/api";
@@ -18,6 +17,10 @@ import styled from "styled-components";
 import { SourceOptions } from "lib/porter-apps";
 import { usePorterYaml } from "lib/hooks/usePorterYaml";
 import { DetectedServices } from "lib/porter-apps/services";
+import {
+  DeploymentTarget,
+  useDeploymentTarget,
+} from "shared/DeploymentTargetContext";
 
 export const LatestRevisionContext = createContext<{
   porterApp: PorterAppRecord;
@@ -26,7 +29,7 @@ export const LatestRevisionContext = createContext<{
   servicesFromYaml: DetectedServices | null;
   clusterId: number;
   projectId: number;
-  deploymentTargetId: string;
+  deploymentTarget: DeploymentTarget;
   previewRevision: AppRevision | null;
   setPreviewRevision: Dispatch<SetStateAction<AppRevision | null>>;
 } | null>(null);
@@ -48,12 +51,17 @@ export const LatestRevisionProvider = ({
   appName?: string;
   children: JSX.Element;
 }) => {
-  const [previewRevision, setPreviewRevision] = useState<AppRevision | null>(null);
+  const [previewRevision, setPreviewRevision] = useState<AppRevision | null>(
+    null
+  );
   const { currentCluster, currentProject } = useContext(Context);
-  const deploymentTarget = useDefaultDeploymentTarget();
+  const { currentDeploymentTarget } = useDeploymentTarget();
 
   const appParamsExist =
-    !!appName && !!currentCluster && !!currentProject && !!deploymentTarget;
+    !!appName &&
+    !!currentCluster &&
+    !!currentProject &&
+    !!currentDeploymentTarget;
 
   const { data: porterApp, status: porterAppStatus } = useQuery(
     ["getPorterApp", currentCluster?.id, currentProject?.id, appName],
@@ -85,7 +93,7 @@ export const LatestRevisionProvider = ({
       "getLatestRevision",
       currentProject?.id,
       currentCluster?.id,
-      deploymentTarget?.deployment_target_id,
+      currentDeploymentTarget,
       appName,
     ],
     async () => {
@@ -95,7 +103,7 @@ export const LatestRevisionProvider = ({
       const res = await api.getLatestRevision(
         "<token>",
         {
-          deployment_target_id: deploymentTarget.deployment_target_id,
+          deployment_target_id: currentDeploymentTarget.id,
         },
         {
           project_id: currentProject.id,
@@ -195,7 +203,7 @@ export const LatestRevisionProvider = ({
         porterApp,
         clusterId: currentCluster.id,
         projectId: currentProject.id,
-        deploymentTargetId: deploymentTarget.deployment_target_id,
+        deploymentTarget: currentDeploymentTarget,
         servicesFromYaml: detectedServices,
         previewRevision,
         setPreviewRevision,

+ 16 - 11
dashboard/src/main/home/app-dashboard/app-view/tabs/Activity.tsx

@@ -3,18 +3,23 @@ import { useLatestRevision } from "../LatestRevisionContext";
 import ActivityFeed from "./activity-feed/ActivityFeed";
 
 const Activity: React.FC = () => {
-    const { projectId, clusterId, latestProto, deploymentTargetId } = useLatestRevision();
+  const {
+    projectId,
+    clusterId,
+    latestProto,
+    deploymentTarget,
+  } = useLatestRevision();
 
-    return (
-        <>
-            <ActivityFeed
-                currentProject={projectId}
-                currentCluster={clusterId}
-                appName={latestProto.name}
-                deploymentTargetId={deploymentTargetId}
-            />
-        </>
-    );
+  return (
+    <>
+      <ActivityFeed
+        currentProject={projectId}
+        currentCluster={clusterId}
+        appName={latestProto.name}
+        deploymentTargetId={deploymentTarget.id}
+      />
+    </>
+  );
 };
 
 export default Activity;

+ 2 - 2
dashboard/src/main/home/app-dashboard/app-view/tabs/JobsTab.tsx

@@ -3,7 +3,7 @@ import { useLatestRevision } from "../LatestRevisionContext";
 import JobsSection from "../../validate-apply/jobs/JobsSection";
 
 const JobsTab: React.FC = () => {
-    const { projectId, clusterId, latestProto, deploymentTargetId } = useLatestRevision();
+    const { projectId, clusterId, latestProto, deploymentTarget } = useLatestRevision();
 
     const appName = latestProto.name
 
@@ -12,7 +12,7 @@ const JobsTab: React.FC = () => {
             <JobsSection
                 projectId={projectId}
                 clusterId={clusterId}
-                deploymentTargetId={deploymentTargetId}
+                deploymentTargetId={deploymentTarget.id}
                 appName={appName}
                 jobNames={Object.keys(latestProto.services).filter(name => latestProto.services[name].config.case === "jobConfig")}
             />

+ 2 - 2
dashboard/src/main/home/app-dashboard/app-view/tabs/LogsTab.tsx

@@ -3,7 +3,7 @@ import Logs from "../../validate-apply/logs/Logs"
 import { useLatestRevision } from "../LatestRevisionContext";
 
 const LogsTab: React.FC = () => {
-    const { projectId, clusterId, latestProto, deploymentTargetId } = useLatestRevision();
+    const { projectId, clusterId, latestProto, deploymentTarget } = useLatestRevision();
 
     const appName = latestProto.name
     const serviceNames = Object.keys(latestProto.services)
@@ -14,7 +14,7 @@ const LogsTab: React.FC = () => {
             clusterId={clusterId}
             appName={appName}
             serviceNames={serviceNames}
-            deploymentTargetId={deploymentTargetId}
+            deploymentTargetId={deploymentTarget.id}
             filterPredeploy={true}
         />
     );

+ 2 - 2
dashboard/src/main/home/app-dashboard/app-view/tabs/MetricsTab.tsx

@@ -3,7 +3,7 @@ import { useLatestRevision } from "../LatestRevisionContext";
 import MetricsSection from "../../validate-apply/metrics/MetricsSection";
 
 const MetricsTab: React.FC = () => {
-    const { projectId, clusterId, latestProto , deploymentTargetId} = useLatestRevision();
+    const { projectId, clusterId, latestProto , deploymentTarget} = useLatestRevision();
 
     const appName = latestProto.name
 
@@ -14,7 +14,7 @@ const MetricsTab: React.FC = () => {
                 clusterId={clusterId}
                 appName={appName}
                 services={latestProto.services}
-                deploymentTargetId={deploymentTargetId}
+                deploymentTargetId={deploymentTarget.id}
             />
         </>
     );

+ 2 - 2
dashboard/src/main/home/app-dashboard/app-view/tabs/Overview.tsx

@@ -16,13 +16,13 @@ import { useAppStatus } from "lib/hooks/useAppStatus";
 
 const Overview: React.FC = () => {
   const { formState } = useFormContext<PorterAppFormData>();
-  const { porterApp, latestProto, latestRevision, projectId, clusterId, deploymentTargetId } = useLatestRevision();
+  const { porterApp, latestProto, latestRevision, projectId, clusterId, deploymentTarget } = useLatestRevision();
 
   const { serviceVersionStatus } = useAppStatus({
     projectId,
     clusterId,
     serviceNames: Object.keys(latestProto.services),
-    deploymentTargetId,
+    deploymentTargetId: deploymentTarget.id,
     appName: latestProto.name,
   });
 

+ 1 - 1
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/focus-views/EventFocusView.tsx

@@ -21,7 +21,7 @@ const EventFocusView: React.FC = ({ }) => {
     const { search } = useLocation();
     const queryParams = new URLSearchParams(search);
     const eventId = queryParams.get("event_id");
-    const { projectId, clusterId, latestProto, deploymentTargetId, latestRevision } = useLatestRevision();
+    const { projectId, clusterId, latestProto } = useLatestRevision();
 
     const [event, setEvent] = useState<SupportedEventFocusViewEvent | null>(null);
 

+ 2 - 2
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/focus-views/PredeployEventFocusView.tsx

@@ -19,7 +19,7 @@ type Props = {
 const PreDeployEventFocusView: React.FC<Props> = ({
   event,
 }) => {
-  const { projectId, clusterId, latestProto, deploymentTargetId, latestRevision } = useLatestRevision();
+  const { projectId, clusterId, latestProto, deploymentTarget } = useLatestRevision();
 
   const appName = latestProto.name
   const serviceNames = [`${latestProto.name}-predeploy`]
@@ -63,7 +63,7 @@ const PreDeployEventFocusView: React.FC<Props> = ({
         clusterId={clusterId}
         appName={appName}
         serviceNames={serviceNames}
-        deploymentTargetId={deploymentTargetId}
+        deploymentTargetId={deploymentTarget.id}
         appRevisionId={event.metadata.app_revision_id}
         logFilterNames={["service_name"]}
       />

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

@@ -20,6 +20,7 @@ import { PorterApp } from "@porter-dev/api-contracts";
 import Icon from "components/porter/Icon";
 import Spacer from "components/porter/Spacer";
 import { readableDate } from "shared/string_utils";
+import { useDeploymentTarget } from "shared/DeploymentTargetContext";
 
 type AppGridProps = {
   apps: AppRevisionWithSource[];
@@ -37,6 +38,7 @@ const icons = [
 ];
 
 const AppGrid: React.FC<AppGridProps> = ({ apps, searchValue, view, sort }) => {
+  const { currentDeploymentTarget } = useDeploymentTarget();
   const appsWithProto = useMemo(() => {
     return apps.map((app) => {
       return {
@@ -138,7 +140,14 @@ const AppGrid: React.FC<AppGridProps> = ({ apps, searchValue, view, sort }) => {
         {(filteredApps ?? []).map(
           ({ app_revision: { proto, updated_at }, source }, i) => {
             return (
-              <Link to={`/apps/${proto.name}`} key={i}>
+              <Link
+                to={
+                  currentDeploymentTarget?.preview
+                    ? `/preview-environments/apps/${proto.name}/activity?target=${currentDeploymentTarget.id}`
+                    : `/apps/${proto.name}`
+                }
+                key={i}
+              >
                 <Block>
                   <Container row>
                     {renderIcon(proto.build?.buildpacks ?? [])}
@@ -166,7 +175,14 @@ const AppGrid: React.FC<AppGridProps> = ({ apps, searchValue, view, sort }) => {
         {(filteredApps ?? []).map(
           ({ app_revision: { proto, updated_at }, source }, i) => {
             return (
-              <Link to={`/apps/${proto.name}`} key={i}>
+              <Link
+                to={
+                  currentDeploymentTarget?.preview
+                    ? `/preview-environments/apps/${proto.name}/activity?target=${currentDeploymentTarget.id}`
+                    : `/apps/${proto.name}`
+                }
+                key={i}
+              >
                 <Row>
                   <Container row>
                     <Spacer inline width="1px" />

+ 40 - 25
dashboard/src/main/home/app-dashboard/apps/Apps.tsx

@@ -27,12 +27,14 @@ import { appRevisionWithSourceValidator } from "./types";
 import AppGrid from "./AppGrid";
 import DashboardHeader from "main/home/cluster-dashboard/DashboardHeader";
 import { z } from "zod";
+import { useDeploymentTarget } from "shared/DeploymentTargetContext";
 
 type Props = {};
 
 const Apps: React.FC<Props> = ({}) => {
   const { currentProject, currentCluster } = useContext(Context);
   const { updateAppStep } = useAppAnalytics();
+  const { currentDeploymentTarget } = useDeploymentTarget();
 
   const [searchValue, setSearchValue] = useState("");
   const [view, setView] = useState<"grid" | "list">("grid");
@@ -41,21 +43,28 @@ const Apps: React.FC<Props> = ({}) => {
   const { data: apps = [], status } = useQuery(
     [
       "getLatestAppRevisions",
-      { cluster_id: currentCluster?.id, project_id: currentProject?.id },
+      {
+        cluster_id: currentCluster?.id,
+        project_id: currentProject?.id,
+        deployment_target_id: currentDeploymentTarget?.id,
+      },
     ],
     async () => {
       if (
         !currentCluster ||
         !currentProject ||
         currentCluster.id === -1 ||
-        currentProject.id === -1
+        currentProject.id === -1 ||
+        !currentDeploymentTarget
       ) {
         return;
       }
 
       const res = await api.getLatestAppRevisions(
         "<token>",
-        {},
+        {
+          deployment_target_id: currentDeploymentTarget?.id,
+        },
         { cluster_id: currentCluster.id, project_id: currentProject.id }
       );
 
@@ -66,6 +75,10 @@ const Apps: React.FC<Props> = ({}) => {
         .parseAsync(res.data);
 
       return apps.app_revisions;
+    },
+    {
+      enabled:
+        !!currentCluster && !!currentProject && !!currentDeploymentTarget,
     }
   );
 
@@ -79,28 +92,30 @@ const Apps: React.FC<Props> = ({}) => {
     }
 
     if (apps.length === 0) {
-      <Fieldset>
-        <CentralContainer>
-          <Text size={16}>No apps have been deployed yet.</Text>
-          <Spacer y={1} />
-
-          <Text color={"helper"}>Get started by deploying your app.</Text>
-          <Spacer y={0.5} />
-          <PorterLink to="/apps/new/app">
-            <Button
-              onClick={async () =>
-                updateAppStep({ step: "stack-launch-start" })
-              }
-              height="35px"
-            >
-              Deploy app <Spacer inline x={1} />{" "}
-              <i className="material-icons" style={{ fontSize: "18px" }}>
-                east
-              </i>
-            </Button>
-          </PorterLink>
-        </CentralContainer>
-      </Fieldset>;
+      return (
+        <Fieldset>
+          <CentralContainer>
+            <Text size={16}>No apps have been deployed yet.</Text>
+            <Spacer y={1} />
+
+            <Text color={"helper"}>Get started by deploying your app.</Text>
+            <Spacer y={0.5} />
+            <PorterLink to="/apps/new/app">
+              <Button
+                onClick={async () =>
+                  updateAppStep({ step: "stack-launch-start" })
+                }
+                height="35px"
+              >
+                Deploy app <Spacer inline x={1} />{" "}
+                <i className="material-icons" style={{ fontSize: "18px" }}>
+                  east
+                </i>
+              </Button>
+            </PorterLink>
+          </CentralContainer>
+        </Fieldset>
+      );
     }
 
     return (

+ 2 - 2
dashboard/src/main/home/app-dashboard/validate-apply/jobs/JobRunDetails.tsx

@@ -23,7 +23,7 @@ type Props = {
 const JobRunDetails: React.FC<Props> = ({
     jobRun,
 }) => {
-    const { projectId, clusterId, latestProto, deploymentTargetId } = useLatestRevision();
+    const { projectId, clusterId, latestProto, deploymentTarget } = useLatestRevision();
 
     const appName = latestProto.name
 
@@ -67,7 +67,7 @@ const JobRunDetails: React.FC<Props> = ({
                 clusterId={clusterId}
                 appName={appName}
                 serviceNames={[jobRun.jobName ?? "all"]}
-                deploymentTargetId={deploymentTargetId}
+                deploymentTargetId={deploymentTarget.id}
                 appRevisionId={jobRun.metadata.labels["porter.run/app-revision-id"]}
                 logFilterNames={["service_name"]}
                 timeRange={{

+ 193 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/PreviewEnvGrid.tsx

@@ -0,0 +1,193 @@
+import React, { useMemo } from "react";
+import { RawDeploymentTarget } from "./PreviewEnvs";
+import { match } from "ts-pattern";
+import _ from "lodash";
+import styled from "styled-components";
+import { Link } from "react-router-dom";
+
+import time from "assets/time.png";
+import healthy from "assets/status-healthy.png";
+import notFound from "assets/not-found.png";
+import pull_request from "assets/pull_request_icon.svg";
+
+import { search } from "shared/search";
+import Fieldset from "components/porter/Fieldset";
+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 { readableDate } from "shared/string_utils";
+
+type PreviewEnvGridProps = {
+  deploymentTargets: RawDeploymentTarget[];
+  searchValue: string;
+  view: "grid" | "list";
+  sort: "letter" | "calendar";
+};
+
+const PreviewEnvGrid: React.FC<PreviewEnvGridProps> = ({
+  deploymentTargets,
+  searchValue,
+  view,
+  sort,
+}) => {
+  const filteredEnvs = useMemo(() => {
+    const filteredBySearch = search(deploymentTargets ?? [], searchValue, {
+      keys: ["selector"],
+      isCaseSensitive: false,
+    });
+
+    return match(sort)
+      .with("calendar", () =>
+        _.sortBy(filteredBySearch, ["created_at"]).reverse()
+      )
+      .with("letter", () => _.sortBy(filteredBySearch, ["selector"]))
+      .exhaustive();
+  }, [deploymentTargets, searchValue, sort]);
+
+  if (filteredEnvs.length === 0) {
+    return (
+      <Fieldset>
+        <Container row>
+          <PlaceholderIcon src={notFound} />
+          <Text color="helper">No matching environments were found.</Text>
+        </Container>
+      </Fieldset>
+    );
+  }
+
+  return match(view)
+    .with("grid", () => (
+      <GridList>
+        {(filteredEnvs ?? []).map((env) => {
+          return (
+            <Link
+              to={`/preview-environments/apps?target=${env.id}`}
+              key={env.selector}
+            >
+              <Block>
+                <Container row>
+                  <Icon height="18px" src={pull_request} />
+                  <Spacer inline width="12px" />
+                  <Text size={14}>{env.selector}</Text>
+                  <Spacer inline x={2} />
+                </Container>
+                <StatusIcon src={healthy} />
+                <Container row>
+                  <SmallIcon opacity="0.4" src={time} />
+                  <Text size={13} color="#ffffff44">
+                    {readableDate(env.created_at)}
+                  </Text>
+                </Container>
+              </Block>
+            </Link>
+          );
+        })}
+      </GridList>
+    ))
+    .with("list", () => (
+      <List>
+        {(filteredEnvs ?? []).map((env) => {
+          return (
+            <Link
+              to={`/preview-environments/apps?target=${env.id}`}
+              key={env.selector}
+            >
+              <Row>
+                <Container row>
+                  <Spacer inline width="1px" />
+                  <Icon height="18px" src={pull_request} />
+                  <Spacer inline width="12px" />
+                  <Text size={14}>{env.selector}</Text>
+                  <Spacer inline x={1} />
+                  <Icon height="16px" src={healthy} />
+                </Container>
+                <Spacer height="15px" />
+                <Container row>
+                  <Spacer inline x={1} />
+                  <SmallIcon opacity="0.4" src={time} />
+                  <Text size={13} color="#ffffff44">
+                    {readableDate(env.created_at)}
+                  </Text>
+                </Container>
+              </Row>
+            </Link>
+          );
+        })}
+      </List>
+    ))
+    .exhaustive();
+};
+
+export default PreviewEnvGrid;
+
+const PlaceholderIcon = styled.img`
+  height: 13px;
+  margin-right: 12px;
+  opacity: 0.65;
+`;
+
+const GridList = styled.div`
+  display: grid;
+  grid-column-gap: 25px;
+  grid-row-gap: 25px;
+  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+`;
+
+const Block = styled.div`
+  height: 150px;
+  flex-direction: column;
+  display: flex;
+  justify-content: space-between;
+  cursor: pointer;
+  padding: 20px;
+  color: ${(props) => props.theme.text.primary};
+  position: relative;
+  border-radius: 5px;
+  background: ${(props) => props.theme.clickable.bg};
+  border: 1px solid #494b4f;
+  :hover {
+    border: 1px solid #7a7b80;
+  }
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const StatusIcon = styled.img`
+  position: absolute;
+  top: 20px;
+  right: 20px;
+  height: 18px;
+`;
+
+const List = styled.div`
+  overflow: hidden;
+`;
+
+const Row = styled.div<{ isAtBottom?: boolean }>`
+  cursor: pointer;
+  padding: 15px;
+  border-bottom: ${(props) =>
+    props.isAtBottom ? "none" : "1px solid #494b4f"};
+  background: ${(props) => props.theme.clickable.bg};
+  position: relative;
+  border: 1px solid #494b4f;
+  border-radius: 5px;
+  margin-bottom: 15px;
+  animation: fadeIn 0.3s 0s;
+`;
+
+const SmallIcon = 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;
+`;

+ 229 - 0
dashboard/src/main/home/cluster-dashboard/preview-environments/v2/PreviewEnvs.tsx

@@ -0,0 +1,229 @@
+import { useQuery } from "@tanstack/react-query";
+import Loading from "components/Loading";
+import Container from "components/porter/Container";
+import Link from "components/porter/Link";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import React, { useContext, useState } from "react";
+import { Context } from "shared/Context";
+import api from "shared/api";
+import styled from "styled-components";
+import { z } from "zod";
+
+import PullRequestIcon from "assets/pull_request_icon.svg";
+import grid from "assets/grid.png";
+import list from "assets/list.png";
+import letter from "assets/vector.svg";
+import calendar from "assets/calendar-number.svg";
+
+import PorterLink from "components/porter/Link";
+import SearchBar from "components/porter/SearchBar";
+import Toggle from "components/porter/Toggle";
+import DashboardHeader from "../../DashboardHeader";
+import Fieldset from "components/porter/Fieldset";
+import Button from "components/porter/Button";
+import PreviewEnvGrid from "./PreviewEnvGrid";
+
+const rawDeploymentTargetValidator = z.object({
+  id: z.string(),
+  project_id: z.number(),
+  cluster_id: z.number(),
+  selector: z.string(),
+  selector_type: z.string(),
+  created_at: z.string(),
+  updated_at: z.string(),
+});
+export type RawDeploymentTarget = z.infer<typeof rawDeploymentTargetValidator>;
+
+const PreviewEnvs: React.FC = () => {
+  const { currentProject, currentCluster } = useContext(Context);
+
+  const [searchValue, setSearchValue] = useState("");
+  const [view, setView] = useState<"grid" | "list">("grid");
+  const [sort, setSort] = useState<"calendar" | "letter">("calendar");
+
+  const { data: deploymentTargets = [], status } = useQuery(
+    ["listDeploymentTargets", currentProject?.id, currentCluster?.id],
+    async () => {
+      if (!currentProject || !currentCluster) {
+        return;
+      }
+
+      const res = await api.listDeploymentTargets(
+        "<token>",
+        {
+          preview: true,
+        },
+        {
+          project_id: currentProject?.id,
+          cluster_id: currentCluster?.id,
+        }
+      );
+
+      const deploymentTargets = await z
+        .object({
+          deployment_targets: z.array(rawDeploymentTargetValidator),
+        })
+        .parseAsync(res.data);
+
+      return deploymentTargets.deployment_targets;
+    },
+    {
+      enabled: !!currentProject && !!currentCluster,
+    }
+  );
+
+  const renderContents = () => {
+    if (status === "loading") {
+      return <Loading offset="-150px" />;
+    }
+
+    if (!deploymentTargets || deploymentTargets.length === 0) {
+      <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={0.5} />
+        </CentralContainer>
+      </Fieldset>;
+    }
+
+    return (
+      <>
+        <Container row spaced>
+          <SearchBar
+            value={searchValue}
+            setValue={(x) => {
+              setSearchValue(x);
+            }}
+            placeholder="Search environments . . ."
+            width="100%"
+          />
+          <Spacer inline x={2} />
+          <Toggle
+            items={[
+              { label: <ToggleIcon src={calendar} />, value: "calendar" },
+              { label: <ToggleIcon src={letter} />, value: "letter" },
+            ]}
+            active={sort}
+            setActive={(x) => {
+              if (x === "calendar") {
+                setSort("calendar");
+              } else {
+                setSort("letter");
+              }
+            }}
+          />
+          <Spacer inline x={1} />
+
+          <Toggle
+            items={[
+              { label: <ToggleIcon src={grid} />, value: "grid" },
+              { label: <ToggleIcon src={list} />, value: "list" },
+            ]}
+            active={view}
+            setActive={(x) => {
+              if (x === "grid") {
+                setView("grid");
+              } else {
+                setView("list");
+              }
+            }}
+          />
+        </Container>
+        <Spacer y={1} />
+        <PreviewEnvGrid
+          deploymentTargets={deploymentTargets}
+          sort={sort}
+          view={view}
+          searchValue={searchValue}
+        />
+      </>
+    );
+  };
+
+  return (
+    <StyledAppDashboard>
+      <DashboardHeader
+        image={PullRequestIcon}
+        title="Preview Environments"
+        description="Preview environments are created for each pull request. They are automatically deleted when the pull request is closed."
+        disableLineBreak
+      />
+      {renderContents()}
+      <Spacer y={5} />
+    </StyledAppDashboard>
+  );
+};
+
+export default PreviewEnvs;
+
+const StyledAppDashboard = styled.div`
+  width: 100%;
+  height: 100%;
+`;
+
+const CentralContainer = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: left;
+  align-items: left;
+`;
+
+const ToggleIcon = styled.img`
+  height: 12px;
+  margin: 0 5px;
+  min-width: 12px;
+`;
+
+const GridList = styled.div`
+  display: grid;
+  grid-column-gap: 25px;
+  grid-row-gap: 25px;
+  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+`;
+
+const Block = styled.div`
+  height: 150px;
+  flex-direction: column;
+  display: flex;
+  justify-content: space-between;
+  cursor: pointer;
+  padding: 20px;
+  color: ${(props) => props.theme.text.primary};
+  position: relative;
+  border-radius: 5px;
+  background: ${(props) => props.theme.clickable.bg};
+  border: 1px solid #494b4f;
+  :hover {
+    border: 1px solid #7a7b80;
+  }
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const StatusIcon = styled.img`
+  position: absolute;
+  top: 20px;
+  right: 20px;
+  height: 18px;
+`;
+
+const SmallIcon = 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;
+`;

+ 71 - 0
dashboard/src/shared/DeploymentTargetContext.tsx

@@ -0,0 +1,71 @@
+import { useDefaultDeploymentTarget } from "lib/hooks/useDeploymentTarget";
+import React, {
+  Dispatch,
+  SetStateAction,
+  createContext,
+  useContext,
+  useMemo,
+  useState,
+} from "react";
+import { useLocation } from "react-router";
+
+export type DeploymentTarget = {
+  id: string;
+  preview: boolean;
+};
+
+export const DeploymentTargetContext = createContext<{
+  currentDeploymentTarget: DeploymentTarget | null;
+} | null>(null);
+
+export const useDeploymentTarget = () => {
+  const context = useContext(DeploymentTargetContext);
+  if (context === null) {
+    throw new Error(
+      "useDeploymentTarget must be used within a DeploymentTargetContext"
+    );
+  }
+  return context;
+};
+
+const DeploymentTargetProvider = ({ children }: { children: JSX.Element }) => {
+  const { search } = useLocation();
+  const queryParams = new URLSearchParams(search);
+
+  const idParam = queryParams.get("target");
+  const defaultDeploymentTarget = useDefaultDeploymentTarget();
+
+  const deploymentTarget: DeploymentTarget | null = useMemo(() => {
+    if (!idParam && !defaultDeploymentTarget) {
+      return null;
+    }
+
+    if (idParam) {
+      return {
+        id: idParam,
+        preview: true,
+      };
+    }
+
+    if (defaultDeploymentTarget) {
+      return {
+        id: defaultDeploymentTarget.deployment_target_id,
+        preview: false,
+      };
+    }
+
+    return null;
+  }, [idParam, defaultDeploymentTarget]);
+
+  return (
+    <DeploymentTargetContext.Provider
+      value={{
+        currentDeploymentTarget: deploymentTarget,
+      }}
+    >
+      {children}
+    </DeploymentTargetContext.Provider>
+  );
+};
+
+export default DeploymentTargetProvider;

+ 17 - 2
dashboard/src/shared/api.tsx

@@ -322,7 +322,7 @@ const appPodStatus = baseApi<
     deployment_target_id: string;
     service: string;
   },
-  { project_id: number; cluster_id: number, app_name: string }
+  { project_id: number; cluster_id: number; app_name: string }
 >("GET", ({ project_id, cluster_id, app_name }) => {
   return `/api/projects/${project_id}/clusters/${cluster_id}/apps/${app_name}/pods`;
 });
@@ -1000,7 +1000,9 @@ const listAppRevisions = baseApi<
 });
 
 const getLatestAppRevisions = baseApi<
-  {},
+  {
+    deployment_target_id: string;
+  },
   {
     project_id: number;
     cluster_id: number;
@@ -1009,6 +1011,18 @@ const getLatestAppRevisions = baseApi<
   return `/api/projects/${project_id}/clusters/${cluster_id}/apps/revisions`;
 });
 
+const listDeploymentTargets = baseApi<
+  {
+    preview: boolean;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+  }
+>("GET", ({ project_id, cluster_id }) => {
+  return `/api/projects/${project_id}/clusters/${cluster_id}/deployment-targets`;
+});
+
 const getGitlabProcfileContents = baseApi<
   {
     path: string;
@@ -3132,6 +3146,7 @@ export default {
   getRevision,
   listAppRevisions,
   getLatestAppRevisions,
+  listDeploymentTargets,
   getGitlabProcfileContents,
   getProjectClusters,
   getProjectRegistries,

+ 14 - 0
internal/models/deployment_target.go

@@ -2,6 +2,7 @@ package models
 
 import (
 	"github.com/google/uuid"
+	"github.com/porter-dev/porter/api/types"
 	"gorm.io/gorm"
 )
 
@@ -35,3 +36,16 @@ type DeploymentTarget struct {
 	// Preview is a boolean indicating whether this target is a preview target.
 	Preview bool `gorm:"default:false" json:"preview"`
 }
+
+// ToDeploymentTargetType generates an external types.PorterApp to be shared over REST
+func (d *DeploymentTarget) ToDeploymentTargetType() *types.DeploymentTarget {
+	return &types.DeploymentTarget{
+		ID:           d.ID,
+		ProjectID:    uint(d.ProjectID),
+		ClusterID:    uint(d.ClusterID),
+		Selector:     d.Selector,
+		SelectorType: string(d.SelectorType),
+		CreatedAt:    d.CreatedAt,
+		UpdatedAt:    d.UpdatedAt,
+	}
+}

+ 1 - 1
internal/repository/deployment_target.go

@@ -9,7 +9,7 @@ type DeploymentTargetRepository interface {
 	// DeploymentTargetBySelectorAndSelectorType finds a deployment target for a projectID and clusterID by its selector and selector type
 	DeploymentTargetBySelectorAndSelectorType(projectID uint, clusterID uint, selector, selectorType string) (*models.DeploymentTarget, error)
 	// List returns all deployment targets for a project
-	List(projectID uint) ([]*models.DeploymentTarget, error)
+	List(projectID uint, clusterID uint, preview bool) ([]*models.DeploymentTarget, error)
 	// CreateDeploymentTarget creates a new deployment target
 	CreateDeploymentTarget(deploymentTarget *models.DeploymentTarget) (*models.DeploymentTarget, error)
 }

+ 2 - 7
internal/repository/gorm/deployment_target.go

@@ -29,18 +29,13 @@ func (repo *DeploymentTargetRepository) DeploymentTargetBySelectorAndSelectorTyp
 		return nil, err
 	}
 
-	if deploymentTarget.ID == uuid.Nil {
-		return nil, errors.New("deployment target not found")
-	}
-
 	return deploymentTarget, nil
 }
 
 // List finds all deployment targets for a given project
-func (repo *DeploymentTargetRepository) List(projectID uint) ([]*models.DeploymentTarget, error) {
+func (repo *DeploymentTargetRepository) List(projectID uint, clusterID uint, preview bool) ([]*models.DeploymentTarget, error) {
 	deploymentTargets := []*models.DeploymentTarget{}
-
-	if err := repo.db.Where("project_id = ?", projectID).Find(&deploymentTargets).Error; err != nil {
+	if err := repo.db.Where("project_id = ? AND cluster_id = ? AND preview = ?", projectID, clusterID, preview).Find(&deploymentTargets).Error; err != nil {
 		return nil, err
 	}
 

+ 1 - 1
internal/repository/test/deployment_target.go

@@ -23,7 +23,7 @@ func (repo *DeploymentTargetRepository) DeploymentTargetBySelectorAndSelectorTyp
 }
 
 // List returns all deployment targets for a project
-func (repo *DeploymentTargetRepository) List(projectID uint) ([]*models.DeploymentTarget, error) {
+func (repo *DeploymentTargetRepository) List(projectID uint, clusterID uint, preview bool) ([]*models.DeploymentTarget, error) {
 	return nil, errors.New("cannot read database")
 }