Quellcode durchsuchen

POR-2115 improve app creation in new update flow (#3978)

ianedwards vor 2 Jahren
Ursprung
Commit
1bb23e8016

+ 31 - 6
api/server/handlers/porter_app/update_app.go

@@ -1,6 +1,7 @@
 package porter_app
 
 import (
+	"context"
 	"encoding/base64"
 	"net/http"
 
@@ -164,10 +165,15 @@ func (c *UpdateAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		appProto.Name = request.Name
 	}
 
-	sourceType, image := sourceFromAppAndGitSource(appProto, request.GitSource)
+	sourceType, image, err := sourceFromAppAndGitSource(ctx, appProto, request.GitSource)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error getting source from app and git source")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
 
 	// create porter app if it doesn't exist for the given name
-	_, err := porter_app.CreateOrGetAppRecord(ctx, porter_app.CreateOrGetAppRecordInput{
+	_, err = porter_app.CreateOrGetAppRecord(ctx, porter_app.CreateOrGetAppRecordInput{
 		ClusterID:           cluster.ID,
 		ProjectID:           project.ID,
 		Name:                appProto.Name,
@@ -253,16 +259,33 @@ func (c *UpdateAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	c.WriteResult(w, r, response)
 }
 
-func sourceFromAppAndGitSource(appProto *porterv1.PorterApp, gitSource GitSource) (porter_app.SourceType, *porter_app.Image) {
+func sourceFromAppAndGitSource(ctx context.Context, appProto *porterv1.PorterApp, gitSource GitSource) (porter_app.SourceType, *porter_app.Image, error) {
+	ctx, span := telemetry.NewSpan(ctx, "source-from-app-and-git-source")
+	defer span.End()
+
 	var sourceType porter_app.SourceType
 	var image *porter_app.Image
 
+	if appProto == nil {
+		return sourceType, image, telemetry.Error(ctx, span, nil, "app proto is nil")
+	}
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "app-name", Value: appProto.Name},
+		telemetry.AttributeKV{Key: "git-repo-id", Value: gitSource.GitRepoID},
+		telemetry.AttributeKV{Key: "has-build", Value: appProto.Build != nil},
+		telemetry.AttributeKV{Key: "has-image", Value: appProto.Image != nil},
+	)
+
 	if appProto.Build != nil {
 		if gitSource.GitRepoID == 0 {
-			return porter_app.SourceType_Local, nil
+			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "source-type", Value: porter_app.SourceType_Local})
+
+			return porter_app.SourceType_Local, image, nil
 		}
 
-		sourceType = porter_app.SourceType_Github
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "source-type", Value: porter_app.SourceType_Github})
+		return porter_app.SourceType_Github, image, nil
 	}
 
 	if appProto.Image != nil {
@@ -273,7 +296,9 @@ func sourceFromAppAndGitSource(appProto *porterv1.PorterApp, gitSource GitSource
 		}
 	}
 
-	return sourceType, image
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "source-type", Value: sourceType})
+
+	return sourceType, image, nil
 }
 
 func mergeEnvVariables(currentEnv, previousEnv map[string]string) map[string]string {

+ 70 - 35
dashboard/src/lib/hooks/useAppValidation.ts

@@ -1,19 +1,41 @@
+import { useCallback, useContext } from "react";
 import { PorterApp } from "@porter-dev/api-contracts";
+import { match } from "ts-pattern";
+import { z } from "zod";
+
 import {
-  PorterAppFormData,
-  SourceOptions,
   clientAppToProto,
+  type ClientPorterApp,
+  type PorterAppFormData,
+  type SourceOptions,
 } from "lib/porter-apps";
-import { useCallback, useContext } from "react";
-import { Context } from "shared/Context";
+
 import api from "shared/api";
-import { match } from "ts-pattern";
-import { z } from "zod";
+import { Context } from "shared/Context";
 
 export type AppValidationResult = {
   validatedAppProto: PorterApp;
   variables: Record<string, string>;
   secrets: Record<string, string>;
+  commitSha: string;
+};
+
+type ServiceDeletions = Record<
+  string,
+  {
+    domain_names: string[];
+    ingress_annotation_keys: string[];
+  }
+>;
+
+type AppValidationHook = {
+  validateApp: (
+    data: PorterAppFormData,
+    skipValidation?: boolean
+  ) => Promise<AppValidationResult>;
+  setServiceDeletions: (
+    services: ClientPorterApp["services"]
+  ) => ServiceDeletions;
 };
 
 export const useAppValidation = ({
@@ -22,9 +44,35 @@ export const useAppValidation = ({
 }: {
   deploymentTargetID?: string;
   creating?: boolean;
-}) => {
+}): AppValidationHook => {
   const { currentProject, currentCluster } = useContext(Context);
 
+  const setServiceDeletions = (
+    services: ClientPorterApp["services"]
+  ): ServiceDeletions => {
+    const serviceDeletions = services.reduce(
+      (
+        acc: Record<
+          string,
+          { domain_names: string[]; ingress_annotation_keys: string[] }
+        >,
+        svc
+      ) => {
+        acc[svc.name.value] = {
+          domain_names: svc.domainDeletions.map((d) => d.name),
+          ingress_annotation_keys: svc.ingressAnnotationDeletions.map(
+            (ia) => ia.key
+          ),
+        };
+
+        return acc;
+      },
+      {}
+    );
+
+    return serviceDeletions;
+  };
+
   const getBranchHead = async ({
     projectID,
     source,
@@ -33,8 +81,8 @@ export const useAppValidation = ({
     source: SourceOptions & {
       type: "github";
     };
-  }) => {
-    const [owner, repo_name] = await z
+  }): Promise<string> => {
+    const [owner, repoName] = await z
       .tuple([z.string(), z.string()])
       .parseAsync(source.git_repo_name?.split("/"));
 
@@ -46,7 +94,7 @@ export const useAppValidation = ({
         project_id: projectID,
         kind: "github",
         owner,
-        name: repo_name,
+        name: repoName,
         branch: source.git_branch,
       }
     );
@@ -57,13 +105,13 @@ export const useAppValidation = ({
       })
       .parseAsync(res.data);
 
-    return commitData;
+    return commitData.commit_sha;
   };
 
   const validateApp = useCallback(
     async (
       data: PorterAppFormData,
-      prevRevision?: PorterApp
+      skipValidation = false
     ): Promise<AppValidationResult> => {
       if (!currentProject || !currentCluster) {
         throw new Error("No project or cluster selected");
@@ -93,42 +141,28 @@ export const useAppValidation = ({
         }, {});
 
       const proto = clientAppToProto(data);
-      const commit_sha = await match(data.source)
+
+      const commitSha = await match(data.source)
         .with({ type: "github" }, async (src) => {
           if (!creating) {
             return "";
           }
 
-          const { commit_sha } = await getBranchHead({
+          return await getBranchHead({
             projectID: currentProject.id,
             source: src,
           });
-          return commit_sha;
         })
         .with({ type: "docker-registry" }, () => {
           return "";
         })
         .exhaustive();
 
-      const serviceDeletions = data.app.services.reduce(
-        (
-          acc: Record<
-            string,
-            { domain_names: string[]; ingress_annotation_keys: string[] }
-          >,
-          svc
-        ) => {
-          acc[svc.name.value] = {
-            domain_names: svc.domainDeletions.map((d) => d.name),
-            ingress_annotation_keys: svc.ingressAnnotationDeletions.map(
-              (ia) => ia.key
-            ),
-          };
+      if (skipValidation) {
+        return { validatedAppProto: proto, variables, secrets, commitSha };
+      }
 
-          return acc;
-        },
-        {}
-      );
+      const serviceDeletions = setServiceDeletions(data.app.services);
 
       const res = await api.validatePorterApp(
         "<token>",
@@ -139,7 +173,7 @@ export const useAppValidation = ({
             })
           ),
           deployment_target_id: deploymentTargetID,
-          commit_sha,
+          commit_sha: commitSha,
           deletions: {
             service_names: data.deletions.serviceNames.map((s) => s.name),
             predeploy: data.deletions.predeploy.map((s) => s.name),
@@ -167,12 +201,13 @@ export const useAppValidation = ({
         }
       );
 
-      return { validatedAppProto: validatedAppProto, variables, secrets };
+      return { validatedAppProto, variables, secrets, commitSha };
     },
     [deploymentTargetID, currentProject, currentCluster]
   );
 
   return {
     validateApp,
+    setServiceDeletions,
   };
 };

+ 12 - 2
dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx

@@ -107,7 +107,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
     setPreviewRevision,
     latestNotifications,
   } = useLatestRevision();
-  const { validateApp } = useAppValidation({
+  const { validateApp, setServiceDeletions } = useAppValidation({
     deploymentTargetID: deploymentTarget.id,
   });
 
@@ -214,7 +214,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
     try {
       const { variables, secrets, validatedAppProto } = await validateApp(
         data,
-        latestProto
+        currentProject?.beta_features_enabled
       );
 
       const needsRebuild =
@@ -228,6 +228,8 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
       }
 
       if (currentProject?.beta_features_enabled && !needsRebuild) {
+        const serviceDeletions = setServiceDeletions(data.app.services);
+
         await api.updateApp(
           "<token>",
           {
@@ -236,6 +238,14 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
             variables,
             secrets,
             is_env_override: true,
+            deletions: {
+              service_names: data.deletions.serviceNames.map((s) => s.name),
+              predeploy: data.deletions.predeploy.map((s) => s.name),
+              env_group_names: data.deletions.envGroupNames.map(
+                (eg) => eg.name
+              ),
+              service_deletions: serviceDeletions,
+            },
           },
           {
             project_id: projectId,

+ 54 - 6
dashboard/src/main/home/app-dashboard/app-view/AppHeader.tsx

@@ -34,6 +34,8 @@ const icons = [
   web,
 ];
 
+const HELLO_PORTER_PLACEHOLDER_TAG = "porter-initial-image";
+
 const AppHeader: React.FC = () => {
   const { latestProto, porterApp, latestRevision, deploymentTarget } =
     useLatestRevision();
@@ -99,6 +101,28 @@ const AppHeader: React.FC = () => {
     }
   };
 
+  const renderTagBadge = (tag: string): JSX.Element => {
+    if (tag === HELLO_PORTER_PLACEHOLDER_TAG) {
+      return (
+        <ImageTagContainer hoverable={false}>
+          <TagContainer>
+            <StatusDot color="#FFA500" />
+            <Code>Awaiting Build</Code>
+          </TagContainer>
+        </ImageTagContainer>
+      );
+    }
+
+    return (
+      <ImageTagContainer hoverable={false}>
+        <TagContainer>
+          <CommitIcon src={tag_icon} />
+          <Code>{tag}</Code>
+        </TagContainer>
+      </ImageTagContainer>
+    );
+  };
+
   const displayDomain = useMemo(() => {
     const domains = Object.values(latestProto.services).reduce(
       (acc: string[], s) => {
@@ -220,12 +244,7 @@ const AppHeader: React.FC = () => {
               </Link>
             </ImageTagContainer>
           ) : latestProto.image?.tag ? (
-            <ImageTagContainer hoverable={false}>
-              <TagContainer>
-                <CommitIcon src={tag_icon} />
-                <Code>{latestProto.image.tag}</Code>
-              </TagContainer>
-            </ImageTagContainer>
+            renderTagBadge(latestProto.image.tag)
           ) : null}
         </NoShrink>
         <Spacer y={0.5} />
@@ -298,3 +317,32 @@ const BranchTag = styled.div<{ preview?: boolean }>`
   overflow: hidden;
   text-overflow: ellipsis;
 `;
+
+const StatusDot = styled.div<{ color?: string }>`
+  min-width: 7px;
+  max-width: 7px;
+  height: 7px;
+  border-radius: 50%;
+  margin-right: 10px;
+  background: ${(props) => props.color ?? "#38a88a"};
+
+  box-shadow: 0 0 0 0 rgba(0, 0, 0, 1);
+  transform: scale(1);
+  animation: pulse 2s infinite;
+  @keyframes pulse {
+    0% {
+      transform: scale(0.95);
+      box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.7);
+    }
+
+    70% {
+      transform: scale(1);
+      box-shadow: 0 0 0 10px rgba(0, 0, 0, 0);
+    }
+
+    100% {
+      transform: scale(0.95);
+      box-shadow: 0 0 0 0 rgba(0, 0, 0, 0);
+    }
+  }
+`;

+ 15 - 3
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/DeployEventCard.tsx

@@ -18,6 +18,7 @@ import view_changes from "assets/edit-contained.svg";
 import revert from "assets/fast-backward.svg";
 import pull_request_icon from "assets/pull_request_icon.svg";
 import run_for from "assets/run_for.png";
+import tag_icon from "assets/tag.png";
 
 import RevisionDiffModal from "../modals/RevisionDiffModal";
 import { type PorterAppDeployEvent } from "../types";
@@ -276,14 +277,17 @@ const DeployEventCard: React.FC<Props> = ({
                 </Link>
               </ImageTagContainer>
             </>
-          ) : (
+          ) : event.metadata.image_tag ? (
             <>
               <Spacer inline x={0.5} />
               <ImageTagContainer hoverable={false}>
-                <Code>{event.metadata.image_tag}</Code>
+                <TagContainer>
+                  <CommitIcon src={tag_icon} />
+                  <Code>{event.metadata.image_tag}</Code>
+                </TagContainer>
               </ImageTagContainer>
             </>
-          )}
+          ) : null}
         </Container>
         <Container row>
           <Icon height="14px" src={run_for} />
@@ -384,3 +388,11 @@ const TagIcon = styled.img`
   height: 12px;
   margin-right: 3px;
 `;
+
+const TagContainer = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  column-gap: 1px;
+  padding: 0px 2px;
+`;

+ 1 - 1
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/types.ts

@@ -18,7 +18,7 @@ const porterAppAppEventMetadataValidator = z.object({
   agent_event_id: z.number(),
 });
 const porterAppDeployEventMetadataValidator = z.object({
-  image_tag: z.string(),
+  image_tag: z.string().optional(),
   app_revision_id: z.string(),
   service_deployment_metadata: z
     .record(

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

@@ -220,7 +220,8 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
   const onSubmit = handleSubmit(async (data) => {
     try {
       setDeployError("");
-      const { validatedAppProto, variables, secrets } = await validateApp(data);
+      const { validatedAppProto, variables, secrets } =
+        await validateApp(data, currentProject?.beta_features_enabled);
       setValidatedAppProto(validatedAppProto);
       setFinalizedAppEnv({ variables, secrets });
 

+ 1 - 1
dashboard/src/main/home/app-dashboard/new-app-flow/serviceTypes.ts

@@ -9,7 +9,7 @@ export type ImageInfo = {
 }
 export const ImageInfo = {
     BASE_IMAGE: {
-        repository: "public.ecr.aws/o1j4x7p4/hello-porter",
+        repository: "ghcr.io/porter-dev/porter/hello-porter",
         tag: "latest",
     } as const,
 }

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

@@ -1015,6 +1015,19 @@ const updateApp = baseApi<
     variables?: Record<string, string>;
     secrets?: Record<string, string>;
     is_env_override?: boolean;
+    deletions?: {
+      service_names: string[];
+      predeploy: string[];
+      env_variable_names?: string[];
+      env_group_names: string[];
+      service_deletions: Record<
+        string,
+        {
+          domain_names: string[];
+          ingress_annotation_keys: string[];
+        }
+      >;
+    };
   },
   {
     project_id: number;

+ 1 - 0
dashboard/src/shared/common.tsx

@@ -155,4 +155,5 @@ export const PORTER_IMAGE_TEMPLATES = [
   "public.ecr.aws/o1j4x7p4/hello-porter-job:latest",
   "public.ecr.aws/o1j4x7p4/hello-porter",
   "public.ecr.aws/o1j4x7p4/hello-porter:latest",
+  "ghcr.io/porter-dev/porter/hello-porter",
 ];