Browse Source

POR-1674 how banner if pr not merged or build not completed (#3540)

Co-authored-by: Feroze Mohideen <feroze@porter.run>
ianedwards 2 năm trước cách đây
mục cha
commit
fd721f2af9

+ 153 - 0
dashboard/src/lib/hooks/useGithubWorkflow.ts

@@ -0,0 +1,153 @@
+import { useQueries } from "@tanstack/react-query";
+import axios from "axios";
+import { PorterAppRecord } from "main/home/app-dashboard/app-view/AppView";
+import { useCallback, useContext, useEffect, useMemo, useState } from "react";
+import { Context } from "shared/Context";
+import api from "shared/api";
+import { z } from "zod";
+
+export const useGithubWorkflow = (
+  porterApp: PorterAppRecord,
+  previouslyBuilt: boolean
+) => {
+  const { currentProject, currentCluster } = useContext(Context);
+  const [githubWorkflowFilename, setGithubWorkflowName] = useState<string>("");
+  const [userHasGithubAccess, setUserHasGithubAccess] = useState<boolean>(true);
+
+  const gitMetadata = useMemo(() => {
+    const repoNameParts = z
+      .tuple([z.string(), z.string()])
+      .safeParse(porterApp.repo_name?.split("/"));
+    if (
+      !repoNameParts.success ||
+      !porterApp.git_repo_id ||
+      !porterApp.git_branch
+    ) {
+      return {
+        repo_id: 0,
+        owner: "",
+        name: "",
+        branch: "",
+      };
+    }
+
+    return {
+      repo_id: porterApp.git_repo_id,
+      owner: repoNameParts.data[0],
+      name: repoNameParts.data[1],
+      branch: porterApp.git_branch,
+    };
+  }, [porterApp.git_repo_id, porterApp.repo_name, porterApp.git_branch]);
+
+  const fetchGithubWorkflow = useCallback(
+    async (fileName: string) => {
+      try {
+        if (githubWorkflowFilename !== "") {
+          return githubWorkflowFilename;
+        }
+
+        if (currentProject == null || currentCluster == null) {
+          return "";
+        }
+
+        const res = await api.getBranchContents(
+          "<token>",
+          {
+            dir: `./.github/workflows/${fileName}`,
+          },
+          {
+            project_id: currentProject.id,
+            git_repo_id: gitMetadata.repo_id,
+            kind: "github",
+            owner: gitMetadata.owner,
+            name: gitMetadata.name,
+            branch: gitMetadata.branch,
+          }
+        );
+
+        if (res.data) {
+          return fileName;
+        }
+
+        return "";
+      } catch (err) {
+        return "";
+      }
+    },
+    [currentProject, currentCluster, gitMetadata, githubWorkflowFilename]
+  );
+
+  const enabled =
+    !previouslyBuilt &&
+    !!currentProject &&
+    !!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;
+          }
+
+          return true;
+        },
+        refetchOnWindowFocus: false,
+      },
+    ],
+  });
+
+  useEffect(() => {
+    if (!!applicationWorkflowCheck) {
+      setGithubWorkflowName(applicationWorkflowCheck);
+    } else if (!!defaultWorkflowCheck) {
+      setGithubWorkflowName(defaultWorkflowCheck);
+    }
+  }, [applicationWorkflowCheck, defaultWorkflowCheck]);
+
+  return {
+    githubWorkflowFilename,
+    isLoading: isLoadingApplicationWorkflow || isLoadingDefaultWorkflow,
+    userHasGithubAccess,
+  };
+};

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

@@ -7,7 +7,6 @@ import {
   porterAppFormValidator,
 } from "lib/porter-apps";
 import { zodResolver } from "@hookform/resolvers/zod";
-import RevisionsList from "./RevisionsList";
 import { useLatestRevision } from "./LatestRevisionContext";
 import Spacer from "components/porter/Spacer";
 import TabSelector from "components/TabSelector";
@@ -27,6 +26,7 @@ import Icon from "components/porter/Icon";
 import save from "assets/save-01.svg";
 import LogsTab from "./tabs/LogsTab";
 import MetricsTab from "./tabs/MetricsTab";
