Ver Fonte

checkpoint

Ian Edwards há 2 anos atrás
pai
commit
f2ac144073

+ 85 - 0
api/server/handlers/deployment_target/get.go

@@ -0,0 +1,85 @@
+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/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/deployment_target"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// GetDeploymentTargetHandler is the handler for the /deployment-targets/{deployment_target_id} endpoint
+type GetDeploymentTargetHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+// NewGetDeploymentTargetHandler handles GET requests to the endpoint /deployment-targets/{deployment_target_id}
+func NewGetDeploymentTargetHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GetDeploymentTargetHandler {
+	return &GetDeploymentTargetHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+// GetDeploymentTargetRequest is the request object for the /deployment-targets/{deployment_target_id} GET endpoint
+type GetDeploymentTargetRequest struct {
+	Preview bool `json:"preview"`
+}
+
+// GetDeploymentTargetResponse is the response object for the /deployment-targets/{deployment_target_id} GET endpoint
+type GetDeploymentTargetResponse struct {
+	DeploymentTarget deployment_target.DeploymentTarget `json:"deployment_target"`
+}
+
+func (c *GetDeploymentTargetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-get-deployment-target")
+	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
+	}
+
+	deploymentTargetID, reqErr := requestutils.GetURLParamString(r, types.URLParamDeploymentTargetID)
+	if reqErr != nil {
+		err := telemetry.Error(ctx, span, reqErr, "error parsing deployment target id")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	if deploymentTargetID == "" {
+		err := telemetry.Error(ctx, span, nil, "deployment target id cannot be empty")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	deploymentTarget, err := deployment_target.DeploymentTargetDetails(ctx, deployment_target.DeploymentTargetDetailsInput{
+		ProjectID:          int64(project.ID),
+		ClusterID:          int64(cluster.ID),
+		DeploymentTargetID: deploymentTargetID,
+		CCPClient:          c.Config().ClusterControlPlaneClient,
+	})
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error getting deployment target details")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	res := &GetDeploymentTargetResponse{
+		DeploymentTarget: deploymentTarget,
+	}
+
+	c.WriteResult(w, r, res)
+}

+ 3 - 3
api/server/handlers/porter_app/get_app_template.go

@@ -14,12 +14,12 @@ import (
 	"github.com/porter-dev/porter/internal/telemetry"
 )
 
-// GetAppTemplateHandler is the handler for the /app-template endpoint
+// GetAppTemplateHandler is the handler for the /apps/{porter_app_name}/templates endpoint
 type GetAppTemplateHandler struct {
 	handlers.PorterHandlerReadWriter
 }
 
-// NewGetAppTemplateHandler handles GET requests to the endpoint /apps/{porter_app_name}/app-template
+// NewGetAppTemplateHandler handles GET requests to the endpoint /apps/{porter_app_name}/templates
 func NewGetAppTemplateHandler(
 	config *config.Config,
 	decoderValidator shared.RequestDecoderValidator,
@@ -30,7 +30,7 @@ func NewGetAppTemplateHandler(
 	}
 }
 
-// GetAppTemplateResponse is the response object for the /apps/{porter_app_name}/app-template GET endpoint
+// GetAppTemplateResponse is the response object for the /apps/{porter_app_name}/templates GET endpoint
 type GetAppTemplateResponse struct {
 	TemplateB64AppProto string `json:"template_b64_app_proto"`
 }

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

@@ -145,5 +145,34 @@ func getDeploymentTargetRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/deployment-targets/{deployment_target_id} -> deployment_target.GetDeploymentTargetHandler
+	getDeploymentTargetEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/{%s}", relPath, types.URLParamDeploymentTargetID),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	getDeploymentTargetHandler := deployment_target.NewGetDeploymentTargetHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: getDeploymentTargetEndpoint,
+		Handler:  getDeploymentTargetHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 2 - 2
api/server/router/porter_app.go

@@ -1241,7 +1241,7 @@ func getPorterAppRoutes(
 		Router:   r,
 	})
 
-	// GET /api/projects/{project_id}/clusters/{cluster_id}/apps/{porter_app_name}/app-templates -> porter_app.NewGetAppTemplateHandler
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/apps/{porter_app_name}/templates -> porter_app.NewGetAppTemplateHandler
 	getAppTemplateEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
