Просмотр исходного кода

Merge pull request #1000 from porter-dev/master

Stage source visibility
jusrhee 4 лет назад
Родитель
Сommit
5dc64b68fd

+ 0 - 1
dashboard/src/components/ResourceTab.tsx

@@ -142,7 +142,6 @@ export default class ResourceTab extends Component<PropsType, StateType> {
 const StyledResourceTab = styled.div`
   width: 100%;
   margin-bottom: 2px;
-  overflow: hidden;
   background: #ffffff11;
   border-bottom-left-radius: ${(props: {
     isLast: boolean;

+ 102 - 2
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -34,6 +34,7 @@ import SettingsSection from "./SettingsSection";
 import { useWebsockets } from "shared/hooks/useWebsockets";
 import useAuth from "shared/auth/useAuth";
 import TitleSection from "components/TitleSection";
+import { integrationList } from "shared/common";
 
 type Props = {
   namespace: string;
@@ -79,7 +80,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
   const [imageIsPlaceholder, setImageIsPlaceholer] = useState<boolean>(false);
   const [newestImage, setNewestImage] = useState<string>(null);
   const [isLoadingChartData, setIsLoadingChartData] = useState<boolean>(true);
-
+  const [showRepoTooltip, setShowRepoTooltip] = useState(false);
   const [isAuthorized] = useAuth();
 
   const {
@@ -663,6 +664,50 @@ const ExpandedChart: React.FC<Props> = (props) => {
     return () => (isSubscribed = false);
   }, [components, currentCluster, currentProject, currentChart]);
 
+  const renderDeploymentType = () => {
+    const githubRepository = currentChart?.git_action_config?.git_repo;
+    const icon = githubRepository
+      ? integrationList.repo.icon
+      : integrationList.registry.icon;
+
+    const isWebOrWorkerDeployment = ["web", "worker"].includes(
+      currentChart?.chart?.metadata?.name
+    );
+    if (!isWebOrWorkerDeployment) {
+      return null;
+    }
+
+    const repository =
+      githubRepository ||
+      currentChart?.image_repo_uri ||
+      currentChart?.config?.image?.repository;
+
+    if (repository?.includes("hello-porter")) {
+      return null;
+    }
+
+    return (
+      <DeploymentImageContainer>
+        <DeploymentTypeIcon src={icon} />
+        <RepositoryName
+          onMouseOver={() => {
+            setShowRepoTooltip(true);
+          }}
+          onMouseOut={() => {
+            setShowRepoTooltip(false);
+          }}
+        >
+          {repository}
+        </RepositoryName>
+        {
+          showRepoTooltip && (
+            <Tooltip>{repository}</Tooltip>
+          )
+        }
+      </DeploymentImageContainer>
+    );
+  };
+
   return (
     <>
       <StyledExpandedChart>
@@ -675,6 +720,7 @@ const ExpandedChart: React.FC<Props> = (props) => {
             iconWidth="33px"
           >
             {currentChart.name}
+            {renderDeploymentType()}
             <TagWrapper>
               Namespace <NamespaceTag>{currentChart.namespace}</NamespaceTag>
             </TagWrapper>
@@ -765,6 +811,41 @@ const ExpandedChart: React.FC<Props> = (props) => {
 
 export default ExpandedChart;
 
+const RepositoryName = styled.div`
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  max-width: 390px;
+  position: relative;
+  margin-right: 3px;
+`;
+
+const Tooltip = styled.div`
+  position: absolute;
+  left: -40px;
+  top: 28px;
+  min-height: 18px;
+  max-width: calc(700px);
+  padding: 5px 7px;
+  background: #272731;
+  z-index: 999;
+  color: white;
+  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 TextWrap = styled.div``;
 
 const LineBreak = styled.div`
@@ -914,7 +995,7 @@ const TagWrapper = styled.div`
   height: 20px;
   font-size: 12px;
   display: flex;
-  margin-left: 20px;
+  margin-left: 15px;
   margin-bottom: -3px;
   align-items: center;
   font-weight: 400;
@@ -981,3 +1062,22 @@ const StyledExpandedChart = styled.div`
     }
   }
 `;
+
+const DeploymentImageContainer = styled.div`
+  height: 20px;
+  font-size: 13px;
+  position: relative;
+  display: flex;
+  margin-left: 15px;
+  margin-bottom: -3px;
+  align-items: center;
+  font-weight: 400;
+  justify-content: center;
+  color: #ffffff66;
+  padding-left: 5px;
+`;
+
+const DeploymentTypeIcon = styled(Icon)`
+  width: 20px;
+  margin-right: 10px;
+`;

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

@@ -219,6 +219,13 @@ class RevisionSection extends Component<PropsType, StateType> {
   renderRevisionList = () => {
     return this.state.revisions.map((revision: ChartType, i: number) => {
       let isCurrent = revision.version === this.state.maxVersion;
+      const isGithubApp = !!this.props.chart.git_action_config;
+      const imageTag = revision.config?.image?.tag;
+
+      const parsedImageTag = isGithubApp
+        ? String(imageTag).slice(0, 7)
+        : imageTag;
+
       return (
         <Tr
           key={i}
@@ -227,7 +234,7 @@ class RevisionSection extends Component<PropsType, StateType> {
         >
           <Td>{revision.version}</Td>
           <Td>{this.readableDate(revision.info.last_deployed)}</Td>
-          <Td>{this.renderStatus(revision)}</Td>
+          <Td>{parsedImageTag || "N/A"}</Td>
           <Td>v{revision.chart.metadata.version}</Td>
           <Td>
             <RollbackButton
@@ -256,7 +263,9 @@ class RevisionSection extends Component<PropsType, StateType> {
               <Tr disableHover={true}>
                 <Th>Revision No.</Th>
                 <Th>Timestamp</Th>
-                <Th>Status</Th>
+                <Th>
+                  {this.props.chart.git_action_config ? "Commit" : "Image Tag"}
+                </Th>
                 <Th>Template Version</Th>
                 <Th>Rollback</Th>
               </Tr>

+ 327 - 405
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx

@@ -1,220 +1,201 @@
-import React, { Component } from "react";
+import React, { useContext, useEffect, useMemo, useState } from "react";
 import styled from "styled-components";
 import api from "shared/api";
 import { Context } from "shared/Context";
-import { ChartType } from "shared/types";
 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 PropsType = {
+type Props = {
   controller: any;
   selectedPod: any;
-  selectPod: Function;
+  selectPod: (newPod: any) => unknown;
   selectors: any;
   isLast?: boolean;
   isFirst?: boolean;
   setPodError: (x: string) => void;
 };
 
-type StateType = {
-  pods: any[];
-  raw: any[];
-  showTooltip: boolean[];
-  podPendingDelete: any;
-  websockets: Record<string, any>;
-  selectors: string[];
-  available: number;
-  total: number;
-  canUpdatePod: boolean;
-};
-
 // Controller tab in log section that displays list of pods on click.
-export default class ControllerTab extends Component<PropsType, StateType> {
-  state = {
-    pods: [] as any[],
-    raw: [] as any[],
-    showTooltip: [] as boolean[],
-    podPendingDelete: null as any,
-    websockets: {} as Record<string, any>,
-    selectors: [] as string[],
-    available: null as number,
-    total: null as number,
-    canUpdatePod: true,
-  };
+export type ControllerTabPodType = {
+  namespace: string;
+  name: string;
+  phase: string;
+  status: any;
+  replicaSetName: string;
+  restartCount: number | string;
+  podAge: string;
+  revisionNumber?: number;
+};
 
-  updatePods = () => {
-    let { currentCluster, currentProject, setCurrentError } = this.context;
-    let { controller, selectPod, isFirst } = this.props;
+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]);
 
-    api
-      .getMatchingPods(
+  const updatePods = async () => {
+    try {
+      const res = await api.getMatchingPods(
         "<token>",
         {
           cluster_id: currentCluster.id,
           namespace: controller?.metadata?.namespace,
-          selectors: this.state.selectors,
+          selectors: currentSelectors,
         },
         {
           id: currentProject.id,
         }
-      )
-      .then((res) => {
-        let pods = res?.data?.map((pod: any) => {
+      );
+      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,
+            podAge: pod?.metadata?.creationTimestamp ? podAge : "N/A",
+            revisionNumber:
+              (pod?.metadata?.annotations &&
+                pod?.metadata?.annotations["helm.sh/revision"]) ||
+              "N/A",
           };
         });
-        let showTooltip = new Array(pods.length);
-        for (let j = 0; j < pods.length; j++) {
-          showTooltip[j] = false;
-        }
-
-        this.setState({ pods, raw: res.data, showTooltip });
-
-        if (isFirst) {
-          let pod = res.data[0];
-          let status = this.getPodStatus(pod.status);
-          status === "failed" &&
-            pod.status?.message &&
-            this.props.setPodError(pod.status?.message);
-          if (this.state.canUpdatePod) {
-            // this prevents multiple requests from changing the first pod
-            selectPod(res.data[0]);
-            this.setState({
-              canUpdatePod: false,
-            });
-          }
-        }
-      })
-      .catch((err) => {
-        console.log(err);
-        setCurrentError(JSON.stringify(err));
-        return;
-      });
-  };
-
-  getPodSelectors = (callback: () => void) => {
-    let { controller } = this.props;
 
-    let selectors = [] 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 += ",";
+      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);
       }
-      i += 1;
-    }
-    selectors.push(selector);
-    if (controller.kind.toLowerCase() == "job" && this.props.selectors) {
-      selectors = this.props.selectors;
-    }
-
-    this.setState({ selectors }, () => {
-      callback();
-    });
+    } catch (error) {}
   };
 
-  componentDidMount() {
-    this.getPodSelectors(() => {
-      this.updatePods();
-      this.setControllerWebsockets([this.props.controller.kind, "pod"]);
-    });
-  }
-
-  componentWillUnmount() {
-    if (this.state.websockets) {
-      this.state.websockets.forEach((ws: WebSocket) => {
-        ws.close();
-      });
-    }
-  }
-
-  setControllerWebsockets = (controller_types: any[]) => {
-    let websockets = controller_types.map((kind: string) => {
-      return this.setupWebsocket(kind);
-    });
-    this.setState({ websockets });
+  /**
+   * 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);
   };
 
-  setupWebsocket = (kind: string) => {
-    let { currentCluster, currentProject } = this.context;
-    let protocol = window.location.protocol == "https:" ? "wss" : "ws";
-    let connString = `${protocol}://${window.location.host}/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`;
-
-    if (kind == "pod" && this.state.selectors) {
-      connString += `&selectors=${this.state.selectors[0]}`;
-    }
-    let ws = new WebSocket(connString);
-
-    ws.onopen = () => {
-      console.log("connected to websocket");
-    };
-
-    ws.onmessage = (evt: MessageEvent) => {
-      let event = JSON.parse(evt.data);
-      let object = event.Object;
-      object.metadata.kind = event.Kind;
+  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";
 
-      // 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.
+    controller?.status?.conditions?.forEach((condition: any) => {
       if (
-        event.Kind != "pod" &&
-        object.metadata.uid != this.props.controller.metadata.uid
-      )
-        return;
-
-      if (event.Kind != "pod") {
-        let [available, total] = this.getAvailability(
-          object.metadata.kind,
-          object
-        );
-        this.setState({ available, total });
+        condition.type == "Progressing" &&
+        condition.status == "False" &&
+        condition.reason == "ProgressDeadlineExceeded"
+      ) {
+        status = "failed";
       }
+    });
 
-      this.updatePods();
-    };
-
-    ws.onclose = () => {
-      console.log("closing websocket");
-    };
-
-    ws.onerror = (err: ErrorEvent) => {
-      console.log(err);
-      ws.close();
-    };
-
-    return ws;
-  };
-
-  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];
+    if (controller.kind.toLowerCase() === "job" && pods.length == 0) {
+      status = "completed";
     }
-  };
+    return status;
+  }, [controller, available, total, pods]);
 
-  getPodStatus = (status: any) => {
+  const getPodStatus = (status: any) => {
     if (
       status?.phase === "Pending" &&
       status?.containerStatuses !== undefined
@@ -245,272 +226,213 @@ export default class ControllerTab extends Component<PropsType, StateType> {
     }
   };
 
-  renderTooltip = (x: string, ind: number): JSX.Element | undefined => {
-    if (this.state.showTooltip[ind]) {
-      return <Tooltip>{x}</Tooltip>;
-    }
-  };
-
-  handleDeletePod = (pod: any) => {
+  const handleDeletePod = (pod: any) => {
     api
       .deletePod(
         "<token>",
         {
-          cluster_id: this.context.currentCluster.id,
+          cluster_id: currentCluster.id,
         },
         {
           name: pod.metadata?.name,
           namespace: pod.metadata?.namespace,
-          id: this.context.currentProject.id,
+          id: currentProject.id,
         }
       )
       .then((res) => {
-        this.updatePods();
-        this.setState({ podPendingDelete: null });
+        updatePods();
+        setPodPendingDelete(null);
       })
       .catch((err) => {
-        this.context.setCurrentError(JSON.stringify(err));
-        this.setState({ podPendingDelete: null });
+        setCurrentError(JSON.stringify(err));
+        setPodPendingDelete(null);
       });
   };
 
-  renderDeleteButton = (pod: any) => {
-    return (
-      <CloseIcon
-        className="material-icons-outlined"
-        onClick={() => this.setState({ podPendingDelete: pod })}
-      >
-        close
-      </CloseIcon>
-    );
-  };
-
-  render() {
-    let { controller, selectedPod, isLast, selectPod, isFirst } = this.props;
-    let { available, total } = this.state;
-    let status = available == total ? "running" : "waiting";
-
-    controller?.status?.conditions?.forEach((condition: any) => {
+  const replicaSetArray = useMemo(() => {
+    const podsDividedByReplicaSet = pods.reduce<
+      Array<Array<ControllerTabPodType>>
+    >(function (prev, currentPod, i) {
       if (
-        condition.type == "Progressing" &&
-        condition.status == "False" &&
-        condition.reason == "ProgressDeadlineExceeded"
+        !i ||
+        prev[prev.length - 1][0].replicaSetName !== currentPod.replicaSetName
       ) {
-        status = "failed";
+        return prev.concat([[currentPod]]);
       }
-    });
+      prev[prev.length - 1].push(currentPod);
+      return prev;
+    }, []);
+
+    if (podsDividedByReplicaSet.length === 1) {
+      return [];
+    } else {
+      return podsDividedByReplicaSet;
+    }
+  }, [pods]);
 
-    if (controller.kind.toLowerCase() === "job" && this.state.raw.length == 0) {
-      status = "completed";
+  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];
     }
+  };
 
-    return (
-      <ResourceTab
-        label={controller.kind}
-        // handle CronJob case
-        name={controller.metadata?.name || controller.name}
-        status={{ label: status, available, total }}
-        isLast={isLast}
-        expanded={isFirst}
-      >
-        {this.state.raw.map((pod, i) => {
-          let status = this.getPodStatus(pod.status);
-          return (
-            <Tab
-              key={pod.metadata?.name}
-              selected={selectedPod?.metadata?.name === pod?.metadata?.name}
-              onClick={() => {
-                this.props.setPodError("");
-                status === "failed" &&
-                  pod.status?.message &&
-                  this.props.setPodError(pod.status?.message);
-                selectPod(pod);
-                this.setState({
-                  canUpdatePod: false,
-                });
-              }}
-            >
-              <Gutter>
-                <Rail />
-                <Circle />
-                <Rail lastTab={i === this.state.raw.length - 1} />
-              </Gutter>
-              <Name
-                onMouseOver={() => {
-                  let showTooltip = this.state.showTooltip;
-                  showTooltip[i] = true;
-                  this.setState({ showTooltip });
-                }}
-                onMouseOut={() => {
-                  let showTooltip = this.state.showTooltip;
-                  showTooltip[i] = false;
-                  this.setState({ showTooltip });
-                }}
-              >
-                {pod.metadata?.name}
-              </Name>
-              {this.renderTooltip(pod.metadata?.name, i)}
-              <Status>
-                <StatusColor status={status} />
-                {status}
-                {status === "failed" && this.renderDeleteButton(pod)}
-              </Status>
-            </Tab>
-          );
-        })}
-        <ConfirmOverlay
-          message="Are you sure you want to delete this pod?"
-          show={this.state.podPendingDelete}
-          onYes={() => this.handleDeletePod(this.state.podPendingDelete)}
-          onNo={() => this.setState({ podPendingDelete: null })}
-        />
-      </ResourceTab>
-    );
-  }
-}
+  const setupWebsocket = (kind: string, controllerUid: string) => {
+    let apiEndpoint = `/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`;
+    if (kind == "pod" && currentSelectors) {
+      apiEndpoint += `&selectors=${currentSelectors[0]}`;
+    }
 
-ControllerTab.contextType = Context;
+    const options: NewWebsocketOptions = {};
+    options.onopen = () => {
+      console.log("connected to websocket");
+    };
 
-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;
-  }
-`;
+    options.onmessage = (evt: MessageEvent) => {
+      let event = JSON.parse(evt.data);
+      let object = event.Object;
+      object.metadata.kind = event.Kind;
 
-const Rail = styled.div`
-  width: 2px;
-  background: ${(props: { lastTab?: boolean }) =>
-    props.lastTab ? "" : "#52545D"};
-  height: 50%;
-`;
+      // 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;
+      }
 
-const Circle = styled.div`
-  min-width: 10px;
-  min-height: 2px;
-  margin-bottom: -2px;
-  margin-left: 8px;
-  background: #52545d;
-`;
+      if (event.Kind != "pod") {
+        let [available, total] = getAvailability(object.metadata.kind, object);
+        setAvailable(available);
+        setTotal(total);
+        return;
+      }
+      updatePods();
+    };
 
-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;
-`;
+    options.onclose = () => {
+      console.log("closing websocket");
+    };
 
-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;
-    }
-  }
-`;
+    options.onerror = (err: ErrorEvent) => {
+      console.log(err);
+      closeWebsocket(kind);
+    };
 
-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;
-`;
+    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)}
+        />
+      );
+    });
+  };
 
-const Name = styled.div`
-  overflow: hidden;
-  text-overflow: ellipsis;
-  line-height: 16px;
-  word-wrap: break-word;
-  max-height: 32px;
-  display: -webkit-box;
-  -webkit-box-orient: vertical;
-  -webkit-line-clamp: 2;
+  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 Tooltip = styled.div`
-  position: absolute;
-  left: 35px;
-  word-wrap: break-word;
-  top: 38px;
-  min-height: 18px;
-  max-width: calc(100% - 75px);
-  padding: 2px 5px;
-  background: #383842dd;
-  display: flex;
-  justify-content: center;
-  flex: 1;
-  color: white;
-  text-transform: none;
+const RevisionLabel = styled.div`
   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;
-    }
-  }
+  color: #ffffff33;
+  width: 78px;
+  text-align: right;
+  padding-top: 7px;
+  margin-right: 10px;
+  margin-left: 10px;
+  overflow-wrap: anywhere;
 `;
 
-const Tab = styled.div`
-  width: 100%;
-  height: 50px;
-  position: relative;
+const ReplicaSetContainer = styled.div`
+  padding: 10px 5px;
   display: flex;
-  align-items: center;
+  overflow-wrap: anywhere;
   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;
-  }
+  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;
 `;

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

@@ -337,7 +337,7 @@ const Scroll = styled.div`
   }
 
   > input {
-    width; 18px;
+    width: 18px;
     margin-left: 10px;
     margin-right: 6px;
     pointer-events: none;

+ 221 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/PodRow.tsx

@@ -0,0 +1,221 @@
+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>
+          </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 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;
+`;

+ 72 - 81
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/StatusSection.tsx

@@ -1,4 +1,4 @@
-import React, { Component } from "react";
+import React, { Component, useContext, useEffect, useState } from "react";
 import styled from "styled-components";
 
 import api from "shared/api";
@@ -9,82 +9,108 @@ import Loading from "components/Loading";
 import Logs from "./Logs";
 import ControllerTab from "./ControllerTab";
 
-type PropsType = {
+type Props = {
   selectors?: string[];
   currentChart: ChartType;
 };
 
-type StateType = {
-  logs: string[];
-  pods: any[];
-  selectedPod: any;
-  controllers: any[];
-  loading: boolean;
-  podError: string;
-};
-
-export default class StatusSection extends Component<PropsType, StateType> {
-  state = {
-    logs: [] as string[],
-    pods: [] as any[],
-    selectedPod: {} as any,
-    controllers: [] as any[],
-    loading: true,
-    podError: "",
-  };
+const StatusSectionFC: 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,
+          storage: StorageType.Secret,
+        },
+        {
+          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]);
 
-  renderLogs = () => {
+  const renderLogs = () => {
     return (
       <Logs
-        podError={this.state.podError}
-        key={this.state.selectedPod?.metadata?.name}
-        selectedPod={this.state.selectedPod}
+        podError={podError}
+        key={selectedPod?.metadata?.name}
+        selectedPod={selectedPod}
       />
     );
   };
 
-  selectPod = (pod: any) => {
-    this.setState({
-      selectedPod: pod,
-    });
-  };
-
-  renderTabs = () => {
-    return this.state.controllers.map((c, i) => {
+  const renderTabs = () => {
+    return controllers.map((c, i) => {
       return (
         <ControllerTab
           // handle CronJob case
           key={c.metadata?.uid || c.uid}
-          selectedPod={this.state.selectedPod}
-          selectPod={this.selectPod.bind(this)}
-          selectors={this.props.selectors ? [this.props.selectors[i]] : null}
+          selectedPod={selectedPod}
+          selectPod={setSelectedPod}
+          selectors={selectors ? [selectors[i]] : null}
           controller={c}
-          isLast={i === this.state.controllers?.length - 1}
+          isLast={i === controllers?.length - 1}
           isFirst={i === 0}
-          setPodError={(x: string) => this.setState({ podError: x })}
+          setPodError={(x: string) => setPodError(x)}
         />
       );
     });
   };
 
-  renderStatusSection = () => {
-    if (this.state.loading) {
+  const renderStatusSection = () => {
+    if (isLoading) {
       return (
         <NoControllers>
           <Loading />
         </NoControllers>
       );
     }
-    if (this.state.controllers?.length > 0) {
+    if (controllers?.length > 0) {
       return (
         <Wrapper>
-          <TabWrapper>{this.renderTabs()}</TabWrapper>
-          {this.renderLogs()}
+          <TabWrapper>{renderTabs()}</TabWrapper>
+          {renderLogs()}
         </Wrapper>
       );
     }
 
-    if (this.props.currentChart.chart.metadata.name === "job") {
+    if (currentChart?.chart?.metadata?.name === "job") {
       return (
         <NoControllers>
           <i className="material-icons">category</i>
@@ -102,45 +128,10 @@ export default class StatusSection extends Component<PropsType, StateType> {
     );
   };
 
-  componentDidMount() {
-    const { currentChart } = this.props;
-    let { currentCluster, currentProject, setCurrentError } = this.context;
-
-    api
-      .getChartControllers(
-        "<token>",
-        {
-          namespace: currentChart.namespace,
-          cluster_id: currentCluster.id,
-          storage: StorageType.Secret,
-        },
-        {
-          id: currentProject.id,
-          name: currentChart.name,
-          revision: currentChart.version,
-        }
-      )
-      .then((res: any) => {
-        let controllers =
-          currentChart.chart.metadata.name == "job"
-            ? res.data[0]?.status.active
-            : res.data;
-        this.setState({ controllers, loading: false });
-      })
-      .catch((err) => {
-        setCurrentError(JSON.stringify(err));
-        this.setState({ controllers: [], loading: false });
-      });
-  }
-
-  render() {
-    return (
-      <StyledStatusSection>{this.renderStatusSection()}</StyledStatusSection>
-    );
-  }
-}
+  return <StyledStatusSection>{renderStatusSection()}</StyledStatusSection>;
+};
 
-StatusSection.contextType = Context;
+export default StatusSectionFC;
 
 const TabWrapper = styled.div`
   width: 35%;

+ 1 - 1
dashboard/src/shared/hooks/useWebsockets.ts

@@ -1,6 +1,6 @@
 import { useRef } from "react";
 
-interface NewWebsocketOptions {
+export interface NewWebsocketOptions {
   onopen?: () => void;
   onmessage?: (evt: MessageEvent) => void;
   onerror?: (err: ErrorEvent) => void;

+ 2 - 0
dashboard/src/shared/types.tsx

@@ -19,6 +19,8 @@ export interface DetailedIngressError {
 }
 
 export interface ChartType {
+  image_repo_uri: string;
+  git_action_config: any;
   name: string;
   info: {
     last_deployed: string;

+ 53 - 9
docs/developing/setup.md

@@ -22,21 +22,65 @@ DB_NAME=porter
 SQL_LITE=false
 ```
 
-Once you've done this, go to the root repository, and run `docker-compose -f docker-compose.dev.yaml up`. You should see postgres, webpack, and porter containers spin up. When the webpack and porter containers have finished compiling and have spun up successfully (this will take 5-10 minutes after the containers start), you can navigate to `localhost:8080` and you should be greeted with the "Log In" screen.
+Once you've done this, go to the root repository, and run `docker-compose -f docker-compose.dev.yaml up`. You should see postgres, webpack, and porter containers spin up. When the webpack and porter containers have finished compiling and have spun up successfully (this will take 5-10 minutes after the containers start), you can navigate to `localhost:8080` and you should be greeted with the "Log In" screen. Create a user by entering an email/password on the "Register" screen. 
 
-Next, register your admin account. Once it's complete, it will ask you to verify your email; we will manually verify it through Postgres.
+At this point, you can make a change to any `.go` file to trigger a backend rebuild, and any file in `/dashboard/src` to trigger a hot reload. 
 
-Open your terminal in the root repository and enter:
+## Getting PostgreSQL Access
+
+You can get `psql` access by running the following:
 
 `psql --host localhost --port 5400 --username porter --dbname porter -W`
 
-It will promt you for a password. Enter `porter`
+This will prompt you for a password. Enter `porter`, and you should see the `psql` shell!
+
+### Setting your email to be verified 
 
-Next, update your email in the database to `verified":
+If you are getting blocked out of the dashboard because your email is not verified (fixed in `v0.6.2` of Porter, so make sure you've pulled from `master` recently), you can update your email in the database to `verified":
 
 `UPDATE users SET email_verified='t' WHERE id=1;`
 
-At this point, you can make a change to any `.go` file to trigger a backend rebuild, and any file in `/dashboard/src` to trigger a hot reload.
+## Setting up Minikube
+
+These steps will help you get set up with a minikube cluster that can be used for development. Prerequisities:
+- `kubectl` installed locally
+- Development instance of Porter is running
+
+Following the OS-specific steps to get minikube running:
+- [MacOS](#macos)
+- [Linux](#linux)
+
+If you now navigate to `http://localhost:8080`, you should see the minikube cluster attached! There will be some limitations:
+- **It is not possible to expose a service that you create. Whenever you create a web service, de-select the "Expose to external traffic" option.**
+
+
+### MacOS
+
+1. [Install minikube](https://minikube.sigs.k8s.io/docs/start/), and install the `hyperkit` driver. The easiest way to do this is via:
+
+```sh
+brew install minikube
+brew install hyperkit
+```
+
+2. Start minikube with the `hyperkit` driver:
+
+```sh
+minikube start --driver hyperkit
+```
+
+3. Make sure that you've downloaded the latest version of the Porter CLI, and that your development version of Porter is running. Then run:
+
+```sh
+porter config set-host http://localhost:8080
+porter auth login
+```
+
+4. Make sure that `minikube` is selected as the current context (`kubectl config current-context`), and then run:
+
+```sh
+porter connect kubeconfig
+```
 
 ## Setup for WSL
 
@@ -45,9 +89,9 @@ Follow the steps to install WSL on Windows here https://docs.microsoft.com/en-us
 ### Requirements
 
 `sudo apt install xdg-utils` <br/>
-`sudo apt install postgres`
+`sudo apt install postgresql`
 
-### Setup Proccess
+### Setup Process
 
 Once WSL is installed, head to docker and enable WSL Integration.
 ![Docker Enable WSL Integration](https://i.imgur.com/QzMyxQx.png)
@@ -77,4 +121,4 @@ If using Chrome, paste the following into the Chrome address bar:
 
 And then Enable the **Allow invalid certificates for resources loaded from localhost** field. 
 
-Finally, run `docker-compose -f docker-compose.dev-secure.yaml up` instead of the standard docker-compose file. 
+Finally, run `docker-compose -f docker-compose.dev-secure.yaml up` instead of the standard docker-compose file.