Jelajahi Sumber

Stacks improved debug flow - Build failures (#3202)

Feroze Mohideen 2 tahun lalu
induk
melakukan
85113c5c1b

+ 67 - 0
api/server/handlers/porter_app/get_event.go

@@ -0,0 +1,67 @@
+package porter_app
+
+import (
+	"net/http"
+
+	"github.com/google/uuid"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+type GetPorterAppEventHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewGetPorterAppEventHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *GetPorterAppEventHandler {
+	return &GetPorterAppEventHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (p *GetPorterAppEventHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-get-porter-app-event")
+	defer span.End()
+
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "cluster-id", Value: int(cluster.ID)},
+		telemetry.AttributeKV{Key: "project-id", Value: int(cluster.ProjectID)},
+	)
+
+	eventId, reqErr := requestutils.GetURLParamString(r, types.URLParamPorterAppEventID)
+	if reqErr != nil {
+		e := telemetry.Error(ctx, span, nil, "error parsing event id from url")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusBadRequest))
+		return
+	}
+
+	submittedEventID, err := uuid.Parse(eventId)
+	if err != nil {
+		e := telemetry.Error(ctx, span, err, "error parsing porter app event id as uuid")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusBadRequest))
+		return
+	}
+
+	event, err := p.Repo().PorterAppEvent().ReadEvent(ctx, submittedEventID)
+	if err != nil {
+		e := telemetry.Error(ctx, span, err, "error retrieving porter app event")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusBadRequest))
+		return
+	}
+
+	res := struct {
+		Event types.PorterAppEvent `json:"event"`
+	}{
+		Event: event.ToPorterAppEvent(),
+	}
+	p.WriteResult(w, r, res)
+}

+ 29 - 1
api/server/router/porter_app.go

@@ -255,7 +255,7 @@ func getStackRoutes(
 		Router:   r,
 	})
 
-	// POST /api/projects/{project_id}/clusters/{cluster_id}/stacks/{name}/events -> porter_app.NewCreatePorterAppEventEndpoint
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/stacks/{name}/events -> porter_app.NewCreatePorterAppEventHandler
 	createPorterAppEventEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbCreate,
@@ -284,6 +284,34 @@ func getStackRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/events/id -> porter_app.NewGetPorterAppEventHandler
+	getPorterAppEventEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("/events/{%s}", types.URLParamPorterAppEventID),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	getPorterAppEventHandler := porter_app.NewGetPorterAppEventHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: getPorterAppEventEndpoint,
+		Handler:  getPorterAppEventHandler,
+		Router:   r,
+	})
+
 	// POST /api/projects/{project_id}/clusters/{cluster_id}/stacks/analytics -> porter_app.NewPorterAppAnalyticsHandler
 	porterAppAnalyticsEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 1 - 0
api/types/request.go

