2
0
Эх сурвалжийг харах

improve revisions list and bug fixes (#3471)

ianedwards 2 жил өмнө
parent
commit
2be36cd9c2

+ 2 - 54
dashboard/src/lib/hooks/useAppValidation.ts

@@ -1,13 +1,8 @@
 import { PorterApp } from "@porter-dev/api-contracts";
-import {
-  PorterAppFormData,
-  SourceOptions,
-  clientAppToProto,
-} from "lib/porter-apps";
+import { PorterAppFormData, clientAppToProto } 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";
 
 export const useAppValidation = ({
@@ -17,41 +12,6 @@ export const useAppValidation = ({
 }) => {
   const { currentProject, currentCluster } = useContext(Context);
 
-  const getBranchHead = async ({
-    projectID,
-    source,
-  }: {
-    projectID: number;
-    source: SourceOptions & {
-      type: "github";
-    };
-  }) => {
-    const [owner, repo_name] = await z
-      .tuple([z.string(), z.string()])
-      .parseAsync(source.git_repo_name?.split("/"));
-
-    const res = await api.getBranchHead(
-      "<token>",
-      {},
-      {
-        ...source,
-        project_id: projectID,
-        kind: "github",
-        owner,
-        name: repo_name,
-        branch: source.git_branch,
-      }
-    );
-
-    const commitData = await z
-      .object({
-        commit_sha: z.string(),
-      })
-      .parseAsync(res.data);
-
-    return commitData;
-  };
-
   const validateApp = useCallback(
     async (data: PorterAppFormData) => {
       if (!currentProject || !currentCluster) {
@@ -63,18 +23,6 @@ export const useAppValidation = ({
       }
 
       const proto = clientAppToProto(data);
-      const commit_sha = await match(data.source)
-        .with({ type: "github" }, async (src) => {
-          const { commit_sha } = await getBranchHead({
-            projectID: currentProject.id,
-            source: src,
-          });
-          return commit_sha;
-        })
-        .with({ type: "docker-registry" }, () => {
-          return "";
-        })
-        .exhaustive();
 
       const res = await api.validatePorterApp(
         "<token>",
@@ -85,7 +33,7 @@ export const useAppValidation = ({
             })
           ),
           deployment_target_id: deploymentTargetID,
-          commit_sha,
+          commit_sha: "", // not sending a commit sha since the CLI will handle this
         },
         {
           project_id: currentProject.id,

+ 8 - 2
dashboard/src/lib/porter-apps/index.ts

@@ -1,4 +1,7 @@
-import { buildpackSchema } from "main/home/app-dashboard/types/buildpack";
+import {
+  BUILDPACK_TO_NAME,
+  buildpackSchema,
+} from "main/home/app-dashboard/types/buildpack";
 import { z } from "zod";
 import {
   DetectedServices,
@@ -224,7 +227,10 @@ const clientBuildFromProto = (proto?: Build): BuildOptions | undefined => {
       Object.freeze({
         method: b.method,
         context: b.context,
-        buildpacks: b.buildpacks.map((b) => ({ name: b, buildpack: b })),
+        buildpacks: b.buildpacks.map((b) => ({
+          name: BUILDPACK_TO_NAME[b],
+          buildpack: b,
+        })),
         builder: b.builder,
       })
     )

+ 7 - 0
dashboard/src/lib/porter-apps/services.ts

@@ -99,6 +99,13 @@ export function isPredeployService(service: SerializedService | ClientService) {
   return service.config.type == "predeploy";
 }
 
+export function prefixSubdomain(subdomain: string) {
+  if (subdomain.startsWith("https://") || subdomain.startsWith("http://")) {
+    return subdomain;
+  }
+  return "https://" + subdomain;
+}
+
 export function defaultSerialized({
   name,
   type,

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

@@ -184,11 +184,6 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
         deploymentTargetId,
         porterApp.name,
       ]);
-
-      reset({
-        app: clientAppFromProto(latestProto, servicesFromYaml),
-        source: latestSource,
-      });
     } catch (err) {}
   });
 
@@ -199,7 +194,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
         source: latestSource,
       });
     }
-  }, [servicesFromYaml, currentTab]);
+  }, [servicesFromYaml, currentTab, latestProto]);
 
   return (
     <FormProvider {...porterAppFormMethods}>
@@ -210,7 +205,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
           projectId={projectId}
           clusterId={clusterId}
           appName={porterApp.name}
-          sourceType={latestSource.type}
+          latestSource={latestSource}
         />
         <Spacer y={1} />
         <AnimateHeight height={isDirty && !onlyExpandedChanged ? "auto" : 0}>

+ 71 - 35
dashboard/src/main/home/app-dashboard/app-view/AppHeader.tsx

@@ -9,10 +9,12 @@ import github from "assets/github-white.png";
 import pr_icon from "assets/pull_request_icon.svg";
 
 import { PorterApp } from "@porter-dev/api-contracts";
-import { useLatestRevision } from "./LatestRevisionContext";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import styled from "styled-components";
+import { useLatestRevision } from "./LatestRevisionContext";
+import { prefixSubdomain } from "lib/porter-apps/services";
+import { readableDate } from "shared/string_utils";
 
 // Buildpack icons
 const icons = [
@@ -24,7 +26,7 @@ const icons = [
 ];
 
 const AppHeader: React.FC = () => {
-  const { latestProto, porterApp } = useLatestRevision();
+  const { latestProto, porterApp, latestRevision } = useLatestRevision();
 
   const gitData = useMemo(() => {
     if (
@@ -62,45 +64,79 @@ const AppHeader: React.FC = () => {
     }
   };
 
+  const displayDomain = useMemo(() => {
+    const domains = Object.values(latestProto.services).reduce(
+      (acc: string[], s) => {
+        if (s.config.case === "webConfig") {
+          const names = s.config.value.domains.map((d) => d.name);
+          return [...acc, ...names];
+        }
+
+        return acc;
+      },
+      []
+    );
+
+    return domains.length === 1 ? prefixSubdomain(domains[0]) : "";
+  }, [latestProto]);
+
   return (
-    <Container row>
-      <Icon src={getIconSvg(latestProto.build)} height={"24px"} />
-      <Spacer inline x={1} />
-      <Text size={21}>{latestProto.name}</Text>
-      {gitData && (
-        <>
-          <Spacer inline x={1} />
-          <Container row>
-            <A target="_blank" href={`https://github.com/${gitData.repo}`}>
-              <SmallIcon src={github} />
-              <Text size={13}>{gitData.repo}</Text>
-            </A>
-          </Container>
-          <Spacer inline x={1} />
-          <TagWrapper>
-            Branch
-            <BranchTag>
-              <BranchIcon src={pr_icon} />
-              {gitData.branch}
-            </BranchTag>
-          </TagWrapper>
-        </>
-      )}
-      {!gitData && porterApp.image_repo_uri && (
+    <>
+      <Container row>
+        <Icon src={getIconSvg(latestProto.build)} height={"24px"} />
+        <Spacer inline x={1} />
+        <Text size={21}>{latestProto.name}</Text>
+        {gitData && (
+          <>
+            <Spacer inline x={1} />
+            <Container row>
+              <A target="_blank" href={`https://github.com/${gitData.repo}`}>
+                <SmallIcon src={github} />
+                <Text size={13}>{gitData.repo}</Text>
+              </A>
+            </Container>
+            <Spacer inline x={1} />
+            <TagWrapper>
+              Branch
+              <BranchTag>
+                <BranchIcon src={pr_icon} />
+                {gitData.branch}
+              </BranchTag>
+            </TagWrapper>
+          </>
+        )}
+        {!gitData && porterApp.image_repo_uri && (
+          <>
+            <Spacer inline x={1} />
+            <Container row>
+              <SmallIcon
+                height="19px"
+                src="https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/97_Docker_logo_logos-512.png"
+              />
+              <Text size={13} color="helper">
+                {porterApp.image_repo_uri}
+              </Text>
+            </Container>
+          </>
+        )}
+      </Container>
+      <Spacer y={0.5} />
+      {displayDomain && (
         <>
-          <Spacer inline x={1} />
-          <Container row>
-            <SmallIcon
-              height="19px"
-              src="https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/97_Docker_logo_logos-512.png"
-            />
-            <Text size={13} color="helper">
-              {porterApp.image_repo_uri}
+          <Container>
+            <Text>
+              <a href={displayDomain} target="_blank">
+                {displayDomain}
+              </a>
             </Text>
           </Container>
+          <Spacer y={0.5} />
         </>
       )}
-    </Container>
+      <Text color="#aaaabb66">
+        Last deployed {readableDate(latestRevision.created_at)}
+      </Text>
+    </>
   );
 };
 

+ 6 - 1
dashboard/src/main/home/app-dashboard/app-view/LatestRevisionContext.tsx

@@ -1,4 +1,4 @@
-import React, { useMemo } from "react";
+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";
@@ -27,6 +27,8 @@ export const LatestRevisionContext = createContext<{
   clusterId: number;
   projectId: number;
   deploymentTargetId: string;
+  previewRevision: number | null;
+  setPreviewRevision: Dispatch<SetStateAction<number | null>>;
 } | null>(null);
 
 export const useLatestRevision = () => {
@@ -46,6 +48,7 @@ export const LatestRevisionProvider = ({
   appName?: string;
   children: JSX.Element;
 }) => {
+  const [previewRevision, setPreviewRevision] = useState<number | null>(null);
   const { currentCluster, currentProject } = useContext(Context);
   const deploymentTarget = useDefaultDeploymentTarget();
 
@@ -193,6 +196,8 @@ export const LatestRevisionProvider = ({
         projectId: currentProject.id,
         deploymentTargetId: deploymentTarget.deployment_target_id,
         servicesFromYaml: detectedServices,
+        previewRevision,
+        setPreviewRevision,
       }}
     >
       {children}

+ 204 - 31
dashboard/src/main/home/app-dashboard/app-view/RevisionsList.tsx

@@ -5,28 +5,44 @@ import api from "shared/api";
 import styled from "styled-components";
 import { match } from "ts-pattern";
 import loading from "assets/loading.gif";
-import { SourceOptions } from "lib/porter-apps";
+import {
+  PorterAppFormData,
+  SourceOptions,
+  clientAppFromProto,
+} from "lib/porter-apps";
 import { z } from "zod";
 import { PorterApp } from "@porter-dev/api-contracts";
 import { readableDate } from "shared/string_utils";
+import Text from "components/porter/Text";
+import { useLatestRevision } from "./LatestRevisionContext";
+import { useFormContext } from "react-hook-form";
 
 type Props = {
-  latestRevisionNumber: number;
   deploymentTargetId: string;
   projectId: number;
   clusterId: number;
   appName: string;
-  sourceType: SourceOptions["type"];
+  latestSource: SourceOptions;
+  latestRevisionNumber: number;
 };
 
+const RED = "#ff0000";
+const YELLOW = "#FFA500";
+
 const RevisionsList: React.FC<Props> = ({
   latestRevisionNumber,
   deploymentTargetId,
   projectId,
   clusterId,
   appName,
-  sourceType,
+  latestSource,
 }) => {
+  const {
+    previewRevision,
+    setPreviewRevision,
+    servicesFromYaml,
+  } = useLatestRevision();
+  const { reset } = useFormContext<PorterAppFormData>();
   const [expandRevisions, setExpandRevisions] = useState(false);
 
   const res = useQuery(
@@ -54,6 +70,58 @@ const RevisionsList: React.FC<Props> = ({
     }
   );
 
+  const getReadableStatus = (status: AppRevision["status"]) =>
+    match(status)
+      .with("CREATED", () => "Created")
+      .with("AWAITING_BUILD_ARTIFACT", () => "Awaiting Build")
+      .with("READY_TO_APPLY", () => "Deploying")
+      .with("AWAITING_PREDEPLOY", () => "Awaiting Pre-Deploy")
+      .with("BUILD_CANCELED", () => "Build Canceled")
+      .with("BUILD_FAILED", () => "Build Failed")
+      .with("DEPLOY_FAILED", () => "Deploy Failed")
+      .with("DEPLOYED", () => "Deployed")
+      .exhaustive();
+
+  const getDotColor = (status: AppRevision["status"]) =>
+    match(status)
+      .with(
+        "CREATED",
+        "AWAITING_BUILD_ARTIFACT",
+        "READY_TO_APPLY",
+        "AWAITING_PREDEPLOY",
+        () => YELLOW
+      )
+      .otherwise(() => RED);
+
+  const getTableHeader = (latestRevision?: AppRevision) => {
+    if (!latestRevision) {
+      return "Revisions";
+    }
+
+    if (previewRevision) {
+      return "Previewing revision (not deployed) -";
+    }
+
+    return "Current revision - ";
+  };
+
+  const getSelectedRevisionNumber = (args: {
+    numDeployed: number;
+    latestRevision?: AppRevision;
+  }) => {
+    const { numDeployed, latestRevision } = args;
+
+    if (previewRevision) {
+      return previewRevision;
+    }
+
+    if (latestRevision && latestRevision.revision_number !== 0) {
+      return latestRevision.revision_number;
+    }
+
+    return numDeployed + 1;
+  };
+
   const renderContents = (revisions: AppRevision[]) => {
     const revisionsWithProto = revisions.map((revision) => {
       return {
@@ -62,19 +130,34 @@ const RevisionsList: React.FC<Props> = ({
       };
     });
 
+    const deployedRevisions = revisionsWithProto.filter(
+      (r) => r.revision_number !== 0
+    );
+    const pendingRevisions = revisionsWithProto.filter(
+      (r) => r.revision_number === 0
+    );
+
     return (
       <div>
         <RevisionHeader
           showRevisions={expandRevisions}
-          isCurrent
+          isCurrent={!previewRevision}
           onClick={() => {
             setExpandRevisions((prev) => !prev);
           }}
         >
           <RevisionPreview>
             <i className="material-icons">arrow_drop_down</i>
-            Current version -{" "}
-            <Revision>No. {revisions[0].revision_number}</Revision>
+            {getTableHeader(revisions[0])}
+            {revisions[0] ? (
+              <Revision>
+                No.{" "}
+                {getSelectedRevisionNumber({
+                  numDeployed: deployedRevisions.length,
+                  latestRevision: revisions[0],
+                })}
+              </Revision>
+            ) : null}
           </RevisionPreview>
         </RevisionHeader>
         <RevisionList>
@@ -83,37 +166,93 @@ const RevisionsList: React.FC<Props> = ({
               <tbody>
                 <Tr disableHover>
                   <Th>Revision no.</Th>
-                  <Th>Timestamp</Th>
                   <Th>
                     {revisionsWithProto[0]?.app_proto.build
                       ? "Commit SHA"
                       : "Image Tag"}
                   </Th>
+                  <Th>Timestamp</Th>
+                  <Th>Status</Th>
                   <Th>Rollback</Th>
                 </Tr>
-                {revisionsWithProto.map((revision) => (
-                  <Tr key={revision.revision_number}>
-                    <Td>{revision.revision_number}</Td>
-                    <Td>{readableDate(revision.updated_at)}</Td>
-                    <Td>
-                      {revision.app_proto.build
-                        ? revision.app_proto.build.commitSha.substring(0, 7)
-                        : revision.app_proto.image?.tag}
-                    </Td>
-                    <Td>
-                      <RollbackButton
-                        disabled={
-                          revision.revision_number === latestRevisionNumber
-                        }
-                        onClick={() => {}}
-                      >
-                        {revision.revision_number === latestRevisionNumber
-                          ? "Current"
-                          : "Revert"}
-                      </RollbackButton>
-                    </Td>
-                  </Tr>
-                ))}
+                {pendingRevisions.length > 0 &&
+                  pendingRevisions.map((revision) => (
+                    <Tr key={new Date(revision.updated_at).toUTCString()}>
+                      <Td>{deployedRevisions.length + 1}</Td>
+                      <Td>
+                        {revision.app_proto.build
+                          ? revision.app_proto.build.commitSha.substring(0, 7)
+                          : revision.app_proto.image?.tag}
+                      </Td>
+                      <Td>{readableDate(revision.updated_at)}</Td>
+                      <Td>
+                        <StatusContainer>
+                          <Text>{getReadableStatus(revision.status)}</Text>
+                          <StatusDot color={getDotColor(revision.status)} />
+                        </StatusContainer>
+                      </Td>
+                      <Td>-</Td>
+                    </Tr>
+                  ))}
+
+                {deployedRevisions.map((revision, i) => {
+                  const isLatestDeployedRevision =
+                    latestRevisionNumber !== 0
+                      ? revision.revision_number === latestRevisionNumber
+                      : i === 0;
+
+                  return (
+                    <Tr
+                      key={revision.revision_number}
+                      selected={
+                        previewRevision
+                          ? revision.revision_number === previewRevision
+                          : isLatestDeployedRevision
+                      }
+                      onClick={() => {
+                        reset({
+                          app: clientAppFromProto(
+                            revision.app_proto,
+                            servicesFromYaml
+                          ),
+                          source: latestSource,
+                        });
+                        setPreviewRevision(
+                          isLatestDeployedRevision
+                            ? null
+                            : revision.revision_number
+                        );
+                      }}
+                    >
+                      <Td>{revision.revision_number}</Td>
+
+                      <Td>
+                        {revision.app_proto.build
+                          ? revision.app_proto.build.commitSha.substring(0, 7)
+                          : revision.app_proto.image?.tag}
+                      </Td>
+                      <Td>{readableDate(revision.updated_at)}</Td>
+                      <Td>
+                        {!isLatestDeployedRevision ? (
+                          getReadableStatus(revision.status)
+                        ) : (
+                          <StatusContainer>
+                            <Text>{getReadableStatus(revision.status)}</Text>
+                            <StatusDot />
+                          </StatusContainer>
+                        )}
+                      </Td>
+                      <Td>
+                        <RollbackButton
+                          disabled={isLatestDeployedRevision}
+                          onClick={() => {}}
+                        >
+                          {isLatestDeployedRevision ? "Current" : "Revert"}
+                        </RollbackButton>
+                      </Td>
+                    </Tr>
+                  );
+                })}
               </tbody>
             </RevisionsTable>
           </TableWrapper>
@@ -291,3 +430,37 @@ const RollbackButton = styled.div`
       props.disabled ? "" : "#405eddbb"};
   }
 `;
+
+const StatusContainer = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const StatusDot = styled.div<{ color?: string }>`
+  min-width: 7px;
+  max-width: 7px;
+  height: 7px;
+  margin-left: 10px;
+  border-radius: 50%;
+  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.9);
+    }
+
+    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);
+    }
+  }
+`;

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

@@ -36,7 +36,6 @@ const Overview: React.FC = () => {
           <Text size={16}>Pre-deploy job</Text>
           <Spacer y={0.5} />
           <ServiceList
-            limitOne={true}
             addNewText={"Add a new pre-deploy job"}
             prePopulateService={deserializeService({
               service: defaultSerialized({

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

@@ -473,7 +473,6 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
                       </Text>
                       <Spacer y={0.5} />
                       <ServiceList
-                        limitOne={true}
                         addNewText={"Add a new pre-deploy job"}
                         prePopulateService={deserializeService({
                           service: defaultSerialized({

+ 8 - 3
dashboard/src/main/home/app-dashboard/validate-apply/build-settings/buildpacks/BuildpackSettings.tsx

@@ -104,8 +104,8 @@ const BuildpackSettings: React.FC<Props> = ({
       }
       if (build.buildpacks.length) {
         const bps = build.buildpacks.map((bp) => ({
-          ...bp,
-          name: BUILDPACK_TO_NAME[bp.buildpack] ?? bp,
+          name: BUILDPACK_TO_NAME[bp.buildpack] ?? bp.buildpack,
+          buildpack: bp.buildpack,
         }));
         replace(bps);
       }
@@ -159,7 +159,12 @@ const BuildpackSettings: React.FC<Props> = ({
 
       if (!autoDetectionDisabled) {
         setValue("app.build.builder", detectedBuilder);
-        replace(defaultBuilder.detected);
+        replace(
+          defaultBuilder.detected.map((bp) => ({
+            name: bp.name,
+            buildpack: bp.buildpack,
+          }))
+        );
         setAvailableBuildpacks(defaultBuilder.others);
       } else {
         setValue("app.build.builder", detectedBuilder);

+ 1 - 3
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceList.tsx

@@ -41,14 +41,12 @@ type AddServiceFormValues = z.infer<typeof addServiceFormValidator>;
 
 type ServiceListProps = {
   addNewText: string;
-  limitOne?: boolean;
   prePopulateService?: ClientService;
   isPredeploy?: boolean;
 };
 
 const ServiceList: React.FC<ServiceListProps> = ({
   addNewText,
-  limitOne = false,
   prePopulateService,
   isPredeploy = false,
 }) => {
@@ -100,7 +98,7 @@ const ServiceList: React.FC<ServiceListProps> = ({
   };
 
   const maybeRenderAddServicesButton = () => {
-    if (limitOne && services.length > 0) {
+    if (isPredeploy && services.find((s) => isPredeployService(s.svc))) {
       return null;
     }
     return (

+ 2 - 2
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/CustomDomains.tsx

@@ -25,7 +25,7 @@ const CustomDomains: React.FC<Props> = ({ index, customDomains }) => {
       {fields.length !== 0 && (
         <>
           {fields.map((customDomain, i) => {
-            return (
+            return !customDomain.name.value.includes("onporter.run") ? (
               <div key={customDomain.id}>
                 <AnnotationContainer>
                   <ControlledInput
@@ -51,7 +51,7 @@ const CustomDomains: React.FC<Props> = ({ index, customDomains }) => {
                 </AnnotationContainer>
                 <Spacer y={0.25} />
               </div>
-            );
+            ) : null;
           })}
           <Spacer y={0.5} />
         </>

+ 1 - 8
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/Networking.tsx

@@ -1,7 +1,7 @@
 import React from "react";
 import { ControlledInput } from "components/porter/ControlledInput";
 import Spacer from "components/porter/Spacer";
-import { ClientService } from "lib/porter-apps/services";
+import { ClientService, prefixSubdomain } from "lib/porter-apps/services";
 import { Controller, useFormContext } from "react-hook-form";
 import { PorterAppFormData } from "lib/porter-apps";
 import Checkbox from "components/porter/Checkbox";
@@ -18,13 +18,6 @@ type NetworkingProps = {
   };
 };
 
-const prefixSubdomain = (subdomain: string) => {
-  if (subdomain.startsWith("https://") || subdomain.startsWith("http://")) {
-    return subdomain;
-  }
-  return "https://" + subdomain;
-};
-
 const Networking: React.FC<NetworkingProps> = ({ index, service }) => {
   const { register, control, watch } = useFormContext<PorterAppFormData>();