+import RevisionsList from "../validate-apply/revisions-list/RevisionsList";
 import Activity from "./tabs/Activity";
 
 // commented out tabs are not yet implemented
@@ -197,7 +197,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
         porterApp.name,
       ]);
       setPreviewRevision(null);
-    } catch (err) { }
+    } catch (err) {}
   });
 
   useEffect(() => {
@@ -258,11 +258,11 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
             { label: "Environment", value: "environment" },
             ...(latestProto.build
               ? [
-                {
-                  label: "Build Settings",
-                  value: "build-settings",
-                },
-              ]
+                  {
+                    label: "Build Settings",
+                    value: "build-settings",
+                  },
+                ]
               : []),
             { label: "Settings", value: "settings" },
           ]}

+ 2 - 44
dashboard/src/main/home/app-dashboard/app-view/AppView.tsx

@@ -23,6 +23,7 @@ export const porterAppValidator = z.object({
   dockerfile: z.string().optional(),
   image_repo_uri: z.string().optional(),
   porter_yaml_path: z.string().optional(),
+  pull_request_url: z.string().optional(),
 });
 export type PorterAppRecord = z.infer<typeof porterAppValidator>;
 
@@ -102,47 +103,4 @@ const StyledExpandedApp = styled.div`
       opacity: 1;
     }
   }
-`;
-const A = styled.a`
-  display: flex;
-  align-items: center;
-`;
-const SmallIcon = styled.img<{ opacity?: string; height?: string }>`
-  height: ${(props) => props.height || "15px"};
-  opacity: ${(props) => props.opacity || 1};
-  margin-right: 10px;
-`;
-const BranchIcon = styled.img`
-  height: 14px;
-  opacity: 0.65;
-  margin-right: 5px;
-`;
-const TagWrapper = styled.div`
-  height: 20px;
-  font-size: 12px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  color: #ffffff44;
-  border: 1px solid #ffffff44;
-  border-radius: 3px;
-  padding-left: 6px;
-`;
-const BranchTag = styled.div`
-  height: 20px;
-  margin-left: 6px;
-  color: #aaaabb;
-  background: #ffffff22;
-  border-radius: 3px;
-  font-size: 12px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  padding: 0px 6px;
-  padding-left: 7px;
-  border-top-left-radius: 0px;
-  border-bottom-left-radius: 0px;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-`;
+`;

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

@@ -1,502 +0,0 @@
-import { useQuery } from "@tanstack/react-query";
-import { AppRevision, appRevisionValidator } from "lib/revisions/types";
-import React, { useCallback, 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 {
-  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";
-import ConfirmOverlay from "components/porter/ConfirmOverlay";
-
-type Props = {
-  deploymentTargetId: string;
-  projectId: number;
-  clusterId: number;
-  appName: string;
-  latestSource: SourceOptions;
-  latestRevisionNumber: number;
-  onSubmit: () => Promise<void>;
-};
-
-const RED = "#ff0000";
-const YELLOW = "#FFA500";
-
-const RevisionsList: React.FC<Props> = ({
-  latestRevisionNumber,
-  deploymentTargetId,
-  projectId,
-  clusterId,
-  appName,
-  latestSource,
-  onSubmit,
-}) => {
-  const {
-    previewRevision,
-    setPreviewRevision,
-    servicesFromYaml,
-  } = useLatestRevision();
-  const { reset, setValue } = useFormContext<PorterAppFormData>();
-  const [expandRevisions, setExpandRevisions] = useState(false);
-  const [revertData, setRevertData] = useState<{
-    app: PorterApp;
-    revision: number;
-  } | null>(null);
-
-  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({
-          app_revisions: z.array(appRevisionValidator),
-        })
-        .parseAsync(res.data);
-
-      return revisions;
-    }
-  );
-
-  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 onRevert = useCallback(async () => {
-    if (!revertData) {
-      return;
-    }
-
-    setValue("app", clientAppFromProto(revertData.app, servicesFromYaml));
-    setRevertData(null);
-
-    void onSubmit();
-  }, [onSubmit, setValue, revertData]);
-
-  const renderContents = (revisions: AppRevision[]) => {
-    const revisionsWithProto = revisions.map((revision) => {
-      return {
-        ...revision,
-        app_proto: PorterApp.fromJsonString(atob(revision.b64_app_proto)),
-      };
-    });
-
-    const deployedRevisions = revisionsWithProto.filter(
-      (r) => r.revision_number !== 0
-    );
-    const pendingRevisions = revisionsWithProto.filter(
-      (r) => r.revision_number === 0
-    );
-
-    return (
-      <div>
-        <RevisionHeader
-          showRevisions={expandRevisions}
-          isCurrent={!previewRevision}
-          onClick={() => {
-            setExpandRevisions((prev) => !prev);
-          }}
-        >
-          <RevisionPreview>
-            <i className="material-icons">arrow_drop_down</i>
-            {getTableHeader(revisions[0])}
-            {revisions[0] ? (
-              <Revision>
-                No.{" "}
-                {getSelectedRevisionNumber({
-                  numDeployed: deployedRevisions.length,
-                  latestRevision: revisions[0],
-                })}
-              </Revision>
-            ) : null}
-          </RevisionPreview>
-        </RevisionHeader>
-        <RevisionList>
-          <TableWrapper>
-            <RevisionsTable>
-              <tbody>
-                <Tr disableHover>
-                  <Th>Revision no.</Th>
-                  <Th>
-                    {revisionsWithProto[0]?.app_proto.build
-                      ? "Commit SHA"
-                      : "Image Tag"}
-                  </Th>
-                  <Th>Timestamp</Th>
-                  <Th>Status</Th>
-                  <Th>Rollback</Th>
-                </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={() => {
-                            if (isLatestDeployedRevision) {
-                              return;
-                            }
-
-                            setRevertData({
-                              app: revision.app_proto,
-                              revision: revision.revision_number,
-                            });
-                          }}
-                        >
-                          {isLatestDeployedRevision ? "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.app_revisions)
-        )
-        .otherwise(() => null)}
-      {revertData ? (
-        <ConfirmOverlay
-          message={`Are you sure you want to revert to revision ${revertData?.revision}?`}
-          onYes={onRevert}
-          onNo={() => {
-            setRevertData(null);
-          }}
-        />
-      ) : 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"};
-  }
-`;
-
-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);
-    }
-  }
-`;

+ 93 - 0
dashboard/src/main/home/app-dashboard/validate-apply/revisions-list/GHStatusBanner.tsx

@@ -0,0 +1,93 @@
+import React, { useContext, useMemo } from "react";
+import { useLatestRevision } from "../../app-view/LatestRevisionContext";
+import { Context } from "shared/Context";
+import { useGithubWorkflow } from "lib/hooks/useGithubWorkflow";
+import Banner from "components/porter/Banner";
+import Spacer from "components/porter/Spacer";
+import Link from "components/porter/Link";
+import GHABanner from "../../expanded-app/GHABanner";
+import { match } from "ts-pattern";
+import { AppRevision } from "lib/revisions/types";
+
+type GHStatusBannerProps = {
+  revisions: AppRevision[];
+};
+
+const GHStatusBanner: React.FC<GHStatusBannerProps> = ({ revisions }) => {
+  const { setCurrentModal } = useContext(Context);
+  const { porterApp } = useLatestRevision();
+
+  const previouslyBuilt = useMemo(() => {
+    return revisions.some((r) =>
+      match(r.status)
+        .with(
+          "AWAITING_PREDEPLOY",
+          "READY_TO_APPLY",
+          "DEPLOYED",
+          "DEPLOY_FAILED",
+          "BUILD_FAILED",
+          () => true
+        )
+        .otherwise(() => false)
+    );
+  }, [revisions]);
+
+  const {
+    githubWorkflowFilename,
+    userHasGithubAccess,
+    isLoading,
+  } = useGithubWorkflow(porterApp, previouslyBuilt);
+
+  if (previouslyBuilt) {
+    return null;
+  }
+
+  if (!userHasGithubAccess) {
+    return (
+      <Banner type="warning">
+        You do not have access to the GitHub repo associated with this
+        application.
+        <Spacer inline width="5px" />
+        <Link
+          hasunderline
+          onClick={() => setCurrentModal?.("AccountSettingsModal", {})}
+        >
+          Check account settings
+        </Link>
+      </Banner>
+    );
+  }
+
+  if (githubWorkflowFilename) {
+    return (
+      <Banner>
+        Your GitHub repo has not been built yet.
+        <Spacer inline width="5px" />
+        <Link
+          hasunderline
+          target="_blank"
+          to={`https://github.com/${porterApp.repo_name}/actions`}
+        >
+          Check status
+        </Link>
+      </Banner>
+    );
+  }
+
+  if (!isLoading) {
+    return (
+      <GHABanner
+        repoName={porterApp.repo_name ?? ""}
+        branchName={porterApp.git_branch ?? ""}
+        pullRequestUrl={porterApp.pull_request_url ?? ""}
+        stackName={porterApp.name}
+        gitRepoId={porterApp.git_repo_id ?? 0}
+        porterYamlPath={porterApp.porter_yaml_path}
+      />
+    );
+  }
+
+  return null
+};
+
+export default GHStatusBanner;

