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

Merge pull request #1072 from porter-dev/0.8.0-live-deployment-updates

[0.8.0] Live Deployment Updates
jusrhee 4 жил өмнө
parent
commit
8ec7927270

+ 66 - 0
cli/cmd/api/api.go

@@ -1,6 +1,8 @@
 package api
 
 import (
+	"bytes"
+	"context"
 	"encoding/base64"
 	"encoding/json"
 	"fmt"
@@ -28,6 +30,32 @@ type HTTPError struct {
 	Errors []string `json:"errors"`
 }
 
+type EventStatus int64
+
+const (
+	EventStatusSuccess    EventStatus = 1
+	EventStatusInProgress             = 2
+	EventStatusFailed                 = 3
+)
+
+// Event represents an event that happens during
+type Event struct {
+	ID     string      `json:"event_id"` // events with the same id wil be treated the same, and the highest index one is retained
+	Name   string      `json:"name"`
+	Index  int64       `json:"index"` // priority of the event, used for sorting
+	Status EventStatus `json:"status"`
+	Info   string      `json:"info"` // extra information (can be error or success)
+}
+
+// StreamEventForm is used to send event data to the api
+type StreamEventForm struct {
+	Event     `json:"event"`
+	Token     string `json:"token"`
+	ClusterID uint   `json:"cluster_id"`
+	Name      string `json:"name"`
+	Namespace string `json:"namespace"`
+}
+
 // NewClient constructs a new client based on a set of options
 func NewClient(baseURL string, cookieFileName string) *Client {
 	home := homedir.HomeDir()
@@ -121,6 +149,44 @@ func (c *Client) saveCookie(cookie *http.Cookie) error {
 	return ioutil.WriteFile(c.CookieFilePath, data, 0644)
 }
 
+// StreamEvent sends an event from deployment to the api
+func (c *Client) StreamEvent(event Event, projID uint, clusterID uint, name string, namespace string) error {
+	form := StreamEventForm{
+		Event:     event,
+		ClusterID: clusterID,
+		Name:      name,
+		Namespace: namespace,
+	}
+
+	body := new(bytes.Buffer)
+	err := json.NewEncoder(body).Encode(form)
+
+	if err != nil {
+		return err
+	}
+
+	req, err := http.NewRequest(
+		"POST",
+		fmt.Sprintf("%s/projects/%d/releases/%s/steps", c.BaseURL, projID, name),
+		body,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	req = req.WithContext(context.Background())
+
+	if httpErr, err := c.sendRequest(req, nil, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+		return err
+	}
+
+	return nil
+}
+
 // retrieves single cookie from file
 func (c *Client) getCookie() (*http.Cookie, error) {
 	data, err := ioutil.ReadFile(c.CookieFilePath)

+ 133 - 2
cli/cmd/deploy.go

@@ -202,6 +202,7 @@ var localPath string
 var tag string
 var dockerfile string
 var method string
+var stream bool
 
 func init() {
 	rootCmd.AddCommand(updateCmd)
@@ -267,6 +268,13 @@ func init() {
 		"the build method to use (\"docker\" or \"pack\")",
 	)
 
+	updateCmd.PersistentFlags().BoolVar(
+		&stream,
+		"stream",
+		false,
+		"stream update logs to porter dashboard",
+	)
+
 	updateCmd.AddCommand(updateGetEnvCmd)
 
 	updateGetEnvCmd.PersistentFlags().StringVar(
@@ -392,9 +400,29 @@ func updateBuildWithAgent(updateAgent *deploy.DeployAgent) error {
 	// build the deployment
 	color.New(color.FgGreen).Println("Building docker image for", app)
 
+	if stream {
+		updateAgent.StreamEvent(api.Event{
+			ID:     "build",
+			Name:   "Build",
+			Index:  100,
+			Status: api.EventStatusInProgress,
+			Info:   "",
+		})
+	}
+
 	buildEnv, err := updateAgent.GetBuildEnv()
 
 	if err != nil {
+		if stream {
+			// another concern: is it safe to ignore the error here?
+			updateAgent.StreamEvent(api.Event{
+				ID:     "build",
+				Name:   "Build",
+				Index:  110,
+				Status: api.EventStatusFailed,
+				Info:   err.Error(),
+			})
+		}
 		return err
 	}
 
@@ -402,36 +430,139 @@ func updateBuildWithAgent(updateAgent *deploy.DeployAgent) error {
 	err = updateAgent.SetBuildEnv(buildEnv)
 
 	if err != nil {
+		if stream {
+			updateAgent.StreamEvent(api.Event{
+				ID:     "build",
+				Name:   "Build",
+				Index:  120,
+				Status: api.EventStatusFailed,
+				Info:   err.Error(),
+			})
+		}
+		return err
+	}
+
+	if err := updateAgent.Build(); err != nil {
+		if stream {
+			updateAgent.StreamEvent(api.Event{
+				ID:     "build",
+				Name:   "Build",
+				Index:  130,
+				Status: api.EventStatusFailed,
+				Info:   err.Error(),
+			})
+		}
 		return err
 	}
 
-	return updateAgent.Build()
+	if stream {
+		updateAgent.StreamEvent(api.Event{
+			ID:     "build",
+			Name:   "Build",
+			Index:  140,
+			Status: api.EventStatusSuccess,
+			Info:   "",
+		})
+	}
+
+	return nil
 }
 
 func updatePushWithAgent(updateAgent *deploy.DeployAgent) error {
 	// push the deployment
 	color.New(color.FgGreen).Println("Pushing new image for", app)
 
-	return updateAgent.Push()
+	if stream {
+		updateAgent.StreamEvent(api.Event{
+			ID:     "push",
+			Name:   "Push",
+			Index:  200,
+			Status: api.EventStatusInProgress,
+			Info:   "",
+		})
+	}
+
+	if err := updateAgent.Push(); err != nil {
+		if stream {
+			updateAgent.StreamEvent(api.Event{
+				ID:     "push",
+				Name:   "Push",
+				Index:  210,
+				Status: api.EventStatusFailed,
+				Info:   err.Error(),
+			})
+		}
+		return err
+	}
+
+	if stream {
+		updateAgent.StreamEvent(api.Event{
+			ID:     "push",
+			Name:   "Push",
+			Index:  220,
+			Status: api.EventStatusSuccess,
+			Info:   "",
+		})
+	}
+
+	return nil
 }
 
 func updateUpgradeWithAgent(updateAgent *deploy.DeployAgent) error {
 	// push the deployment
 	color.New(color.FgGreen).Println("Upgrading configuration for", app)
 
+	if stream {
+		updateAgent.StreamEvent(api.Event{
+			ID:     "upgrade",
+			Name:   "Upgrade",
+			Index:  300,
+			Status: api.EventStatusInProgress,
+			Info:   "",
+		})
+	}
+
 	// read the values if necessary
 	valuesObj, err := readValuesFile()
 
 	if err != nil {
+		if stream {
+			updateAgent.StreamEvent(api.Event{
+				ID:     "upgrade",
+				Name:   "Upgrade",
+				Index:  310,
+				Status: api.EventStatusFailed,
+				Info:   err.Error(),
+			})
+		}
 		return err
 	}
 
 	err = updateAgent.UpdateImageAndValues(valuesObj)
 
 	if err != nil {
+		if stream {
+			updateAgent.StreamEvent(api.Event{
+				ID:     "upgrade",
+				Name:   "Upgrade",
+				Index:  320,
+				Status: api.EventStatusFailed,
+				Info:   err.Error(),
+			})
+		}
 		return err
 	}
 
+	if stream {
+		updateAgent.StreamEvent(api.Event{
+			ID:     "upgrade",
+			Name:   "Upgrade",
+			Index:  330,
+			Status: api.EventStatusSuccess,
+			Info:   "",
+		})
+	}
+
 	color.New(color.FgGreen).Println("Successfully updated", app)
 
 	return nil

+ 4 - 0
cli/cmd/deploy/deploy.go

@@ -447,6 +447,10 @@ func (d *DeployAgent) downloadRepoToDir(downloadURL string) (string, error) {
 	return res, nil
 }
 
+func (d *DeployAgent) StreamEvent(event api.Event) error {
+	return d.client.StreamEvent(event, d.opts.ProjectID, d.opts.ClusterID, d.release.Name, d.release.Namespace)
+}
+
 type NestedMapFieldNotFoundError struct {
 	Field string
 }

+ 25 - 22
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -28,11 +28,14 @@ import MetricsSection from "./metrics/MetricsSection";
 import ListSection from "./ListSection";
 import StatusSection from "./status/StatusSection";
 import SettingsSection from "./SettingsSection";
+import Loading from "components/Loading";
 import { useWebsockets } from "shared/hooks/useWebsockets";
 import useAuth from "shared/auth/useAuth";
 import TitleSection from "components/TitleSection";
 import { integrationList } from "shared/common";
 import DeploymentType from "./DeploymentType";
+import DeployStatus from "./status/DeployStatus";
+import EventsTab from "./events/EventsTab";
 
 type Props = {
   namespace: string;
@@ -67,9 +70,8 @@ const ExpandedChart: React.FC<Props> = (props) => {
   const [rightTabOptions, setRightTabOptions] = useState<any[]>([]);
   const [leftTabOptions, setLeftTabOptions] = useState<any[]>([]);
   const [saveValuesStatus, setSaveValueStatus] = useState<string>(null);
-  const [forceRefreshRevisions, setForceRefreshRevisions] = useState<boolean>(
-    false
-  );
+  const [forceRefreshRevisions, setForceRefreshRevisions] =
+    useState<boolean>(false);
   const [controllers, setControllers] = useState<
     Record<string, Record<string, any>>
   >({});
@@ -81,19 +83,11 @@ const ExpandedChart: React.FC<Props> = (props) => {
   const [showRepoTooltip, setShowRepoTooltip] = useState(false);
   const [isAuthorized] = useAuth();
 
-  const {
-    newWebsocket,
-    openWebsocket,
-    closeAllWebsockets,
-    closeWebsocket,
-  } = useWebsockets();
+  const { newWebsocket, openWebsocket, closeAllWebsockets, closeWebsocket } =
+    useWebsockets();
 
-  const {
-    currentCluster,
-    currentProject,
-    setCurrentError,
-    setCurrentOverlay,
-  } = useContext(Context);
+  const { currentCluster, currentProject, setCurrentError, setCurrentOverlay } =
+    useContext(Context);
 
   // Retrieve full chart data (includes form and values)
   const getChartData = async (chart: ChartType) => {
@@ -358,15 +352,14 @@ const ExpandedChart: React.FC<Props> = (props) => {
     switch (currentTab) {
       case "metrics":
         return <MetricsSection currentChart={chart} />;
+      case "events":
+        return <EventsTab currentChart={chart} />;
       case "status":
         if (isLoadingChartData) {
           return (
             <Placeholder>
-              <TextWrap>
-                <Header>
-                  <Spinner src={loadingSrc} />
-                </Header>
-              </TextWrap>
+              <Loading />
+              <DeployStatus chart={chart} />
             </Placeholder>
           );
         }
@@ -390,6 +383,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
                 </A>{" "}
                 tab of your GitHub repo to view live build logs.
               </TextWrap>
+              <DeployStatus chart={chart} />
             </Placeholder>
           );
         } else {
@@ -449,6 +443,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
     let rightTabOptions = [] as any[];
     let leftTabOptions = [] as any[];
     leftTabOptions.push({ label: "Status", value: "status" });
+    leftTabOptions.push({ label: "Events", value: "events" });
 
     if (props.isMetricsInstalled) {
       leftTabOptions.push({ label: "Metrics", value: "metrics" });
@@ -630,7 +625,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
     localStorage.setItem("devOpsMode", devOpsMode.toString());
   }, [devOpsMode, currentChart?.form, isPreview]);
 
-  useEffect(() => {
+  useEffect((): any => {
     let isSubscribed = true;
 
     const ingressComponent = components?.find((c) => c.Kind === "Ingress");
@@ -812,6 +807,14 @@ const Tooltip = styled.div`
 
 const TextWrap = styled.div``;
 
+const LoadingWrapper = styled.div`
+  width: 100%;
+  height: 200px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;
+
 const LineBreak = styled.div`
   width: calc(100% - 0px);
   height: 2px;
@@ -867,9 +870,9 @@ const Placeholder = styled.div`
   font-size: 13px;
   color: #ffffff44;
   width: 100%;
-  display: flex;
   align-items: center;
   justify-content: center;
+  overflow-y: scroll;
 `;
 
 const Spinner = styled.img`

+ 121 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventCard.tsx

@@ -0,0 +1,121 @@
+import React, { useState } from "react";
+import styled from "styled-components";
+import { Event } from "./EventsTab";
+import Loading from "../../../../../components/Loading";
+
+type CardProps = {
+  event: Event;
+  selectEvent?: () => void;
+  overrideName?: string;
+};
+
+export const getReadableDate = (s: number) => {
+  let ts = new Date(s * 1000);
+  let date = ts.toLocaleDateString();
+  let time = ts.toLocaleTimeString([], {
+    hour: "numeric",
+    minute: "2-digit",
+  });
+  return `${time} ${date}`;
+};
+
+// Rename to Event Card
+const EventCard: React.FunctionComponent<CardProps> = ({
+  event,
+  selectEvent,
+  overrideName,
+}) => {
+  return (
+    <StyledCard onClick={() => selectEvent && selectEvent()}>
+      {event.status == 1 && (
+        <Icon status="normal" className="material-icons-outlined">
+          check
+        </Icon>
+      )}
+      {event.status == 2 && (
+        <Icon className="material-icons-outlined">
+          autorenew
+        </Icon>
+      )}
+      {event.status == 3 && (
+        <Icon status="critical" className="material-icons-outlined">
+          error
+        </Icon>
+      )}
+       
+      <InfoWrapper>
+        <EventName>
+          {overrideName ? overrideName : event.name}
+          {event.status == 1 && " successful"}
+          {event.status == 2 && " in progress"}
+          {event.status == 3 && ` failed: ${event.info}`}
+        </EventName>
+        <TimestampContainer>
+          <i className="material-icons-outlined">access_time</i>
+          {getReadableDate(event.time)}
+        </TimestampContainer>
+      </InfoWrapper>
+    </StyledCard>
+  );
+};
+
+export default EventCard;
+
+const StyledCard = styled.div`
+  display: flex;
+  align-items: center;
+  border: 1px solid #ffffff44;
+  background: #ffffff08;
+  margin-bottom: 10px;
+  border-radius: 10px;
+  padding-left: 20px;
+  overflow: hidden;
+  height: 80px;
+  cursor: pointer;
+
+  :hover {
+    background: #ffffff11;
+    border: 1px solid #ffffff66;
+  }
+`;
+
+const Icon = styled.span<{ status?: "critical" | "normal" }>`
+  font-size: 22px;
+  margin-right: 18px;
+  color: ${({ status }) => status ? (status === "critical" ? "#cc3d42" : "#38a88a" ) : "#efefef"};
+  animation: ${({ status }) => !status && "rotating 3s linear infinite"};
+  @keyframes rotating {
+    from {
+      transform: rotate(0deg);
+    }
+    to {
+      transform: rotate(360deg);
+    }
+  }
+`;
+
+const InfoWrapper = styled.div`
+  display: flex;
+  flex-direction: column;
+`;
+
+const EventName = styled.div`
+  font-size: 13px;
+  font-family: "Work Sans", sans-serif;
+  font-weight: 500;
+  color: #ffffff;
+`;
+
+const TimestampContainer = styled.div`
+  display: flex;
+  align-items: center;
+  color: #ffffff55;
+  font-size: 13px;
+  margin-top: 8px;
+
+  > i {
+    margin-right: 5px;
+    font-size: 18px;
+    margin-left: -1px;
+  }
+`;

+ 94 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventDetail.tsx

@@ -0,0 +1,94 @@
+import React, { Fragment } from "react";
+import { EventContainer } from "./EventsTab";
+import TitleSection from "components/TitleSection";
+import EventCard, { getReadableDate } from "./EventCard";
+import styled from "styled-components";
+
+interface Props {
+  container: EventContainer;
+  resetSelection: () => {};
+}
+
+const EventDetail: React.FC<Props> = (props) => {
+  return (
+    <>
+      <Flex>
+      <TitleSection handleNavBack={props.resetSelection}>
+        {props.container.name}
+      </TitleSection>
+      <P>
+        <i className="material-icons-outlined">access_time</i>
+        {getReadableDate(props.container.started_at)}
+      </P>
+      </Flex>
+      <EventsGrid>
+        {props.container.events
+        .slice(0)
+        .reverse()
+        .map((event) => {
+          return (
+            <React.Fragment key={event.index}>
+              <EventCard event={event} />
+            </React.Fragment>
+          );
+        })}
+      </EventsGrid>
+    </>
+  );
+};
+
+export default EventDetail;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+  margin-bottom: 10px;
+`;
+
+const P = styled.p`
+  display: flex;
+  align-items: center;
+  color: #ffffff44;
+  font-size: 13px;
+  margin-left: 20px;
+  margin-top: 0px;
+
+  > i {
+    margin-right: 5px;
+    font-size: 18px;
+    margin-left: -1px;
+  }
+`;
+
+const BackButton = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  cursor: pointer;
+  font-size: 13px;
+  height: 35px;
+  padding: 5px 16px;
+  padding-right: 15px;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  width: ${(props: { width: string }) => props.width};
+  color: white;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: white;
+    font-size: 16px;
+    margin-right: 6px;
+    margin-left: -2px;
+  }
+`;
+
+const EventsGrid = styled.div`
+  display: grid;
+  grid-row-gap: 15px;
+  grid-template-columns: 1;
+`;

+ 254 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventsTab.tsx

@@ -0,0 +1,254 @@
+import React, { useContext, useEffect, useState } from "react";
+import styled from "styled-components";
+
+import loadingSrc from "assets/loading.gif";
+import { Context } from "shared/Context";
+import { ChartType } from "../../../../../shared/types";
+import api from "../../../../../shared/api";
+import EventCard from "./EventCard";
+import Loading from "components/Loading";
+import EventDetail from "./EventDetail";
+
+export type Event = {
+  event_id: string;
+  index: number;
+  info: string;
+  name: string;
+  status: number;
+  time: number;
+};
+
+export type EventContainer = {
+  events: Event[];
+  name: string;
+  started_at: number;
+};
+
+type Props = {
+  currentChart: ChartType;
+};
+
+const REFRESH_TIME = 1000; // SHOULD BE MADE HIGHER!
+
+const EventsTab: React.FunctionComponent<Props> = (props) => {
+  const { currentCluster, currentProject } = useContext(Context);
+  const [isLoading, setIsLoading] = useState(true);
+  const [shouldRequest, setShouldRequest] = useState(true);
+  const [eventData, setEventData] = useState<EventContainer[]>([]); // most recent event is last
+  const [selectedEvent, setSelectedEvent] = useState<number | null>(null);
+
+  // sort by time, ensure sequences are monotonically increasing by time, collapse by id
+  const filterData = (data: Event[]) => {
+    data = data.sort((a, b) => a.time - b.time);
+
+    if (data.length == 0) return;
+
+    let seq: Event[][] = [];
+    let cur: Event[] = [data[0]];
+
+    for (let i = 1; i < data.length; ++i) {
+      if (data[i].index < data[i - 1].index) {
+        seq.push(cur);
+        cur = [];
+      }
+      cur.push(data[i]);
+    }
+    if (cur) seq.push(cur);
+
+    let ret: EventContainer[] = [];
+    seq.forEach((j) => {
+      j.push({
+        event_id: "",
+        index: 0,
+        info: "",
+        name: "",
+        status: 0,
+        time: 0,
+      });
+
+      let fin: EventContainer = {
+        events: [],
+        name: "Deployment",
+        started_at: j[0].time,
+      };
+      for (let i = 0; i < j.length - 1; ++i) {
+        if (j[i].event_id != j[i + 1].event_id) {
+          fin.events.push(j[i]);
+        }
+      }
+      ret.push(fin);
+    });
+
+    setEventData(ret);
+  };
+
+  useEffect(() => {
+    const id = window.setInterval(() => {
+      if (!shouldRequest) return;
+      setShouldRequest(false);
+      api
+        .getReleaseSteps(
+          "<token>",
+          {
+            cluster_id: currentCluster.id,
+            namespace: props.currentChart.namespace,
+          },
+          {
+            id: currentProject.id,
+            name: props.currentChart.name,
+          }
+        )
+        .then((data) => {
+          setIsLoading(false);
+          filterData(data.data);
+        })
+        .catch((err) => {})
+        .finally(() => {
+          setShouldRequest(true);
+        });
+    }, REFRESH_TIME);
+    return () => {
+      setIsLoading(true);
+      window.clearInterval(id);
+    };
+  }, [currentProject, currentCluster, props.currentChart]);
+
+  if (isLoading) {
+    return (
+      <Placeholder>
+        <Loading />
+      </Placeholder>
+    );
+  }
+
+  if (eventData.length === 0) {
+    return (
+      <Placeholder>
+        <i className="material-icons">category</i>
+        No application events found.
+      </Placeholder>
+    );
+  }
+
+  if (selectedEvent !== null) {
+    return (
+      <EventDetail
+        container={eventData[selectedEvent]}
+        resetSelection={() => {
+          setSelectedEvent(null);
+          return null;
+        }}
+      />
+    );
+  }
+
+  return (
+    <EventsGrid>
+      {eventData
+      .slice(0)
+      .reverse()
+      .map((dat, i) => {
+        console.log(dat.started_at);
+        return (
+          <React.Fragment key={dat.started_at}>
+            <EventCard
+              event={dat.events[dat.events.length - 1]}
+              selectEvent={() => {
+                setSelectedEvent(eventData.length - i - 1);
+              }}
+              overrideName={"Deployment"}
+            />
+          </React.Fragment>
+        );
+      })}
+    </EventsGrid>
+  );
+};
+
+export default EventsTab;
+
+const EventsPageWrapper = styled.div`
+  margin-top: 35px;
+  padding-bottom: 80px;
+`;
+
+const InstallPorterAgentButton = styled.button`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border: none;
+  border-radius: 20px;
+  color: white;
+  height: 35px;
+  padding: 0px 8px;
+  padding-bottom: 1px;
+  margin-top: 10px;
+  font-weight: 500;
+  padding-right: 15px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  box-shadow: 0 5px 8px 0px #00000010;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+  :hover {
+    background: ${(props: { disabled?: boolean }) =>
+      props.disabled ? "" : "#505edddd"};
+  }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;
+
+const Placeholder = styled.div`
+  width: 100%;
+  min-height: 300px;
+  height: 40vh;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff44;
+  font-size: 14px;
+
+  > i {
+    font-size: 18px;
+    margin-right: 10px;
+  }
+`;
+
+const Header = styled.div`
+  font-weight: 500;
+  color: #aaaabb;
+  font-size: 16px;
+  margin-bottom: 15px;
+`;
+
+const Spinner = styled.img`
+  width: 15px;
+  height: 15px;
+  margin-right: 12px;
+  margin-bottom: -2px;
+`;
+
+const EventsGrid = styled.div`
+  display: grid;
+  grid-row-gap: 15px;
+  grid-template-columns: 1;
+`;

+ 136 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/DeployStatus.tsx

@@ -0,0 +1,136 @@
+import React, { useEffect, useState, useContext } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { ChartType } from "../../../../../shared/types";
+import { filter } from "d3-array";
+import { render } from "react-dom";
+
+const REFRESH_TIME = 1000; // SHOULD BE MADE HIGHER!
+
+interface Event {
+  event_id: string;
+  index: number;
+  info: string;
+  name: string;
+  status: number;
+  time: number;
+}
+
+interface Props {
+  chart: ChartType;
+}
+
+const DeployStatus: React.FC<Props> = (props) => {
+  const [shouldRequest, setShouldRequest] = useState(true);
+  const [eventData, setEventData] = useState<Event[][]>([]); // most recent event is first
+  const { currentCluster, currentProject } = useContext(Context);
+
+  // sort by time, ensure sequences are monotonically increasing by time, collapse by id
+  const filterData = (data: Event[]) => {
+    data = data.sort((a, b) => a.time - b.time);
+
+    if (data.length == 0) return;
+
+    let seq: Event[][] = [];
+    let cur: Event[] = [data[0]];
+
+    for (let i = 1; i < data.length; ++i) {
+      if (data[i].index < data[i - 1].index) {
+        seq.push(cur);
+        cur = [];
+      }
+      cur.push(data[i]);
+    }
+    if (cur) seq.push(cur);
+
+    let ret: Event[][] = [];
+    seq.forEach((j) => {
+      j.push({
+        event_id: "",
+        index: 0,
+        info: "",
+        name: "",
+        status: 0,
+        time: 0,
+      });
+
+      let fin: Event[] = [];
+      for (let i = 0; i < j.length - 1; ++i) {
+        if (j[i].event_id != j[i + 1].event_id) {
+          fin.push(j[i]);
+        }
+      }
+      ret.push(fin);
+    });
+
+    setEventData(ret.reverse());
+  };
+
+  useEffect(() => {
+    const id = window.setInterval(() => {
+      if (!shouldRequest) return;
+      setShouldRequest(false);
+      api
+        .getReleaseSteps(
+          "<token>",
+          {
+            cluster_id: currentCluster.id,
+            namespace: props.chart.namespace,
+          },
+          {
+            id: currentProject.id,
+            name: props.chart.name,
+          }
+        )
+        .then((data) => {
+          filterData(data.data);
+        })
+        .catch((err) => {})
+        .finally(() => {
+          setShouldRequest(true);
+        });
+    }, REFRESH_TIME);
+    return () => {
+      window.clearInterval(id);
+    };
+  }, []);
+
+  const renderEvent = (ev: Event) => {
+    return (
+      <tr>
+        <td>{ev.name}</td>
+        <td>{ev.time}</td>
+        <td>
+          {ev.status == 1
+            ? "Success"
+            : ev.status == 2
+            ? "In Progress"
+            : "Failed"}
+        </td>
+      </tr>
+    );
+  };
+
+  return eventData.length ? (
+    <React.Fragment>
+      {eventData.map((group, j) => (
+        <table key={j}>
+          <thead>
+            <td>Name</td>
+            <td>Time</td>
+            <td>Status</td>
+          </thead>
+          <tbody>
+            {group.map((ev) => (
+              <React.Fragment key={ev.index}>{renderEvent(ev)}</React.Fragment>
+            ))}
+          </tbody>
+        </table>
+      ))}
+    </React.Fragment>
+  ) : (
+    <React.Fragment />
+  );
+};
+
+export default DeployStatus;

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

@@ -691,6 +691,16 @@ const getReleaseToken = baseApi<
   return `/api/projects/${pathParams.id}/releases/${pathParams.name}/webhook_token`;
 });
 
+const getReleaseSteps = baseApi<
+  {
+    namespace: string;
+    cluster_id: number;
+  },
+  { name: string; id: number }
+>("GET", (pathParams) => {
+  return `/api/projects/${pathParams.id}/releases/${pathParams.name}/steps`;
+});
+
 const destroyEKS = baseApi<
   {
     eks_name: string;
@@ -1132,6 +1142,7 @@ export default {
   getPrometheusIsInstalled,
   getRegistryIntegrations,
   getReleaseToken,
+  getReleaseSteps,
   getRepoIntegrations,
   getSlackIntegrations,
   getRepos,

+ 51 - 0
internal/models/event.go

@@ -0,0 +1,51 @@
+package models
+
+import (
+	"gorm.io/gorm"
+)
+
+type EventStatus int64
+
+const (
+	EventStatusSuccess    EventStatus = 1
+	EventStatusInProgress             = 2
+	EventStatusFailed                 = 3
+)
+
+type EventContainer struct {
+	gorm.Model
+	ReleaseID uint
+	Steps     []SubEvent
+}
+
+type SubEvent struct {
+	gorm.Model
+
+	EventContainerID uint
+
+	EventID string // events with the same id wil be treated the same, and the highest index one is retained
+	Name    string
+	Index   int64 // priority of the event, used for sorting
+	Status  EventStatus
+	Info    string
+}
+
+type SubEventExternal struct {
+	EventID string      `json:"event_id"`
+	Name    string      `json:"name"`
+	Index   int64       `json:"index"`
+	Status  EventStatus `json:"status"`
+	Info    string      `json:"info"`
+	Time    int64       `json:"time""`
+}
+
+func (event *SubEvent) Externalize() SubEventExternal {
+	return SubEventExternal{
+		EventID: event.EventID,
+		Name:    event.Name,
+		Index:   event.Index,
+		Status:  event.Status,
+		Info:    event.Info,
+		Time:    event.UpdatedAt.Unix(),
+	}
+}

+ 1 - 0
internal/models/release.go

@@ -21,6 +21,7 @@ type Release struct {
 	ImageRepoURI string `json:"image_repo_uri,omitempty"`
 
 	GitActionConfig    GitActionConfig `json:"git_action_config"`
+	EventContainer     uint
 	NotificationConfig uint
 }
 

+ 12 - 0
internal/repository/event.go

@@ -0,0 +1,12 @@
+package repository
+
+import "github.com/porter-dev/porter/internal/models"
+
+type EventRepository interface {
+	CreateEventContainer(am *models.EventContainer) (*models.EventContainer, error)
+	CreateSubEvent(am *models.SubEvent) (*models.SubEvent, error)
+	ReadEventsByContainerID(id uint) ([]*models.SubEvent, error)
+	ReadEventContainer(id uint) (*models.EventContainer, error)
+	ReadSubEvent(id uint) (*models.SubEvent, error)
+	AppendEvent(container *models.EventContainer, event *models.SubEvent) error
+}

+ 63 - 0
internal/repository/gorm/event.go

@@ -0,0 +1,63 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// EventRepository holds both EventContainer and SubEvent models
+type EventRepository struct {
+	db *gorm.DB
+}
+
+// NewEventRepository returns a EventRepository which uses
+// gorm.DB for querying the database
+func NewEventRepository(db *gorm.DB) repository.EventRepository {
+	return &EventRepository{db}
+}
+
+func (repo EventRepository) CreateEventContainer(am *models.EventContainer) (*models.EventContainer, error) {
+	if err := repo.db.Create(am).Error; err != nil {
+		return nil, err
+	}
+	return am, nil
+}
+
+func (repo EventRepository) CreateSubEvent(am *models.SubEvent) (*models.SubEvent, error) {
+	if err := repo.db.Create(am).Error; err != nil {
+		return nil, err
+	}
+	return am, nil
+}
+
+func (repo EventRepository) ReadEventsByContainerID(id uint) ([]*models.SubEvent, error) {
+	var events []*models.SubEvent
+	if err := repo.db.Where("event_container_id = ?", id).Find(&events).Error; err != nil {
+		return nil, err
+	}
+	return events, nil
+}
+
+func (repo EventRepository) ReadEventContainer(id uint) (*models.EventContainer, error) {
+	container := &models.EventContainer{}
+	if err := repo.db.Where("id = ?", id).First(&container).Error; err != nil {
+		return nil, err
+	}
+	return container, nil
+}
+
+func (repo EventRepository) ReadSubEvent(id uint) (*models.SubEvent, error) {
+	event := &models.SubEvent{}
+	if err := repo.db.Where("id = ?", id).First(&event).Error; err != nil {
+		return nil, err
+	}
+	return event, nil
+}
+
+// AppendEvent will check if subevent with same (id, index) already exists
+// if yes, overrite it, otherwise make a new subevent
+func (repo EventRepository) AppendEvent(container *models.EventContainer, event *models.SubEvent) error {
+	event.EventContainerID = container.ID
+	return repo.db.Create(event).Error
+}

+ 2 - 0
internal/repository/gorm/migrate.go

@@ -27,6 +27,8 @@ func AutoMigrate(db *gorm.DB) error {
 		&models.DNSRecord{},
 		&models.PWResetToken{},
 		&models.NotificationConfig{},
+		&models.EventContainer{},
+		&models.SubEvent{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},

+ 1 - 1
internal/repository/gorm/notification.go

@@ -23,7 +23,7 @@ func (repo NotificationConfigRepository) CreateNotificationConfig(am *models.Not
 	return am, nil
 }
 
-// ReadNotificationConfig reads a NotificationConfig by Id
+// ReadNotificationConfig reads a NotificationConfig by ID
 func (repo NotificationConfigRepository) ReadNotificationConfig(id uint) (*models.NotificationConfig, error) {
 	ret := &models.NotificationConfig{}
 

+ 1 - 0
internal/repository/gorm/repository.go

@@ -33,5 +33,6 @@ func NewRepository(db *gorm.DB, key *[32]byte) *repository.Repository {
 		GithubAppOAuthIntegration: NewGithubAppOAuthIntegrationRepository(db),
 		SlackIntegration:          NewSlackIntegrationRepository(db, key),
 		NotificationConfig:        NewNotificationConfigRepository(db),
+		Event:                     NewEventRepository(db),
 	}
 }

+ 1 - 0
internal/repository/repository.go

@@ -26,4 +26,5 @@ type Repository struct {
 	GithubAppOAuthIntegration GithubAppOAuthIntegrationRepository
 	SlackIntegration          SlackIntegrationRepository
 	NotificationConfig        NotificationConfigRepository
+	Event                     EventRepository
 }

+ 1 - 0
server/api/capability_handler.go

@@ -10,5 +10,6 @@ func (app *App) HandleGetCapabilities(w http.ResponseWriter, r *http.Request) {
 	if err := json.NewEncoder(w).Encode(app.Capabilities); err != nil {
 		app.handleErrorFormDecoding(err, ErrK8sDecode, w)
 		return
+
 	}
 }

+ 118 - 0
server/api/release_handler.go

@@ -1628,6 +1628,124 @@ func (app *App) HandleRollbackRelease(w http.ResponseWriter, r *http.Request) {
 	w.WriteHeader(http.StatusOK)
 }
 
+// HandleGetReleaseSteps returns a list of all steps for a given release
+// note that steps are not guaranteed to be in any specific order, so they should be ordered if needed
+func (app *App) HandleGetReleaseSteps(w http.ResponseWriter, r *http.Request) {
+	name := chi.URLParam(r, "name")
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+	}
+
+	namespace := vals["namespace"][0]
+	clusterId, err := strconv.ParseUint(vals["cluster_id"][0], 0, 64)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	rel, err := app.Repo.Release.ReadRelease(uint(clusterId), name, namespace)
+
+	if err != nil {
+		app.sendExternalError(err, http.StatusNotFound, HTTPError{
+			Code:   ErrReleaseReadData,
+			Errors: []string{"release not found"},
+		}, w)
+
+		return
+	}
+
+	res := make([]models.SubEventExternal, 0)
+
+	if rel.EventContainer != 0 {
+		subevents, err := app.Repo.Event.ReadEventsByContainerID(rel.EventContainer)
+
+		if err != nil {
+			app.handleErrorInternal(err, w)
+		}
+
+		for _, sub := range subevents {
+			res = append(res, sub.Externalize())
+		}
+	}
+
+	json.NewEncoder(w).Encode(res)
+}
+
+type HandleUpdateReleaseStepsForm struct {
+	Event struct {
+		ID     string             `json:"event_id" form:"required"`
+		Name   string             `json:"name" form:"required"`
+		Index  int64              `json:"index" form:"required"`
+		Status models.EventStatus `json:"status" form:"required"`
+		Info   string             `json:"info" form:"required"`
+	} `json:"event" form:"required"`
+	Name      string `json:"name"`
+	Namespace string `json:"namespace"`
+	ClusterID uint   `json:"cluster_id"`
+}
+
+// HandleUpdateReleaseSteps adds a new step to a release
+func (app *App) HandleUpdateReleaseSteps(w http.ResponseWriter, r *http.Request) {
+	form := &HandleUpdateReleaseStepsForm{}
+
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	rel, err := app.Repo.Release.ReadRelease(form.ClusterID, form.Name, form.Namespace)
+
+	if err != nil {
+		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
+			Code:   ErrReleaseReadData,
+			Errors: []string{"Release not found"},
+		}, w)
+
+		return
+	}
+
+	if rel.EventContainer == 0 {
+		// create new event container
+		container, err := app.Repo.Event.CreateEventContainer(&models.EventContainer{ReleaseID: rel.ID})
+		if err != nil {
+			app.handleErrorDataWrite(err, w)
+			return
+		}
+
+		rel.EventContainer = container.ID
+
+		rel, err = app.Repo.Release.UpdateRelease(rel)
+
+		if err != nil {
+			app.handleErrorInternal(err, w)
+			return
+		}
+
+	}
+
+	container, err := app.Repo.Event.ReadEventContainer(rel.EventContainer)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	if err := app.Repo.Event.AppendEvent(container, &models.SubEvent{
+		EventContainerID: container.ID,
+		EventID:          form.Event.ID,
+		Name:             form.Event.Name,
+		Index:            form.Event.Index,
+		Status:           form.Event.Status,
+		Info:             form.Event.Info,
+	}); err != nil {
+		app.handleErrorInternal(err, w)
+	}
+
+}
+
 // ------------------------ Release handler helper functions ------------------------ //
 
 // getAgentFromQueryParams uses the query params to populate a form, and then

+ 28 - 0
server/router/router.go

@@ -1243,6 +1243,34 @@ func New(a *api.App) *chi.Mux {
 				),
 			)
 
+			r.Method(
+				"GET",
+				"/projects/{project_id}/releases/{name}/steps",
+				auth.DoesUserHaveProjectAccess(
+					auth.DoesUserHaveClusterAccess(
+						requestlog.NewHandler(a.HandleGetReleaseSteps, l),
+						mw.URLParam,
+						mw.QueryParam,
+					),
+					mw.URLParam,
+					mw.ReadAccess,
+				),
+			)
+
+			r.Method(
+				"POST",
+				"/projects/{project_id}/releases/{name}/steps",
+				auth.DoesUserHaveProjectAccess(
+					auth.DoesUserHaveClusterAccess(
+						requestlog.NewHandler(a.HandleUpdateReleaseSteps, l),
+						mw.URLParam,
+						mw.BodyParam,
+					),
+					mw.URLParam,
+					mw.ReadAccess,
+				),
+			)
+
 			// /api/projects/{project_id}/gitrepos routes
 			r.Method(
 				"GET",