@@ -51,6 +51,7 @@ const (
 	URLParamAPIContractRevisionID URLParam = "contract_revision_id"
 	URLParamStackName             URLParam = "stack_name"
 	URLParamStackEventID          URLParam = "stack_event_id"
+	URLParamPorterAppEventID      URLParam = "porter_app_event_id"
 )
 
 type Path struct {

+ 10 - 0
dashboard/src/components/porter/TemplateComponent.tsx

@@ -21,4 +21,14 @@ const TemplateComponent: React.FC<Props> = ({
 export default TemplateComponent;
 
 const StyledTemplateComponent = styled.div`
+width: 100%;
+animation: fadeIn 0.3s 0s;
+@keyframes fadeIn {
+  from {
+    opacity: 0;
+  }
+  to {
+    opacity: 1;
+  }
+}
 `;

+ 0 - 2
dashboard/src/main/home/app-dashboard/expanded-app/ExpandedApp.tsx

@@ -41,7 +41,6 @@ import { EnvVariablesTab } from "./EnvVariablesTab";
 import GHABanner from "./GHABanner";
 import LogSection from "./LogSection";
 import ActivityFeed from "./activity-feed/ActivityFeed";
-import JobRuns from "./JobRuns";
 import MetricsSection from "./MetricsSection";
 import StatusSectionFC from "./status/StatusSection";
 import ExpandedJob from "./expanded-job/ExpandedJob";
@@ -49,7 +48,6 @@ import { Log } from "main/home/cluster-dashboard/expanded-chart/logs-section/use
 import Anser, { AnserJsonEntry } from "anser";
 import _ from "lodash";
 import AnimateHeight from "react-animate-height";
-import EventsTab from "./EventsTab";
 import { PorterApp } from "../types/porterApp";
 
 type Props = RouteComponentProps & {};

+ 6 - 6
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/ActivityFeed.tsx

@@ -8,7 +8,7 @@ import refresh from "assets/refresh.png";
 
 import Text from "components/porter/Text";
 
-import EventCard from "./events/EventCard";
+import EventCard from "./events/cards/EventCard";
 import Loading from "components/Loading";
 import Spacer from "components/porter/Spacer";
 import Fieldset from "components/porter/Fieldset";
@@ -19,6 +19,7 @@ import _ from "lodash";
 import Button from "components/porter/Button";
 import Icon from "components/porter/Icon";
 import Container from "components/porter/Container";
+import EventFocusView from "./events/focus-views/EventFocusView";
 
 type Props = {
   chart: any;
@@ -140,11 +141,10 @@ const ActivityFeed: React.FC<Props> = ({ chart, stackName, appData, eventId }) =
   }
 
   if (eventId != null) {
-    return (
-      <StyledActivityFeed>
-        {eventId}
-      </StyledActivityFeed>
-    )
+    return <EventFocusView
+      eventId={eventId}
+      appData={appData}
+    />;
   }
 
   if (!hasPorterAgent) {

+ 1 - 1
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/AppEventCard.tsx → dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/AppEventCard.tsx

@@ -10,7 +10,7 @@ import Icon from "components/porter/Icon";
 import { PorterAppEvent } from "shared/types";
 import { StyledEventCard } from "./EventCard";
 import styled from "styled-components";
-import AppEventModal from "../../status/AppEventModal";
+import AppEventModal from "../../../status/AppEventModal";
 import { readableDate } from "shared/string_utils";
 import dayjs from "dayjs";
 import Anser from "anser";

+ 10 - 34
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/BuildEventCard.tsx → dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/BuildEventCard.tsx

@@ -15,10 +15,11 @@ import api from "shared/api";
 import { Log } from "main/home/cluster-dashboard/expanded-chart/logs-section/useAgentLogs";
 import JSZip from "jszip";
 import Anser, { AnserJsonEntry } from "anser";
-import GHALogsModal from "../../status/GHALogsModal";
+import GHALogsModal from "../../../status/GHALogsModal";
 import { PorterAppEvent } from "shared/types";
-import { getDuration, getStatusIcon, triggerWorkflow } from './utils';
+import { getDuration, getStatusIcon, triggerWorkflow } from '../utils';
 import { StyledEventCard } from "./EventCard";
+import document from "assets/document.svg";
 
 type Props = {
   event: PorterAppEvent;
@@ -116,42 +117,18 @@ const BuildEventCard: React.FC<Props> = ({ event, appData }) => {
   const renderInfoCta = (event: PorterAppEvent) => {
     switch (event.status) {
       case "SUCCESS":
-        return (
-          <Wrapper>
-            <Link hasunderline onClick={() => getBuildLogs()}>
-              View logs
-            </Link>
-
-            {logModalVisible && (
-              <GHALogsModal
-                appData={appData}
-                logs={logs}
-                modalVisible={logModalVisible}
-                setModalVisible={setLogModalVisible}
-                actionRunId={event.metadata?.action_run_id}
-              />
-            )}
-            <Spacer inline x={1} />
-          </Wrapper>
-        );
+        return null;
       case "FAILED":
         return (
           <Wrapper>
-            <Link hasunderline onClick={() => getBuildLogs()}>
-              View logs
+            <Link to={`/apps/${appData.app.name}/events/${event.id}`} hasunderline>
+              <Container row>
+                <Icon src={document} height="10px" />
+                <Spacer inline width="5px" />
+                View more details
+              </Container>
             </Link>
-
-            {logModalVisible && (
-              <GHALogsModal
-                appData={appData}
-                logs={logs}
-                modalVisible={logModalVisible}
-                setModalVisible={setLogModalVisible}
-                actionRunId={event.metadata?.action_run_id}
-              />
-            )}
             <Spacer inline x={1} />
-
             <Link hasunderline onClick={() => triggerWorkflow(appData)}>
               <Container row>
                 <Icon height="10px" src={refresh} />
@@ -200,7 +177,6 @@ const BuildEventCard: React.FC<Props> = ({ event, appData }) => {
           <Spacer inline x={1} />
           {renderInfoCta(event)}
           <Spacer inline x={1} />
-          {/* <Link to={`/apps/${appData.app.name}/events/${event.id}`} hasunderline>View event</Link> */}
         </Container>
       </Container>
     </StyledEventCard>

+ 2 - 2
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/DeployEventCard.tsx → dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/DeployEventCard.tsx

@@ -10,13 +10,13 @@ import Spacer from "components/porter/Spacer";
 import Icon from "components/porter/Icon";
 import Modal from "components/porter/Modal";
 import { PorterAppEvent } from "shared/types";
-import { getDuration, getStatusIcon } from './utils';
+import { getDuration, getStatusIcon } from '../utils';
 import { StyledEventCard } from "./EventCard";
 import styled from "styled-components";
 import Button from "components/porter/Button";
 import api from "shared/api";
 import Link from "components/porter/Link";
-import ChangeLogModal from "../../ChangeLogModal";
+import ChangeLogModal from "../../../ChangeLogModal";
 
 type Props = {
   event: PorterAppEvent;

+ 0 - 1
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/EventCard.tsx → dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/EventCard.tsx

@@ -1,7 +1,6 @@
 import React from "react";
 import styled from "styled-components";
 
-import Text from "components/porter/Text";
 import { PorterAppEvent, PorterAppEventType } from "shared/types";
 import BuildEventCard from "./BuildEventCard";
 import PreDeployEventCard from "./PreDeployEventCard";

+ 2 - 2
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/PreDeployEventCard.tsx → dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/cards/PreDeployEventCard.tsx

@@ -13,10 +13,10 @@ import Icon from "components/porter/Icon";
 import Modal from "components/porter/Modal";
 
 import { PorterAppEvent } from "shared/types";
-import { getDuration, getStatusIcon, triggerWorkflow } from './utils';
+import { getDuration, getStatusIcon, triggerWorkflow } from '../utils';
 import { StyledEventCard } from "./EventCard";
 import Link from "components/porter/Link";
-import LogsModal from "../../status/LogsModal";
+import LogsModal from "../../../status/LogsModal";
 import api from "shared/api";
 import dayjs from "dayjs";
 import Anser from "anser";

+ 304 - 0
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/focus-views/BuildFailureEventFocusView.tsx

@@ -0,0 +1,304 @@
+import Breadcrumb from "components/Breadcrumb";
+import Loading from "components/Loading";
+import Spacer from "components/porter/Spacer";
+import React, { useContext, useEffect, useRef, useState } from "react";
+import { Context } from "shared/Context";
+import api from "shared/api";
+import styled from "styled-components";
+import Anser, { AnserJsonEntry } from "anser";
+import JSZip from "jszip";
+import dayjs from "dayjs";
+import { Log as LogType } from "../../../useAgentLogs";
+import { PorterAppEvent } from "shared/types";
+import Text from "components/porter/Text";
+import { readableDate } from "shared/string_utils";
+import { getDuration } from "../utils";
+import Link from "components/porter/Link";
+
+type Props = {
+    event: PorterAppEvent;
+    appData: any;
+};
+
+const BuildFailureEventFocusView: React.FC<Props> = ({
+    event,
+    appData,
+}) => {
+    const [logs, setLogs] = useState<LogType[]>([]);
+    const [isLoading, setIsLoading] = useState<boolean>(true);
+    const scrollToBottomRef = useRef<HTMLDivElement | undefined>(undefined);
+
+    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: LogType[] = [];
+            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: LogType = {
+                                            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();
+    }, [event]);
+
+    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"};
+`;
+
+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;
+  }
+`;

+ 107 - 0
dashboard/src/main/home/app-dashboard/expanded-app/activity-feed/events/focus-views/EventFocusView.tsx

@@ -0,0 +1,107 @@
+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 { PorterAppEvent } from "shared/types";
+import Link from "components/porter/Link";
+import BuildFailureEventFocusView from "./BuildFailureEventFocusView";
+
+type Props = {
+    eventId: string;
+    appData: any;
+};
+
+const EventFocusView: React.FC<Props> = ({
+    eventId,
+    appData,
+}) => {
+    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,
+                    }
+                )
+                setEvent(eventResp.data.event as PorterAppEvent)
+            } catch (err) {
+                console.log(err);
+            }
+        }
+        getEvent();
+    }, []);
+
+    const getEventFocusView = (event: PorterAppEvent, appData: any) => {
+        switch (event.type) {
+            case "BUILD":
+                return <BuildFailureEventFocusView event={event} appData={appData} />
+            default:
+                return null
+        }
+    }
+
+    return (
+        <StyledEventFocusView>
+            <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)}
+        </StyledEventFocusView>
+    );
+};
+
+export default EventFocusView;
+
+const StyledEventFocusView = 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;
+  }
+`;

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

@@ -187,6 +187,18 @@ const getPorterApp = baseApi<
   return `/api/projects/${project_id}/clusters/${cluster_id}/stacks/${name}`;
 });
 
+const getPorterAppEvent = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+    event_id: string;
+  }
+>("GET", (pathParams) => {
+  let { project_id, cluster_id, event_id } = pathParams;
+  return `/api/projects/${project_id}/clusters/${cluster_id}/events/${event_id}`;
+});
+
 const createPorterApp = baseApi<
   PorterAppOptions,
   {
@@ -2669,6 +2681,7 @@ export default {
   // PORTER APP
   getPorterApps,
   getPorterApp,
+  getPorterAppEvent,
   createPorterApp,
   deletePorterApp,
   rollbackPorterApp,