+ 380 - 0
dashboard/src/main/home/app-dashboard/validate-apply/revisions-list/RevisionTableContents.tsx

@@ -0,0 +1,380 @@
+import React, { Dispatch, SetStateAction, useMemo } from "react";
+import { PorterApp } from "@porter-dev/api-contracts";
+import { AppRevision } from "lib/revisions/types";
+import { match } from "ts-pattern";
+import { useLatestRevision } from "../../app-view/LatestRevisionContext";
+import styled from "styled-components";
+import { readableDate } from "shared/string_utils";
+import Text from "components/porter/Text";
+import { useGithubWorkflow } from "lib/hooks/useGithubWorkflow";
+import { useFormContext } from "react-hook-form";
+import {
+  PorterAppFormData,
+  SourceOptions,
+  clientAppFromProto,
+} from "lib/porter-apps";
+import GHStatusBanner from "./GHStatusBanner";
+
+type RevisionTableContentsProps = {
+  latestRevisionNumber: number;
+  revisions: AppRevision[];
+  latestSource: SourceOptions;
+  expandRevisions: boolean;
+  setExpandRevisions: Dispatch<SetStateAction<boolean>>;
+  setRevertData: Dispatch<
+    SetStateAction<{
+      app: PorterApp;
+      revision: number;
+    } | null>
+  >;
+};
+
+const RED = "#ff0000";
+const YELLOW = "#FFA500";
+
+const RevisionTableContents: React.FC<RevisionTableContentsProps> = ({
+  latestRevisionNumber,
+  revisions,
+  latestSource,
+  expandRevisions,
+  setExpandRevisions,
+  setRevertData,
+}) => {
+  const { reset } = useFormContext<PorterAppFormData>();
+  const {
+    previewRevision,
+    setPreviewRevision,
+    servicesFromYaml,
+  } = useLatestRevision();
+
+  const revisionsWithProto = revisions.map((revision) => {
+    return {
+      ...revision,
+      app_proto: PorterApp.fromJsonString(atob(revision.b64_app_proto)),
+    };
+  });
+
+  const deployedRevisions = revisionsWithProto.filter(
+    (r) => r.revision_number !== 0
+  );
+  const pendingRevisions = revisionsWithProto.filter(
+    (r) => r.revision_number === 0
+  );
+  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;
+  };
+
+  return (
+    <div>
+      <RevisionHeader
+        showRevisions={expandRevisions}
+        isCurrent={!previewRevision}
+        onClick={() => {
+          setExpandRevisions((prev) => !prev);
+        }}
+      >
+        <RevisionPreview>
+          <i className="material-icons">arrow_drop_down</i>
+          {getTableHeader(revisions[0])}
+          {revisions[0] ? (
+            <Revision>
+              No.{" "}
+              {getSelectedRevisionNumber({
+                numDeployed: deployedRevisions.length,
+                latestRevision: revisions[0],
+              })}
+            </Revision>
+          ) : null}
+        </RevisionPreview>
+      </RevisionHeader>
+      <RevisionList>
+        <TableWrapper>
+          <RevisionsTable>
+            <tbody>
+              <Tr disableHover>
+                <Th>Revision no.</Th>
+                <Th>
+                  {revisionsWithProto[0]?.app_proto.build
+                    ? "Commit SHA"
+                    : "Image Tag"}
+                </Th>
+                <Th>Timestamp</Th>
+                <Th>Status</Th>
+                <Th>Rollback</Th>
+              </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={() => {
+                          if (isLatestDeployedRevision) {
+                            return;
+                          }
+
+                          setRevertData({
+                            app: revision.app_proto,
+                            revision: revision.revision_number,
+                          });
+                        }}
+                      >
+                        {isLatestDeployedRevision ? "Current" : "Revert"}
+                      </RollbackButton>
+                    </Td>
+                  </Tr>
+                );
+              })}
+            </tbody>
+          </RevisionsTable>
+        </TableWrapper>
+      </RevisionList>
+    </div>
+  );
+};
+
+export default RevisionTableContents;
+
+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"};
+  }
+`;
+
+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);
+    }
+  }
+`;

