Browse Source

checkpoint

Ian Edwards 2 years ago
parent
commit
6bb171ee52

+ 76 - 1
dashboard/src/lib/porter-apps/index.ts

@@ -10,8 +10,9 @@ import {
   serviceProto,
   serviceValidator,
 } from "./services";
-import { PorterApp, Service } from "@porter-dev/api-contracts";
+import { Build, PorterApp, Service } from "@porter-dev/api-contracts";
 import { match } from "ts-pattern";
+import { valueExists } from "shared/util";
 
 // buildValidator is used to validate inputs for build setting fields
 export const buildValidator = z.discriminatedUnion("method", [
@@ -171,3 +172,77 @@ export function clientAppToProto(data: PorterAppFormData): PorterApp {
 
   return proto;
 }
+
+const clientBuildFromProto = (proto?: Build): BuildOptions | undefined => {
+  if (!proto) {
+    return;
+  }
+
+  const buildValidation = z
+    .discriminatedUnion("method", [
+      z.object({
+        method: z.literal("pack"),
+        context: z.string(),
+        buildpacks: z.array(z.string()).default([]),
+        builder: z.string(),
+      }),
+      z.object({
+        method: z.literal("docker"),
+        context: z.string(),
+        dockerfile: z.string(),
+      }),
+    ])
+    .safeParse(proto);
+
+  if (!buildValidation.success) {
+    return;
+  }
+
+  const build = buildValidation.data;
+
+  return match(build)
+    .with({ method: "pack" }, (b) =>
+      Object.freeze({
+        method: b.method,
+        context: b.context,
+        buildpacks: b.buildpacks.map((b) => ({ name: b, buildpack: b })),
+        builder: b.builder,
+      })
+    )
+    .with({ method: "docker" }, (b) =>
+      Object.freeze({
+        method: b.method,
+        context: b.context,
+        dockerfile: b.dockerfile,
+      })
+    )
+    .exhaustive();
+};
+
+export function clientAppFromProto(proto: PorterApp): ClientPorterApp {
+  const services = Object.entries(proto.services)
+    .map(([name, service]) => serializedServiceFromProto({ name, service }))
+    .map((svc) => deserializeService(svc));
+
+  const predeploy = proto.predeploy
+    ? deserializeService(
+        serializedServiceFromProto({
+          name: "pre-deploy",
+          service: proto.predeploy,
+          isPredeploy: true,
+        })
+      )
+    : undefined;
+
+  return {
+    name: proto.name,
+    services: [...services, predeploy].filter(valueExists),
+    env: proto.env,
+    build: clientBuildFromProto(proto.build) ?? {
+      method: "pack",
+      context: "./",
+      buildpacks: [],
+      builder: "",
+    },
+  };
+}

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

@@ -0,0 +1,19 @@
+import { z } from "zod";
+
+export const appRevisionValidator = z.object({
+  status: z.enum([
+    "CREATED",
+    "AWAITING_BUILD_ARTIFACT",
+    "AWAITING_PREDEPLOY",
+    "READY_TO_APPLY",
+    "DEPLOYED",
+    "BUILD_FAILED",
+    "BUILD_CANCELED",
+    "DEPLOY_FAILED",
+  ]),
+  b64_app_proto: z.string(),
+  revision_number: z.number(),
+  updated_at: z.string(),
+});
+
+export type AppRevision = z.infer<typeof appRevisionValidator>;

+ 83 - 0
dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx

@@ -0,0 +1,83 @@
+import React, { useContext, useMemo } from "react";
+import { AppRevision } from "lib/revisions/types";
+import { PorterApp } from "@porter-dev/api-contracts";
+import { useForm } from "react-hook-form";
+import {
+  PorterAppFormData,
+  SourceOptions,
+  clientAppFromProto,
+  porterAppFormValidator,
+} from "lib/porter-apps";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { PorterAppRecord } from "./AppView";
+import RevisionsList from "./RevisionsList";
+import { Context } from "shared/Context";
+import { useDefaultDeploymentTarget } from "lib/hooks/useDeploymentTarget";
+
+type AppDataContainerProps = {
+  latestRevision: AppRevision;
+  porterApp: PorterAppRecord;
+};
+
+const AppDataContainer: React.FC<AppDataContainerProps> = ({
+  latestRevision,
+  porterApp,
+}) => {
+  const { currentProject, currentCluster } = useContext(Context);
+  const deploymentTarget = useDefaultDeploymentTarget();
+
+  const latestProto = useMemo(
+    () => PorterApp.fromJsonString(atob(latestRevision.b64_app_proto)),
+    [latestRevision]
+  );
+  const latestSource: SourceOptions = useMemo(() => {
+    if (porterApp.image_repo_uri) {
+      const [repository, tag] = porterApp.image_repo_uri.split(":");
+      return {
+        type: "docker-registry",
+        image: {
+          repository,
+          tag,
+        },
+      };
+    }
+
+    return {
+      type: "github",
+      git_repo_id: porterApp.git_repo_id ?? 0,
+      git_repo_name: porterApp.repo_name ?? "",
+      git_branch: porterApp.git_branch ?? "",
+      porter_yaml_path: porterApp.porter_yaml_path ?? "./porter.yaml",
+    };
+  }, [porterApp]);
+
+  const porterAppFormMethods = useForm<PorterAppFormData>({
+    reValidateMode: "onSubmit",
+    resolver: zodResolver(porterAppFormValidator),
+    defaultValues: {
+      app: clientAppFromProto(latestProto),
+      source: latestSource,
+    },
+  });
+
+  if (!currentProject || !currentCluster) {
+    return null;
+  }
+
+  if (!deploymentTarget) {
+    return null;
+  }
+
+  return (
+    <RevisionsList
+      latestRevisionNumber={latestRevision.revision_number}
+      deploymentTargetId={deploymentTarget?.deployment_target_id}
+      projectId={currentProject.id}
+      clusterId={currentCluster.id}
+      appName={porterApp.name}
+      sourceType={latestSource.type}
+    />
+  );
+};
+
+export default AppDataContainer;

+ 39 - 14
dashboard/src/main/home/app-dashboard/app-view/AppView.tsx

@@ -21,6 +21,8 @@ import Icon from "components/porter/Icon";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import Link from "components/porter/Link";
+import AppDataContainer from "./AppDataContainer";
+import { appRevisionValidator } from "lib/revisions/types";
 
 export const porterAppValidator = z.object({
   name: z.string(),
@@ -34,6 +36,7 @@ export const porterAppValidator = z.object({
   image_repo_uri: z.string().optional(),
   porter_yaml_path: z.string().optional(),
 });
+export type PorterAppRecord = z.infer<typeof porterAppValidator>;
 
 // Buildpack icons
 const icons = [
@@ -66,6 +69,7 @@ type Props = RouteComponentProps & {};
 
 const AppView: React.FC<Props> = ({ match }) => {
   const { currentCluster, currentProject } = useContext(Context);
+
   const deploymentTarget = useDefaultDeploymentTarget();
 
   const params = useMemo(() => {
@@ -113,11 +117,12 @@ const AppView: React.FC<Props> = ({ match }) => {
     },
     {
       enabled: appParamsExist,
+      refetchInterval: 5000,
     }
   );
 
   const { data: revision, status } = useQuery(
-    ["getAppRevision", params.appName, "latest"],
+    ["getLatestRevision", params.appName, "latest"],
     async () => {
       if (!appParamsExist) {
         return null;
@@ -135,17 +140,13 @@ const AppView: React.FC<Props> = ({ match }) => {
         }
       );
 
-      const rawAppData = await z
-        .object({
-          b64_app_proto: z.string(),
-        })
-        .parseAsync(res.data);
+      console.log("res.data", res.data);
 
-      const porterApp = PorterApp.fromJsonString(
-        atob(rawAppData.b64_app_proto)
-      );
+      const revision = await appRevisionValidator
+        .omit({ updated_at: true })
+        .parseAsync(res.data);
 
-      return porterApp;
+      return revision;
     },
     {
       enabled: appParamsExist,
@@ -164,6 +165,14 @@ const AppView: React.FC<Props> = ({ match }) => {
     };
   }, [appData]);
 
+  const appProto = useMemo(
+    () =>
+      revision?.b64_app_proto
+        ? PorterApp.fromJsonString(atob(revision?.b64_app_proto))
+        : null,
+    [revision]
+  );
+
   const getIconSvg = (build: PorterApp["build"]) => {
     if (!build) {
       return box;
@@ -192,7 +201,7 @@ const AppView: React.FC<Props> = ({ match }) => {
     return <Loading />;
   }
 
-  if (status === "error" || porterAppStatus === "error" || !revision) {
+  if (status === "error" || porterAppStatus === "error" || !appData) {
     return (
       <Placeholder>
         <Container row>
@@ -211,9 +220,9 @@ const AppView: React.FC<Props> = ({ match }) => {
     <StyledExpandedApp>
       <Back to="/apps" />
       <Container row>
-        <Icon src={getIconSvg(revision.build)} height={"24px"} />
+        <Icon src={getIconSvg(appProto?.build)} height={"24px"} />
         <Spacer inline x={1} />
-        <Text size={21}>{revision.name}</Text>
+        <Text size={21}>{appProto?.name}</Text>
         {gitData && (
           <>
             <Spacer inline x={1} />
@@ -233,9 +242,25 @@ const AppView: React.FC<Props> = ({ match }) => {
             </TagWrapper>
           </>
         )}
+        {!gitData && appData?.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">
+                {appData.image_repo_uri}
+              </Text>
+            </Container>
+          </>
+        )}
       </Container>
       <Spacer y={0.5} />
-      
+      {appData && revision && (
+        <AppDataContainer porterApp={appData} latestRevision={revision} />
+      )}
     </StyledExpandedApp>
   );
 };

+ 286 - 0
dashboard/src/main/home/app-dashboard/app-view/RevisionsList.tsx

@@ -0,0 +1,286 @@
+import { useQuery } from "@tanstack/react-query";
+import { AppRevision, appRevisionValidator } from "lib/revisions/types";
+import React, { useState } from "react";
+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 { z } from "zod";
+import { PorterApp } from "@porter-dev/api-contracts";
+import { readableDate } from "shared/string_utils";
+
+type Props = {
+  latestRevisionNumber: number;
+  deploymentTargetId: string;
+  projectId: number;
+  clusterId: number;
+  appName: string;
+  sourceType: SourceOptions["type"];
+};
+
+const RevisionsList: React.FC<Props> = ({
+  latestRevisionNumber,
+  deploymentTargetId,
+  projectId,
+  clusterId,
+  appName,
+  sourceType,
+}) => {
+  const [expandRevisions, setExpandRevisions] = useState(false);
+
+  const res = useQuery(
+    ["listAppRevisions", projectId, clusterId, latestRevisionNumber, appName],
+    async () => {
+      const res = await api.listAppRevisions(
+        "<token>",
+        {
+          deployment_target_id: deploymentTargetId,
+        },
+        {
+          project_id: projectId,
+          cluster_id: clusterId,
+          porter_app_name: appName,
+        }
+      );
+
+      const revisions = await z
+        .object({
+          revisions: z.array(appRevisionValidator),
+        })
+        .parseAsync(res.data);
+
+      return revisions;
+    }
+  );
+
+  const renderContents = (revisions: AppRevision[]) => {
+    const revisionsWithProto = revisions.map((revision) => {
+      return {
+        ...revision,
+        app_proto: PorterApp.fromJsonString(atob(revision.b64_app_proto)),
+      };
+    });
+
+    return (
+      <div>
+        <RevisionHeader
+          showRevisions={expandRevisions}
+          isCurrent
+          onClick={() => {
+            setExpandRevisions((prev) => !prev);
+          }}
+        >
+          <RevisionPreview>
+            <i className="material-icons">arrow_drop_down</i>
+            Current version -{" "}
+            <Revision>No. {revisions[0].revision_number}</Revision>
+          </RevisionPreview>
+        </RevisionHeader>
+        <RevisionList>
+          <TableWrapper>
+            <RevisionsTable>
+              <tbody>
+                <Tr disableHover>
+                  <Th>Revision no.</Th>
+                  <Th>Timestamp</Th>
+                  <Th>Image Tag</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.image?.tag}</Td>
+                    <Td>
+                      <RollbackButton
+                        disabled={
+                          revision.revision_number === latestRevisionNumber
+                        }
+                        onClick={() => {}}
+                      >
+                        {revision.revision_number === latestRevisionNumber
+                          ? "Current"
+                          : "Revert"}
+                      </RollbackButton>
+                    </Td>
+                  </Tr>
+                ))}
+              </tbody>
+            </RevisionsTable>
+          </TableWrapper>
+        </RevisionList>
+      </div>
+    );
+  };
+
+  return (
+    <StyledRevisionSection showRevisions={expandRevisions}>
+      {match(res)
+        .with({ status: "loading" }, () => (
+          <LoadingPlaceholder>
+            <StatusWrapper>
+              <LoadingGif src={loading} revision={false} /> Updating . . .
+            </StatusWrapper>
+          </LoadingPlaceholder>
+        ))
+        .with({ status: "success" }, ({ data }) =>
+          renderContents(data.revisions)
+        )
+        .otherwise(() => null)}
+    </StyledRevisionSection>
+  );
+};
+
+export default RevisionsList;
+
+const StyledRevisionSection = styled.div`
+  width: 100%;
+  max-height: ${(props: { showRevisions: boolean }) =>
+    props.showRevisions ? "255px" : "40px"};
+  margin: 20px 0px 18px;
+  overflow: hidden;
+  border-radius: 5px;
+  background: ${(props) => props.theme.fg};
+  border: 1px solid #494b4f;
+  :hover {
+    border: 1px solid #7a7b80;
+  }
+  animation: ${(props: { showRevisions: boolean }) =>
+    props.showRevisions ? "expandRevisions 0.3s" : ""};
+  animation-timing-function: ease-out;
+  @keyframes expandRevisions {
+    from {
+      max-height: 40px;
+    }
+    to {
+      max-height: 250px;
+    }
+  }
+`;
+
+const LoadingPlaceholder = styled.div`
+  height: 40px;
+  display: flex;
+  align-items: center;
+  padding-left: 20px;
+`;
+
+const LoadingGif = styled.img`
+  width: 15px;
+  height: 15px;
+  margin-right: ${(props: { revision: boolean }) =>
+    props.revision ? "0px" : "9px"};
+  margin-left: ${(props: { revision: boolean }) =>
+    props.revision ? "10px" : "0px"};
+  margin-bottom: ${(props: { revision: boolean }) =>
+    props.revision ? "-2px" : "0px"};
+`;
+
+const StatusWrapper = styled.div`
+  display: flex;
+  align-items: center;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #ffffff55;
+  margin-right: 25px;
+`;
+
+const RevisionHeader = styled.div`
+  color: ${(props: { showRevisions: boolean; isCurrent: boolean }) =>
+    props.isCurrent ? "#ffffff66" : "#f5cb42"};
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  height: 40px;
+  font-size: 13px;
+  width: 100%;
+  padding-left: 10px;
+  cursor: pointer;
+  background: ${({ theme }) => theme.fg};
+  :hover {
+    background: ${(props) => props.showRevisions && props.theme.fg2};
+  }
+
+  > div > i {
+    margin-right: 8px;
+    font-size: 20px;
+    cursor: pointer;
+    border-radius: 20px;
+    transform: ${(props: { showRevisions: boolean; isCurrent: boolean }) =>
+      props.showRevisions ? "" : "rotate(-90deg)"};
+    transition: transform 0.1s ease;
+  }
+`;
+
+const RevisionPreview = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const Revision = styled.div`
+  color: #ffffff;
+  margin-left: 5px;
+`;
+
+const RevisionList = styled.div`
+  overflow-y: auto;
+  max-height: 215px;
+`;
+
+const TableWrapper = styled.div`
+  padding-bottom: 20px;
+`;
+
+const RevisionsTable = styled.table`
+  width: 100%;
+  margin-top: 5px;
+  padding-left: 32px;
+  padding-bottom: 20px;
+  min-width: 500px;
+  border-collapse: collapse;
+`;
+
+const Tr = styled.tr`
+  line-height: 2.2em;
+  cursor: ${(props: { disableHover?: boolean; selected?: boolean }) =>
+    props.disableHover ? "" : "pointer"};
+  background: ${(props: { disableHover?: boolean; selected?: boolean }) =>
+    props.selected ? "#ffffff11" : ""};
+  :hover {
+    background: ${(props: { disableHover?: boolean; selected?: boolean }) =>
+      props.disableHover ? "" : "#ffffff22"};
+  }
+`;
+
+const Td = styled.td`
+  font-size: 13px;
+  color: #ffffff;
+  padding-left: 32px;
+`;
+
+const Th = styled.td`
+  font-size: 13px;
+  font-weight: 500;
+  color: #aaaabb;
+  padding-left: 32px;
+`;
+
+const RollbackButton = styled.div`
+  cursor: ${(props: { disabled: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+  display: flex;
+  border-radius: 3px;
+  align-items: center;
+  justify-content: center;
+  font-weight: 500;
+  height: 21px;
+  font-size: 13px;
+  width: 70px;
+  background: ${(props: { disabled: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled: boolean }) =>
+      props.disabled ? "" : "#405eddbb"};
+  }
+`;

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

@@ -14,7 +14,9 @@ import { Controller, useFieldArray, useFormContext } from "react-hook-form";
 import { BuildOptions, PorterAppFormData } from "lib/porter-apps";
 
 type Props = {
-  build: BuildOptions;
+  build: BuildOptions & { 
+    method: 'pack'
+  };
   closeModal: () => void;
   sortedStackOptions: { value: string; label: string }[];
   availableBuildpacks: Buildpack[];

+ 4 - 2
dashboard/src/main/home/app-dashboard/validate-apply/build-settings/buildpacks/BuildpackList.tsx

@@ -10,7 +10,9 @@ import { useFieldArray, useFormContext } from "react-hook-form";
 import { BuildOptions, PorterAppFormData } from "lib/porter-apps";
 
 interface Props {
-  build: BuildOptions;
+  build: BuildOptions & {
+    method: "pack";
+  };
   availableBuildpacks: Buildpack[];
   setAvailableBuildpacks: (buildpacks: Buildpack[]) => void;
   showAvailableBuildpacks: boolean;
@@ -142,4 +144,4 @@ const BuildpackList: React.FC<Props> = ({
   );
 };
 
-export default BuildpackList;
+export default BuildpackList;

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

@@ -26,7 +26,9 @@ import {
 
 type Props = {
   projectId: number;
-  build: BuildOptions;
+  build: BuildOptions & {
+    method: "pack";
+  };
   source: SourceOptions & { type: "github" };
   autoDetectionDisabled?: boolean;
 };

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

@@ -869,6 +869,19 @@ const getLatestRevision = baseApi<
   return `/api/projects/${project_id}/clusters/${cluster_id}/apps/${porter_app_name}/latest`;
 });
 
+const listAppRevisions = baseApi<
+  {
+    deployment_target_id: string;
+  },
+  {
+    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}/revisions`;
+});
+
 const getGitlabProcfileContents = baseApi<
   {
     path: string;
@@ -2941,6 +2954,7 @@ export default {
   createApp,
   applyApp,
   getLatestRevision,
+  listAppRevisions,
   getGitlabProcfileContents,
   getProjectClusters,
   getProjectRegistries,