Explorar el Código

placeholder deployment status component

Justin Rhee hace 3 años
padre
commit
fefd431c85

+ 27 - 37
dashboard/src/components/StatusIndicator.tsx

@@ -1,8 +1,8 @@
-import React, { useState, useEffect } from "react";
+import React, { Component } from "react";
 import styled from "styled-components";
 import styled from "styled-components";
 import loading from "assets/loading.gif";
 import loading from "assets/loading.gif";
 
 
-type Props = {
+type PropsType = {
   status: string;
   status: string;
   controllers: Record<string, Record<string, any>>;
   controllers: Record<string, Record<string, any>>;
   margin_left: string;
   margin_left: string;
@@ -11,32 +11,31 @@ type Props = {
 type StateType = {};
 type StateType = {};
 
 
 // Manages a tab selector and renders the associated view
 // Manages a tab selector and renders the associated view
-const StatusIndicator: React.FC<Props> = (props) => {
-
-  useEffect(() => {
-    console.log(props.controllers)
-  }, []);
-
-
-  const renderStatus = (status: string) => {
+export default class StatusIndicator extends Component<PropsType, StateType> {
+  renderStatus = (status: string) => {
     if (status == "loading") {
     if (status == "loading") {
-      return <Spinner src={loading} />;
+      return (
+        <div>
+          <Spinner src={loading} />
+        </div>
+      );
     }
     }
 
 
     return (
     return (
-      <StatusCircle >
-      </StatusCircle>
+      <div>
+        <StatusColor status={status} />
+      </div>
     );
     );
   };
   };
 
 
-  const getChartStatus = (chartStatus: string) => {
+  getChartStatus = (chartStatus: string) => {
     if (chartStatus === "deployed") {
     if (chartStatus === "deployed") {
-      for (var uid in props.controllers) {
-        let value = props.controllers[uid];
-        let available = getAvailability(value.metadata.kind, value);
+      for (var uid in this.props.controllers) {
+        let value = this.props.controllers[uid];
+        let available = this.getAvailability(value.metadata.kind, value);
         let progressing = true;
         let progressing = true;
 
 
-        props.controllers[uid]?.status?.conditions?.forEach(
+        this.props.controllers[uid]?.status?.conditions?.forEach(
           (condition: any) => {
           (condition: any) => {
             if (
             if (
               condition.type == "Progressing" &&
               condition.type == "Progressing" &&
@@ -59,7 +58,7 @@ const StatusIndicator: React.FC<Props> = (props) => {
     return chartStatus;
     return chartStatus;
   };
   };
 
 
-  const getAvailability = (kind: string, c: any) => {
+  getAvailability = (kind: string, c: any) => {
     switch (kind?.toLowerCase()) {
     switch (kind?.toLowerCase()) {
       case "deployment":
       case "deployment":
       case "replicaset":
       case "replicaset":
@@ -73,26 +72,17 @@ const StatusIndicator: React.FC<Props> = (props) => {
     }
     }
   };
   };
 
 
-  return (
-    <Status margin_left={props.margin_left}>
-      {renderStatus(getChartStatus(props.status))}
-      {getChartStatus(props.status)}
-    </Status>
-  );
+  render() {
+    let status = this.getChartStatus(this.props.status);
+    return (
+      <Status margin_left={this.props.margin_left}>
+        {this.renderStatus(status)}
+        {status}
+      </Status>
+    );
+  }
 }
 }
 
 
-export default StatusIndicator;
-
-const StatusCircle = styled.div`
-  width: 20px;
-  height: 20px;
-  border-radius: 50%;
-  margin-right: 10px;
-  background: 
-    conic-gradient(from 0deg, 
-      #ffffff33 5%, #ffffffaa 0% 5%);
-`;
-
 const Spinner = styled.img`
 const Spinner = styled.img`
   width: 15px;
   width: 15px;
   height: 15px;
   height: 15px;

+ 1 - 1
dashboard/src/components/porter-form/PorterForm.tsx

@@ -254,7 +254,7 @@ const StyledPorterForm = styled.div<{ showSave?: boolean }>`
   height: ${(props) => (props.showSave ? "calc(100% - 50px)" : "100%")};
   height: ${(props) => (props.showSave ? "calc(100% - 50px)" : "100%")};
   background: #ffffff11;
   background: #ffffff11;
   color: #ffffff;
   color: #ffffff;
-  padding: 0px 35px 0;
+  padding: 0px 35px 20px;
   position: relative;
   position: relative;
   border-radius: 8px;
   border-radius: 8px;
   font-size: 13px;
   font-size: 13px;

+ 11 - 6
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -27,6 +27,7 @@ import EventsTab from "./events/EventsTab";
 import BuildSettingsTab from "./build-settings/BuildSettingsTab";
 import BuildSettingsTab from "./build-settings/BuildSettingsTab";
 import { DisabledNamespacesForIncidents } from "./incidents/DisabledNamespaces";
 import { DisabledNamespacesForIncidents } from "./incidents/DisabledNamespaces";
 import { useStackEnvGroups } from "./useStackEnvGroups";
 import { useStackEnvGroups } from "./useStackEnvGroups";
+import DeployStatusSection from "./deploy-status-section/DeployStatusSection";
 
 
 type Props = {
 type Props = {
   namespace: string;
   namespace: string;
@@ -601,9 +602,9 @@ const ExpandedChart: React.FC<Props> = (props) => {
   const renderUrl = () => {
   const renderUrl = () => {
     if (url) {
     if (url) {
       return (
       return (
-        <Url href={url} target="_blank">
+        <Url>
           <i className="material-icons">link</i>
           <i className="material-icons">link</i>
-          {url}
+          <a href={url} target="_blank">{url}</a>
         </Url>
         </Url>
       );
       );
     }
     }
@@ -802,11 +803,14 @@ const ExpandedChart: React.FC<Props> = (props) => {
                   currentChart.chart.metadata.name != "job" &&
                   currentChart.chart.metadata.name != "job" &&
                   renderUrl()}
                   renderUrl()}
                 <InfoWrapper>
                 <InfoWrapper>
+                  {/*
                   <StatusIndicator
                   <StatusIndicator
                     controllers={controllers}
                     controllers={controllers}
                     status={currentChart.info.status}
                     status={currentChart.info.status}
                     margin_left={"0px"}
                     margin_left={"0px"}
                   />
                   />
+                  */}
+                  <DeployStatusSection chart={currentChart} />
                   <LastDeployed>
                   <LastDeployed>
                     <Dot>•</Dot>Last deployed
                     <Dot>•</Dot>Last deployed
                     {" " + getReadableDate(currentChart.info.last_deployed)}
                     {" " + getReadableDate(currentChart.info.last_deployed)}
@@ -1001,15 +1005,16 @@ const Bolded = styled.div`
   margin-right: 6px;
   margin-right: 6px;
 `;
 `;
 
 
-const Url = styled.a`
+const Url = styled.div`
   display: block;
   display: block;
-  margin-left: 2px;
+  margin-left: 5px;
   font-size: 13px;
   font-size: 13px;
   margin-top: 16px;
   margin-top: 16px;
   user-select: all;
   user-select: all;
   margin-bottom: -5px;
   margin-bottom: -5px;
   user-select: text;
   user-select: text;
   display: flex;
   display: flex;
+  color: #949eff;
   align-items: center;
   align-items: center;
 
 
   > i {
   > i {
@@ -1052,7 +1057,7 @@ const HeaderWrapper = styled.div`
 `;
 `;
 
 
 const Dot = styled.div`
 const Dot = styled.div`
-  margin-right: 9px;
+  margin-right: 16px;
 `;
 `;
 
 
 const InfoWrapper = styled.div`
 const InfoWrapper = styled.div`
@@ -1064,7 +1069,7 @@ const InfoWrapper = styled.div`
 
 
 const LastDeployed = styled.div`
 const LastDeployed = styled.div`
   font-size: 13px;
   font-size: 13px;
-  margin-left: 10px;
+  margin-left: 8px;
   margin-top: -1px;
   margin-top: -1px;
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx

@@ -500,7 +500,7 @@ const StyledRevisionSection = styled.div`
   max-height: ${(props: { showRevisions: boolean }) =>
   max-height: ${(props: { showRevisions: boolean }) =>
     props.showRevisions ? "255px" : "40px"};
     props.showRevisions ? "255px" : "40px"};
   background: #ffffff11;
   background: #ffffff11;
-  margin: 25px 0px 18px;
+  margin: 20px 0px 18px;
   overflow: hidden;
   overflow: hidden;
   border-radius: 8px;
   border-radius: 8px;
   animation: ${(props: { showRevisions: boolean }) =>
   animation: ${(props: { showRevisions: boolean }) =>

+ 427 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/deploy-status-section/ControllerTab.tsx

@@ -0,0 +1,427 @@
+import React, { useContext, useEffect, useMemo, useState } from "react";
+import styled from "styled-components";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import ResourceTab from "./ResourceTab";
+import ConfirmOverlay from "components/ConfirmOverlay";
+import { NewWebsocketOptions, useWebsockets } from "shared/hooks/useWebsockets";
+import PodRow from "./PodRow";
+import { timeFormat } from "d3-time-format";
+
+type Props = {
+  controller: any;
+  selectedPod: any;
+  selectPod: (newPod: any) => unknown;
+  selectors: any;
+  isLast?: boolean;
+  isFirst?: boolean;
+  setPodError: (x: string) => void;
+};
+
+// Controller tab in log section that displays list of pods on click.
+export type ControllerTabPodType = {
+  namespace: string;
+  name: string;
+  phase: string;
+  status: any;
+  replicaSetName: string;
+  restartCount: number | string;
+  podAge: string;
+  revisionNumber?: number;
+  containerStatus: any;
+};
+
+const formatCreationTimestamp = timeFormat("%H:%M:%S %b %d, '%y");
+
+const ControllerTabFC: React.FunctionComponent<Props> = ({
+  controller,
+  selectPod,
+  isFirst,
+  isLast,
+  selectors,
+  setPodError,
+  selectedPod,
+}) => {
+  const [pods, setPods] = useState<ControllerTabPodType[]>([]);
+  const [rawPodList, setRawPodList] = useState<any[]>([]);
+  const [podPendingDelete, setPodPendingDelete] = useState<any>(null);
+  const [available, setAvailable] = useState<number>(null);
+  const [total, setTotal] = useState<number>(null);
+  const [userSelectedPod, setUserSelectedPod] = useState<boolean>(false);
+
+  const { currentCluster, currentProject, setCurrentError } = useContext(
+    Context
+  );
+  const {
+    newWebsocket,
+    openWebsocket,
+    closeAllWebsockets,
+    closeWebsocket,
+  } = useWebsockets();
+
+  const currentSelectors = useMemo(() => {
+    if (controller.kind.toLowerCase() == "job" && selectors) {
+      return [...selectors];
+    }
+    let newSelectors = [] as string[];
+    let ml =
+      controller?.spec?.selector?.matchLabels || controller?.spec?.selector;
+    let i = 1;
+    let selector = "";
+    for (var key in ml) {
+      selector += key + "=" + ml[key];
+      if (i != Object.keys(ml).length) {
+        selector += ",";
+      }
+      i += 1;
+    }
+    newSelectors.push(selector);
+    return [...newSelectors];
+  }, [controller, selectors]);
+
+  useEffect(() => {
+    updatePods();
+    [controller?.kind, "pod"].forEach((kind) => {
+      setupWebsocket(kind, controller?.metadata?.uid);
+    });
+    () => closeAllWebsockets();
+  }, [currentSelectors, controller, currentCluster, currentProject]);
+
+  const updatePods = async () => {
+    try {
+      const res = await api.getMatchingPods(
+        "<token>",
+        {
+          namespace: controller?.metadata?.namespace,
+          selectors: currentSelectors,
+        },
+        {
+          id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      );
+      const data = res?.data as any[];
+      let newPods = data
+        // Parse only data that we need
+        .map<ControllerTabPodType>((pod: any) => {
+          const replicaSetName =
+            Array.isArray(pod?.metadata?.ownerReferences) &&
+            pod?.metadata?.ownerReferences[0]?.name;
+          const containerStatus =
+            Array.isArray(pod?.status?.containerStatuses) &&
+            pod?.status?.containerStatuses[0];
+
+          const restartCount = containerStatus
+            ? containerStatus.restartCount
+            : "N/A";
+
+          const podAge = formatCreationTimestamp(
+            new Date(pod?.metadata?.creationTimestamp)
+          );
+
+          return {
+            namespace: pod?.metadata?.namespace,
+            name: pod?.metadata?.name,
+            phase: pod?.status?.phase,
+            status: pod?.status,
+            replicaSetName,
+            restartCount,
+            containerStatus,
+            podAge: pod?.metadata?.creationTimestamp ? podAge : "N/A",
+            revisionNumber:
+              (pod?.metadata?.annotations &&
+                pod?.metadata?.annotations["helm.sh/revision"]) ||
+              "N/A",
+          };
+        });
+
+      setPods(newPods);
+      setRawPodList(data);
+      // If the user didn't click a pod, select the first returned from list.
+      if (!userSelectedPod) {
+        let status = getPodStatus(newPods[0].status);
+        status === "failed" &&
+          newPods[0].status?.message &&
+          setPodError(newPods[0].status?.message);
+        handleSelectPod(newPods[0], data);
+      }
+    } catch (error) {}
+  };
+
+  /**
+   * handleSelectPod is a wrapper for the selectPod function received from parent.
+   * Internally we use the ControllerPodType but we want to pass to the parent the
+   * raw pod returned from the API.
+   *
+   * @param pod A ControllerPodType pod that will be used to search the raw pod to pass
+   * @param rawList A rawList of pods in case we don't want to use the state one. Useful to
+   * avoid problems with reactivity
+   */
+  const handleSelectPod = (pod: ControllerTabPodType, rawList?: any[]) => {
+    const rawPod = [...rawPodList, ...(rawList || [])].find(
+      (rawPod) => rawPod?.metadata?.name === pod?.name
+    );
+    selectPod(rawPod);
+  };
+
+  const currentSelectedPod = useMemo(() => {
+    const pod = selectedPod;
+    const replicaSetName =
+      Array.isArray(pod?.metadata?.ownerReferences) &&
+      pod?.metadata?.ownerReferences[0]?.name;
+    return {
+      namespace: pod?.metadata?.namespace,
+      name: pod?.metadata?.name,
+      phase: pod?.status?.phase,
+      status: pod?.status,
+      replicaSetName,
+    } as ControllerTabPodType;
+  }, [selectedPod]);
+
+  const currentControllerStatus = useMemo(() => {
+    let status = available == total ? "running" : "waiting";
+
+    controller?.status?.conditions?.forEach((condition: any) => {
+      if (
+        condition.type == "Progressing" &&
+        condition.status == "False" &&
+        condition.reason == "ProgressDeadlineExceeded"
+      ) {
+        status = "failed";
+      }
+    });
+
+    if (controller.kind.toLowerCase() === "job" && pods.length == 0) {
+      status = "completed";
+    }
+    return status;
+  }, [controller, available, total, pods]);
+
+  const getPodStatus = (status: any) => {
+    if (
+      status?.phase === "Pending" &&
+      status?.containerStatuses !== undefined
+    ) {
+      return status.containerStatuses[0].state?.waiting?.reason || "Pending";
+    } else if (status?.phase === "Pending") {
+      return "Pending";
+    }
+
+    if (status?.phase === "Failed") {
+      return "failed";
+    }
+
+    if (status?.phase === "Running") {
+      let collatedStatus = "running";
+
+      status?.containerStatuses?.forEach((s: any) => {
+        if (s.state?.waiting) {
+          collatedStatus =
+            s.state?.waiting?.reason === "CrashLoopBackOff"
+              ? "failed"
+              : "waiting";
+        } else if (s.state?.terminated) {
+          collatedStatus = "failed";
+        }
+      });
+      return collatedStatus;
+    }
+  };
+
+  const handleDeletePod = (pod: any) => {
+    api
+      .deletePod(
+        "<token>",
+        {},
+        {
+          cluster_id: currentCluster.id,
+          name: pod?.name,
+          namespace: pod?.namespace,
+          id: currentProject.id,
+        }
+      )
+      .then((res) => {
+        updatePods();
+        setPodPendingDelete(null);
+      })
+      .catch((err) => {
+        setCurrentError(JSON.stringify(err));
+        setPodPendingDelete(null);
+      });
+  };
+
+  const replicaSetArray = useMemo(() => {
+    const podsDividedByReplicaSet = pods.reduce<
+      Array<Array<ControllerTabPodType>>
+    >(function (prev, currentPod, i) {
+      if (
+        !i ||
+        prev[prev.length - 1][0].replicaSetName !== currentPod.replicaSetName
+      ) {
+        return prev.concat([[currentPod]]);
+      }
+      prev[prev.length - 1].push(currentPod);
+      return prev;
+    }, []);
+
+    if (podsDividedByReplicaSet.length === 1) {
+      return [];
+    } else {
+      return podsDividedByReplicaSet;
+    }
+  }, [pods]);
+
+  const getAvailability = (kind: string, c: any) => {
+    switch (kind?.toLowerCase()) {
+      case "deployment":
+      case "replicaset":
+        return [
+          c.status?.availableReplicas ||
+            c.status?.replicas - c.status?.unavailableReplicas ||
+            0,
+          c.status?.replicas || 0,
+        ];
+      case "statefulset":
+        return [c.status?.readyReplicas || 0, c.status?.replicas || 0];
+      case "daemonset":
+        return [
+          c.status?.numberAvailable || 0,
+          c.status?.desiredNumberScheduled || 0,
+        ];
+      case "job":
+        return [1, 1];
+    }
+  };
+
+  const setupWebsocket = (kind: string, controllerUid: string) => {
+    let apiEndpoint = `/api/projects/${currentProject.id}/clusters/${currentCluster.id}/${kind}/status?`;
+    if (kind == "pod" && currentSelectors) {
+      apiEndpoint += `selectors=${currentSelectors[0]}`;
+    }
+
+    const options: NewWebsocketOptions = {};
+    options.onopen = () => {
+      console.log("connected to websocket");
+    };
+
+    options.onmessage = (evt: MessageEvent) => {
+      let event = JSON.parse(evt.data);
+      let object = event.Object;
+      object.metadata.kind = event.Kind;
+
+      // Make a new API call to update pods only when the event type is UPDATE
+      if (event.event_type !== "UPDATE") {
+        return;
+      }
+      // update pods no matter what if ws message is a pod event.
+      // If controller event, check if ws message corresponds to the designated controller in props.
+      if (event.Kind != "pod" && object.metadata.uid !== controllerUid) {
+        return;
+      }
+
+      if (event.Kind != "pod") {
+        let [available, total] = getAvailability(object.metadata.kind, object);
+        setAvailable(available);
+        setTotal(total);
+        return;
+      }
+      updatePods();
+    };
+
+    options.onclose = () => {
+      console.log("closing websocket");
+    };
+
+    options.onerror = (err: ErrorEvent) => {
+      console.log(err);
+      closeWebsocket(kind);
+    };
+
+    newWebsocket(kind, apiEndpoint, options);
+    openWebsocket(kind);
+  };
+
+  const mapPods = (podList: ControllerTabPodType[]) => {
+    return podList.map((pod, i, arr) => {
+      let status = getPodStatus(pod.status);
+      return (
+        <PodRow
+          key={i}
+          pod={pod}
+          isSelected={currentSelectedPod?.name === pod?.name}
+          podStatus={status}
+          isLastItem={i === arr.length - 1}
+          onTabClick={() => {
+            setPodError("");
+            status === "failed" &&
+              pod.status?.message &&
+              setPodError(pod.status?.message);
+            handleSelectPod(pod);
+            setUserSelectedPod(true);
+          }}
+          onDeleteClick={() => setPodPendingDelete(pod)}
+        />
+      );
+    });
+  };
+
+  return (
+    <ResourceTab
+      label={controller.kind}
+      // handle CronJob case
+      name={controller.metadata?.name || controller.name}
+      status={{ label: currentControllerStatus, available, total }}
+      isLast={isLast}
+      expanded={isFirst}
+    >
+      {!!replicaSetArray.length &&
+        replicaSetArray.map((subArray, index) => {
+          const firstItem = subArray[0];
+          return (
+            <div key={firstItem.replicaSetName + index}>
+              <ReplicaSetContainer>
+                <ReplicaSetName>
+                  {firstItem?.revisionNumber &&
+                    firstItem?.revisionNumber.toString() != "N/A" && (
+                      <Bold>Revision {firstItem.revisionNumber}:</Bold>
+                    )}{" "}
+                  {firstItem.replicaSetName}
+                </ReplicaSetName>
+              </ReplicaSetContainer>
+              {mapPods(subArray)}
+            </div>
+          );
+        })}
+      {!replicaSetArray.length && mapPods(pods)}
+      <ConfirmOverlay
+        message="Are you sure you want to delete this pod?"
+        show={podPendingDelete}
+        onYes={() => handleDeletePod(podPendingDelete)}
+        onNo={() => setPodPendingDelete(null)}
+      />
+    </ResourceTab>
+  );
+};
+
+export default ControllerTabFC;
+
+const Bold = styled.span`
+  font-weight: 500;
+  display: inline;
+  color: #ffffff;
+`;
+
+const ReplicaSetContainer = styled.div`
+  padding: 10px 5px;
+  display: flex;
+  overflow-wrap: anywhere;
+  justify-content: space-between;
+`;
+
+const ReplicaSetName = styled.span`
+  padding-left: 10px;
+  overflow-wrap: anywhere;
+  max-width: calc(100% - 45px);
+  line-height: 1.5em;
+  color: #ffffff33;
+`;

+ 138 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/deploy-status-section/DeployStatusSection.tsx

@@ -0,0 +1,138 @@
+import React, { useState, useRef, useEffect } from "react";
+import PodDropdown from "./PodDropdown";
+
+import styled from "styled-components";
+
+type Props = {
+  chart?: any;
+};
+
+const DeployStatusSection: React.FC<Props> = (props) => {
+  const [someState, setSomeState] = useState("");
+  const [isExpanded, setIsExpanded] = useState(false);
+  const [percentage, setPercentage] = useState<string>("10%");
+
+  const wrapperRef = useRef<HTMLInputElement>(null);
+  const parentRef = useRef<HTMLInputElement>(null);
+
+  useEffect(() => {
+    document.addEventListener("mousedown", handleClickOutside.bind(this));
+    return () =>
+      document.removeEventListener("mousedown", handleClickOutside.bind(this));
+  }, []);
+
+  const handleClickOutside = (event: any) => {
+    if (
+      wrapperRef &&
+      wrapperRef.current &&
+      !wrapperRef.current.contains(event.target) &&
+      parentRef &&
+      parentRef.current &&
+      !parentRef.current.contains(event.target)
+    ) {
+      setIsExpanded(false);
+    }
+  };
+  
+  const renderDropdown = () => {
+    if (isExpanded) {
+      return (
+        <DropdownWrapper>
+          <Dropdown ref={wrapperRef}>
+            <PodDropdown currentChart={props.chart} />
+          </Dropdown>
+        </DropdownWrapper>
+      );
+    }
+  }
+
+  return (
+    <>
+      <StyledDeployStatusSection 
+        onClick={() => setIsExpanded(!isExpanded)}
+        ref={parentRef}
+        isExpanded={isExpanded}
+      >
+        <StatusCircle percentage={percentage} />
+        Deploying
+      </StyledDeployStatusSection>
+      {renderDropdown()}
+    </>
+  );
+};
+
+export default DeployStatusSection;
+
+const StatusCircle = styled.div<{ percentage?: any }>`
+  width: 16px;
+  height: 16px;
+  border-radius: 50%;
+  margin-right: 10px;
+  background: 
+    conic-gradient(from 0deg, 
+      #ffffff33 ${props => props.percentage}, #ffffffaa 0% ${props => props.percentage});
+`;
+
+const DropdownWrapper = styled.div<{ dropdownAlignRight?: boolean }>`
+  position: absolute;
+  left: ${(props) => (props.dropdownAlignRight ? "" : "0")};
+  right: ${(props) => (props.dropdownAlignRight ? "0" : "")};
+  z-index: 1;
+  top: calc(100% + 7px);
+  width: 35%;
+  min-width: 400px;
+  animation: floatIn 0.2s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(10px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;
+
+const Dropdown = styled.div`
+  z-index: 999;
+  overflow-y: auto;
+  background: #2f3135;
+  padding: 0;
+  border-radius: 5px;
+  border: 1px solid #aaaabb33;
+`;
+
+const DropdownIcon = styled.img`
+  width: 8px;
+  margin-left: 12px;
+`;
+
+const StyledDeployStatusSection = styled.div<{ isExpanded?: boolean }>`
+  font-size: 13px;
+  height: 30px;
+  border-radius: 5px;
+  padding: 0 9px;
+  padding-left: 7px;
+  display: flex;
+  margin-left: -1px;
+  align-items: center;
+  ${props => props.isExpanded && `
+  background: #26292e;
+  border: 1px solid #494b4f;
+  border: 1px solid #7a7b80;
+  margin-left: -2px;
+  margin-right: -1px;
+  `}
+  justify-content: center;
+  cursor: pointer;
+  :hover {
+    background: #26292e;
+    border: 1px solid #494b4f;
+    border: 1px solid #7a7b80;
+    margin-left: -2px;
+    margin-right: -1px;
+  }
+`;

+ 144 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/deploy-status-section/PodDropdown.tsx

@@ -0,0 +1,144 @@
+import React, { useContext, useEffect, useState } from "react";
+import styled from "styled-components";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { ChartType } from "shared/types";
+import Loading from "components/Loading";
+
+import ControllerTab from "./ControllerTab";
+
+type Props = {
+  selectors?: string[];
+  currentChart: ChartType;
+};
+
+const PodDropdown: React.FunctionComponent<Props> = ({
+  currentChart,
+  selectors,
+}) => {
+  const [selectedPod, setSelectedPod] = useState<any>({});
+  const [controllers, setControllers] = useState<any[]>([]);
+  const [isLoading, setIsLoading] = useState<boolean>(true);
+  const [podError, setPodError] = useState<string>("");
+
+  const { currentProject, currentCluster, setCurrentError } = useContext(
+    Context
+  );
+
+  useEffect(() => {
+    let isSubscribed = true;
+    api
+      .getChartControllers(
+        "<token>",
+        {},
+        {
+          namespace: currentChart.namespace,
+          cluster_id: currentCluster.id,
+          id: currentProject.id,
+          name: currentChart.name,
+          revision: currentChart.version,
+        }
+      )
+      .then((res: any) => {
+        if (!isSubscribed) {
+          return;
+        }
+        let controllers =
+          currentChart.chart.metadata.name == "job"
+            ? res.data[0]?.status.active
+            : res.data;
+        setControllers(controllers);
+        setIsLoading(false);
+      })
+      .catch((err) => {
+        if (!isSubscribed) {
+          return;
+        }
+        setCurrentError(JSON.stringify(err));
+        setControllers([]);
+        setIsLoading(false);
+      });
+    return () => {
+      isSubscribed = false;
+    };
+  }, [currentProject, currentCluster, setCurrentError, currentChart]);
+
+  const renderTabs = () => {
+    return controllers.map((c, i) => {
+      return (
+        <ControllerTab
+          // handle CronJob case
+          key={c.metadata?.uid || c.uid}
+          selectedPod={selectedPod}
+          selectPod={setSelectedPod}
+          selectors={selectors ? [selectors[i]] : null}
+          controller={c}
+          isLast={i === controllers?.length - 1}
+          isFirst={i === 0}
+          setPodError={(x: string) => setPodError(x)}
+        />
+      );
+    });
+  };
+
+  const renderStatusSection = () => {
+    if (isLoading) {
+      return (
+        <NoControllers>
+          <Loading />
+        </NoControllers>
+      );
+    }
+    if (controllers?.length > 0) {
+      return (
+        <TabWrapper>{renderTabs()}</TabWrapper>
+      );
+    }
+
+    return (
+      <NoControllers>
+        <i className="material-icons">category</i>
+        No objects to display. This might happen while your app is still
+        deploying.
+      </NoControllers>
+    );
+  };
+
+  return (
+    <StyledStatusSection>
+      {renderStatusSection()}
+    </StyledStatusSection>
+  );
+};
+
+export default PodDropdown;
+
+const TabWrapper = styled.div`
+  width: 100%; 
+  min-height: 50px;
+`;
+
+const StyledStatusSection = styled.div`
+  padding: 0px;
+  user-select: text;
+  overflow: hidden;
+  width: 100%;
+  font-size: 13px;
+`;
+
+const NoControllers = styled.div`
+  position: relative;
+  width: 100%;
+  display: flex;
+  min-height: 50px;
+  justify-content: center;
+  align-items: center;
+  color: #ffffff44;
+  font-size: 14px;
+
+  > i {
+    font-size: 18px;
+    margin-right: 12px;
+  }
+`;

+ 222 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/deploy-status-section/PodRow.tsx

@@ -0,0 +1,222 @@
+import React, { useState } from "react";
+import styled from "styled-components";
+import { ControllerTabPodType } from "./ControllerTab";
+
+type PodRowProps = {
+  pod: ControllerTabPodType;
+  isSelected: boolean;
+  isLastItem: boolean;
+  onTabClick: any;
+  onDeleteClick: any;
+  podStatus: string;
+};
+
+const PodRow: React.FunctionComponent<PodRowProps> = ({
+  pod,
+  isSelected,
+  onTabClick,
+  onDeleteClick,
+  isLastItem,
+  podStatus,
+}) => {
+  const [showTooltip, setShowTooltip] = useState(false);
+
+  return (
+    <Tab key={pod?.name}>
+      <Gutter>
+        <Rail />
+        <Circle />
+        <Rail lastTab={isLastItem} />
+      </Gutter>
+      <Name
+        onMouseOver={() => {
+          // setShowTooltip(true);
+        }}
+        onMouseOut={() => {
+          setShowTooltip(false);
+        }}
+      >
+        {pod?.name}
+      </Name>
+      {showTooltip && (
+        <Tooltip>
+          {pod?.name}
+          <Grey>Restart count: {pod.restartCount}</Grey>
+          <Grey>Created on: {pod.podAge}</Grey>
+          {podStatus === "failed" ? (
+            <FailedStatusContainer>
+              <Grey>
+                Failure Reason: {pod?.containerStatus?.state?.waiting?.reason}
+              </Grey>
+              <Grey>{pod?.containerStatus?.state?.waiting?.message}</Grey>
+            </FailedStatusContainer>
+          ) : null}
+        </Tooltip>
+      )}
+
+      <Status>
+        {podStatus}
+        <StatusColor status={podStatus} />
+        {podStatus === "failed" && (
+          <CloseIcon
+            className="material-icons-outlined"
+            onClick={onDeleteClick}
+          >
+            close
+          </CloseIcon>
+        )}
+      </Status>
+    </Tab>
+  );
+};
+
+export default PodRow;
+
+const Grey = styled.div`
+  margin-top: 5px;
+  color: #aaaabb;
+`;
+
+const FailedStatusContainer = styled.div`
+  width: 100%;
+  border: 1px solid hsl(0deg, 100%, 30%);
+  padding: 5px;
+  margin-block: 5px;
+`;
+
+const Tooltip = styled.div`
+  position: absolute;
+  left: 35px;
+  word-wrap: break-word;
+  top: 38px;
+  min-height: 18px;
+  max-width: calc(100% - 75px);
+  padding: 5px 7px;
+  background: #272731;
+  z-index: 999;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  flex: 1;
+  color: white;
+  text-transform: none;
+  font-size: 12px;
+  font-family: "Work Sans", sans-serif;
+  outline: 1px solid #ffffff55;
+  opacity: 0;
+  animation: faded-in 0.2s 0.15s;
+  animation-fill-mode: forwards;
+  @keyframes faded-in {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const CloseIcon = styled.i`
+  font-size: 14px;
+  display: flex;
+  font-weight: bold;
+  align-items: center;
+  justify-content: center;
+  border-radius: 5px;
+  background: #ffffff22;
+  width: 18px;
+  height: 18px;
+  margin-right: -6px;
+  margin-left: 10px;
+  cursor: pointer;
+  :hover {
+    background: #ffffff44;
+  }
+`;
+
+const Tab = styled.div`
+  width: 100%;
+  height: 50px;
+  position: relative;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 13px;
+  padding: 20px 18px 20px 42px;
+  text-shadow: 0px 0px 8px none;
+  overflow: visible;
+`;
+
+const Rail = styled.div`
+  width: 2px;
+  background: ${(props: { lastTab?: boolean }) =>
+    props.lastTab ? "" : "#52545D"};
+  height: 50%;
+`;
+
+const Circle = styled.div`
+  min-width: 10px;
+  min-height: 2px;
+  margin-bottom: -2px;
+  margin-left: 8px;
+  background: #52545d;
+`;
+
+const Gutter = styled.div`
+  position: absolute;
+  top: 0px;
+  left: 10px;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  overflow: visible;
+`;
+
+const Status = styled.div`
+  display: flex;
+  font-size: 12px;
+  text-transform: capitalize;
+  margin-left: 10px;
+  justify-content: flex-end;
+  align-items: center;
+  font-family: "Work Sans", sans-serif;
+  color: #aaaabb;
+  animation: fadeIn 0.5s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const StatusColor = styled.div`
+  margin-left: 12px;
+  mnargin-right: -1px;
+  width: 8px;
+  min-width: 7px;
+  height: 8px;
+  background: ${(props: { status: string }) =>
+    props.status === "running"
+      ? "#4797ff"
+      : props.status === "failed"
+      ? "#ed5f85"
+      : props.status === "completed"
+      ? "#00d12a"
+      : "#f5cb42"};
+  border-radius: 20px;
+`;
+
+const Name = styled.div`
+  overflow: hidden;
+  text-overflow: ellipsis;
+  line-height: 1.5em;
+  display: -webkit-box;
+  overflow-wrap: anywhere;
+  -webkit-box-orient: vertical;
+  -webkit-line-clamp: 2;
+`;

+ 316 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/deploy-status-section/ResourceTab.tsx

@@ -0,0 +1,316 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+
+import { kindToIcon } from "shared/rosettaStone";
+
+type PropsType = {
+  label: string;
+  name: string;
+  handleClick?: () => void;
+  selected?: boolean;
+  isLast?: boolean;
+  roundAllCorners?: boolean;
+  status?: {
+    label: string;
+    available?: number;
+    total?: number;
+  } | null;
+  expanded?: boolean;
+};
+
+type StateType = {
+  expanded: boolean;
+  showTooltip: boolean;
+};
+
+export default class ResourceTab extends Component<PropsType, StateType> {
+  state = {
+    expanded: this.props.expanded || false,
+    showTooltip: false,
+  };
+
+  renderDropdownIcon = () => {
+    if (this.props.children) {
+      return (
+        <DropdownIcon expanded={this.state.expanded}>
+          <i className="material-icons">arrow_right</i>
+        </DropdownIcon>
+      );
+    }
+  };
+
+  renderIcon = (kind: string) => {
+    let icon = "tonality";
+    if (Object.keys(kindToIcon).includes(kind)) {
+      icon = kindToIcon[kind];
+    }
+
+    return (
+      <IconWrapper>
+        <i className="material-icons">{icon}</i>
+      </IconWrapper>
+    );
+  };
+
+  renderTooltip = (x: string): JSX.Element | undefined => {
+    if (this.state.showTooltip) {
+      return <Tooltip>{x}</Tooltip>;
+    }
+  };
+
+  getStatusText = () => {
+    let { status } = this.props;
+    if (status.available && status.total) {
+      return `${status.available}/${status.total}`;
+    } else if (status.label) {
+      return status.label;
+    }
+  };
+
+  renderStatus = () => {
+    let { status } = this.props;
+    if (status) {
+      return (
+        <Status>
+          {this.getStatusText()}
+
+          <StatusColor status={status.label} />
+        </Status>
+      );
+    }
+  };
+
+  renderExpanded = () => {
+    if (this.props.children && this.state.expanded) {
+      return <ExpandWrapper>{this.props.children}</ExpandWrapper>;
+    }
+  };
+
+  render() {
+    let {
+      label,
+      name,
+      children,
+      isLast,
+      handleClick,
+      selected,
+      status,
+      roundAllCorners,
+    } = this.props;
+    return (
+      <StyledResourceTab
+        isLast={isLast}
+        onClick={() => handleClick && handleClick()}
+        roundAllCorners={roundAllCorners}
+      >
+        <ResourceHeader
+          hasChildren={children && true}
+          expanded={this.state.expanded || selected}
+          onClick={() => {
+            if (children) {
+              this.setState({ expanded: !this.state.expanded });
+            }
+          }}
+        >
+          <Info>
+            {this.renderDropdownIcon()}
+            <Metadata hasStatus={status && true}>
+              {this.renderIcon(label)}
+              {label}
+              <ResourceName
+                showKindLabels={true}
+                onMouseOver={() => {
+                  this.setState({ showTooltip: true });
+                }}
+                onMouseOut={() => {
+                  this.setState({ showTooltip: false });
+                }}
+              >
+                {name}
+              </ResourceName>
+              {this.renderTooltip(name)}
+            </Metadata>
+          </Info>
+          {this.renderStatus()}
+        </ResourceHeader>
+        {this.renderExpanded()}
+      </StyledResourceTab>
+    );
+  }
+}
+
+const StyledResourceTab = styled.div`
+  width: 100%;
+  font-size: 13px;
+  border-bottom-left-radius: ${(props: {
+    isLast: boolean;
+    roundAllCorners: boolean;
+  }) => (props.isLast ? "10px" : "")};
+  animation: fadeIn 0.2s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const Tooltip = styled.div`
+  position: absolute;
+  right: 0px;
+  top: 25px;
+  white-space: nowrap;
+  height: 18px;
+  padding: 2px 5px;
+  background: #383842dd;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex: 1;
+  color: white;
+  text-transform: none;
+  font-size: 12px;
+  font-family: "Work Sans", sans-serif;
+  outline: 1px solid #ffffff55;
+  opacity: 0;
+  animation: faded-in 0.2s 0.15s;
+  animation-fill-mode: forwards;
+  @keyframes faded-in {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const ExpandWrapper = styled.div``;
+
+const ResourceHeader = styled.div`
+  width: 100%;
+  height: 50px;
+  display: flex;
+  font-size: 13px;
+  align-items: center;
+  justify-content: space-between;
+  color: #ffffff66;
+  user-select: none;
+  padding: 8px 18px;
+  padding-left: ${(props: { expanded: boolean; hasChildren: boolean }) =>
+    props.hasChildren ? "10px" : "22px"};
+  cursor: pointer;
+  :hover {
+    background: #ffffff18;
+
+    > i {
+      background: #ffffff22;
+    }
+  }
+`;
+
+const Info = styled.div`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  width: 80%;
+  height: 100%;
+`;
+
+const Metadata = styled.div`
+  display: flex;
+  align-items: center;
+  position: relative;
+  max-width: ${(props: { hasStatus: boolean }) =>
+    props.hasStatus ? "calc(100% - 20px)" : "100%"};
+`;
+
+const Status = styled.div`
+  display: flex;
+  width; 20%;
+  font-size: 12px;
+  text-transform: capitalize;
+  justify-content: flex-end;
+  align-items: center;
+  font-family: 'Work Sans', sans-serif;
+  color: #aaaabb;
+  animation: fadeIn 0.5s;
+  @keyframes fadeIn {
+    from { opacity: 0 }
+    to { opacity: 1 }
+  }
+`;
+
+const StatusColor = styled.div`
+  margin-left: 12px;
+  width: 8px;
+  min-width: 8px;
+  height: 8px;
+  background: ${(props: { status: string }) =>
+    props.status === "running" ||
+    props.status === "Ready" ||
+    props.status === "Completed"
+      ? "#4797ff"
+      : props.status === "failed" || props.status === "FailedValidation"
+      ? "#ed5f85"
+      : props.status === "completed"
+      ? "#00d12a"
+      : "#f5cb42"};
+  border-radius: 20px;
+`;
+
+const ResourceName = styled.div`
+  color: #ffffff;
+  margin-right: 15px;
+  margin-left: ${(props: { showKindLabels: boolean }) =>
+    props.showKindLabels ? "10px" : ""};
+  text-transform: none;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const IconWrapper = styled.div`
+  width: 25px;
+  height: 25px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  > i {
+    font-size: 15px;
+    color: #ffffff;
+    margin-right: 14px;
+  }
+`;
+
+const DropdownIcon = styled.div`
+  > i {
+    margin-top: 2px;
+    margin-right: 11px;
+    font-size: 20px;
+    color: #ffffff66;
+    cursor: pointer;
+    border-radius: 20px;
+    background: ${(props: { expanded: boolean }) =>
+      props.expanded ? "#ffffff18" : ""};
+    transform: ${(props: { expanded: boolean }) =>
+      props.expanded ? "rotate(180deg)" : ""};
+    animation: ${(props: { expanded: boolean }) =>
+      props.expanded ? "quarterTurn 0.3s" : ""};
+    animation-fill-mode: forwards;
+
+    @keyframes quarterTurn {
+      from {
+        transform: rotate(0deg);
+      }
+      to {
+        transform: rotate(90deg);
+      }
+    }
+  }
+`;

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/MetricsSection.tsx

@@ -650,7 +650,7 @@ const DropdownAlt = styled(Dropdown)`
 const RangeWrapper = styled.div`
 const RangeWrapper = styled.div`
   float: right;
   float: right;
   font-weight: bold;
   font-weight: bold;
-  width: 156px;
+  width: 158px;
   margin-top: -8px;
   margin-top: -8px;
 `;
 `;
 
 

+ 0 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/StatusSection.tsx

@@ -32,7 +32,6 @@ const StatusSectionFC: React.FunctionComponent<Props> = ({
   );
   );
 
 
   useEffect(() => {
   useEffect(() => {
-    console.log(currentChart);
     let isSubscribed = true;
     let isSubscribed = true;
     api
     api
       .getChartControllers(
       .getChartControllers(