@@ -1270,7 +1270,7 @@ func getPorterAppRoutes(
 		Router:   r,
 	})
 
-	// POST /api/projects/{project_id}/clusters/{cluster_id}/apps/{porter_app_name}/app-templates -> porter_app.NewCreateAppTemplateHandler
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/apps/{porter_app_name}/templates -> porter_app.NewCreateAppTemplateHandler
 	createAppTemplateEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbCreate,

+ 35 - 59
dashboard/src/lib/hooks/useGithubWorkflow.ts

@@ -6,10 +6,15 @@ import { Context } from "shared/Context";
 import api from "shared/api";
 import { z } from "zod";
 
-export const useGithubWorkflow = (
-  porterApp: PorterAppRecord,
-  previouslyBuilt: boolean
-) => {
+export const useGithubWorkflow = ({
+  porterApp,
+  fileNames,
+  previouslyBuilt = false,
+}: {
+  porterApp: PorterAppRecord;
+  fileNames: string[];
+  previouslyBuilt?: boolean;
+}) => {
   const { currentProject, currentCluster } = useContext(Context);
   const [githubWorkflowFilename, setGithubWorkflowName] = useState<string>("");
   const [userHasGithubAccess, setUserHasGithubAccess] = useState<boolean>(true);
@@ -83,71 +88,42 @@ export const useGithubWorkflow = (
     !!currentCluster &&
     githubWorkflowFilename === "";
 
-  const [
-    {
-      data: applicationWorkflowCheck,
-      isLoading: isLoadingApplicationWorkflow,
-    },
-    { data: defaultWorkflowCheck, isLoading: isLoadingDefaultWorkflow },
-  ] = useQueries({
-    queries: [
-      {
-        queryKey: [
-          `checkForApplicationWorkflow_porter_stack_${porterApp.name}`,
-          currentProject?.id,
-          currentCluster?.id,
-          githubWorkflowFilename,
-          previouslyBuilt,
-        ],
-        queryFn: () =>
-          fetchGithubWorkflow(`porter_stack_${porterApp.name}.yml`),
-        enabled,
-        refetchInterval: 5000,
-        retry: (_failureCount: number, error: unknown) => {
-          if (axios.isAxiosError(error) && error.response?.status === 403) {
-            setUserHasGithubAccess(false);
-            return false;
-          }
-
-          return true;
-        },
-        refetchOnWindowFocus: false,
-      },
-      {
-        queryKey: [
-          `checkForApplicationWorkflow_porter`,
-          currentProject?.id,
-          currentCluster?.id,
-          githubWorkflowFilename,
-          previouslyBuilt,
-        ],
-        queryFn: () => fetchGithubWorkflow("porter.yml"),
-        enabled,
-        refetchInterval: 5000,
-        retry: (_failureCount: number, error: unknown) => {
-          if (axios.isAxiosError(error) && error.response?.status === 403) {
-            setUserHasGithubAccess(false);
-            return false;
-          }
+  const results = useQueries({
+    queries: fileNames.map((fn) => ({
+      queryKey: [
+        `checkForApplicationWorkflow_${fn}`,
+        currentProject?.id,
+        currentCluster?.id,
+        fn,
+        previouslyBuilt,
+      ],
+      queryFn: () => fetchGithubWorkflow(fn),
+      enabled,
+      refetchInterval: 5000,
+      retry: (_failureCount: number, error: unknown) => {
+        if (axios.isAxiosError(error) && error.response?.status === 403) {
+          setUserHasGithubAccess(false);
+          return false;
+        }
 
-          return true;
-        },
-        refetchOnWindowFocus: false,
+        return true;
       },
-    ],
+      refetchOnWindowFocus: false,
+    })),
   });
 
   useEffect(() => {
-    if (!!applicationWorkflowCheck) {
+    const applicationWorkflowCheck = results
+      .map(({ data }) => data)
+      .find((d) => !!d);
+    if (applicationWorkflowCheck) {
       setGithubWorkflowName(applicationWorkflowCheck);
-    } else if (!!defaultWorkflowCheck) {
-      setGithubWorkflowName(defaultWorkflowCheck);
     }
-  }, [applicationWorkflowCheck, defaultWorkflowCheck]);
+  }, [results]);
 
   return {
     githubWorkflowFilename,
-    isLoading: isLoadingApplicationWorkflow || isLoadingDefaultWorkflow,
+    isLoading: results.some((r) => r.isLoading),
     userHasGithubAccess,
   };
 };

+ 31 - 17
dashboard/src/main/home/app-dashboard/app-view/AppHeader.tsx

@@ -15,6 +15,7 @@ import styled from "styled-components";
 import { useLatestRevision } from "./LatestRevisionContext";
 import { prefixSubdomain } from "lib/porter-apps/services";
 import { readableDate } from "shared/string_utils";
+import PullRequestIcon from "shared/icons/PullRequest";
 
 // Buildpack icons
 const icons = [
@@ -26,7 +27,12 @@ const icons = [
 ];
 
 const AppHeader: React.FC = () => {
-  const { latestProto, porterApp, latestRevision } = useLatestRevision();
+  const {
+    latestProto,
+    porterApp,
+    latestRevision,
+    deploymentTarget,
+  } = useLatestRevision();
 
   const gitData = useMemo(() => {
     if (
@@ -109,11 +115,20 @@ const AppHeader: React.FC = () => {
               </A>
             </Container>
             <Spacer inline x={1} />
-            <TagWrapper>
-              Branch
-              <BranchTag>
-                <BranchIcon src={pr_icon} />
-                {gitData.branch}
+            <TagWrapper preview={deploymentTarget.preview}>
+              {deploymentTarget.preview ? "Preview" : "Branch"}
+              <BranchTag preview={deploymentTarget.preview}>
+                <PullRequestIcon
+                  styles={{
+                    height: "14px",
+                    opacity: "0.65",
+                    marginRight: "5px",
+                    fill: deploymentTarget.preview ? "" : "#fff",
+                  }}
+                />
+                {deploymentTarget.preview
+                  ? deploymentTarget.namespace
+                  : gitData.branch}
               </BranchTag>
             </TagWrapper>
           </>
@@ -164,27 +179,26 @@ const SmallIcon = styled.img<{ opacity?: string; height?: string }>`
   opacity: ${(props) => props.opacity || 1};
   margin-right: 10px;
 `;
-const BranchIcon = styled.img`
-  height: 14px;
-  opacity: 0.65;
-  margin-right: 5px;
-`;
-const TagWrapper = styled.div`
+
+const TagWrapper = styled.div<{ preview?: boolean }>`
   height: 20px;
   font-size: 12px;
   display: flex;
   align-items: center;
   justify-content: center;
-  color: #ffffff44;
-  border: 1px solid #ffffff44;
+  background: ${(props) => (props.preview ? "#fefce8" : "")};
+  color: ${(props) => (props.preview ? "#ca8a04" : "#ffffff44")};
+  border: 1px solid ${(props) => (props.preview ? "#ca8a04" : "#ffffff44")};
   border-radius: 3px;
   padding-left: 6px;
 `;
-const BranchTag = styled.div`
+
+const BranchTag = styled.div<{ preview?: boolean }>`
   height: 20px;
   margin-left: 6px;
-  color: #aaaabb;
-  background: #ffffff22;
+  color: ${(props) => (props.preview ? "#ca8a04" : "#aaaabb")};
+  background: ${(props) => (props.preview ? "#fefce8" : "#ffffff22")};
+  border: 1px solid ${(props) => (props.preview ? "#ca8a04" : "#ffffff44")};
   border-radius: 3px;
   font-size: 12px;
   display: flex;

+ 47 - 2
dashboard/src/main/home/app-dashboard/app-view/LatestRevisionContext.tsx

@@ -33,7 +33,7 @@ export const LatestRevisionContext = createContext<{
   servicesFromYaml: DetectedServices | null;
   clusterId: number;
   projectId: number;
-  deploymentTarget: DeploymentTarget;
+  deploymentTarget: DeploymentTarget & { namespace: string };
   previewRevision: AppRevision | null;
   attachedEnvGroups: PopulatedEnvGroup[];
   appEnv?: PopulatedEnvGroup;
@@ -132,6 +132,46 @@ export const LatestRevisionProvider = ({
     }
   );
 
+  const { data, status: deploymentTargetStatus } = useQuery(
+    [
+      "getDeploymentTarget",
+      {
+        cluster_id: currentCluster?.id,
+        project_id: currentProject?.id,
+        deployment_target_id: currentDeploymentTarget?.id,
+      },
+    ],
+    async () => {
+      if (!currentCluster || !currentProject || !currentDeploymentTarget) {
+        return;
+      }
+      const res = await api.getDeploymentTarget(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          deployment_target_id: currentDeploymentTarget.id,
+        }
+      );
+
+      const { deployment_target } = await z
+        .object({
+          deployment_target: z.object({
+            cluster_id: z.number(),
+            namespace: z.string(),
+            preview: z.boolean(),
+          }),
+        })
+        .parseAsync(res.data);
+
+      return deployment_target;
+    },
+    {
+      enabled: !!currentCluster && !!currentProject,
+    }
+  );
+
   const revisionId = previewRevision?.id ?? latestRevision?.id;
   const { data: { attachedEnvGroups = [], appEnv } = {} } = useQuery(
     ["getAttachedEnvGroups", appName, revisionId],
@@ -221,6 +261,7 @@ export const LatestRevisionProvider = ({
   if (
     status === "loading" ||
     porterAppStatus === "loading" ||
+    deploymentTargetStatus === "loading" ||
     !appParamsExist ||
     porterYamlLoading
   ) {
@@ -230,6 +271,7 @@ export const LatestRevisionProvider = ({
   if (
     status === "error" ||
     porterAppStatus === "error" ||
+    deploymentTargetStatus === "error" ||
     !latestRevision ||
     !latestProto ||
     !porterApp
@@ -256,7 +298,10 @@ export const LatestRevisionProvider = ({
         porterApp,
         clusterId: currentCluster.id,
         projectId: currentProject.id,
-        deploymentTarget: currentDeploymentTarget,
+        deploymentTarget: {
+          ...currentDeploymentTarget,
+          namespace: data?.namespace ?? "",
+        },
         servicesFromYaml: detectedServices,
         attachedEnvGroups,
         appEnv,

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

@@ -11,8 +11,8 @@ import { useLatestRevision } from "../LatestRevisionContext";
 import api from "shared/api";
 import { useAppAnalytics } from "lib/hooks/useAppAnalytics";
 import { useQueryClient } from "@tanstack/react-query";
-import { Link } from "react-router-dom";
 import { Context } from "shared/Context";
+import PreviewEnvironmentSettings from "./preview-environments/PreviewEnvironmentSettings";
 
 const Settings: React.FC = () => {
   const { currentProject } = useContext(Context);
@@ -139,32 +139,7 @@ const Settings: React.FC = () => {
 
   return (
     <StyledSettingsTab>
-      {currentProject?.preview_envs_enabled && (
-        <>
-          <Text size={16}>
-            Enable preview environments for "{porterApp.name}"
-          </Text>
-          <Spacer y={0.5} />
-          <Text color="helper">
-            Setup your application to automatically create preview environments
-            for each pull request.
-          </Text>
-          <Spacer y={0.5} />
-          <Link
-            to={`/preview-environments/configure?app_name=${porterApp.name}`}
-          >
-            <Button
-              type="button"
-              onClick={() => {
-                setIsDeleteModalOpen(true);
-              }}
-            >
-              Enable
-            </Button>
-          </Link>
-          <Spacer y={1} />
-        </>
-      )}
+      {currentProject?.preview_envs_enabled && <PreviewEnvironmentSettings />}
       <Text size={16}>Delete "{porterApp.name}"</Text>
       <Spacer y={0.5} />
       <Text color="helper">

+ 86 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/preview-environments/PreviewEnvironmentSettings.tsx

@@ -0,0 +1,86 @@
+import Button from "components/porter/Button";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import React from "react";
+import { Link } from "react-router-dom";
+import { useLatestRevision } from "../../LatestRevisionContext";
+import { useQuery } from "@tanstack/react-query";
+import api from "shared/api";
+import { useGithubWorkflow } from "lib/hooks/useGithubWorkflow";
+import styled from "styled-components";
+import healthy from "assets/status-healthy.png";
+import Icon from "components/porter/Icon";
+
+type Props = {};
+
+const PreviewEnvironmentSettings: React.FC<Props> = ({}) => {
+  const { porterApp, clusterId, projectId } = useLatestRevision();
+
+  const { data: templateExists, status } = useQuery(
+    ["getAppTemplate", projectId, clusterId, porterApp.name],
+    async () => {
+      try {
+        await api.getAppTemplate(
+          "<token>",
+          {},
+          {
+            project_id: projectId,
+            cluster_id: clusterId,
+            porter_app_name: porterApp.name,
+          }
+        );
+
+        return true;
+      } catch (err) {
+        return false;
+      }
+    }
+  );
+
+  const { githubWorkflowFilename, isLoading } = useGithubWorkflow({
+    porterApp,
+    fileNames: [`porter_preview_${porterApp.name}.yml`],
+  });
+
+  if (status === "loading" || isLoading) {
+    return null;
+  }
+
+  return (
+    <>
+      {templateExists && githubWorkflowFilename ? (
+        <EnabledContainer>
+          <Text size={16}>Preview Environments Enabled</Text>
+          <Icon src={healthy} />
+        </EnabledContainer>
+      ) : (
+        <Text size={16}>
+          Enable preview environments for "{porterApp.name}"
+        </Text>
+      )}
+      <Spacer y={0.5} />
+      <Text color="helper">
+        {templateExists && githubWorkflowFilename
+          ? "Preview environments are enabled for this app"
+          : "Setup your app to automatically create preview environments for each pull request."}
+      </Text>
+      <Spacer y={0.5} />
+      <Link to={`/preview-environments/configure?app_name=${porterApp.name}`}>
+        <Button type="button">
+          {templateExists && githubWorkflowFilename
+            ? "Update Settings"
+            : "Enable"}
+        </Button>
+      </Link>
+      <Spacer y={1} />
+    </>
+  );
+};
+
+export default PreviewEnvironmentSettings;
+
+const EnabledContainer = styled.div`
+  display: flex;
+  align-items: center;
+  column-gap: 0.75rem;
+`;

+ 86 - 7
dashboard/src/main/home/app-dashboard/apps/Apps.tsx

@@ -7,6 +7,7 @@ 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 pull_request from "assets/pull_request_icon.svg";
 
 import { Context } from "shared/Context";
 import api from "shared/api";
@@ -83,12 +84,58 @@ const Apps: React.FC<Props> = ({ }) => {
     }
   );
 
+  const { data, status: deploymentTargetStatus } = useQuery(
+    [
+      "getDeploymentTarget",
+      {
+        cluster_id: currentCluster?.id,
+        project_id: currentProject?.id,
+        deployment_target_id: currentDeploymentTarget?.id,
+      },
+    ],
+    async () => {
+      if (!currentCluster || !currentProject || !currentDeploymentTarget) {
+        return;
+      }
+      const res = await api.getDeploymentTarget(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          deployment_target_id: currentDeploymentTarget.id,
+        }
+      );
+
+      const { deployment_target } = await z
+        .object({
+          deployment_target: z.object({
+            cluster_id: z.number(),
+            namespace: z.string(),
+            preview: z.boolean(),
+          }),
+        })
+        .parseAsync(res.data);
+
+      return deployment_target;
+    },
+    {
+      enabled:
+        !!currentCluster &&
+        !!currentProject &&
+        currentDeploymentTarget?.preview,
+    }
+  );
+
   const renderContents = () => {
     if (currentCluster?.status === "UPDATING_UNAVAILABLE") {
       return <ClusterProvisioningPlaceholder />;
     }
 
-    if (status === "loading") {
+    if (
+      status === "loading" ||
+      (currentDeploymentTarget?.preview && deploymentTargetStatus === "loading")
+    ) {
       return <Loading offset="-150px" />;
     }
 
@@ -121,6 +168,26 @@ const Apps: React.FC<Props> = ({ }) => {
 
     return (
       <>
+        {currentDeploymentTarget?.preview && (
+          <DashboardHeader
+            image={pull_request}
+            title={
+              <div
+                style={{
+                  display: "flex",
+                  columnGap: "0.75rem",
+                  alignItems: "center",
+                }}
+              >
+                <div>{data?.namespace ?? "Preview Apps"}</div>
+                <Badge>Preview</Badge>
+              </div>
+            }
+            description={"Apps deployed to this preview environment"}
+            disableLineBreak
+            capitalize={false}
+          />
+        )}
         <Container row spaced>
           <SearchBar
             value={searchValue}
@@ -188,12 +255,14 @@ const Apps: React.FC<Props> = ({ }) => {
 
   return (
     <StyledAppDashboard>
-      <DashboardHeader
-        image={web}
-        title="Applications"
-        description="Web services, workers, and jobs for this project."
-        disableLineBreak
-      />
+      {!currentDeploymentTarget?.preview && (
+        <DashboardHeader
+          image={web}
+          title="Applications"
+          description="Web services, workers, and jobs for this project."
+          disableLineBreak
+        />
+      )}
       {renderContents()}
       <Spacer y={5} />
     </StyledAppDashboard>
@@ -228,3 +297,13 @@ const CentralContainer = styled.div`
   justify-content: left;
   align-items: left;
 `;
+
+const Badge = styled.div`
+  border: 1px solid #ca8a04;
+  background-color: #fefce8;
+  color: #ca8a04;
+  padding: 0.15rem 0.3rem;
+  text-align: center;
+  border-radius: 3px;
+  font-size: 12px;
+`;

+ 6 - 2
dashboard/src/main/home/app-dashboard/validate-apply/revisions-list/GHStatusBanner.tsx

@@ -36,7 +36,11 @@ const GHStatusBanner: React.FC<GHStatusBannerProps> = ({ revisions }) => {
     githubWorkflowFilename,
     userHasGithubAccess,
     isLoading,
-  } = useGithubWorkflow(porterApp, previouslyBuilt);
+  } = useGithubWorkflow({
+    porterApp,
+    previouslyBuilt,
+    fileNames: ["porter.yml", `porter_stack_${porterApp.name}.yml`],
+  });
 
   if (previouslyBuilt) {
     return null;
@@ -87,7 +91,7 @@ const GHStatusBanner: React.FC<GHStatusBannerProps> = ({ revisions }) => {
     );
   }
 
-  return null
+  return null;
 };
 
 export default GHStatusBanner;

+ 83 - 46
dashboard/src/shared/api.tsx

@@ -352,8 +352,9 @@ const getFeedEvents = baseApi<
   }
 >("GET", (pathParams) => {
   let { project_id, cluster_id, stack_name, page } = pathParams;
-  return `/api/projects/${project_id}/clusters/${cluster_id}/applications/${stack_name}/events?page=${page || 1
-    }`;
+  return `/api/projects/${project_id}/clusters/${cluster_id}/applications/${stack_name}/events?page=${
+    page || 1
+  }`;
 });
 
 const createEnvironment = baseApi<
@@ -778,9 +779,11 @@ const detectBuildpack = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
-    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
-    }/${encodeURIComponent(pathParams.branch)}/buildpack/detect`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${
+    pathParams.git_repo_id
+  }/repos/${pathParams.kind}/${pathParams.owner}/${
+    pathParams.name
+  }/${encodeURIComponent(pathParams.branch)}/buildpack/detect`;
 });
 
 const detectGitlabBuildpack = baseApi<
@@ -811,9 +814,11 @@ const getBranchContents = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
-    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
-    }/${encodeURIComponent(pathParams.branch)}/contents`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${
+    pathParams.git_repo_id
+  }/repos/${pathParams.kind}/${pathParams.owner}/${
+    pathParams.name
+  }/${encodeURIComponent(pathParams.branch)}/contents`;
 });
 
 const getProcfileContents = baseApi<
@@ -829,9 +834,11 @@ const getProcfileContents = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
-    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
-    }/${encodeURIComponent(pathParams.branch)}/procfile`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${
+    pathParams.git_repo_id
+  }/repos/${pathParams.kind}/${pathParams.owner}/${
+    pathParams.name
+  }/${encodeURIComponent(pathParams.branch)}/procfile`;
 });
 
 const getPorterYamlContents = baseApi<
@@ -847,9 +854,11 @@ const getPorterYamlContents = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
-    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
-    }/${encodeURIComponent(pathParams.branch)}/porteryaml`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${
+    pathParams.git_repo_id
+  }/repos/${pathParams.kind}/${pathParams.owner}/${
+    pathParams.name
+  }/${encodeURIComponent(pathParams.branch)}/porteryaml`;
 });
 
 const parsePorterYaml = baseApi<
@@ -886,9 +895,11 @@ const getBranchHead = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
-    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
-    }/${encodeURIComponent(pathParams.branch)}/head`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${
+    pathParams.git_repo_id
+  }/repos/${pathParams.kind}/${pathParams.owner}/${
+    pathParams.name
+  }/${encodeURIComponent(pathParams.branch)}/head`;
 });
 
 const validatePorterApp = baseApi<
@@ -914,21 +925,21 @@ const validatePorterApp = baseApi<
 
 const createApp = baseApi<
   | {
-    name: string;
-    type: "github";
-    git_repo_id: number;
-    git_branch: string;
-    git_repo_name: string;
-    porter_yaml_path: string;
-  }
+      name: string;
+      type: "github";
+      git_repo_id: number;
+      git_branch: string;
+      git_repo_name: string;
+      porter_yaml_path: string;
+    }
   | {
-    name: string;
-    type: "docker-registry";
-    image: {
-      repository: string;
-      tag: string;
-    };
-  },
+      name: string;
+      type: "docker-registry";
+      image: {
+        repository: string;
+        tag: string;
+      };
+    },
   {
     project_id: number;
     cluster_id: number;
@@ -938,18 +949,19 @@ const createApp = baseApi<
 });
 
 const createAppTemplate = baseApi<
-{
-  b64_app_proto: string;
-  variables: Record<string, string>
-  secrets: Record<string, string>
-},
-{
-  project_id: number;
-  cluster_id: number;
-  porter_app_name: string;
-}>("POST", ({ project_id, cluster_id, porter_app_name}) => {
+  {
+    b64_app_proto: string;
+    variables: Record<string, string>;
+    secrets: Record<string, string>;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+    porter_app_name: string;
+  }
+>("POST", ({ project_id, cluster_id, porter_app_name }) => {
   return `/api/projects/${project_id}/clusters/${cluster_id}/apps/${porter_app_name}/templates`;
-})
+});
 
 const applyApp = baseApi<
   {
@@ -1040,6 +1052,27 @@ const listDeploymentTargets = baseApi<
   return `/api/projects/${project_id}/clusters/${cluster_id}/deployment-targets`;
 });
 
+const getDeploymentTarget = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+    deployment_target_id: string;
+  }
+>("GET", ({ project_id, cluster_id, deployment_target_id }) => {
+  return `/api/projects/${project_id}/clusters/${cluster_id}/deployment-targets/${deployment_target_id}`;
+});
+
+const getAppTemplate = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+    porter_app_name: string;
+  }>("GET", ({ project_id, cluster_id, porter_app_name }) => {
+  return `/api/projects/${project_id}/clusters/${cluster_id}/apps/${porter_app_name}/templates`;
+  })
+
 const getGitlabProcfileContents = baseApi<
   {
     path: string;
@@ -1944,9 +1977,11 @@ const getEnvGroup = baseApi<
     version?: number;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.id}/clusters/${pathParams.cluster_id
-    }/namespaces/${pathParams.namespace}/envgroup?name=${pathParams.name}${pathParams.version ? "&version=" + pathParams.version : ""
-    }`;
+  return `/api/projects/${pathParams.id}/clusters/${
+    pathParams.cluster_id
+  }/namespaces/${pathParams.namespace}/envgroup?name=${pathParams.name}${
+    pathParams.version ? "&version=" + pathParams.version : ""
+  }`;
 });
 
 const getConfigMap = baseApi<
@@ -3004,7 +3039,7 @@ const removeStackEnvGroup = baseApi<
     `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}/remove_env_group/${env_group_name}`
 );
 
-const getGithubStatus = baseApi<{}, {}>("GET", ({ }) => `/api/status/github`);
+const getGithubStatus = baseApi<{}, {}>("GET", ({}) => `/api/status/github`);
 
 const createSecretAndOpenGitHubPullRequest = baseApi<
   {
@@ -3167,6 +3202,8 @@ export default {
   listAppRevisions,
   getLatestAppRevisions,
   listDeploymentTargets,
+  getDeploymentTarget,
+  getAppTemplate,
   getGitlabProcfileContents,
   getProjectClusters,
   getProjectRegistries,

+ 23 - 0
dashboard/src/shared/icons/PullRequest.tsx

@@ -0,0 +1,23 @@
+import React from "react";
+type IconProps = {
+  className?: string;
+  styles?: React.CSSProperties;
+  fill?: string;
+};
+
+const PullRequestIcon: React.FC<IconProps> = ({ className, styles, fill }) => {
+  return (
+    <svg
+      id="Flat"
+      xmlns="http://www.w3.org/2000/svg"
+      viewBox="0 0 256 256"
+      className={className}
+      style={styles}
+      fill={fill}
+    >
+      <path d="M103.99951,68a36,36,0,1,0-44,35.0929v49.8142a36,36,0,1,0,16,0V103.0929A36.05516,36.05516,0,0,0,103.99951,68Zm-56,0a20,20,0,1,1,20,20A20.0226,20.0226,0,0,1,47.99951,68Zm40,120a20,20,0,1,1-20-20A20.0226,20.0226,0,0,1,87.99951,188ZM196.002,152.907l-.00146-33.02563a55.63508,55.63508,0,0,0-16.40137-39.59619L155.31348,56h20.686a8,8,0,0,0,0-16h-40c-.02978,0-.05859.00415-.08838.00446-.2334.00256-.46631.01245-.69824.03527-.12891.01258-.25391.03632-.38086.05494-.13135.01928-.26318.03424-.39355.06-.14014.02778-.27686.06611-.41455.10114-.11475.02924-.23047.05426-.34424.08862-.13428.04059-.26367.0907-.395.13806-.11524.04151-.231.07929-.34473.12629-.12109.05011-.23681.10876-.35449.16455-.11914.05621-.23926.10907-.356.17144-.11133.0597-.21728.12757-.32519.1922-.11621.06928-.23389.13483-.34668.21051-.11719.07831-.227.16553-.33985.24976-.09668.07227-.1958.1394-.28955.21655-.18652.1529-.36426.31531-.53564.48413-.01612.01593-.03418.02918-.05029.04529-.02051.02051-.0376.04321-.05762.06391-.16358.16711-.32178.33941-.47022.52032-.083.10059-.15527.20648-.23193.31006-.07861.10571-.16064.20862-.23438.3183-.08056.12072-.15087.24591-.2246.36993-.05958.1-.12208.19757-.17725.30036-.06787.12591-.125.25531-.18506.384-.05078.1084-.10547.21466-.15137.32568-.05127.12463-.09326.25189-.13867.37848-.04248.11987-.08887.238-.126.36047-.03857.12775-.06738.25757-.09912.38678-.03125.124-.06591.24622-.0913.37244-.02979.15088-.04786.30328-.06934.45544-.01465.10645-.03516.21094-.0459.31867q-.03955.39752-.04.79706V88a8,8,0,0,0,16,0V67.31378l24.28516,24.28485a39.73874,39.73874,0,0,1,11.71582,28.28321l.00146,33.02533a36.00007,36.00007,0,1,0,16-.00019ZM188.00244,208a20,20,0,1,1,20-20A20.0226,20.0226,0,0,1,188.00244,208Z" />
+    </svg>
+  );
+};
+
+export default PullRequestIcon;

+ 2 - 0
internal/deployment_target/get.go

@@ -21,6 +21,7 @@ type DeploymentTargetDetailsInput struct {
 type DeploymentTarget struct {
 	ClusterID int64  `json:"cluster_id"`
 	Namespace string `json:"namespace"`
+	Preview   bool   `json:"preview"`
 }
 
 // DeploymentTargetDetails gets the deployment target details from CCP
@@ -64,6 +65,7 @@ func DeploymentTargetDetails(ctx context.Context, inp DeploymentTargetDetailsInp
 	deploymentTarget = DeploymentTarget{
 		Namespace: deploymentTargetDetailsResp.Msg.Namespace,
 		ClusterID: deploymentTargetDetailsResp.Msg.ClusterId,
+		Preview:   deploymentTargetDetailsResp.Msg.IsPreview,
 	}
 
 	return deploymentTarget, nil