Explorar el Código

add status tab (#3027)

Co-authored-by: Justin Rhee <jusrhee@Justins-MacBook-Air.local>
jusrhee hace 3 años
padre
commit
d23f5d4b3a

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

@@ -45,6 +45,7 @@ import EventsTab from "./EventsTab";
 import ActivityFeed from "./ActivityFeed";
 import JobRuns from "./JobRuns";
 import MetricsSection from "./MetricsSection";
+import StatusSectionFC from "./status/StatusSection";
 
 type Props = RouteComponentProps & {};
 
@@ -605,6 +606,8 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
         return <LogSection currentChart={appData.chart} />;
       case "metrics":
         return <MetricsSection currentChart={appData.chart} />;
+      case "status":
+        return <StatusSectionFC currentChart={appData.chart} />;
       case "environment-variables":
         return (
           <EnvVariablesTab
@@ -821,6 +824,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
                         { label: "Events", value: "events" },
                         { label: "Logs", value: "logs" },
                         { label: "Metrics", value: "metrics" },
+                        { label: "Debug", value: "status" },
                         { label: "Pre-deploy", value: "pre-deploy" },
                         {
                           label: "Environment variables",
@@ -844,6 +848,7 @@ const ExpandedApp: React.FC<Props> = ({ ...props }) => {
                       { label: "Events", value: "events" },
                       { label: "Logs", value: "logs" },
                       { label: "Metrics", value: "metrics" },
+                      { label: "Debug", value: "status" },
                       { label: "Pre-deploy", value: "pre-deploy" },
                       {
                         label: "Environment variables",

+ 52 - 0
dashboard/src/main/home/app-dashboard/expanded-app/status/ConnectToLogsInstructionModal.tsx

@@ -0,0 +1,52 @@
+import Modal from "main/home/modals/Modal";
+import React from "react";
+import styled from "styled-components";
+
+const ConnectToLogsInstructionModal: React.FC<{
+  show: boolean;
+  onClose: () => void;
+  chartName: string;
+  namespace: string;
+}> = ({ show, chartName, namespace, onClose }) => {
+  if (!show) {
+    return null;
+  }
+
+  return (
+    <Modal
+      onRequestClose={() => onClose()}
+      width="700px"
+      height="300px"
+      title="Shell Access Instructions"
+    >
+      To get shell live logs for this pod, make sure you have the Porter CLI
+      installed (installation instructions&nbsp;
+      <a href={"https://docs.porter.run/cli/installation"} target="_blank">
+        here
+      </a>
+      ).
+      <br />
+      <br />
+      Run the following line of code:
+      <Code>
+        porter logs {chartName || "[APP-NAME]"} --follow --namespace{" "}
+        {namespace || "[NAMESPACE]"}
+      </Code>
+    </Modal>
+  );
+};
+
+export default ConnectToLogsInstructionModal;
+
+const Code = styled.div`
+  background: #181b21;
+  padding: 10px 15px;
+  border: 1px solid #ffffff44;
+  border-radius: 5px;
+  margin: 10px 0px 15px;
+  color: #ffffff;
+  font-size: 13px;
+  user-select: text;
+  line-height: 1em;
+  font-family: monospace;
+`;

+ 439 - 0
dashboard/src/main/home/app-dashboard/expanded-app/status/ControllerTab.tsx

@@ -0,0 +1,439 @@
+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 "components/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 RevisionLabel = styled.div`
+  font-size: 12px;
+  color: #ffffff33;
+  width: 78px;
+  text-align: right;
+  padding-top: 7px;
+  margin-right: 10px;
+  margin-left: 10px;
+  overflow-wrap: anywhere;
+`;
+
+const ReplicaSetContainer = styled.div`
+  padding: 10px 5px;
+  display: flex;
+  overflow-wrap: anywhere;
+  justify-content: space-between;
+  border-top: 2px solid #ffffff11;
+`;
+
+const ReplicaSetName = styled.span`
+  padding-left: 10px;
+  overflow-wrap: anywhere;
+  max-width: calc(100% - 45px);
+  line-height: 1.5em;
+  color: #ffffff33;
+`;

+ 398 - 0
dashboard/src/main/home/app-dashboard/expanded-app/status/Logs.tsx

@@ -0,0 +1,398 @@
+import React, { useEffect, useRef, useState } from "react";
+import styled from "styled-components";
+import Anser from "anser";
+import CommandLineIcon from "assets/command-line-icon";
+import { SelectedPodType } from "./types";
+import { useLogs } from "./useLogs";
+
+const LogsFC: React.FC<{
+  selectedPod: SelectedPodType;
+  podError: string;
+  rawText?: boolean;
+}> = ({ selectedPod, podError, rawText }) => {
+  const [isScrollToBottomEnabled, setIsScrollToBottomEnabled] = useState(true);
+
+  const [showConnectionModal, setShowConnectionModal] = useState(false);
+
+  const shouldScroll = useRef<boolean>(true);
+  const wrapperRef = useRef<HTMLDivElement>();
+
+  const scrollToBottom = (smooth: boolean) => {
+    if (!wrapperRef.current || !shouldScroll.current) {
+      return;
+    }
+
+    if (smooth) {
+      wrapperRef.current.lastElementChild.scrollIntoView({
+        behavior: "smooth",
+        block: "nearest",
+        inline: "start",
+      });
+    } else {
+      wrapperRef.current.lastElementChild.scrollIntoView({
+        behavior: "auto",
+        block: "nearest",
+        inline: "start",
+      });
+    }
+  };
+
+  const {
+    logs,
+    previousLogs,
+    containers,
+    currentContainer,
+    setCurrentContainer,
+    refresh,
+  } = useLogs(selectedPod, scrollToBottom);
+
+  const [showPreviousLogs, setShowPreviousLogs] = useState<boolean>(false);
+
+  useEffect(() => {
+    shouldScroll.current = isScrollToBottomEnabled;
+  }, [isScrollToBottomEnabled]);
+
+  const renderLogs = () => {
+    if (podError && podError != "") {
+      return <Message>{podError}</Message>;
+    }
+
+    if (!selectedPod?.metadata?.name) {
+      return <Message>Please select a pod to view its logs.</Message>;
+    }
+
+    if (selectedPod?.status.phase === "Succeeded" && !rawText) {
+      return (
+        <Message>
+          ⌛ This job has been completed. You can now delete this job.
+        </Message>
+      );
+    }
+
+    if (
+      showPreviousLogs &&
+      Array.isArray(previousLogs) &&
+      previousLogs.length
+    ) {
+      return previousLogs?.map((log, i) => {
+        return (
+          <Log key={i}>
+            {log.map((ansi, j) => {
+              if (ansi.clearLine) {
+                return null;
+              }
+
+              return (
+                <LogSpan key={i + "." + j} ansi={ansi}>
+                  {ansi.content.replace(/ /g, "\u00a0")}
+                </LogSpan>
+              );
+            })}
+          </Log>
+        );
+      });
+    }
+
+    if (!Array.isArray(logs) || logs?.length === 0) {
+      return (
+        <Message>
+          No logs to display from this pod.
+          <Highlight onClick={refresh}>
+            <i className="material-icons">autorenew</i>
+            Refresh
+          </Highlight>
+        </Message>
+      );
+    }
+
+    return logs?.map((log, i) => {
+      return (
+        <Log key={i}>
+          {log.map((ansi, j) => {
+            if (ansi.clearLine) {
+              return null;
+            }
+
+            return (
+              <LogSpan key={i + "." + j} ansi={ansi}>
+                {ansi.content.replace(/ /g, "\u00a0")}
+              </LogSpan>
+            );
+          })}
+        </Log>
+      );
+    });
+  };
+
+  const renderContent = () => (
+    <>
+      {/* <ConnectToLogsInstructionModal
+        show={showConnectionModal}
+        onClose={() => setShowConnectionModal(false)}
+        chartName={selectedPod?.metadata?.labels["app.kubernetes.io/instance"]}
+        namespace={selectedPod?.metadata?.namespace}
+      />
+      <CLIModalIconWrapper
+        onClick={(e) => {
+          e.preventDefault();
+          setShowConnectionModal(true);
+        }}
+      >
+        <CLIModalIcon />
+        CLI Logs Instructions
+      </CLIModalIconWrapper> */}
+      <Wrapper ref={wrapperRef}>{renderLogs()}</Wrapper>
+      <LogTabs>
+        {containers.map((containerName, _i, arr) => {
+          return (
+            <Tab
+              key={containerName}
+              onClick={() => {
+                setCurrentContainer(containerName);
+              }}
+              clicked={currentContainer === containerName}
+            >
+              {arr.length > 1 ? containerName : "Application"}
+            </Tab>
+          );
+        })}
+        <Tab
+          onClick={() => {
+            setCurrentContainer("system");
+          }}
+          clicked={currentContainer == "system"}
+        >
+          System
+        </Tab>
+      </LogTabs>
+      <Options>
+        <Scroll
+          onClick={() => {
+            setIsScrollToBottomEnabled(!isScrollToBottomEnabled);
+            if (isScrollToBottomEnabled) {
+              scrollToBottom(true);
+            }
+          }}
+        >
+          <input
+            type="checkbox"
+            checked={isScrollToBottomEnabled}
+            onChange={() => {}}
+          />
+          Scroll to bottom
+        </Scroll>
+        {Array.isArray(previousLogs) && previousLogs.length > 0 && (
+          <Scroll
+            onClick={() => {
+              setShowPreviousLogs(!showPreviousLogs);
+            }}
+          >
+            <input
+              type="checkbox"
+              checked={showPreviousLogs}
+              onChange={() => {}}
+            />
+            Show previous logs
+          </Scroll>
+        )}
+        <Refresh onClick={() => refresh()}>
+          <i className="material-icons">autorenew</i>
+          Refresh
+        </Refresh>
+      </Options>
+    </>
+  );
+
+  if (!containers?.length) {
+    return null;
+  }
+
+  if (rawText) {
+    return <LogStreamAlt>{renderContent()}</LogStreamAlt>;
+  }
+
+  return <LogStream>{renderContent()}</LogStream>;
+};
+
+export default LogsFC;
+
+const Highlight = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-left: 8px;
+  color: #8590ff;
+  cursor: pointer;
+
+  > i {
+    font-size: 16px;
+    margin-right: 3px;
+  }
+`;
+
+const Scroll = styled.div`
+  align-items: center;
+  display: flex;
+  cursor: pointer;
+  width: max-content;
+  height: 100%;
+
+  :hover {
+    background: #2468d6;
+  }
+
+  > input {
+    width: 18px;
+    margin-left: 10px;
+    margin-right: 6px;
+    pointer-events: none;
+  }
+`;
+
+const Tab = styled.div`
+  background: ${(props: { clicked: boolean }) =>
+    props.clicked ? "#503559" : "#7c548a"};
+  padding: 0px 10px;
+  margin: 0px 7px 0px 0px;
+  align-items: center;
+  display: flex;
+  cursor: pointer;
+  height: 100%;
+  border-radius: 8px 8px 0px 0px;
+
+  :hover {
+    background: #503559;
+  }
+`;
+
+const Refresh = styled.div`
+  display: flex;
+  align-items: center;
+  width: 87px;
+  user-select: none;
+  cursor: pointer;
+  height: 100%;
+
+  > i {
+    margin-left: 6px;
+    font-size: 17px;
+    margin-right: 6px;
+  }
+
+  :hover {
+    background: #2468d6;
+  }
+`;
+
+const LogTabs = styled.div`
+  width: 100%;
+  height: 25px;
+  margin-top: -25px;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: flex-end;
+`;
+
+const Options = styled.div`
+  width: 100%;
+  height: 25px;
+  background: #397ae3;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+`;
+
+const Wrapper = styled.div`
+  width: 100%;
+  height: 100%;
+  overflow: auto;
+  padding: 25px 30px;
+`;
+
+const LogStream = styled.div`
+  display: flex;
+  flex-direction: column;
+  flex: 1;
+  float: right;
+  height: 100%;
+  font-size: 13px;
+  background: #000000;
+  user-select: text;
+  max-width: 65%;
+  overflow-y: auto;
+  overflow-wrap: break-word;
+`;
+
+const LogStreamAlt = styled(LogStream)`
+  width: 100%;
+  max-width: 100%;
+`;
+
+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;
+`;
+
+const LogSpan = 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 CLIModalIconWrapper = styled.div`
+  max-width: 200px;
+  height: 35px;
+  margin: 10px;
+  font-size: 13px;
+  font-weight: 500;
+  font-family: "Work Sans", sans-serif;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 6px 20px 6px 10px;
+  text-align: left;
+  border: 1px solid #ffffff55;
+  border-radius: 8px;
+  background: #ffffff11;
+  color: #ffffffdd;
+  cursor: pointer;
+  :hover {
+    cursor: pointer;
+    background: #ffffff22;
+    > path {
+      fill: #ffffff77;
+    }
+  }
+
+  > path {
+    fill: #ffffff99;
+  }
+`;
+
+const CLIModalIcon = styled(CommandLineIcon)`
+  width: 32px;
+  height: 32px;
+  padding: 8px;
+
+  > path {
+    fill: #ffffff99;
+  }
+`;

+ 234 - 0
dashboard/src/main/home/app-dashboard/expanded-app/status/PodRow.tsx

@@ -0,0 +1,234 @@
+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} selected={isSelected} onClick={onTabClick}>
+      <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>
+        <StatusColor status={podStatus} />
+        {podStatus}
+        {podStatus === "failed" && (
+          <CloseIcon
+            className="material-icons-outlined"
+            onClick={onDeleteClick}
+          >
+            close
+          </CloseIcon>
+        )}
+      </Status>
+    </Tab>
+  );
+};
+
+export default PodRow;
+
+const InfoIcon = styled.div`
+  width: 22px;
+`;
+
+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;
+  color: ${(props: { selected: boolean }) =>
+    props.selected ? "white" : "#ffffff66"};
+  background: ${(props: { selected: boolean }) =>
+    props.selected ? "#ffffff18" : ""};
+  font-size: 13px;
+  padding: 20px 19px 20px 42px;
+  text-shadow: 0px 0px 8px none;
+  overflow: visible;
+  cursor: pointer;
+  :hover {
+    color: white;
+    background: #ffffff18;
+  }
+`;
+
+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: 5px;
+  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-right: 7px;
+  width: 7px;
+  min-width: 7px;
+  height: 7px;
+  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;
+`;

+ 280 - 0
dashboard/src/main/home/app-dashboard/expanded-app/status/StatusSection.tsx

@@ -0,0 +1,280 @@
+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 Logs from "./Logs";
+import ControllerTab from "./ControllerTab";
+import Banner from "components/porter/Banner";
+import Spacer from "components/porter/Spacer";
+
+type Props = {
+  selectors?: string[];
+  currentChart: ChartType;
+  fullscreen?: boolean;
+  setFullScreenLogs?: any;
+};
+
+const StatusSectionFC: React.FunctionComponent<Props> = ({
+  currentChart,
+  fullscreen,
+  setFullScreenLogs,
+  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 renderLogs = () => {
+    return (
+      <Logs
+        podError={podError}
+        key={selectedPod?.metadata?.name}
+        selectedPod={selectedPod}
+      />
+    );
+  };
+
+  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 (
+        <Wrapper>
+          <TabWrapper>{renderTabs()}</TabWrapper>
+          {renderLogs()}
+        </Wrapper>
+      );
+    }
+
+    if (currentChart?.chart?.metadata?.name === "job") {
+      return (
+        <NoControllers>
+          <i className="material-icons">category</i>
+          There are no jobs currently running.
+        </NoControllers>
+      );
+    }
+
+    return (
+      <NoControllers>
+        <i className="material-icons">category</i>
+        No objects to display. This might happen while your app is still
+        deploying.
+      </NoControllers>
+    );
+  };
+
+  return (
+    <>
+      <Banner type="info">An improved debugging view is under construction. Unable to debug your application? <MyLink id={"intercom_help"}>Contact us</MyLink></Banner>
+      <Spacer y={1} />
+      <StyledStatusSection>
+        {renderStatusSection()}
+      </StyledStatusSection>
+    </>
+  );
+};
+
+export default StatusSectionFC;
+
+const MyLink = styled.a`
+  cursor: pointer;
+  color: #ffffff;
+  text-decoration: underline;
+`;
+
+const FullScreenButton = styled.div<{ top?: string }>`
+  position: absolute;
+  top: ${(props) => props.top || "10px"};
+  right: 10px;
+  width: 24px;
+  height: 24px;
+  cursor: pointer;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 5px;
+  background: #ffffff11;
+  border: 1px solid #aaaabb;
+
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    font-size: 14px;
+  }
+`;
+
+const BackButton = styled.div`
+  display: flex;
+  width: 30px;
+  z-index: 999;
+  cursor: pointer;
+  height: 30px;
+  align-items: center;
+  margin-right: 15px;
+  justify-content: center;
+  cursor: pointer;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  background: #ffffff11;
+
+  > i {
+    font-size: 18px;
+  }
+
+  :hover {
+    background: #ffffff22;
+    > img {
+      opacity: 1;
+    }
+  }
+`;
+
+const AbsoluteTitle = styled.div`
+  position: absolute;
+  top: 0px;
+  left: 0px;
+  width: 100%;
+  height: 60px;
+  display: flex;
+  align-items: center;
+  padding-left: 20px;
+  font-size: 18px;
+  font-weight: 500;
+  user-select: text;
+`;
+
+const TabWrapper = styled.div`
+  width: 35%;
+  min-width: 250px;
+  height: 100%;
+  overflow-y: auto;
+`;
+
+const StyledStatusSection = styled.div`
+  padding: 0px;
+  user-select: text;
+  overflow: hidden;
+  width: 100%;
+  min-height: 400px;
+  height: calc(100vh - 400px);
+  font-size: 13px;
+  overflow: hidden;
+  border-radius: 8px;
+  animation: floatIn 0.3s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(10px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;
+
+const FullScreen = styled.div`
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  padding-top: 60px;
+`;
+
+const Wrapper = styled.div`
+  width: 100%;
+  height: 100%;
+  display: flex;
+`;
+
+const NoControllers = styled.div`
+  padding-top: 20%;
+  position: relative;
+  width: 100%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  color: #ffffff44;
+  font-size: 14px;
+
+  > i {
+    font-size: 18px;
+    margin-right: 12px;
+  }
+`;

+ 19 - 0
dashboard/src/main/home/app-dashboard/expanded-app/status/types.ts

@@ -0,0 +1,19 @@
+export type SelectedPodType = {
+  spec: {
+    [key: string]: any;
+    containers: {
+      [key: string]: any;
+      name: string;
+    }[];
+  };
+  metadata: {
+    name: string;
+    namespace: string;
+    labels: {
+      [key: string]: string;
+    };
+  };
+  status: {
+    phase: string;
+  };
+};

+ 218 - 0
dashboard/src/main/home/app-dashboard/expanded-app/status/useLogs.ts

@@ -0,0 +1,218 @@
+import Anser from "anser";
+import { useContext, useEffect, useMemo, useRef, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { useWebsockets, NewWebsocketOptions } from "shared/hooks/useWebsockets";
+import { SelectedPodType } from "./types";
+
+const MAX_LOGS = 250;
+
+export const useLogs = (
+  currentPod: SelectedPodType,
+  scroll?: (smooth: boolean) => void
+) => {
+  const currentPodName = useRef<string>();
+
+  const { currentCluster, currentProject } = useContext(Context);
+  const [containers, setContainers] = useState<string[]>([]);
+  const [currentContainer, setCurrentContainer] = useState<string>("");
+  const [logs, setLogs] = useState<{
+    [key: string]: Anser.AnserJsonEntry[][];
+  }>({});
+
+  const [prevLogs, setPrevLogs] = useState<{
+    [key: string]: Anser.AnserJsonEntry[][];
+  }>({});
+
+  const {
+    newWebsocket,
+    openWebsocket,
+    closeAllWebsockets,
+    getWebsocket,
+    closeWebsocket,
+  } = useWebsockets();
+
+  const getSystemLogs = async () => {
+    const events = await api
+      .getPodEvents(
+        "<token>",
+        {},
+        {
+          name: currentPod?.metadata?.name,
+          namespace: currentPod?.metadata?.namespace,
+          cluster_id: currentCluster?.id,
+          id: currentProject?.id,
+        }
+      )
+      .then((res) => res.data);
+
+    let processedLogs = [] as Anser.AnserJsonEntry[][];
+
+    events.items.forEach((evt: any) => {
+      let ansiEvtType = evt.type == "Warning" ? "\u001b[31m" : "\u001b[32m";
+      let ansiLog = Anser.ansiToJson(
+        `${ansiEvtType}${evt.type}\u001b[0m \t \u001b[43m\u001b[34m\t${evt.reason} \u001b[0m \t ${evt.message}`
+      );
+      processedLogs.push(ansiLog);
+    });
+
+    // SET LOGS FOR SYSTEM
+    setLogs((prevState) => ({
+      ...prevState,
+      system: processedLogs,
+    }));
+  };
+
+  const getContainerPreviousLogs = async (containerName: string) => {
+    try {
+      const logs = await api
+        .getPreviousLogsForContainer<{ previous_logs: string[] }>(
+          "<token>",
+          {
+            container_name: containerName,
+          },
+          {
+            pod_name: currentPod?.metadata?.name,
+            namespace: currentPod?.metadata?.namespace,
+            cluster_id: currentCluster?.id,
+            project_id: currentProject?.id,
+          }
+        )
+        .then((res) => res.data);
+      // Process logs
+      const processedLogs: Anser.AnserJsonEntry[][] = logs.previous_logs.map(
+        (currentLog) => {
+          let ansiLog = Anser.ansiToJson(currentLog);
+          return ansiLog;
+        }
+      );
+
+      setPrevLogs((pl) => ({
+        ...pl,
+        [containerName]: processedLogs,
+      }));
+    } catch (error) {}
+  };
+
+  const setupWebsocket = (containerName: string, websocketKey: string) => {
+    if (!currentPod?.metadata?.name) return;
+
+    const endpoint = `/api/projects/${currentProject.id}/clusters/${currentCluster.id}/namespaces/${currentPod?.metadata?.namespace}/pod/${currentPod?.metadata?.name}/logs?container_name=${containerName}`;
+
+    const config: NewWebsocketOptions = {
+      onopen: () => {
+        console.log("Opened websocket:", websocketKey);
+      },
+      onmessage: (evt: MessageEvent) => {
+        let ansiLog = Anser.ansiToJson(evt.data);
+        setLogs((logs) => {
+          const tmpLogs = { ...logs };
+          let containerLogs = tmpLogs[containerName] || [];
+
+          containerLogs.push(ansiLog);
+          // this is technically not as efficient as things could be
+          // if there are performance issues, a deque can be used in place of a list
+          // for storing logs
+          if (containerLogs.length > MAX_LOGS) {
+            containerLogs.shift();
+          }
+          if (typeof scroll === "function") {
+            scroll(true);
+          }
+          return {
+            ...logs,
+            [containerName]: containerLogs,
+          };
+        });
+      },
+      onclose: () => {
+        console.log("Closed websocket:", websocketKey);
+      },
+    };
+
+    newWebsocket(websocketKey, endpoint, config);
+    openWebsocket(websocketKey);
+  };
+
+  const refresh = () => {
+    const websocketKey = `${currentPodName.current}-${currentContainer}-websocket`;
+    closeWebsocket(websocketKey);
+
+    setPrevLogs((prev) => ({ ...prev, [currentContainer]: [] }));
+    setLogs((prev) => ({ ...prev, [currentContainer]: [] }));
+
+    if (!Array.isArray(containers)) {
+      return;
+    }
+
+    if (currentContainer === "system") {
+      getSystemLogs();
+    } else {
+      getContainerPreviousLogs(currentContainer);
+      setupWebsocket(currentContainer, websocketKey);
+    }
+  };
+
+  useEffect(() => {
+    // console.log("Selected pod updated");
+    if (currentPod?.metadata?.name === currentPodName.current) {
+      return () => {};
+    }
+    currentPodName.current = currentPod?.metadata?.name;
+    const currentContainers =
+      currentPod?.spec?.containers?.map((container) => container?.name) || [];
+
+    setContainers(currentContainers);
+    setCurrentContainer(currentContainers[0]);
+  }, [currentPod]);
+
+  // Retrieve all previous logs for containers
+  useEffect(() => {
+    if (!Array.isArray(containers)) {
+      return;
+    }
+
+    closeAllWebsockets();
+
+    setPrevLogs({});
+    setLogs({});
+
+    getSystemLogs();
+    containers.forEach((containerName) => {
+      const websocketKey = `${currentPodName.current}-${containerName}-websocket`;
+
+      getContainerPreviousLogs(containerName);
+
+      if (!getWebsocket(websocketKey)) {
+        setupWebsocket(containerName, websocketKey);
+      }
+    });
+
+    return () => {
+      closeAllWebsockets();
+    };
+  }, [containers]);
+
+  useEffect(() => {
+    return () => {
+      closeAllWebsockets();
+    };
+  }, []);
+
+  const currentLogs = useMemo(() => {
+    return logs[currentContainer] || [];
+  }, [currentContainer, logs]);
+
+  const currentPreviousLogs = useMemo(() => {
+    return prevLogs[currentContainer] || [];
+  }, [currentContainer, prevLogs]);
+
+  return {
+    containers,
+    currentContainer,
+    setCurrentContainer,
+    logs: currentLogs,
+    previousLogs: currentPreviousLogs,
+    refresh,
+  };
+};