Forráskód Böngészése

[towards POR-1668] Copy activity feed components to v2 porter yaml flow (#3526)

Feroze Mohideen 2 éve
szülő
commit
72425362fa
17 módosított fájl, 1797 hozzáadás és 2 törlés
  1. 5 2
      dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx
  2. 20 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/Activity.tsx
  3. 288 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/ActivityFeed.tsx
  4. 103 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/AppEventCard.tsx
  5. 117 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/BuildEventCard.tsx
  6. 230 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/DeployEventCard.tsx
  7. 55 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/EventCard.tsx
  8. 99 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/PreDeployEventCard.tsx
  9. 137 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/ServiceStatusDetail.tsx
  10. 278 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/focus-views/BuildFailureEventFocusView.tsx
  11. 71 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/focus-views/DeployEventFocusView.tsx
  12. 129 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/focus-views/EventFocusView.tsx
  13. 70 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/focus-views/PredeployEventFocusView.tsx
  14. 89 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/types.ts
  15. 104 0
      dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/utils.ts
  16. 1 0
      dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx
  17. 1 0
      dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/CustomDomains.tsx

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

@@ -27,11 +27,12 @@ import Icon from "components/porter/Icon";
 import save from "assets/save-01.svg";
 import LogsTab from "./tabs/LogsTab";
 import MetricsTab from "./tabs/MetricsTab";
+import Activity from "./tabs/Activity";
 
 // commented out tabs are not yet implemented
 // will be included as support is available based on data from app revisions rather than helm releases
 const validTabs = [
-  // "activity",
+  "activity",
   // "events",
   "overview",
   "logs",
@@ -43,7 +44,7 @@ const validTabs = [
   // "helm-values",
   // "job-history",
 ] as const;
-const DEFAULT_TAB = "overview";
+const DEFAULT_TAB = "activity";
 type ValidTab = typeof validTabs[number];
 
 type AppDataContainerProps = {
@@ -246,6 +247,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
         <TabSelector
           noBuffer
           options={[
+            { label: "Activity", value: "activity" },
             { label: "Overview", value: "overview" },
             { label: "Logs", value: "logs" },
             { label: "Metrics", value: "metrics" },
@@ -267,6 +269,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
         />
         <Spacer y={1} />
         {match(currentTab)
+          .with("activity", () => <Activity />)
           .with("overview", () => <Overview />)
           .with("build-settings", () => (
             <BuildSettings

+ 20 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/Activity.tsx

@@ -0,0 +1,20 @@
+import React from "react";
+import { useLatestRevision } from "../LatestRevisionContext";
+import ActivityFeed from "./activity-feed/ActivityFeed";
+
+const Activity: React.FC = () => {
+    const { projectId, clusterId, latestProto, deploymentTargetId } = useLatestRevision();
+
+    return (
+        <>
+            <ActivityFeed
+                currentProject={projectId}
+                currentCluster={clusterId}
+                appName={latestProto.name}
+                deploymentTargetId={deploymentTargetId}
+            />
+        </>
+    );
+};
+
+export default Activity;

+ 288 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/ActivityFeed.tsx

@@ -0,0 +1,288 @@
+import React, { useEffect, useState } from "react";
+import styled from "styled-components";
+
+import api from "shared/api";
+
+import Text from "components/porter/Text";
+
+import EventCard from "./events/cards/EventCard";
+import Loading from "components/Loading";
+import Spacer from "components/porter/Spacer";
+import Fieldset from "components/porter/Fieldset";
+
+import { feedDate } from "shared/string_utils";
+import Pagination from "components/porter/Pagination";
+import _ from "lodash";
+import Button from "components/porter/Button";
+import { PorterAppEvent, PorterAppEventType, porterAppEventValidator } from "./events/types";
+import { z } from "zod";
+
+type Props = {
+    appName: string;
+    currentProject: number;
+    currentCluster: number;
+    deploymentTargetId: string;
+};
+
+const EVENTS_POLL_INTERVAL = 5000; // poll every 5 seconds
+
+const ActivityFeed: React.FC<Props> = ({ appName, deploymentTargetId, currentCluster, currentProject }) => {
+
+    const [events, setEvents] = useState<PorterAppEvent[]>([]);
+    const [loading, setLoading] = useState<boolean>(true);
+    const [error, setError] = useState<any>(null);
+    const [page, setPage] = useState<number>(1);
+    const [numPages, setNumPages] = useState<number>(0);
+    const [hasPorterAgent, setHasPorterAgent] = useState(false);
+    const [isPorterAgentInstalling, setIsPorterAgentInstalling] = useState(false);
+    const [shouldAnimate, setShouldAnimate] = useState(true);
+
+    const getEvents = async () => {
+        setLoading(true)
+        try {
+            const res = await api.getFeedEvents(
+                "<token>",
+                {},
+                {
+                    cluster_id: currentCluster,
+                    project_id: currentProject,
+                    stack_name: appName,
+                    page,
+                }
+            );
+
+            setNumPages(res.data.num_pages);
+            const events = z.array(porterAppEventValidator).optional().default([]).parse(res.data.events);
+            setEvents(events);
+        } catch (err) {
+            setError(err);
+        } finally {
+            setLoading(false);
+            setShouldAnimate(false);
+        }
+    };
+
+    const getLatestDeployEventIndex = () => {
+        const deployEvents = events.filter((event) => event.type === PorterAppEventType.DEPLOY);
+        if (deployEvents.length === 0) {
+            return -1;
+        }
+        return events.indexOf(deployEvents[0]);
+    };
+
+    const updateEvents = async () => {
+        try {
+            const res = await api.getFeedEvents(
+                "<token>",
+                {},
+                {
+                    cluster_id: currentCluster,
+                    project_id: currentProject,
+                    stack_name: appName,
+                    page,
+                }
+            );
+            setError(undefined)
+            setNumPages(res.data.num_pages);
+            const events = z.array(porterAppEventValidator).optional().default([]).parse(res.data.events);
+            setEvents(events);
+        } catch (err) {
+            setError(err);
+        }
+    }
+
+    useEffect(() => {
+        const checkForAgent = async () => {
+            try {
+                const project_id = currentProject;
+                const cluster_id = currentCluster;
+                const res = await api.detectPorterAgent("<token>", {}, { project_id, cluster_id });
+                const hasAgent = res.data?.version === "v3";
+                setHasPorterAgent(hasAgent);
+            } catch (err) {
+                if (err.response?.status === 404) {
+                    setHasPorterAgent(false);
+                }
+            } finally {
+                setLoading(false);
+            }
+        };
+
+        if (!hasPorterAgent) {
+            checkForAgent();
+        } else {
+            const intervalId = setInterval(updateEvents, EVENTS_POLL_INTERVAL);
+            getEvents();
+            return () => clearInterval(intervalId);
+        }
+
+    }, [currentProject, currentCluster, hasPorterAgent, page]);
+
+    const installAgent = async () => {
+        const project_id = currentProject;
+        const cluster_id = currentCluster;
+
+        setIsPorterAgentInstalling(true);
+        try {
+            await api.installPorterAgent("<token>", {}, { project_id, cluster_id });
+            window.location.reload();
+        } catch (err) {
+            setIsPorterAgentInstalling(false);
+            console.log(err);
+        }
+    };
+
+    if (isPorterAgentInstalling) {
+        return (
+            <Fieldset>
+                <Text size={16}>Installing agent...</Text>
+                <Spacer y={0.5} />
+                <Text color="helper">If you are not redirected automatically after a minute, you may need to refresh this page.</Text>
+            </Fieldset>
+        );
+    }
+
+    if (error) {
+        return (
+            <Fieldset>
+                <Text size={16}>Error retrieving events</Text>
+                <Spacer height="15px" />
+                <Text color="helper">An unexpected error occurred.</Text>
+            </Fieldset>
+        );
+    }
+
+    if (loading) {
+        return (
+            <div>
+                <Spacer y={2} />
+                <Loading />
+            </div>
+        );
+    }
+
+    if (!loading && !hasPorterAgent) {
+        return (
+            <Fieldset>
+                <Text size={16}>
+                    We couldn't detect the Porter agent on your cluster
+                </Text>
+                <Spacer y={0.5} />
+                <Text color="helper">
+                    In order to use the Activity tab, you need to install the Porter agent.
+                </Text>
+                <Spacer y={1} />
+                <Button onClick={() => installAgent()}>
+                    <I className="material-icons">add</I> Install Porter agent
+                </Button>
+            </Fieldset>
+        );
+    }
+
+    if (!loading && events?.length === 0) {
+        return (
+            <Fieldset>
+                <Text size={16}>No events found for "{appName}"</Text>
+                <Spacer height="15px" />
+                <Text color="helper">
+                    This application currently has no associated events.
+                </Text>
+            </Fieldset>
+        );
+    }
+
+    return (
+        <StyledActivityFeed shouldAnimate={shouldAnimate}>
+            {events.map((event, i) => {
+                return (
+                    <EventWrapper isLast={i === events.length - 1} key={i}>
+                        {i !== events.length - 1 && events.length > 1 && <Line shouldAnimate={shouldAnimate} />}
+                        <Dot shouldAnimate={shouldAnimate} />
+                        <Time shouldAnimate={shouldAnimate}>
+                            <Text>{feedDate(event.created_at).split(", ")[0]}</Text>
+                            <Spacer x={0.5} />
+                            <Text>{feedDate(event.created_at).split(", ")[1]}</Text>
+                        </Time>
+                        <EventCard
+                            deploymentTargetId={deploymentTargetId}
+                            event={event}
+                            key={i}
+                            isLatestDeployEvent={i === getLatestDeployEventIndex()}
+                            projectId={currentProject}
+                            clusterId={currentCluster}
+                            appName={appName}
+                        />
+                    </EventWrapper>
+                );
+            })}
+            {numPages > 1 && (
+                <>
+                    <Spacer y={1} />
+                    <Pagination page={page} setPage={setPage} totalPages={numPages} />
+                </>
+            )}
+        </StyledActivityFeed>
+    );
+};
+
+export default ActivityFeed;
+
+const I = styled.i`
+  font-size: 14px;
+  margin-right: 5px;
+`;
+
+const Time = styled.div<{ shouldAnimate: boolean }>`
+  opacity: ${(props) => props.shouldAnimate ? "0" : "1"};
+  ${(props) => props.shouldAnimate && "animation: fadeIn 0.3s 0.1s;"}
+  ${(props) => props.shouldAnimate && "animation-fill-mode: forwards;"}
+  width: 90px;
+`;
+
+const Line = styled.div<{ shouldAnimate: boolean }>`
+  width: 1px;
+  height: calc(100% + 30px);
+  background: #414141;
+  position: absolute;
+  left: 3px;
+  top: 36px;
+  opacity: ${(props) => props.shouldAnimate ? "0" : "1"};
+  ${(props) => props.shouldAnimate && "animation: fadeIn 0.3s 0.1s;"}
+  ${(props) => props.shouldAnimate && "animation-fill-mode: forwards;"}
+`;
+
+const Dot = styled.div<{ shouldAnimate: boolean }>`
+  width: 7px;
+  height: 7px;
+  background: #fff;
+  border-radius: 50%;
+  margin-left: -29px;
+  margin-right: 20px;
+  z-index: 1;
+  opacity: ${(props) => props.shouldAnimate ? "0" : "1"};
+  ${(props) => props.shouldAnimate && "animation: fadeIn 0.3s 0.1s;"}
+  ${(props) => props.shouldAnimate && "animation-fill-mode: forwards;"}
+`;
+
+const EventWrapper = styled.div<{
+    isLast: boolean;
+}>`
+  padding-left: 30px;
+  display: flex;
+  align-items: center;
+  position: relative;
+  margin-bottom: ${(props) => (props.isLast ? "" : "25px")};
+`;
+
+const StyledActivityFeed = styled.div<{ shouldAnimate: boolean }>`
+  width: 100%;
+  ${(props) => props.shouldAnimate && "animation: fadeIn 0.3s 0s;"}
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;

+ 103 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/AppEventCard.tsx

@@ -0,0 +1,103 @@
+import React, { useState } from "react";
+
+import app_event from "assets/app_event.png";
+import Text from "components/porter/Text";
+import Container from "components/porter/Container";
+import Spacer from "components/porter/Spacer";
+import Link from "components/porter/Link";
+import Icon from "components/porter/Icon";
+
+import { StyledEventCard } from "./EventCard";
+import { readableDate } from "shared/string_utils";
+import dayjs from "dayjs";
+import Anser from "anser";
+import api from "shared/api";
+import { PorterAppAppEvent } from "../types";
+import { Direction } from "main/home/app-dashboard/expanded-app/logs/types";
+import AppEventModal from "main/home/app-dashboard/expanded-app/status/AppEventModal";
+
+type Props = {
+  event: PorterAppAppEvent;
+  deploymentTargetId: string;
+  projectId: number;
+  clusterId: number;
+  appName: string;
+};
+
+const AppEventCard: React.FC<Props> = ({ event, deploymentTargetId, projectId, clusterId, appName }) => {
+  const [showModal, setShowModal] = useState<boolean>(false);
+  const [logs, setLogs] = useState([]);
+
+  const getAppLogs = async () => {
+    setShowModal(true);
+    try {
+      const logResp = await api.appLogs(
+        "<token>",
+        {
+          start_range: dayjs(event.created_at).subtract(1, 'minute').toISOString(),
+          end_range: dayjs(event.updated_at).add(1, 'minute').toISOString(),
+          app_name: event.metadata.app_name,
+          service_name: event.metadata.service_name,
+          deployment_target_id: deploymentTargetId,
+          limit: 1000,
+          direction: Direction.forward,
+        },
+        {
+          project_id: projectId,
+          cluster_id: clusterId,
+        }
+      )
+
+      if (logResp.data?.logs != null) {
+        const updatedLogs = logResp.data.logs.map((l: { line: string; timestamp: string; }, index: number) => {
+          try {
+            return {
+              line: JSON.parse(l.line)?.log ?? Anser.ansiToJson(l.line),
+              lineNumber: index + 1,
+              timestamp: l.timestamp,
+            }
+          } catch (err) {
+            return {
+              line: Anser.ansiToJson(l.line),
+              lineNumber: index + 1,
+              timestamp: l.timestamp,
+            }
+          }
+        });
+        setLogs(updatedLogs);
+      }
+    } catch (error) {
+      console.log(error);
+    }
+  };
+
+  return (
+    <StyledEventCard>
+      <Container row spaced>
+        <Container row>
+          <Icon height="16px" src={app_event} />
+          <Spacer inline x={1} />
+          <Text>{event.metadata.summary}</Text>
+        </Container>
+      </Container>
+      <Spacer y={0.5} />
+      <Container row spaced>
+        <Link onClick={getAppLogs} hasunderline>
+          View details
+        </Link>
+      </Container>
+      {showModal && (
+        <AppEventModal
+          setModalVisible={setShowModal}
+          logs={logs}
+          porterAppName={appName}
+          timestamp={readableDate(event.updated_at)}
+          expandedAppEventMessage={event.metadata.detail}
+        />
+      )}
+    </StyledEventCard>
+  );
+};
+
+export default AppEventCard;
+

+ 117 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/BuildEventCard.tsx

@@ -0,0 +1,117 @@
+import React from "react";
+import styled from "styled-components";
+
+import build from "assets/build.png";
+
+import run_for from "assets/run_for.png";
+import refresh from "assets/refresh.png";
+
+import Text from "components/porter/Text";
+import Container from "components/porter/Container";
+import Spacer from "components/porter/Spacer";
+import Link from "components/porter/Link";
+import Icon from "components/porter/Icon";
+import { getDuration, getStatusColor, getStatusIcon, triggerWorkflow } from '../utils';
+import { StyledEventCard } from "./EventCard";
+import document from "assets/document.svg";
+import { PorterAppBuildEvent, PorterAppEvent } from "../types";
+import { useLatestRevision } from "main/home/app-dashboard/app-view/LatestRevisionContext";
+
+type Props = {
+  event: PorterAppBuildEvent;
+  appName: string;
+  projectId: number;
+  clusterId: number;
+};
+
+const BuildEventCard: React.FC<Props> = ({ event, appName, projectId, clusterId }) => {
+  const { porterApp } = useLatestRevision();
+  const renderStatusText = (event: PorterAppEvent) => {
+    switch (event.status) {
+      case "SUCCESS":
+        return <Text color={getStatusColor(event.status)}>Build succeeded</Text>;
+      case "FAILED":
+        return <Text color={getStatusColor(event.status)}>Build failed</Text>;
+      default:
+        return <Text color={getStatusColor(event.status)}>Build in progress...</Text>;
+    }
+  };
+
+  const renderInfoCta = (event: PorterAppBuildEvent) => {
+    switch (event.status) {
+      case "SUCCESS":
+        return null;
+      case "FAILED":
+        return (
+          <Wrapper>
+            <Link to={`/apps/${appName}/events?event_id=${event.id}`} hasunderline>
+              <Container row>
+                <Icon src={document} height="10px" />
+                <Spacer inline width="5px" />
+                View details
+              </Container>
+            </Link>
+            <Spacer inline x={1} />
+            <Link hasunderline onClick={() => triggerWorkflow({
+              projectId,
+              clusterId,
+              porterApp,
+            })}>
+              <Container row>
+                <Icon height="10px" src={refresh} />
+                <Spacer inline width="5px" />
+                Retry
+              </Container>
+            </Link>
+          </Wrapper>
+        );
+      default:
+        return (
+          <Wrapper>
+            <Link
+              hasunderline
+              target="_blank"
+              to={`https://github.com/${porterApp.repo_name}/actions/runs/${event.metadata.action_run_id}`}
+            >
+              View live logs
+            </Link>
+            <Spacer inline x={1} />
+          </Wrapper>
+        );
+    }
+  };
+
+  return (
+    <StyledEventCard>
+      <Container row spaced>
+        <Container row>
+          <Icon height="16px" src={build} />
+          <Spacer inline width="10px" />
+          <Text>Application build</Text>
+        </Container>
+        <Container row>
+          <Icon height="14px" src={run_for} />
+          <Spacer inline width="6px" />
+          <Text color="helper">{getDuration(event)}</Text>
+        </Container>
+      </Container>
+      <Spacer y={0.5} />
+      <Container row spaced>
+        <Container row>
+          <Icon height="12px" src={getStatusIcon(event.status)} />
+          <Spacer inline width="10px" />
+          {renderStatusText(event)}
+          <Spacer inline x={1} />
+          {renderInfoCta(event)}
+          <Spacer inline x={1} />
+        </Container>
+      </Container>
+    </StyledEventCard>
+  );
+};
+
+export default BuildEventCard;
+
+const Wrapper = styled.div`
+  margin-top: -3px;
+`;

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

@@ -0,0 +1,230 @@
+import React, { useState } from "react";
+import deploy from "assets/deploy.png";
+import Text from "components/porter/Text";
+import Container from "components/porter/Container";
+import Spacer from "components/porter/Spacer";
+import Icon from "components/porter/Icon";
+import { getStatusColor, getStatusIcon } from '../utils';
+import { StyledEventCard } from "./EventCard";
+import styled from "styled-components";
+import Link from "components/porter/Link";
+import { PorterAppDeployEvent } from "../types";
+import AnimateHeight from "react-animate-height";
+import ServiceStatusDetail from "./ServiceStatusDetail";
+import { useLatestRevision } from "main/home/app-dashboard/app-view/LatestRevisionContext";
+
+type Props = {
+  event: PorterAppDeployEvent;
+  appName: string;
+  showServiceStatusDetail?: boolean;
+};
+
+const DeployEventCard: React.FC<Props> = ({ event, appName, showServiceStatusDetail = false }) => {
+  const { latestRevision } = useLatestRevision();
+  const [diffModalVisible, setDiffModalVisible] = useState(false);
+  const [revertModalVisible, setRevertModalVisible] = useState(false);
+  const [serviceStatusVisible, setServiceStatusVisible] = useState(showServiceStatusDetail);
+
+  const renderStatusText = () => {
+    switch (event.status) {
+      case "SUCCESS":
+        return event.metadata.image_tag != null ?
+          event.metadata.service_deployment_metadata != null ?
+            <StatusTextContainer>
+              <Text color={getStatusColor(event.status)}>
+                Deployed <Code>{event.metadata.image_tag}</Code> to
+              </Text>
+              <Spacer inline x={0.25} />
+              {renderServiceDropdownCta(Object.keys(event.metadata.service_deployment_metadata).length, getStatusColor(event.status))}
+            </StatusTextContainer>
+            :
+            <Text color={getStatusColor(event.status)}>
+              Deployed <Code>{event.metadata.image_tag}</Code>
+            </Text>
+          :
+          <Text color={getStatusColor(event.status)}>
+            Deployment successful
+          </Text>;
+      case "FAILED":
+        if (event.metadata.service_deployment_metadata != null) {
+          let failedServices = 0;
+          for (const key in event.metadata.service_deployment_metadata) {
+            if (event.metadata.service_deployment_metadata[key].status === "FAILED") {
+              failedServices++;
+            }
+          }
+          return (
+            <StatusTextContainer>
+              <Text color={getStatusColor(event.status)}>
+                Failed to deploy <Code>{event.metadata.image_tag}</Code> to
+              </Text>
+              <Spacer inline x={0.25} />
+              {renderServiceDropdownCta(failedServices, getStatusColor(event.status))}
+            </StatusTextContainer>
+          );
+        } else {
+          return (
+            <Text color={getStatusColor(event.status)}>
+              Deployment failed
+            </Text>
+          );
+        }
+      case "CANCELED":
+        if (event.metadata.service_deployment_metadata != null) {
+          let canceledServices = 0;
+          for (const key in event.metadata.service_deployment_metadata) {
+            if (event.metadata.service_deployment_metadata[key].status === "CANCELED") {
+              canceledServices++;
+            }
+          }
+          return (
+            <StatusTextContainer>
+              <Text color={getStatusColor(event.status)}>
+                Canceled deploy of <Code>{event.metadata.image_tag}</Code> to
+              </Text>
+              <Spacer inline x={0.25} />
+              {renderServiceDropdownCta(canceledServices, getStatusColor(event.status))}
+            </StatusTextContainer>
+          );
+        } else {
+          return (
+            <Text color={getStatusColor(event.status)}>
+              Deployment canceled
+            </Text>
+          );
+        }
+      default:
+        if (event.metadata.service_deployment_metadata != null) {
+          return (
+            <StatusTextContainer>
+              <Text color={getStatusColor(event.status)}>
+                Deploying <Code>{event.metadata.image_tag}</Code> to
+              </Text>
+              <Spacer inline x={0.25} />
+              {renderServiceDropdownCta(Object.keys(event.metadata.service_deployment_metadata).length, getStatusColor(event.status))}
+            </StatusTextContainer>
+          );
+        } else {
+          return (
+            <Text color={getStatusColor(event.status)}>
+              Deploying <Code>{event.metadata.image_tag}</Code>...
+            </Text>
+          );
+        }
+    }
+  };
+
+  const renderServiceDropdownCta = (numServices: number, color?: string) => {
+    return (
+      <ServiceStatusDropdownCtaContainer >
+        <Link color={color} onClick={() => setServiceStatusVisible(!serviceStatusVisible)}>
+          <ServiceStatusDropdownIcon className="material-icons" serviceStatusVisible={serviceStatusVisible}>arrow_drop_down</ServiceStatusDropdownIcon>
+          {numServices} service{numServices === 1 ? "" : "s"}
+        </Link>
+      </ServiceStatusDropdownCtaContainer>
+    )
+  }
+
+  return (
+    <StyledEventCard>
+      <Container row spaced>
+        <Container row>
+          <Icon height="16px" src={deploy} />
+          <Spacer inline width="10px" />
+          <Text>Application version no. {event.metadata?.revision}</Text>
+        </Container>
+      </Container>
+      <Spacer y={0.5} />
+      <Container row spaced>
+        <Container row>
+          <Icon height="12px" src={getStatusIcon(event.status)} />
+          <Spacer inline width="10px" />
+          {renderStatusText()}
+          {latestRevision.id !== event.metadata.app_revision_id && (
+            <>
+              <Spacer inline x={1} />
+              <TempWrapper>
+                <Link hasunderline onClick={() => setRevertModalVisible(true)}>
+                  Revert to version {event.metadata.revision}
+                </Link>
+
+              </TempWrapper>
+            </>
+          )}
+          <Spacer inline x={1} />
+          {/* <TempWrapper>
+            {event.metadata.revision != 1 && (<Link hasunderline onClick={() => setDiffModalVisible(true)}>
+              View changes
+            </Link>)}
+            {diffModalVisible && (
+              <ChangeLogModal
+                revision={event.metadata.revision}
+                currentChart={appData.chart}
+                modalVisible={diffModalVisible}
+                setModalVisible={setDiffModalVisible}
+                appData={appData}
+              />
+            )}
+            {revertModalVisible && (
+              <ChangeLogModal
+                revision={event.metadata.revision}
+                currentChart={appData.chart}
+                modalVisible={revertModalVisible}
+                setModalVisible={setRevertModalVisible}
+                revertModal={true}
+                appData={appData}
+              />
+            )}
+          </TempWrapper> */}
+        </Container>
+      </Container>
+      {event.metadata.service_deployment_metadata != null &&
+        <AnimateHeight height={serviceStatusVisible ? "auto" : 0}>
+          <Spacer y={0.5} />
+          <ServiceStatusDetail
+            serviceDeploymentMetadata={event.metadata.service_deployment_metadata}
+            appName={appName}
+            revision={event.metadata.revision}
+          />
+        </AnimateHeight>
+      }
+    </StyledEventCard>
+  );
+};
+
+export default DeployEventCard;
+
+// TODO: remove after fixing v-align
+const TempWrapper = styled.div`
+  margin-top: -3px;
+`;
+
+const Code = styled.span`
+  font-family: monospace;
+`;
+
+const ServiceStatusDropdownCtaContainer = styled.div`
+  display: flex;
+  justify-content: center;
+  cursor: pointer;
+  padding: 3px 5px;
+  border-radius: 5px;
+  :hover {
+    background: #ffffff11;
+  }
+`;
+
+const ServiceStatusDropdownIcon = styled.i`
+  margin-left: -5px;
+  font-size: 20px;
+  border-radius: 20px;
+  transform: ${(props: { serviceStatusVisible: boolean }) =>
+    props.serviceStatusVisible ? "" : "rotate(-90deg)"};
+  transition: transform 0.1s ease;
+`
+
+const StatusTextContainer = styled.div`
+  display: flex;
+  align-items: center;
+  flex-direction: row;
+`;

+ 55 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/EventCard.tsx

@@ -0,0 +1,55 @@
+import React from "react";
+import styled from "styled-components";
+
+import BuildEventCard from "./BuildEventCard";
+import PreDeployEventCard from "./PreDeployEventCard";
+import AppEventCard from "./AppEventCard";
+import DeployEventCard from "./DeployEventCard";
+import { PorterAppAppEvent, PorterAppBuildEvent, PorterAppDeployEvent, PorterAppEvent, PorterAppEventType, } from "../types";
+import { match } from "ts-pattern";
+
+type Props = {
+  event: PorterAppEvent;
+  deploymentTargetId: string;
+  projectId: number;
+  clusterId: number;
+  appName: string;
+  isLatestDeployEvent?: boolean;
+};
+
+const EventCard: React.FC<Props> = ({ event, deploymentTargetId, isLatestDeployEvent, projectId, clusterId, appName }) => {
+  return match(event.type)
+    .with(PorterAppEventType.APP_EVENT, () => <AppEventCard event={event as PorterAppAppEvent} deploymentTargetId={deploymentTargetId} projectId={projectId} clusterId={clusterId} appName={appName} />)
+    .with(PorterAppEventType.BUILD, () => <BuildEventCard event={event as PorterAppBuildEvent} projectId={projectId} clusterId={clusterId} appName={appName} />)
+    .with(PorterAppEventType.DEPLOY, () => <DeployEventCard event={event as PorterAppDeployEvent} appName={appName} showServiceStatusDetail={isLatestDeployEvent} />)
+    .with(PorterAppEventType.PRE_DEPLOY, () => <PreDeployEventCard event={event} appName={appName} projectId={projectId} clusterId={clusterId} />)
+    .exhaustive();
+};
+
+export default EventCard;
+
+export const StyledEventCard = styled.div<{ row?: boolean }>`
+  width: 100%;
+  padding: 15px;
+  display: flex;
+  flex-direction: ${({ row }) => row ? "row" : "column"};
+  justify-content: space-between;
+  border-radius: 5px;
+  background: ${({ theme }) => theme.fg};
+  border: 1px solid ${({ theme }) => theme.border};
+  opacity: 0;
+  animation: slideIn 0.5s 0s;
+  animation-fill-mode: forwards;
+  @keyframes slideIn {
+    from {
+      margin-left: -10px;
+      opacity: 0;
+      margin-right: 10px;
+    }
+    to {
+      margin-left: 0;
+      opacity: 1;
+      margin-right: 0;
+    }
+  }
+`;

+ 99 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/PreDeployEventCard.tsx

@@ -0,0 +1,99 @@
+import React, { useState } from "react";
+import styled from "styled-components";
+
+import pre_deploy from "assets/pre_deploy.png";
+
+import run_for from "assets/run_for.png";
+import refresh from "assets/refresh.png";
+
+import Text from "components/porter/Text";
+import Container from "components/porter/Container";
+import Spacer from "components/porter/Spacer";
+import Icon from "components/porter/Icon";
+
+import { getDuration, getStatusColor, getStatusIcon, triggerWorkflow } from '../utils';
+import { StyledEventCard } from "./EventCard";
+import Link from "components/porter/Link";
+import document from "assets/document.svg";
+import { PorterAppEvent } from "../types";
+import { useLatestRevision } from "main/home/app-dashboard/app-view/LatestRevisionContext";
+
+type Props = {
+  event: PorterAppEvent;
+  appName: string;
+  projectId: number;
+  clusterId: number;
+};
+
+const PreDeployEventCard: React.FC<Props> = ({ event, appName, projectId, clusterId }) => {
+  const { porterApp } = useLatestRevision();
+
+  const renderStatusText = (event: PorterAppEvent) => {
+    switch (event.status) {
+      case "SUCCESS":
+        return <Text color={getStatusColor(event.status)}>Pre-deploy succeeded</Text>;
+      case "FAILED":
+        return <Text color={getStatusColor(event.status)}>Pre-deploy failed</Text>;
+      default:
+        return <Text color={getStatusColor(event.status)}>Pre-deploy in progress...</Text>;
+    }
+  };
+
+  return (
+    <StyledEventCard>
+      <Container row spaced>
+        <Container row>
+          <Icon height="16px" src={pre_deploy} />
+          <Spacer inline width="10px" />
+          <Text>Application pre-deploy</Text>
+        </Container>
+        <Container row>
+          <Icon height="14px" src={run_for} />
+          <Spacer inline width="6px" />
+          <Text color="helper">{getDuration(event)}</Text>
+        </Container>
+      </Container>
+      <Spacer y={0.5} />
+      <Container row spaced>
+        <Container row>
+          <Icon height="12px" src={getStatusIcon(event.status)} />
+          <Spacer inline width="10px" />
+          {renderStatusText(event)}
+          {(event.status !== "SUCCESS") &&
+            <>
+              <Spacer inline x={1} />
+              <Wrapper>
+                <Link to={`/apps/${appName}/events?event_id=${event.id}`} hasunderline>
+                  <Container row>
+                    <Icon src={document} height="10px" />
+                    <Spacer inline width="5px" />
+                    View details
+                  </Container>
+                </Link>
+                <Spacer inline x={1} />
+                <Link hasunderline onClick={() => triggerWorkflow({
+                  projectId,
+                  clusterId,
+                  porterApp,
+                })}>
+                  <Container row>
+                    <Icon height="10px" src={refresh} />
+                    <Spacer inline width="5px" />
+                    Retry
+                  </Container>
+                </Link>
+              </Wrapper>
+            </>
+          }
+          <Spacer inline x={1} />
+        </Container>
+      </Container>
+    </StyledEventCard>
+  );
+};
+
+export default PreDeployEventCard;
+
+const Wrapper = styled.div`
+  margin-top: -3px;
+`;

+ 137 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/ServiceStatusDetail.tsx

@@ -0,0 +1,137 @@
+import Icon from 'components/porter/Icon';
+import Spacer from 'components/porter/Spacer';
+import Text from 'components/porter/Text';
+import React from 'react'
+import styled from 'styled-components';
+import { getStatusColor, getStatusIcon } from '../utils';
+import Link from 'components/porter/Link';
+import { PorterAppDeployEvent } from "../types";
+import { Service } from 'main/home/app-dashboard/new-app-flow/serviceTypes';
+import { useLatestRevision } from 'main/home/app-dashboard/app-view/LatestRevisionContext';
+import { deserializeService, serializedServiceFromProto } from 'lib/porter-apps/services';
+
+type Props = {
+    serviceDeploymentMetadata: PorterAppDeployEvent["metadata"]["service_deployment_metadata"];
+    appName: string;
+    revision: number;
+}
+
+const ServiceStatusDetail: React.FC<Props> = ({
+    serviceDeploymentMetadata,
+    appName,
+    revision,
+}) => {
+    const { latestProto } = useLatestRevision();
+    const convertEventStatusToCopy = (status: string) => {
+        switch (status) {
+            case "PROGRESSING":
+                return "DEPLOYING";
+            case "SUCCESS":
+                return "DEPLOYED";
+            case "FAILED":
+                return "FAILED";
+            case "CANCELED":
+                return "CANCELED";
+            default:
+                return "UNKNOWN";
+        }
+    };
+
+    return (
+        <ServiceStatusTable>
+            <tbody>
+                {Object.keys(serviceDeploymentMetadata).map((key) => {
+                    const deploymentMetadata = serviceDeploymentMetadata[key];
+                    const service = latestProto.services[key];
+                    let externalUri = "";
+                    if (service != null) {
+                        const deserializedService = deserializeService({ service: serializedServiceFromProto({ service, name: key }) });
+                        if (deserializedService.config.type === "web" && deserializedService.config.domains.length > 0) {
+                            externalUri = deserializedService.config.domains[0].name.value;
+                        }
+                    }
+                    return (
+                        <ServiceStatusTableRow key={key}>
+                            <ServiceStatusTableData width={"100px"}>
+                                <Text>{key}</Text>
+                            </ServiceStatusTableData>
+                            <ServiceStatusTableData width={"120px"}>
+                                <Icon height="12px" src={getStatusIcon(deploymentMetadata.status)} />
+                                <Spacer inline x={0.5} />
+                                <Text color={getStatusColor(deploymentMetadata.status)}>{convertEventStatusToCopy(serviceDeploymentMetadata[key].status)}</Text>
+                            </ServiceStatusTableData>
+                            <ServiceStatusTableData>
+                                {deploymentMetadata.type !== "job" &&
+                                    <>
+                                        <Link
+                                            to={`/apps/${appName}/logs?version=${revision}&service=${key}`}
+                                            hasunderline
+                                            hoverColor="#949eff"
+                                        >
+                                            Logs
+                                        </Link>
+                                        <Spacer inline x={0.5} />
+                                        <Link
+                                            to={`/apps/${appName}/metrics?service=${key}`}
+                                            hasunderline
+                                            hoverColor="#949eff"
+                                        >
+                                            Metrics
+                                        </Link>
+                                    </>
+                                }
+                                {deploymentMetadata.type === "job" &&
+                                    <>
+                                        <Link
+                                            to={`/apps/${appName}/job-history?service=${key}`}
+                                            hasunderline
+                                            hoverColor="#949eff"
+                                        >
+                                            History
+                                        </Link>
+                                    </>
+                                }
+                                {externalUri !== "" &&
+                                    <>
+                                        <Spacer inline x={0.5} />
+                                        <Link
+                                            to={Service.prefixSubdomain(externalUri)}
+                                            hasunderline
+                                            hoverColor="#949eff"
+                                            target={"_blank"}
+                                        >
+                                            External Link
+                                        </Link>
+                                    </>
+                                }
+                            </ServiceStatusTableData>
+                        </ServiceStatusTableRow>
+                    );
+                })}
+            </tbody>
+        </ServiceStatusTable>
+    )
+}
+
+export default ServiceStatusDetail;
+
+const ServiceStatusTable = styled.table`
+  border-collapse: collapse;
+  width: 100%;
+`;
+
+const ServiceStatusTableRow = styled.tr`
+  display: flex;
+  align-items: center;  
+`;
+
+const ServiceStatusTableData = styled.td`
+  padding: 8px;
+  display: flex;
+  align-items: center;
+  ${(props) => props.width && `width: ${props.width};`}
+
+  &:not(:last-child) {
+    border-right: 2px solid #ffffff11;
+  }
+`;

+ 278 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/focus-views/BuildFailureEventFocusView.tsx

@@ -0,0 +1,278 @@
+import Loading from "components/Loading";
+import Spacer from "components/porter/Spacer";
+import React, { useEffect, useRef, useState } from "react";
+import api from "shared/api";
+import styled from "styled-components";
+import Anser, { AnserJsonEntry } from "anser";
+import JSZip from "jszip";
+import dayjs from "dayjs";
+import Text from "components/porter/Text";
+import { readableDate } from "shared/string_utils";
+import { getDuration } from "../utils";
+import Link from "components/porter/Link";
+import { PorterLog } from "../../../logs/types";
+import { PorterAppEvent } from "../types";
+
+type Props = {
+    event: PorterAppEvent;
+    appData: any;
+};
+
+const BuildFailureEventFocusView: React.FC<Props> = ({
+    event,
+    appData,
+}) => {
+    const [logs, setLogs] = useState<PorterLog[]>([]);
+    const [isLoading, setIsLoading] = useState<boolean>(true);
+    const scrollToBottomRef = useRef<HTMLDivElement>(null);
+
+    useEffect(() => {
+        if (!isLoading && scrollToBottomRef.current) {
+            scrollToBottomRef.current.scrollIntoView({
+                behavior: "smooth",
+                block: "end",
+            });
+        }
+    }, [isLoading, logs, scrollToBottomRef]);
+
+    const getBuildLogs = async () => {
+        if (event == null) {
+            return;
+        }
+        try {
+            setLogs([]);
+
+            const res = await api.getGHWorkflowLogById(
+                "",
+                {},
+                {
+                    project_id: appData.app.project_id,
+                    cluster_id: appData.app.cluster_id,
+                    git_installation_id: appData.app.git_repo_id,
+                    owner: appData.app.repo_name?.split("/")[0],
+                    name: appData.app.repo_name?.split("/")[1],
+                    filename: "porter_stack_" + appData.chart.name + ".yml",
+                    run_id: event.metadata.action_run_id,
+                }
+            );
+            let logs: PorterLog[] = [];
+            if (res.data != null) {
+                // Fetch the logs
+                const logsResponse = await fetch(res.data);
+
+                // Ensure that the response body is only read once
+                const logsBlob = await logsResponse.blob();
+
+                if (logsResponse.headers.get("Content-Type") === "application/zip") {
+                    const zip = await JSZip.loadAsync(logsBlob);
+                    const promises: any[] = [];
+
+                    zip.forEach(function (relativePath, zipEntry) {
+                        promises.push(
+                            (async function () {
+                                const fileData = await zip
+                                    .file(relativePath)
+                                    ?.async("string");
+
+                                if (
+                                    fileData &&
+                                    fileData.includes("Run porter-dev/porter-cli-action@v0.1.0")
+                                ) {
+                                    const lines = fileData.split("\n");
+                                    const timestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d+Z/;
+
+                                    for (let i = 0; i < lines.length; i++) {
+                                        const line = lines[i];
+                                        if (line.includes("Post job cleanup.")) {
+                                            break;
+                                        }
+                                        const lineWithoutTimestamp = line.replace(timestampPattern, "").trimStart();
+                                        const anserLine: AnserJsonEntry[] = Anser.ansiToJson(lineWithoutTimestamp);
+                                        if (lineWithoutTimestamp.toLowerCase().includes("error")) {
+                                            anserLine[0].fg = "238,75,43";
+                                        }
+
+                                        const log: PorterLog = {
+                                            line: anserLine,
+                                            lineNumber: i + 1,
+                                            timestamp: line.match(timestampPattern)?.[0],
+                                        };
+
+                                        logs.push(log);
+                                    }
+                                }
+                            })()
+                        );
+                    });
+
+                    await Promise.all(promises);
+                    setLogs(logs);
+                }
+            }
+        } catch (error) {
+            console.log(error);
+        } finally {
+            setIsLoading(false);
+        }
+    };
+
+    useEffect(() => {
+        getBuildLogs();
+    }, []);
+
+    return (
+        <>
+            <Text size={16} color="#FF6060">Build failed</Text>
+            <Spacer y={0.5} />
+            <Text color="helper">Started {readableDate(event.created_at)} and ran for {getDuration(event)}.</Text>
+            <Spacer y={0.5} />
+            <StyledLogsSection>
+                {isLoading ? (
+                    <Loading message="Waiting for logs..." />
+                ) : logs.length == 0 ? (
+                    <>
+                        <Message>
+                            No logs found.
+                        </Message>
+                    </>
+                ) : (
+                    <>
+                        {logs?.map((log, i) => {
+                            return (
+                                <Log key={[log.lineNumber, i].join(".")}>
+                                    <span className="line-number">{log.lineNumber}.</span>
+                                    <span className="line-timestamp">
+                                        {log.timestamp
+                                            ? dayjs(log.timestamp).format("MMM D, YYYY HH:mm:ss")
+                                            : "-"}
+                                    </span>
+                                    <LogOuter key={[log.lineNumber, i].join(".")}>
+                                        {log.line?.map((ansi, j) => {
+                                            if (ansi.clearLine) {
+                                                return null;
+                                            }
+
+                                            return (
+                                                <LogInnerSpan
+                                                    key={[log.lineNumber, i, j].join(".")}
+                                                    ansi={ansi}
+                                                >
+                                                    {ansi.content.replace(/ /g, "\u00a0")}
+                                                </LogInnerSpan>
+                                            );
+                                        })}
+                                    </LogOuter>
+                                </Log>
+                            );
+                        })}
+                    </>
+                )}
+                <div ref={scrollToBottomRef} />
+            </StyledLogsSection>
+            <Spacer y={0.5} />
+            <Link
+                hasunderline
+                target="_blank"
+                to={
+                    event.metadata.action_run_id
+                        ? `https://github.com/${appData.app.repo_name}/actions/runs/${event.metadata.action_run_id}`
+                        : `https://github.com/${appData.app.repo_name}/actions`
+                }
+            >
+                View full build logs
+            </Link>
+        </>
+    );
+};
+
+export default BuildFailureEventFocusView;
+
+const StyledLogsSection = styled.div`
+  width: 100%;
+  min-height: 600px;
+  height: calc(100vh - 460px);
+  display: flex;
+  flex-direction: column;
+  position: relative;
+  font-size: 13px;
+  border-radius: 8px;
+  border: 1px solid #ffffff33;
+  background: #000000;
+  animation: floatIn 0.3s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+  overflow-y: auto;
+  overflow-wrap: break-word;
+  position: relative;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(10px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;
+
+const Message = styled.div`
+  display: flex;
+  height: 100%;
+  width: calc(100% - 150px);
+  align-items: center;
+  justify-content: center;
+  margin-left: 75px;
+  text-align: center;
+  color: #ffffff44;
+  font-size: 13px;
+`;
+
+const Log = styled.div`
+  font-family: monospace;
+  user-select: text;
+  display: flex;
+  align-items: flex-end;
+  gap: 8px;
+  width: 100%;
+  & > * {
+    padding-block: 5px;
+  }
+  & > .line-timestamp {
+    height: 100%;
+    color: #949effff;
+    opacity: 0.5;
+    font-family: monospace;
+    min-width: fit-content;
+    padding-inline-end: 5px;
+  }
+  & > .line-number {
+    height: 100%;
+    background: #202538;
+    display: inline-block;
+    text-align: right;
+    min-width: 45px;
+    padding-inline-end: 5px;
+    opacity: 0.3;
+    font-family: monospace;
+  }
+`;
+
+const LogOuter = styled.div`
+  display: inline-block;
+  word-wrap: anywhere;
+  flex-grow: 1;
+  font-family: monospace, sans-serif;
+  font-size: 12px;
+`;
+
+const LogInnerSpan = styled.span`
+  font-family: monospace, sans-serif;
+  font-size: 12px;
+  font-weight: ${(props: { ansi: Anser.AnserJsonEntry }) =>
+        props.ansi?.decoration && props.ansi?.decoration == "bold" ? "700" : "400"};
+  color: ${(props: { ansi: Anser.AnserJsonEntry }) =>
+        props.ansi?.fg ? `rgb(${props.ansi?.fg})` : "white"};
+  background-color: ${(props: { ansi: Anser.AnserJsonEntry }) =>
+        props.ansi?.bg ? `rgb(${props.ansi?.bg})` : "transparent"};
+`;

+ 71 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/focus-views/DeployEventFocusView.tsx

@@ -0,0 +1,71 @@
+import Spacer from "components/porter/Spacer";
+import React from "react";
+import dayjs from "dayjs";
+import Text from "components/porter/Text";
+import { readableDate } from "shared/string_utils";
+import { getDuration } from "../utils";
+import LogSection from "../../../logs/LogSection";
+import { AppearingView } from "./EventFocusView";
+import Icon from "components/porter/Icon";
+import loading from "assets/loading.gif";
+import Container from "components/porter/Container";
+import { PorterAppDeployEvent } from "../types";
+import { LogFilterQueryParamOpts } from "../../../logs/types";
+
+type Props = {
+    event: PorterAppDeployEvent;
+    appData: any;
+    filterOpts?: LogFilterQueryParamOpts
+};
+
+const DeployEventFocusView: React.FC<Props> = ({
+    event,
+    appData,
+    filterOpts,
+}) => {
+    const renderHeaderText = () => {
+        switch (event.status) {
+            case "SUCCESS":
+                return <Text color="#68BF8B" size={16}>Deploy succeeded</Text>;
+            case "FAILED":
+                return <Text color="#FF6060" size={16}>Deploy failed</Text>;
+            case "CANCELED":
+                return <Text color="#FFBF00" size={16}>Deploy canceled</Text>;
+            default:
+                return (
+                    <Container row>
+                        <Icon height="16px" src={loading} />
+                        <Spacer inline width="10px" />
+                        <Text size={16}>Deploy in progress...</Text>
+                    </Container>
+                );
+        }
+    };
+
+    const renderDurationText = () => {
+        switch (event.status) {
+            case "PROGRESSING":
+                return <Text color="helper">Started {readableDate(event.created_at)}.</Text>
+            default:
+                return <Text color="helper">Started {readableDate(event.created_at)} and ran for {getDuration(event)}.</Text>;
+        }
+    }
+
+    return (
+        <>
+            <AppearingView>
+                {renderHeaderText()}
+            </AppearingView>
+            <Spacer y={0.5} />
+            {renderDurationText()}
+            <Spacer y={0.5} />
+            <LogSection
+                currentChart={appData.chart}
+                appName={appData.app.name}
+                filterOpts={filterOpts}
+            />
+        </>
+    );
+};
+
+export default DeployEventFocusView;

+ 129 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/focus-views/EventFocusView.tsx

@@ -0,0 +1,129 @@
+import Loading from "components/Loading";
+import Spacer from "components/porter/Spacer";
+import React, { useContext, useEffect, useState } from "react";
+import { Context } from "shared/Context";
+import api from "shared/api";
+import styled from "styled-components";
+import Link from "components/porter/Link";
+import BuildFailureEventFocusView from "./BuildFailureEventFocusView";
+import PreDeployEventFocusView from "./PredeployEventFocusView";
+import _ from "lodash";
+import { PorterAppEvent, porterAppEventValidator } from "../types";
+import DeployEventFocusView from "./DeployEventFocusView";
+import { LogFilterQueryParamOpts } from "../../../logs/types";
+
+type Props = {
+    eventId: string;
+    appData: any;
+    filterOpts?: LogFilterQueryParamOpts;
+};
+
+const EVENT_POLL_INTERVAL = 5000; // poll every 5 seconds
+
+const EventFocusView: React.FC<Props> = ({
+    eventId,
+    appData,
+    filterOpts,
+}) => {
+    const { currentProject, currentCluster } = useContext(Context);
+    const [event, setEvent] = useState<PorterAppEvent | null>(null);
+
+    useEffect(() => {
+        const getEvent = async () => {
+            if (currentProject == null || currentCluster == null) {
+                return;
+            }
+            try {
+                const eventResp = await api.getPorterAppEvent(
+                    "<token>",
+                    {},
+                    {
+                        project_id: currentProject.id,
+                        cluster_id: currentCluster.id,
+                        event_id: eventId,
+                    }
+                )
+                const newEvent = porterAppEventValidator.parse(eventResp.data.event);
+                setEvent(newEvent);
+                if (newEvent.metadata?.end_time != null) {
+                    clearInterval(intervalId);
+                }
+            } catch (err) {
+                console.log(err);
+            }
+        }
+        const intervalId = setInterval(getEvent, EVENT_POLL_INTERVAL);
+        getEvent();
+        return () => clearInterval(intervalId);
+    }, []);
+
+    const getEventFocusView = (event: PorterAppEvent, appData: any) => {
+        switch (event.type) {
+            case "BUILD":
+                return <BuildFailureEventFocusView event={event} appData={appData} />
+            case "PRE_DEPLOY":
+                return <PreDeployEventFocusView event={event} appData={appData} />
+            case "DEPLOY":
+                return <DeployEventFocusView
+                    event={event as PorterAppDeployEvent}
+                    appData={appData}
+                    filterOpts={filterOpts}
+                />
+            default:
+                return null
+        }
+    }
+
+    return (
+        <AppearingView>
+            <Link to={`/apps/${appData.app.name}/activity`}>
+                <BackButton>
+                    <i className="material-icons">keyboard_backspace</i>
+                    Activity feed
+                </BackButton>
+            </Link>
+            <Spacer y={0.5} />
+            {event == null && <Loading />}
+            {event != null && getEventFocusView(event, appData)}
+        </AppearingView>
+    );
+};
+
+export default EventFocusView;
+
+export const AppearingView = styled.div`
+    width: 100%;
+    animation: fadeIn 0.3s 0s;
+    @keyframes fadeIn {
+    from {
+        opacity: 0;
+    }
+    to {
+        opacity: 1;
+    }
+    }
+`;
+
+const BackButton = styled.div`
+  display: flex;
+  align-items: center;
+  max-width: fit-content;
+  cursor: pointer;
+  font-size: 11px;
+  max-height: fit-content;
+  padding: 5px 13px;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  color: white;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: white;
+    font-size: 16px;
+    margin-right: 6px;
+  }
+`;

+ 70 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/focus-views/PredeployEventFocusView.tsx

@@ -0,0 +1,70 @@
+import Spacer from "components/porter/Spacer";
+import React from "react";
+import dayjs from "dayjs";
+import Text from "components/porter/Text";
+import { readableDate } from "shared/string_utils";
+import { getDuration } from "../utils";
+import LogSection from "../../../logs/LogSection";
+import { AppearingView } from "./EventFocusView";
+import Icon from "components/porter/Icon";
+import loading from "assets/loading.gif";
+import Container from "components/porter/Container";
+import { PorterAppEvent } from "../types";
+
+type Props = {
+  event: PorterAppEvent;
+  appData: any;
+};
+
+const PreDeployEventFocusView: React.FC<Props> = ({
+  event,
+  appData,
+}) => {
+  const renderHeaderText = () => {
+    switch (event.status) {
+      case "SUCCESS":
+        return <Text color="#68BF8B" size={16}>Pre-deploy succeeded</Text>;
+      case "FAILED":
+        return <Text color="#FF6060" size={16}>Pre-deploy failed</Text>;
+      default:
+        return (
+          <Container row>
+            <Icon height="16px" src={loading} />
+            <Spacer inline width="10px" />
+            <Text size={16}>Pre-deploy in progress...</Text>
+          </Container>
+        );
+    }
+  };
+
+  const renderDurationText = () => {
+    switch (event.status) {
+      case "PROGRESSING":
+        return <Text color="helper">Started {readableDate(event.created_at)}.</Text>
+      default:
+        return <Text color="helper">Started {readableDate(event.created_at)} and ran for {getDuration(event)}.</Text>;
+    }
+  }
+
+  return (
+    <>
+      <AppearingView>
+        {renderHeaderText()}
+      </AppearingView>
+      <Spacer y={0.5} />
+      {renderDurationText()}
+      <Spacer y={0.5} />
+      <LogSection
+        currentChart={appData.releaseChart}
+        timeRange={{
+          startTime: event.metadata.end_time != null ? dayjs(event.metadata.start_time).subtract(1, 'minute') : undefined,
+          endTime: event.metadata.end_time != null ? dayjs(event.metadata.end_time).add(1, 'minute') : undefined,
+        }}
+        showFilter={false}
+        appName={appData.app.name}
+      />
+    </>
+  );
+};
+
+export default PreDeployEventFocusView;

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

@@ -0,0 +1,89 @@
+import { z } from "zod";
+
+export enum PorterAppEventType {
+    BUILD = "BUILD",
+    DEPLOY = "DEPLOY",
+    APP_EVENT = "APP_EVENT",
+    PRE_DEPLOY = "PRE_DEPLOY",
+}
+const porterAppAppEventMetadataValidator = z.object({
+    namespace: z.string(),
+    summary: z.string(),
+    short_summary: z.string(),
+    detail: z.string(),
+    service_name: z.string(),
+    app_revision_id: z.string(),
+    app_name: z.string(),
+    app_id: z.string(),
+    agent_event_id: z.string(),
+});
+const porterAppDeployEventMetadataValidator = z.object({
+    image_tag: z.string(),
+    revision: z.number(),
+    app_revision_id: z.string(),
+    service_deployment_metadata: z.record(z.object({
+        status: z.string(),
+        type: z.string(),
+    })),
+});
+const porterAppBuildEventMetadataValidator = z.object({
+    org: z.string(),
+    repo: z.string(),
+    branch: z.string(),
+    action_run_id: z.string(),
+    github_account_id: z.string(),
+})
+export const porterAppEventValidator = z.object({
+    id: z.string(),
+    created_at: z.string(),
+    updated_at: z.string(),
+    status: z.string().optional().default(""),
+    type: z.nativeEnum(PorterAppEventType),
+    type_external_source: z.string().optional().default(""),
+    porter_app_id: z.number(),
+    metadata: z.union([
+        porterAppAppEventMetadataValidator,
+        porterAppDeployEventMetadataValidator,
+        porterAppBuildEventMetadataValidator,
+    ]).optional(),
+}).refine((data) => {
+    if (data.type === PorterAppEventType.APP_EVENT) {
+        return porterAppAppEventMetadataValidator.safeParse(data.metadata).success;
+    }
+    if (data.type === PorterAppEventType.DEPLOY) {
+        return porterAppDeployEventMetadataValidator.safeParse(data.metadata).success;
+    }
+    if (data.type === PorterAppEventType.BUILD) {
+        return porterAppBuildEventMetadataValidator.safeParse(data.metadata).success;
+    }
+    return true;
+});
+
+export const getPorterAppEventsValidator = z.array(porterAppEventValidator).optional().default([]);
+
+export type PorterAppEvent = z.infer<typeof porterAppEventValidator>;
+// TODO: figure out how to type this easier
+export type PorterAppAppEvent = Omit<PorterAppEvent, 'metadata'> & { type: PorterAppEventType.APP_EVENT, metadata: z.infer<typeof porterAppAppEventMetadataValidator> };
+export type PorterAppDeployEvent = Omit<PorterAppEvent, 'metadata'> & { type: PorterAppEventType.DEPLOY, metadata: z.infer<typeof porterAppDeployEventMetadataValidator> };
+export type PorterAppBuildEvent = Omit<PorterAppEvent, 'metadata'> & { type: PorterAppEventType.BUILD, metadata: z.infer<typeof porterAppBuildEventMetadataValidator> };
+// interface PorterAppServiceDeploymentMetadata {
+//     status: string;
+//     external_uri: string;
+//     type: string;
+// }
+// export interface PorterAppDeployEvent extends PorterAppEvent {
+//     type: PorterAppEventType.DEPLOY;
+//     metadata: {
+//         image_tag: string;
+//         revision: number;
+//         service_deployment_metadata: Record<string, PorterAppServiceDeploymentMetadata>;
+//     };
+// }
+// export interface PorterAppAppEvent extends PorterAppEvent {
+//     type: PorterAppEventType.APP_EVENT;
+//     metadata: {
+//         image_tag: string;
+//         revision: number;
+//         service_deployment_metadata: Record<string, PorterAppServiceDeploymentMetadata>;
+//     };
+// }

+ 104 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/utils.ts

@@ -0,0 +1,104 @@
+import healthy from "assets/status-healthy.png";
+import failure from "assets/failure.svg";
+import loading from "assets/loading.gif";
+import canceled from "assets/canceled.svg"
+import api from "shared/api";
+import { PorterAppEvent } from "./types";
+import { SourceOptions } from "lib/porter-apps";
+import { PorterAppRecord } from "../../../AppView";
+
+export const getDuration = (event: PorterAppEvent): string => {
+    const startTimeStamp = new Date(event.metadata.start_time ?? event.created_at).getTime();
+    const endTimeStamp = new Date(event.metadata.end_time ?? event.updated_at).getTime();
+
+    const timeDifferenceMilliseconds = endTimeStamp - startTimeStamp;
+
+    const seconds = Math.floor(timeDifferenceMilliseconds / 1000);
+    const weeks = Math.floor(seconds / 604800);
+    const remainingDays = Math.floor((seconds % 604800) / 86400);
+    const remainingHours = Math.floor((seconds % 86400) / 3600);
+    const remainingMinutes = Math.floor((seconds % 3600) / 60);
+    const remainingSeconds = seconds % 60;
+
+    if (weeks > 0) {
+        return `${weeks}w ${remainingDays}d`;
+    }
+
+    if (remainingDays > 0) {
+        return `${remainingDays}d ${remainingHours}h`;
+    }
+
+    if (remainingHours > 0) {
+        return `${remainingHours}h ${remainingMinutes}m`;
+    }
+
+    if (remainingMinutes > 0) {
+        return `${remainingMinutes}m ${remainingSeconds}s`;
+    }
+
+    return `${remainingSeconds}s`;
+};
+
+export const getStatusIcon = (status: string) => {
+    switch (status) {
+        case "SUCCESS":
+            return healthy;
+        case "FAILED":
+            return failure;
+        case "PROGRESSING":
+            return loading;
+        case "CANCELED":
+            return canceled;
+        default:
+            return loading;
+    }
+};
+
+export const getStatusColor = (status: string) => {
+    switch (status) {
+        case "SUCCESS":
+            return "#68BF8B";
+        case "FAILED":
+            return "#FF6060";
+        case "PROGRESSING":
+            return "#6e9df5";
+        case "CANCELED":
+            return "#FFBF00";
+        default:
+            return "#6e9df5";
+    }
+};
+
+export const triggerWorkflow = async ({
+    projectId,
+    clusterId,
+    porterApp,
+}: {
+    projectId: number;
+    clusterId: number;
+    porterApp: PorterAppRecord;
+}) => {
+    if (porterApp.git_repo_id != null && porterApp.repo_name != null) {
+        try {
+            const res = await api.reRunGHWorkflow(
+                "<token>",
+                {},
+                {
+                    project_id: projectId,
+                    cluster_id: clusterId,
+                    git_installation_id: porterApp.git_repo_id ?? 0,
+                    owner: porterApp.repo_name.split("/")[0],
+                    name: porterApp.repo_name.split("/")[1],
+                    branch: porterApp.git_branch,
+                    filename: "porter_stack_" + porterApp.name + ".yml",
+                }
+            );
+            if (res.data != null) {
+                window.open(res.data, "_blank", "noreferrer");
+            }
+
+        } catch (error) {
+            console.log(error);
+        }
+    }
+};

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

@@ -140,6 +140,7 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
 
   const onSubmit = handleSubmit(async (data) => {
     try {
+      console.log("submitting!")
       setDeployError("");
       const validatedAppProto = await validateApp(data);
       setValidatedAppProto(validatedAppProto);

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

@@ -54,6 +54,7 @@ const CustomDomains: React.FC<Props> = ({ index }) => {
         </>
       )}
       <Button
+        type="button" // this is required so that CreateApp.tsx doesn't try to submit the form onClick lol
         onClick={() => {
           append({
             name: {