+ 179 - 0
dashboard/src/main/home/app-dashboard/validate-apply/revisions-list/RevisionsList.tsx

@@ -0,0 +1,179 @@
+import { useQuery } from "@tanstack/react-query";
+import { appRevisionValidator } from "lib/revisions/types";
+import React, { useCallback, 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 {
+  PorterAppFormData,
+  SourceOptions,
+  clientAppFromProto,
+} from "lib/porter-apps";
+import { z } from "zod";
+import { PorterApp } from "@porter-dev/api-contracts";
+import { useFormContext } from "react-hook-form";
+import ConfirmOverlay from "components/porter/ConfirmOverlay";
+import { useLatestRevision } from "../../app-view/LatestRevisionContext";
+import RevisionTableContents from "./RevisionTableContents";
+import GHStatusBanner from "./GHStatusBanner";
+import Spacer from "components/porter/Spacer";
+
+type Props = {
+  deploymentTargetId: string;
+  projectId: number;
+  clusterId: number;
+  appName: string;
+  latestSource: SourceOptions;
+  latestRevisionNumber: number;
+  onSubmit: () => Promise<void>;
+};
+
+const RevisionsList: React.FC<Props> = ({
+  latestRevisionNumber,
+  deploymentTargetId,
+  projectId,
+  clusterId,
+  appName,
+  latestSource,
+  onSubmit,
+}) => {
+  const { servicesFromYaml } = useLatestRevision();
+  const { setValue } = useFormContext<PorterAppFormData>();
+  const [expandRevisions, setExpandRevisions] = useState(false);
+  const [revertData, setRevertData] = useState<{
+    app: PorterApp;
+    revision: number;
+  } | null>(null);
+
+  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({
+          app_revisions: z.array(appRevisionValidator),
+        })
+        .parseAsync(res.data);
+
+      return revisions;
+    }
+  );
+
+  const onRevert = useCallback(async () => {
+    if (!revertData) {
+      return;
+    }
+
+    setValue("app", clientAppFromProto(revertData.app, servicesFromYaml));
+    setRevertData(null);
+
+    void onSubmit();
+  }, [onSubmit, setValue, revertData]);
+
+  return (
+    <div>
+      <StyledRevisionSection showRevisions={expandRevisions}>
+        {match(res)
+          .with({ status: "loading" }, () => (
+            <LoadingPlaceholder>
+              <StatusWrapper>
+                <LoadingGif src={loading} revision={false} /> Updating . . .
+              </StatusWrapper>
+            </LoadingPlaceholder>
+          ))
+          .with({ status: "success" }, ({ data }) => (
+            <RevisionTableContents
+              latestRevisionNumber={latestRevisionNumber}
+              revisions={data.app_revisions}
+              latestSource={latestSource}
+              expandRevisions={expandRevisions}
+              setExpandRevisions={setExpandRevisions}
+              setRevertData={setRevertData}
+            />
+          ))
+          .otherwise(() => null)}
+        {revertData ? (
+          <ConfirmOverlay
+            message={`Are you sure you want to revert to revision ${revertData?.revision}?`}
+            onYes={onRevert}
+            onNo={() => {
+              setRevertData(null);
+            }}
+          />
+        ) : null}
+      </StyledRevisionSection>
+      {res.data && (
+        <>
+          <GHStatusBanner revisions={res.data.app_revisions} />
+          <Spacer y={0.5} />
+        </>
+      )}
+    </div>
+  );
+};
+
+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;
+`;