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

Merge pull request #824 from porter-dev/master

Live update chart revision -> staging
abelanger5 4 лет назад
Родитель
Сommit
77f485fb9e

BIN
dashboard/src/assets/node.png


+ 86 - 0
dashboard/src/components/StatusSection.tsx

@@ -0,0 +1,86 @@
+import React, { Component } from "react";
+import styled from "styled-components";
+import loading from "assets/loading.gif";
+
+type PropsType = {
+  status: string;
+};
+
+type StateType = {};
+
+// TODO: replace StatusIndicator
+export default class StatusSection extends Component<PropsType, StateType> {
+  renderIndicator = (status: string) => {
+    if (status == "loading") {
+      return (
+        <div>
+          <Spinner src={loading} />
+        </div>
+      );
+    }
+
+    return (
+      <div>
+        <StatusColor status={status} />
+      </div>
+    );
+  };
+
+  render() {
+    return (
+      <Status>
+        {this.renderIndicator(this.props.status)}
+        {this.props.status}
+      </Status>
+    );
+  }
+}
+
+const Spinner = styled.img`
+  width: 15px;
+  height: 15px;
+  margin-right: 15px;
+  margin-bottom: -3px;
+`;
+
+const StatusColor = styled.div`
+  margin-top: 1px;
+  max-width: 8px;
+  max-height: 8px;
+  min-width: 8px;
+  min-height: 8px;
+  width: 8px;
+  height: 8px;
+  background: ${(props: { status: string }) =>
+    props.status === "deployed" || props.status === "healthy"
+      ? "#4797ff"
+      : props.status === "failed"
+      ? "#ed5f85"
+      : props.status === "completed"
+      ? "#00d12a"
+      : "#f5cb42"};
+  border-radius: 4px;
+  margin-left: 3px;
+  margin-right: 16px;
+`;
+
+const Status = styled.div`
+  display: flex;
+  height: 20px;
+  font-size: 13px;
+  flex-direction: row;
+  text-transform: capitalize;
+  align-items: center;
+  font-family: "Work Sans", sans-serif;
+  color: #aaaabb;
+  animation: fadeIn 0.5s;
+
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;

+ 12 - 3
dashboard/src/components/Table.tsx

@@ -82,6 +82,7 @@ const Table: React.FC<TableProps> = ({
           return (
             <StyledTr
               {...row.getRowProps()}
+              enablePointer={!!onRowClick}
               onClick={() => onRowClick && onRowClick(row)}
               selected={false}
             >
@@ -129,14 +130,21 @@ const TableWrapper = styled.div`
   padding-bottom: 20px;
 `;
 
+type StyledTrProps = {
+  enablePointer?: boolean;
+  disableHover?: boolean;
+  selected?: boolean;
+};
+
 export const StyledTr = styled.tr`
   line-height: 2.2em;
-  background: ${(props: { disableHover?: boolean; selected?: boolean }) =>
-    props.selected ? "#ffffff11" : ""};
+  background: ${(props: StyledTrProps) => (props.selected ? "#ffffff11" : "")};
   :hover {
-    background: ${(props: { disableHover?: boolean; selected?: boolean }) =>
+    background: ${(props: StyledTrProps) =>
       props.disableHover ? "" : "#ffffff22"};
   }
+  cursor: ${(props: StyledTrProps) =>
+    props.enablePointer ? "pointer" : "unset"};
 `;
 
 export const StyledTd = styled.td`
@@ -148,6 +156,7 @@ export const StyledTd = styled.td`
   :last-child {
     padding-right: 10px;
   }
+  user-select: text;
 `;
 
 export const StyledTHead = styled.thead`

+ 13 - 1
dashboard/src/components/values-form/FormWrapper.tsx

@@ -201,7 +201,16 @@ export default class FormWrapper extends Component<PropsType, StateType> {
         });
       }
       if (this.props.tabOptions?.length > 0) {
-        tabOptions = tabOptions.concat(this.props.tabOptions);
+        let prependTabs = [] as { value: string; label: string }[];
+        let appendTabs = [] as { value: string; label: string }[];
+        this.props.tabOptions.forEach((tab: { value: string; label: string }) => {
+          if (tab.value === "status" || tab.value === "metrics") {
+            prependTabs.push(tab);
+          } else {
+            appendTabs.push(tab);
+          }
+        });
+        tabOptions = prependTabs.concat(tabOptions.concat(appendTabs));
       }
       this.setState({ tabOptions }, callback);
     }
@@ -258,6 +267,9 @@ export default class FormWrapper extends Component<PropsType, StateType> {
       !_.isEqual(prevProps.tabOptions, this.props.tabOptions) ||
       !_.isEqual(prevProps.formData, this.props.formData)
     ) {
+      if (prevProps.tabOptions?.length === 0 && !_.isEqual(prevProps.tabOptions, this.props.tabOptions)) {
+        this.setState({ currentTab: "status" });
+      }
       let formHasChanged = !_.isEqual(prevProps.formData, this.props.formData);
       this.updateTabs(formHasChanged);
     }

+ 3 - 3
dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx

@@ -22,7 +22,7 @@ import ExpandedChartWrapper from "./expanded-chart/ExpandedChartWrapper";
 import { RouteComponentProps, withRouter } from "react-router";
 
 import api from "shared/api";
-import { Dashboard } from "./dashboard/Dashboard";
+import DashboardRoutes from "./dashboard/Routes";
 
 type PropsType = RouteComponentProps & {
   currentCluster: ClusterType;
@@ -207,7 +207,7 @@ class ClusterDashboard extends Component<PropsType, StateType> {
           {this.renderContents()}
         </Route>
         <Route path={["/cluster-dashboard"]}>
-          <Dashboard />
+          <DashboardRoutes />
         </Route>
       </Switch>
     );
@@ -388,7 +388,7 @@ const TitleSection = styled.div`
   > i {
     margin-left: 10px;
     cursor: pointer;
-    font-size 18px;
+    font-size: 18px;
     color: #858FAAaa;
     padding: 5px;
     border-radius: 100px;

+ 11 - 3
dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx

@@ -11,9 +11,14 @@ import api from "shared/api";
 type Props = {
   chart: ChartType;
   controllers: Record<string, any>;
+  release: any;
 };
 
-const Chart: React.FunctionComponent<Props> = ({ chart, controllers }) => {
+const Chart: React.FunctionComponent<Props> = ({
+  chart,
+  controllers,
+  release,
+}) => {
   const [expand, setExpand] = useState<boolean>(false);
   const [chartControllers, setChartControllers] = useState<any>([]);
   const context = useContext(Context);
@@ -105,7 +110,10 @@ const Chart: React.FunctionComponent<Props> = ({ chart, controllers }) => {
             margin_left={"17px"}
           />
           <LastDeployed>
-            <Dot>•</Dot> Last deployed {readableDate(chart.info.last_deployed)}
+            <Dot>•</Dot> Last deployed{" "}
+            {readableDate(
+              release?.info?.last_deployed || chart.info.last_deployed
+            )}
           </LastDeployed>
         </InfoWrapper>
 
@@ -115,7 +123,7 @@ const Chart: React.FunctionComponent<Props> = ({ chart, controllers }) => {
         </TagWrapper>
       </BottomWrapper>
 
-      <Version>v{chart.version}</Version>
+      <Version>v{release?.version || chart.version}</Version>
     </StyledChart>
   );
 };

+ 282 - 238
dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx

@@ -1,238 +1,282 @@
-import React, { useContext, useEffect, useState } from "react";
-import styled from "styled-components";
-
-import { Context } from "shared/Context";
-import api from "shared/api";
-import { ChartType, StorageType, ClusterType } from "shared/types";
-import { PorterUrl } from "shared/routing";
-
-import Chart from "./Chart";
-import Loading from "components/Loading";
-import { useWebsockets } from "shared/hooks/useWebsockets";
-
-type Props = {
-  currentCluster: ClusterType;
-  namespace: string;
-  // TODO Convert to enum
-  sortType: string;
-  currentView: PorterUrl;
-};
-
-const ChartList: React.FunctionComponent<Props> = ({
-  namespace,
-  sortType,
-  currentView,
-}) => {
-  const {
-    newWebsocket,
-    openWebsocket,
-    closeWebsocket,
-    closeAllWebsockets,
-  } = useWebsockets();
-  const [charts, setCharts] = useState<ChartType[]>([]);
-  const [controllers, setControllers] = useState<
-    Record<string, Record<string, any>>
-  >({});
-  const [isLoading, setIsLoading] = useState(false);
-  const [isError, setIsError] = useState(false);
-
-  const context = useContext(Context);
-
-  const updateCharts = async () => {
-    try {
-      const { currentCluster, currentProject } = context;
-      setIsLoading(true);
-      const res = await api.getCharts(
-        "<token>",
-        {
-          namespace: namespace,
-          cluster_id: currentCluster.id,
-          storage: StorageType.Secret,
-          limit: 50,
-          skip: 0,
-          byDate: false,
-          statusFilter: [
-            "deployed",
-            "uninstalled",
-            "pending",
-            "pending-install",
-            "pending-upgrade",
-            "pending-rollback",
-            "superseded",
-            "failed",
-          ],
-        },
-        { id: currentProject.id }
-      );
-      const charts = res.data || [];
-
-      // filter charts based on the current view
-      const filteredCharts = charts.filter((chart: ChartType) => {
-        return (
-          (currentView == "jobs" && chart.chart.metadata.name == "job") ||
-          ((currentView == "applications" ||
-            currentView == "cluster-dashboard") &&
-            chart.chart.metadata.name != "job")
-        );
-      });
-
-      let sortedCharts = filteredCharts;
-
-      if (sortType == "Newest") {
-        sortedCharts.sort((a: any, b: any) =>
-          Date.parse(a.info.last_deployed) > Date.parse(b.info.last_deployed)
-            ? -1
-            : 1
-        );
-      } else if (sortType == "Oldest") {
-        sortedCharts.sort((a: any, b: any) =>
-          Date.parse(a.info.last_deployed) > Date.parse(b.info.last_deployed)
-            ? 1
-            : -1
-        );
-      } else if (sortType == "Alphabetical") {
-        sortedCharts.sort((a: any, b: any) => (a.name > b.name ? 1 : -1));
-      }
-
-      setIsError(false);
-      return sortedCharts;
-    } catch (error) {
-      console.log(error);
-      context.setCurrentError(JSON.stringify(error));
-      setIsError(true);
-    }
-  };
-
-  const setupWebsocket = (kind: string) => {
-    let { currentCluster, currentProject } = context;
-    const apiPath = `/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`;
-
-    const wsConfig = {
-      onopen: () => {
-        console.log("connected to websocket");
-      },
-      onmessage: (evt: MessageEvent) => {
-        let event = JSON.parse(evt.data);
-        let object = event.Object;
-        object.metadata.kind = event.Kind;
-
-        setControllers((oldControllers) => ({
-          ...oldControllers,
-          [object.metadata.uid]: object,
-        }));
-      },
-      onclose: () => {
-        console.log("closing websocket");
-      },
-      onerror: (err: ErrorEvent) => {
-        console.log(err);
-        closeWebsocket(kind);
-      },
-    };
-
-    newWebsocket(kind, apiPath, wsConfig);
-
-    openWebsocket(kind);
-  };
-
-  const setControllerWebsockets = (controllers: any[]) => {
-    controllers.map((kind: string) => {
-      return setupWebsocket(kind);
-    });
-  };
-
-  // Setup basic websockets on start
-  useEffect(() => {
-    setControllerWebsockets([
-      "deployment",
-      "statefulset",
-      "daemonset",
-      "replicaset",
-    ]);
-
-    return () => {
-      closeAllWebsockets();
-    };
-  }, []);
-
-  useEffect(() => {
-    let isSubscribed = true;
-
-    if (namespace || namespace === "") {
-      updateCharts().then((charts) => {
-        if (isSubscribed) {
-          setCharts(charts);
-          setIsLoading(false);
-        }
-      });
-    }
-    return () => (isSubscribed = false);
-  }, [namespace, currentView]);
-
-  const renderChartList = () => {
-    if (isLoading || (!namespace && namespace !== "")) {
-      return (
-        <LoadingWrapper>
-          <Loading />
-        </LoadingWrapper>
-      );
-    } else if (isError) {
-      return (
-        <Placeholder>
-          <i className="material-icons">error</i> Error connecting to cluster.
-        </Placeholder>
-      );
-    } else if (charts.length === 0) {
-      return (
-        <Placeholder>
-          <i className="material-icons">category</i> No
-          {currentView === "jobs" ? ` jobs` : ` charts`} found in this
-          namespace.
-        </Placeholder>
-      );
-    }
-
-    return charts.map((chart: ChartType, i: number) => {
-      return (
-        <Chart
-          key={`${chart.namespace}-${chart.name}`}
-          chart={chart}
-          controllers={controllers || {}}
-        />
-      );
-    });
-  };
-
-  return <StyledChartList>{renderChartList()}</StyledChartList>;
-};
-
-export default ChartList;
-
-const Placeholder = styled.div`
-  width: 100%;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  color: #ffffff44;
-  background: #26282f;
-  border-radius: 5px;
-  height: 320px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  color: #ffffff44;
-  font-size: 13px;
-
-  > i {
-    font-size: 16px;
-    margin-right: 12px;
-  }
-`;
-
-const LoadingWrapper = styled.div`
-  padding-top: 100px;
-`;
-
-const StyledChartList = styled.div`
-  padding-bottom: 85px;
-`;
+import React, { useContext, useEffect, useState } from "react";
+import styled from "styled-components";
+
+import { Context } from "shared/Context";
+import api from "shared/api";
+import { ChartType, StorageType, ClusterType } from "shared/types";
+import { PorterUrl } from "shared/routing";
+
+import Chart from "./Chart";
+import Loading from "components/Loading";
+import { useWebsockets } from "shared/hooks/useWebsockets";
+
+type Props = {
+  currentCluster: ClusterType;
+  namespace: string;
+  // TODO Convert to enum
+  sortType: string;
+  currentView: PorterUrl;
+};
+
+const ChartList: React.FunctionComponent<Props> = ({
+  namespace,
+  sortType,
+  currentView,
+}) => {
+  const {
+    newWebsocket,
+    openWebsocket,
+    closeWebsocket,
+    closeAllWebsockets,
+  } = useWebsockets();
+  const [charts, setCharts] = useState<ChartType[]>([]);
+  const [controllers, setControllers] = useState<
+    Record<string, Record<string, any>>
+  >({});
+  const [releases, setReleases] = useState<Record<string, any>>({});
+  const [isLoading, setIsLoading] = useState(false);
+  const [isError, setIsError] = useState(false);
+
+  const context = useContext(Context);
+
+  const updateCharts = async () => {
+    try {
+      const { currentCluster, currentProject } = context;
+      setIsLoading(true);
+      const res = await api.getCharts(
+        "<token>",
+        {
+          namespace: namespace,
+          cluster_id: currentCluster.id,
+          storage: StorageType.Secret,
+          limit: 50,
+          skip: 0,
+          byDate: false,
+          statusFilter: [
+            "deployed",
+            "uninstalled",
+            "pending",
+            "pending-install",
+            "pending-upgrade",
+            "pending-rollback",
+            "superseded",
+            "failed",
+          ],
+        },
+        { id: currentProject.id }
+      );
+      const charts = res.data || [];
+
+      // filter charts based on the current view
+      const filteredCharts = charts.filter((chart: ChartType) => {
+        return (
+          (currentView == "jobs" && chart.chart.metadata.name == "job") ||
+          ((currentView == "applications" ||
+            currentView == "cluster-dashboard") &&
+            chart.chart.metadata.name != "job")
+        );
+      });
+
+      let sortedCharts = filteredCharts;
+
+      if (sortType == "Newest") {
+        sortedCharts.sort((a: any, b: any) =>
+          Date.parse(a.info.last_deployed) > Date.parse(b.info.last_deployed)
+            ? -1
+            : 1
+        );
+      } else if (sortType == "Oldest") {
+        sortedCharts.sort((a: any, b: any) =>
+          Date.parse(a.info.last_deployed) > Date.parse(b.info.last_deployed)
+            ? 1
+            : -1
+        );
+      } else if (sortType == "Alphabetical") {
+        sortedCharts.sort((a: any, b: any) => (a.name > b.name ? 1 : -1));
+      }
+
+      setIsError(false);
+      return sortedCharts;
+    } catch (error) {
+      console.log(error);
+      context.setCurrentError(JSON.stringify(error));
+      setIsError(true);
+    }
+  };
+
+  const setupHelmReleasesWebsocket = () => {
+    const apiPath = `/api/projects/${context.currentProject.id}/k8s/helm_releases?cluster_id=${context.currentCluster.id}`;
+
+    const wsConfig = {
+      onopen: () => {
+        console.log("connected to chart live updates websocket");
+      },
+      onmessage: (evt: MessageEvent) => {
+        let event = JSON.parse(evt.data);
+        const object = event.Object;
+        setReleases((oldReleases) => {
+          const currentRelease = oldReleases[object?.name];
+          const currentReleaseVersion = Number(currentRelease?.version);
+          const newReleaseVersion = Number(object?.version);
+          if (currentReleaseVersion > newReleaseVersion) {
+            return {
+              ...oldReleases,
+            };
+          }
+
+          return {
+            ...oldReleases,
+            [object.name]: object,
+          };
+        });
+      },
+
+      onclose: () => {
+        console.log("closing chart live updates websocket");
+      },
+
+      onerror: (err: ErrorEvent) => {
+        console.log(err);
+        closeWebsocket("helm_releases");
+      },
+    };
+
+    newWebsocket("helm_releases", apiPath, wsConfig);
+    openWebsocket("helm_releases");
+  };
+
+  const setupWebsocket = (kind: string) => {
+    let { currentCluster, currentProject } = context;
+    const apiPath = `/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`;
+
+    const wsConfig = {
+      onopen: () => {
+        console.log("connected to websocket");
+      },
+      onmessage: (evt: MessageEvent) => {
+        let event = JSON.parse(evt.data);
+        let object = event.Object;
+        object.metadata.kind = event.Kind;
+
+        setControllers((oldControllers) => ({
+          ...oldControllers,
+          [object.metadata.uid]: object,
+        }));
+      },
+      onclose: () => {
+        console.log("closing websocket");
+      },
+      onerror: (err: ErrorEvent) => {
+        console.log(err);
+        closeWebsocket(kind);
+      },
+    };
+
+    newWebsocket(kind, apiPath, wsConfig);
+
+    openWebsocket(kind);
+  };
+
+  const setControllerWebsockets = (controllers: any[]) => {
+    controllers.map((kind: string) => {
+      return setupWebsocket(kind);
+    });
+  };
+
+  // Setup basic websockets on start
+  useEffect(() => {
+    setControllerWebsockets([
+      "deployment",
+      "statefulset",
+      "daemonset",
+      "replicaset",
+    ]);
+    setupHelmReleasesWebsocket();
+
+    return () => {
+      closeAllWebsockets();
+    };
+  }, []);
+
+  useEffect(() => {
+    let isSubscribed = true;
+
+    if (namespace || namespace === "") {
+      updateCharts().then((charts) => {
+        if (isSubscribed) {
+          setCharts(charts);
+          setIsLoading(false);
+        }
+      });
+    }
+    return () => (isSubscribed = false);
+  }, [namespace, currentView]);
+
+  const renderChartList = () => {
+    if (isLoading || (!namespace && namespace !== "")) {
+      return (
+        <LoadingWrapper>
+          <Loading />
+        </LoadingWrapper>
+      );
+    } else if (isError) {
+      return (
+        <Placeholder>
+          <i className="material-icons">error</i> Error connecting to cluster.
+        </Placeholder>
+      );
+    } else if (charts.length === 0) {
+      return (
+        <Placeholder>
+          <i className="material-icons">category</i> No
+          {currentView === "jobs" ? ` jobs` : ` charts`} found in this
+          namespace.
+        </Placeholder>
+      );
+    }
+
+    return charts.map((chart: ChartType, i: number) => {
+      return (
+        <Chart
+          key={`${chart.namespace}-${chart.name}`}
+          chart={chart}
+          controllers={controllers || {}}
+          release={releases[chart.name] || {}}
+        />
+      );
+    });
+  };
+
+  return <StyledChartList>{renderChartList()}</StyledChartList>;
+};
+
+export default ChartList;
+
+const Placeholder = styled.div`
+  width: 100%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  color: #ffffff44;
+  background: #26282f;
+  border-radius: 5px;
+  height: 320px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff44;
+  font-size: 13px;
+
+  > i {
+    font-size: 16px;
+    margin-right: 12px;
+  }
+`;
+
+const LoadingWrapper = styled.div`
+  padding-top: 100px;
+`;
+
+const StyledChartList = styled.div`
+  padding-bottom: 85px;
+`;

+ 34 - 22
dashboard/src/main/home/cluster-dashboard/dashboard/NodeList.tsx

@@ -1,26 +1,19 @@
 import React, { useContext, useEffect, useMemo, useState } from "react";
 
 import Table from "components/Table";
-import { Column } from "react-table";
+import { Column, Row } from "react-table";
 import styled from "styled-components";
 import api from "shared/api";
 import { Context } from "shared/Context";
-import { NodeStatusModal } from "./NodeStatusModal";
+import { pushFiltered } from "shared/routing";
+import { useHistory, useLocation } from "react-router";
 
 const NodeList: React.FC = () => {
   const context = useContext(Context);
   const [nodeList, setNodeList] = useState([]);
   const [loading, setLoading] = useState<boolean>(false);
-  const [selectedNode, setSelectedNode] = useState<any>(undefined);
-
-  const triggerPopUp = (node?: any) => {
-    if (node) {
-      setSelectedNode(node);
-      return;
-    }
-
-    setSelectedNode(undefined);
-  };
+  const history = useHistory();
+  const location = useLocation();
 
   const columns = useMemo<Column<any>[]>(
     () => [
@@ -28,6 +21,10 @@ const NodeList: React.FC = () => {
         Header: "Node name",
         accessor: "name",
       },
+      {
+        Header: "Machine type",
+        accessor: "machine_type",
+      },
       {
         Header: "CPU Usage",
         accessor: "cpu_usage",
@@ -42,10 +39,7 @@ const NodeList: React.FC = () => {
         Cell: ({ row }) => {
           return (
             <StatusButtonWrapper>
-              <StatusButton
-                success={row.values.is_node_healthy}
-                onClick={() => triggerPopUp(row.original)}
-              >
+              <StatusButton success={row.values.is_node_healthy}>
                 {row.values.is_node_healthy ? "Healthy" : "Unhealthy"}
               </StatusButton>
             </StatusButtonWrapper>
@@ -60,12 +54,17 @@ const NodeList: React.FC = () => {
     const percentFormatter = (number: number) =>
       `${Number(number).toFixed(2)}%`;
 
+    const getMachineType = (labels: any) => {
+      return (labels && labels["node.kubernetes.io/instance-type"]) || "N/A";
+    };
+
     return nodeList
       .map((node) => {
         return {
           name: node.name,
-          cpu_usage: percentFormatter(node.cpu_reqs),
-          ram_usage: percentFormatter(node.memory_reqs),
+          machine_type: getMachineType(node?.labels),
+          cpu_usage: percentFormatter(node.fraction_cpu_reqs),
+          ram_usage: percentFormatter(node.fraction_memory_reqs),
           node_conditions: node.node_conditions,
           is_node_healthy: node.node_conditions.reduce(
             (prevValue: boolean, current: any) => {
@@ -113,14 +112,27 @@ const NodeList: React.FC = () => {
       .finally(() => setLoading(false));
   }, [context, setNodeList]);
 
+  const handleOnRowClick = (row: any) => {
+    pushFiltered(
+      {
+        history,
+        location,
+      },
+      `/cluster-dashboard/node-view/${row.original.name}`,
+      []
+    );
+  };
+
   return (
     <NodeListWrapper>
       <StyledChart>
-        <Table columns={columns} data={data} isLoading={loading} />
+        <Table
+          columns={columns}
+          data={data}
+          isLoading={loading}
+          onRowClick={handleOnRowClick}
+        />
       </StyledChart>
-      {selectedNode && (
-        <NodeStatusModal node={selectedNode} onClose={() => triggerPopUp()} />
-      )}
     </NodeListWrapper>
   );
 };

+ 22 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/Routes.tsx

@@ -0,0 +1,22 @@
+import React from "react";
+import { Route, Switch, useRouteMatch } from "react-router";
+import { Dashboard } from "./Dashboard";
+import ExpandedNodeView from "./node-view/ExpandedNodeView";
+
+export const Routes = () => {
+  const { url } = useRouteMatch();
+  return (
+    <>
+      <Switch>
+        <Route path={`${url}/node-view/:nodeId`}>
+          <ExpandedNodeView />
+        </Route>
+        <Route path={`${url}/`}>
+          <Dashboard />
+        </Route>
+      </Switch>
+    </>
+  );
+};
+
+export default Routes;

+ 18 - 20
dashboard/src/main/home/cluster-dashboard/dashboard/NodeStatusModal.tsx → dashboard/src/main/home/cluster-dashboard/dashboard/node-view/ConditionsTable.tsx

@@ -1,21 +1,14 @@
 import React, { useMemo } from "react";
-import Modal from "../../modals/Modal";
 import Table from "components/Table";
 import { Column } from "react-table";
 import styled from "styled-components";
 
 type NodeStatusModalProps = {
-  onClose: () => void;
   node: any;
-  width?: string;
-  height?: string;
 };
 
-export const NodeStatusModal: React.FunctionComponent<NodeStatusModalProps> = ({
-  onClose,
+export const ConditionsTable: React.FunctionComponent<NodeStatusModalProps> = ({
   node,
-  width = "800px",
-  height = "min-content",
 }) => {
   const columns = useMemo<Column<any>[]>(
     () => [
@@ -35,27 +28,32 @@ export const NodeStatusModal: React.FunctionComponent<NodeStatusModalProps> = ({
         Header: "Message",
         accessor: "message",
       },
+      {
+        Header: "Last Transition",
+        accessor: "lastTransitionTime",
+        Cell: ({ row }) => {
+          const date = new Date(row.values.lastTransitionTime);
+          return <>{date.toLocaleString()}</>;
+        },
+      },
     ],
     []
   );
 
-  const data = useMemo(() => {
+  const data = useMemo<Array<any>>(() => {
     return node?.node_conditions || [];
   }, [node]);
 
   return (
     <div>
-      <Modal onRequestClose={onClose} width={width} height={height}>
-        Node {node?.name} conditions:
-        <TableWrapper>
-          <Table
-            columns={columns}
-            data={data}
-            isLoading={false}
-            disableGlobalFilter={true}
-          />
-        </TableWrapper>
-      </Modal>
+      <TableWrapper>
+        <Table
+          columns={columns}
+          data={data}
+          isLoading={!data.length}
+          disableGlobalFilter={true}
+        />
+      </TableWrapper>
     </div>
   );
 };

+ 249 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/node-view/ExpandedNodeView.tsx

@@ -0,0 +1,249 @@
+import React, { useContext, useEffect, useMemo, useState } from "react";
+import { useHistory, useLocation, useParams } from "react-router";
+import styled from "styled-components";
+import closeImg from "assets/close.png";
+import api from "shared/api";
+import { Context } from "shared/Context";
+
+import nodePng from "assets/node.png";
+import TabSelector from "components/TabSelector";
+import { pushFiltered } from "shared/routing";
+import NodeUsage from "./NodeUsage";
+import { ConditionsTable } from "./ConditionsTable";
+import StatusSection from "components/StatusSection";
+
+type ExpandedNodeViewParams = {
+  nodeId: string;
+};
+
+type TabEnum = "conditions";
+
+const tabOptions: {
+  label: string;
+  value: TabEnum;
+}[] = [{ label: "Conditions", value: "conditions" }];
+
+export const ExpandedNodeView = () => {
+  const { nodeId } = useParams<ExpandedNodeViewParams>();
+  const history = useHistory();
+  const location = useLocation();
+  const { currentCluster, currentProject } = useContext(Context);
+  const [node, setNode] = useState(undefined);
+  const [currentTab, setCurrentTab] = useState("conditions");
+
+  useEffect(() => {
+    let isSubscribed = true;
+    api
+      .getClusterNode(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          nodeName: nodeId,
+        }
+      )
+      .then((res) => {
+        if (isSubscribed) {
+          setNode(res.data);
+        }
+      });
+
+    return () => (isSubscribed = false);
+  }, [nodeId, currentCluster.id, currentProject.id]);
+
+  const closeNodeView = () => {
+    pushFiltered({ history, location }, "/cluster-dashboard", []);
+  };
+
+  const instanceType = useMemo(() => {
+    const instanceType =
+      node?.labels && node?.labels["node.kubernetes.io/instance-type"];
+    if (instanceType) {
+      return ` (${instanceType})`;
+    }
+    return "";
+  }, [node?.labels]);
+
+  const currentTabPage = useMemo(() => {
+    switch (currentTab) {
+      case "conditions":
+      default:
+        return <ConditionsTable node={node} />;
+    }
+  }, [currentTab, node]);
+
+  const nodeStatus = useMemo(() => {
+    if (!node || !node.node_conditions) {
+      return "loading";
+    }
+
+    return node.node_conditions.reduce((prevValue: boolean, current: any) => {
+      if (current.type !== "Ready" && current.status !== "False") {
+        return "failed";
+      }
+      if (current.type === "Ready" && current.status !== "True") {
+        return "failed";
+      }
+      return prevValue;
+    }, "healthy");
+  }, [node]);
+
+  return (
+    <>
+      <CloseOverlay onClick={closeNodeView} />
+      <StyledExpandedChart>
+        <HeaderWrapper>
+          <TitleSection>
+            <Title>
+              <IconWrapper>
+                <img src={nodePng} />
+              </IconWrapper>
+              {nodeId}
+              <InstanceType>{instanceType}</InstanceType>
+            </Title>
+          </TitleSection>
+
+          <CloseButton onClick={closeNodeView}>
+            <CloseButtonImg src={closeImg} />
+          </CloseButton>
+        </HeaderWrapper>
+        <BodyWrapper>
+          <NodeUsage node={node} />
+
+          <StatusWrapper>
+            <StatusSection status={nodeStatus} />
+          </StatusWrapper>
+
+          <TabSelector
+            options={tabOptions}
+            currentTab={currentTab}
+            setCurrentTab={(value: TabEnum) => setCurrentTab(value)}
+          />
+          {currentTabPage}
+        </BodyWrapper>
+      </StyledExpandedChart>
+    </>
+  );
+};
+
+export default ExpandedNodeView;
+
+const StatusWrapper = styled.div`
+  margin-left: 3px;
+  margin-bottom: 15px;
+`;
+
+const InstanceType = styled.div`
+  font-weight: 400;
+  color: #ffffff44;
+  margin-left: 12px;
+`;
+
+const BodyWrapper = styled.div`
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+`;
+
+const HeaderWrapper = styled.div``;
+
+const CloseOverlay = styled.div`
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  background: #202227;
+  animation: fadeIn 0.2s 0s;
+  opacity: 0;
+  animation-fill-mode: forwards;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const IconWrapper = styled.div`
+  font-size: 16px;
+  height: 20px;
+  width: 20px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 3px;
+  margin-right: 12px;
+
+  > img {
+    filter: brightness(50%);
+    width: 18px;
+  }
+`;
+
+const Title = styled.div`
+  font-size: 18px;
+  font-weight: 500;
+  display: flex;
+  align-items: center;
+  user-select: text;
+`;
+
+const TitleSection = styled.div`
+  width: 100%;
+  position: relative;
+`;
+
+const CloseButton = styled.div`
+  position: absolute;
+  display: block;
+  width: 40px;
+  height: 40px;
+  padding: 13px 0 12px 0;
+  text-align: center;
+  border-radius: 50%;
+  right: 15px;
+  top: 12px;
+  cursor: pointer;
+  :hover {
+    background-color: #ffffff11;
+  }
+`;
+
+const CloseButtonImg = styled.img`
+  width: 14px;
+  margin: 0 auto;
+`;
+
+const StyledExpandedChart = styled.div`
+  width: calc(100% - 50px);
+  height: calc(100% - 50px);
+  z-index: 0;
+  position: absolute;
+  top: 25px;
+  left: 25px;
+  border-radius: 10px;
+  background: #26272f;
+  box-shadow: 0 5px 12px 4px #00000033;
+  animation: floatIn 0.3s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+  padding: 25px;
+  display: flex;
+  overflow: hidden;
+  flex-direction: column;
+
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(30px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;

+ 125 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/node-view/NodeUsage.tsx

@@ -0,0 +1,125 @@
+import React from "react";
+import styled from "styled-components";
+
+type NodeUsageProps = {
+  node: any;
+};
+
+const NodeUsage: React.FunctionComponent<NodeUsageProps> = ({ node }) => {
+  const percentFormatter = (number: number) => `${Number(number).toFixed(2)}%`;
+
+  const formatMemoryUnitToMi = (memory: string) => {
+    if (memory.includes("Mi")) {
+      return memory;
+    }
+
+    if (memory.includes("Gi")) {
+      const [value] = memory.split("Gi");
+      const numValue = Number(value);
+      const giToMiValue = numValue * 1024;
+      return `${giToMiValue.toFixed()}Mi`;
+    }
+
+    if (memory.includes("Ki")) {
+      const [value] = memory.split("Ki");
+      const numValue = Number(value);
+      const kiToMiValue = numValue / 1024;
+      return `${kiToMiValue.toFixed()}Mi`;
+    }
+
+    const value = memory.replace(/[^0-9]/g, "");
+    const numValue = Number(value);
+    const unknownToMiValue = numValue * 1024 * 1024;
+    return `${unknownToMiValue.toFixed()}Mi`;
+  };
+
+  return (
+    <NodeUsageWrapper>
+      <Wrapper>
+        <UsageWrapper>
+          <span>
+            <Bolded>CPU:</Bolded>{" "}
+            {!node?.cpu_reqs && !node?.allocatable_cpu
+              ? "Loading..."
+              : `${percentFormatter(node?.fraction_cpu_reqs)} (${node?.cpu_reqs}/${
+                  node?.allocatable_cpu
+                }m)`}
+          </span>
+          <Buffer />
+          <span>
+            <Bolded>RAM:</Bolded>{" "}
+            {!node?.memory_reqs && !node?.allocatable_memory
+              ? "Loading..."
+              : `${percentFormatter(node?.fraction_memory_reqs)} (${formatMemoryUnitToMi(
+                  node?.memory_reqs
+                )}/${formatMemoryUnitToMi(
+                  node?.allocatable_memory
+                )})`}
+          </span>
+          <I onClick={() => window.open("https://kubernetes.io/docs/tasks/administer-cluster/reserve-compute-resources/#node-allocatable")} className="material-icons">help_outline</I>
+        </UsageWrapper>
+      </Wrapper>
+    </NodeUsageWrapper>
+  );
+};
+
+const I = styled.i`
+  display: flex;
+  align-items: center;
+  cursor: pointer;
+  font-size: 17px;
+  margin-left: 12px;
+  color: #858faaaa;
+  :hover {
+    color: #aaaabb;
+  }
+`;
+
+const Buffer = styled.div`
+  width: 17px;
+  height: 20px;
+`;
+
+const Wrapper = styled.div`
+  display: flex;
+`;
+
+const UsageWrapper = styled.div`
+  display: flex;
+  flex-direction: row;
+  font-size: 14px;
+  color: #aaaabb;
+  line-height: 24px;
+  user-select: text;
+  :not(last-child) {
+    margin-right: 20px;
+  }
+`;
+
+const Bolded = styled.span`
+  font-weight: 500;
+  color: #ffffff44;
+  margin-right: 6px;
+`;
+
+const Help = styled.a`
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+  margin-bottom: 5px;
+  width: fit-content;
+  :hover {
+    color: #ffffff;
+  }
+
+  > i {
+    margin-left: 5px;
+    font-size: 16px;
+  }
+`;
+
+const NodeUsageWrapper = styled.div`
+  margin: 14px 0px 10px;
+`;
+
+export default NodeUsage;

+ 68 - 4
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -3,6 +3,7 @@ import styled from "styled-components";
 import yaml from "js-yaml";
 import close from "assets/close.png";
 import _ from "lodash";
+import loading from "assets/loading.gif";
 
 import {
   ResourceType,
@@ -53,6 +54,8 @@ type StateType = {
   showDeleteOverlay: boolean;
   deleting: boolean;
   formData: any;
+  imageIsPlaceholder: boolean;
+  newestImage: string;
 };
 
 export default class ExpandedChart extends Component<PropsType, StateType> {
@@ -74,6 +77,8 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
     showDeleteOverlay: false,
     deleting: false,
     formData: {} as any,
+    imageIsPlaceholder: false,
+    newestImage: null as string,
   };
 
   // Retrieve full chart data (includes form and values)
@@ -97,8 +102,24 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
         }
       )
       .then((res) => {
+        let image = res.data?.config?.image?.repository;
+        let tag = res.data?.config?.image?.tag.toString();
+        let newestImage = tag ? image + ":" + tag : image;
+        let imageIsPlaceholder = false;
+        if (
+          (image === "porterdev/hello-porter" ||
+            image === "public.ecr.aws/o1j4x7p4/hello-porter") &&
+          !this.state.newestImage
+        ) {
+          imageIsPlaceholder = true;
+        } 
         this.updateComponents(
-          { currentChart: res.data, loading: false },
+          { 
+            currentChart: res.data, 
+            loading: false,
+            imageIsPlaceholder,
+            newestImage,
+          },
           res.data
         );
       })
@@ -362,7 +383,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
   };
 
   renderTabContents = (currentTab: string) => {
-    let { components, showRevisions } = this.state;
+    let { components, showRevisions, imageIsPlaceholder } = this.state;
     let { setSidebar } = this.props;
     let { currentChart } = this.state;
     let chart = currentChart;
@@ -371,7 +392,21 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
       case "metrics":
         return <MetricsSection currentChart={chart} />;
       case "status":
-        return <StatusSection currentChart={chart} />;
+        if (imageIsPlaceholder) {
+          return (
+            <Placeholder>
+              <TextWrap>
+                <Header>
+                  <Spinner src={loading} /> This application is currently being deployed
+                </Header>
+                Navigate to the "Actions" tab of your GitHub repo to view live
+                build logs.
+              </TextWrap>
+            </Placeholder>
+          );
+        } else {
+          return <StatusSection currentChart={chart} />;
+        }
       case "settings":
         return (
           <SettingsSection
@@ -746,6 +781,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
           </HeaderWrapper>
           <BodyWrapper>
             <FormWrapper
+              isReadOnly={this.state.imageIsPlaceholder}
               formData={this.state.formData}
               tabOptions={this.state.tabOptions}
               isInModal={true}
@@ -775,6 +811,34 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
 
 ExpandedChart.contextType = Context;
 
+const TextWrap = styled.div``;
+
+const Header = styled.div`
+  font-weight: 500;
+  color: #aaaabb;
+  font-size: 16px;
+  margin-bottom: 15px;
+`;
+
+const Placeholder = styled.div`
+  height: 100%;
+  padding: 30px;
+  padding-bottom: 90px;
+  font-size: 13px;
+  color: #ffffff44;
+  width: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;
+
+const Spinner = styled.img`
+  width: 15px;
+  height: 15px;
+  margin-right: 12px;
+  margin-bottom: -2px;
+`;
+
 const BodyWrapper = styled.div`
   width: 100%;
   height: 100%;
@@ -894,7 +958,7 @@ const Dot = styled.div`
 const InfoWrapper = styled.div`
   display: flex;
   align-items: center;
-  margin-left: 6px;
+  margin-left: 3px;
   margin-top: 22px;
 `;
 

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

@@ -67,6 +67,66 @@ export default class RevisionSection extends Component<PropsType, StateType> {
 
   componentDidMount() {
     this.refreshHistory();
+    this.connectToLiveUpdates();
+  }
+
+  connectToLiveUpdates() {
+    let { chart } = this.props;
+    let { currentCluster, currentProject } = this.context;
+
+    const apiPath = `/api/projects/${currentProject.id}/k8s/helm_releases?cluster_id=${currentCluster.id}&charts=${chart.name}`;
+    const protocol = window.location.protocol == "https:" ? "wss" : "ws";
+    const url = `${protocol}://${window.location.host}`;
+
+    const ws = new WebSocket(`${url}${apiPath}`);
+
+    ws.onopen = () => {
+      console.log("connected to chart live updates websocket");
+    };
+
+    ws.onmessage = (evt: MessageEvent) => {
+      let event = JSON.parse(evt.data);
+
+      if (event.event_type == "UPDATE") {
+        let object = event.Object;
+
+        this.setState(
+          (prevState) => {
+            const { revisions: oldRevisions } = prevState;
+            // Copy old array to clean up references
+            const prevRevisions = [...oldRevisions];
+
+            // Check if it's an update of a revision or if it's a new one
+            const revisionIndex = prevRevisions.findIndex((rev) => {
+              if (rev.version === object.version) {
+                return true;
+              }
+            });
+
+            // Place new one at top of the array or update the old one
+            if (revisionIndex > -1) {
+              prevRevisions.splice(revisionIndex, 1, object);
+            } else {
+              return { ...prevState, revisions: [object, ...prevRevisions] };
+            }
+
+            return { ...prevState, revisions: prevRevisions };
+          },
+          () => {
+            this.props.setRevision(this.state.revisions[0], true);
+          }
+        );
+      }
+    };
+
+    ws.onclose = () => {
+      console.log("closing chart live updates websocket");
+    };
+
+    ws.onerror = (err: ErrorEvent) => {
+      console.log(err);
+      ws.close();
+    };
   }
 
   // Handle update of values.yaml

+ 1 - 1
dashboard/src/main/home/launch/Launch.tsx

@@ -421,7 +421,7 @@ const TitleSection = styled.div`
       align-items: center;
       margin-bottom: -2px;
       font-size: 18px;
-      margin-left: 18px;
+      margin-left: 15px;
       color: #858faaaa;
       :hover {
         color: #aaaabb;

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

@@ -458,6 +458,19 @@ const getClusterNodes = baseApi<
   return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}/nodes`;
 });
 
+const getClusterNode = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+    nodeName: string;
+  }
+>(
+  "GET",
+  (pathParams) =>
+    `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}/node/${pathParams.nodeName}`
+);
+
 const getGitRepoList = baseApi<
   {},
   {
@@ -951,6 +964,7 @@ export default {
   getClusters,
   getCluster,
   getClusterNodes,
+  getClusterNode,
   getConfigMap,
   getGitRepoList,
   getGitRepos,

+ 5 - 2
internal/auth/sessionstore/sessionstore.go

@@ -120,8 +120,11 @@ func NewStore(repo *repository.Repository, conf config.ServerConf) (*PGStore, er
 	dbStore := &PGStore{
 		Codecs: securecookie.CodecsFromPairs(keyPairs...),
 		Options: &sessions.Options{
-			Path:   "/",
-			MaxAge: 86400 * 30,
+			Path:     "/",
+			MaxAge:   86400 * 30,
+			Secure:   true,
+			HttpOnly: true,
+			SameSite: http.SameSiteStrictMode,
 		},
 		Repo: repo,
 	}

+ 204 - 1
internal/kubernetes/agent.go

@@ -3,10 +3,13 @@ package kubernetes
 import (
 	"bufio"
 	"bytes"
+	"compress/gzip"
 	"context"
+	"encoding/base64"
 	"encoding/json"
 	"fmt"
 	"io"
+	"io/ioutil"
 	"strings"
 
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner"
@@ -45,6 +48,8 @@ import (
 	"k8s.io/client-go/tools/remotecommand"
 
 	"github.com/porter-dev/porter/internal/config"
+
+	rspb "helm.sh/helm/v3/pkg/release"
 )
 
 // Agent is a Kubernetes agent for performing operations that interact with the
@@ -605,7 +610,205 @@ func (a *Agent) StreamControllerStatus(conn *websocket.Conn, kind string, select
 		for {
 			if _, _, err := conn.ReadMessage(); err != nil {
 				defer conn.Close()
-				defer close(stopper)
+				close(stopper)
+				errorchan <- nil
+				return
+			}
+		}
+	}()
+
+	go informer.Run(stopper)
+
+	for {
+		select {
+		case err := <-errorchan:
+			return err
+		}
+	}
+}
+
+var b64 = base64.StdEncoding
+
+var magicGzip = []byte{0x1f, 0x8b, 0x08}
+
+func decodeRelease(data string) (*rspb.Release, error) {
+	// base64 decode string
+	b, err := b64.DecodeString(data)
+	if err != nil {
+		return nil, err
+	}
+
+	// For backwards compatibility with releases that were stored before
+	// compression was introduced we skip decompression if the
+	// gzip magic header is not found
+	if bytes.Equal(b[0:3], magicGzip) {
+		r, err := gzip.NewReader(bytes.NewReader(b))
+		if err != nil {
+			return nil, err
+		}
+		defer r.Close()
+		b2, err := ioutil.ReadAll(r)
+		if err != nil {
+			return nil, err
+		}
+		b = b2
+	}
+
+	var rls rspb.Release
+	// unmarshal release object bytes
+	if err := json.Unmarshal(b, &rls); err != nil {
+		return nil, err
+	}
+	return &rls, nil
+}
+
+func contains(s []string, str string) bool {
+	for _, v := range s {
+		if v == str {
+			return true
+		}
+	}
+
+	return false
+}
+
+func parseSecretToHelmRelease(secret v1.Secret, chartList []string) (*rspb.Release, bool, error) {
+	if secret.Type != "helm.sh/release.v1" {
+		return nil, true, nil
+	}
+
+	releaseData, ok := secret.Data["release"]
+
+	if !ok {
+		return nil, true, fmt.Errorf("release field not found")
+	}
+
+	helm_object, err := decodeRelease(string(releaseData))
+
+	if err != nil {
+		return nil, true, err
+	}
+
+	if len(chartList) > 0 && !contains(chartList, helm_object.Name) {
+		return nil, true, nil
+	}
+
+	return helm_object, false, nil
+}
+
+func (a *Agent) StreamHelmReleases(conn *websocket.Conn, chartList []string, selectors string) error {
+	tweakListOptionsFunc := func(options *metav1.ListOptions) {
+		options.LabelSelector = selectors
+	}
+
+	factory := informers.NewSharedInformerFactoryWithOptions(
+		a.Clientset,
+		0,
+		informers.WithTweakListOptions(tweakListOptionsFunc),
+	)
+
+	informer := factory.Core().V1().Secrets().Informer()
+
+	stopper := make(chan struct{})
+	errorchan := make(chan error)
+	defer close(errorchan)
+
+	informer.AddEventHandler(cache.ResourceEventHandlerFuncs{
+		UpdateFunc: func(oldObj, newObj interface{}) {
+			secretObj, ok := newObj.(*v1.Secret)
+
+			if !ok {
+				errorchan <- fmt.Errorf("could not cast to secret")
+				return
+			}
+
+			helm_object, isNotHelmRelease, err := parseSecretToHelmRelease(*secretObj, chartList)
+
+			if isNotHelmRelease && err == nil {
+				return
+			}
+
+			if err != nil {
+				errorchan <- err
+				return
+			}
+
+			msg := Message{
+				EventType: "UPDATE",
+				Object:    helm_object,
+			}
+
+			if writeErr := conn.WriteJSON(msg); writeErr != nil {
+				errorchan <- writeErr
+				return
+			}
+		},
+		AddFunc: func(obj interface{}) {
+			secretObj, ok := obj.(*v1.Secret)
+
+			if !ok {
+				errorchan <- fmt.Errorf("could not cast to secret")
+				return
+			}
+
+			helm_object, isNotHelmRelease, err := parseSecretToHelmRelease(*secretObj, chartList)
+
+			if isNotHelmRelease && err == nil {
+				return
+			}
+
+			if err != nil {
+				errorchan <- err
+				return
+			}
+
+			msg := Message{
+				EventType: "ADD",
+				Object:    helm_object,
+			}
+
+			if writeErr := conn.WriteJSON(msg); writeErr != nil {
+				errorchan <- writeErr
+				return
+			}
+		},
+		DeleteFunc: func(obj interface{}) {
+			secretObj, ok := obj.(*v1.Secret)
+
+			if !ok {
+				errorchan <- fmt.Errorf("could not cast to secret")
+				return
+			}
+
+			helm_object, isNotHelmRelease, err := parseSecretToHelmRelease(*secretObj, chartList)
+
+			if isNotHelmRelease && err == nil {
+				return
+			}
+
+			if err != nil {
+				errorchan <- err
+				return
+			}
+
+			msg := Message{
+				EventType: "DELETE",
+				Object:    helm_object,
+			}
+
+			if writeErr := conn.WriteJSON(msg); writeErr != nil {
+				errorchan <- writeErr
+				return
+			}
+		},
+	})
+
+	go func() {
+		// listens for websocket closing handshake
+		for {
+			if _, _, err := conn.ReadMessage(); err != nil {
+				defer conn.Close()
+				close(stopper)
 				errorchan <- nil
 				return
 			}

+ 9 - 6
internal/kubernetes/nodes/helpers.go

@@ -112,11 +112,14 @@ func DescribeNodeResource(nodeNonTerminatedPodsList *corev1.PodList, node *corev
 	}
 
 	return &NodeUsage{
-		fractionCpuReqs,
-		fractionCpuLimits,
-		fractionMemoryReqs,
-		fractionMemoryLimits,
-		fractionEphemeralStorageReqs,
-		fractionEphemeralStorageLimits,
+		cpuReqs:                        cpuReqs.String(),
+		memoryReqs:                     memoryReqs.String(),
+		ephemeralStorageReqs:           ephemeralstorageReqs.String(),
+		fractionCpuReqs:                fractionCpuReqs,
+		fractionCpuLimits:              fractionCpuLimits,
+		fractionMemoryReqs:             fractionMemoryReqs,
+		fractionMemoryLimits:           fractionMemoryLimits,
+		fractionEphemeralStorageReqs:   fractionEphemeralStorageReqs,
+		fractionEphemeralStorageLimits: fractionEphemeralStorageLimits,
 	}
 }

+ 37 - 6
internal/kubernetes/nodes/nodes.go

@@ -11,6 +11,9 @@ import (
 )
 
 type NodeUsage struct {
+	cpuReqs                        string
+	memoryReqs                     string
+	ephemeralStorageReqs           string
 	fractionCpuReqs                float64
 	fractionCpuLimits              float64
 	fractionMemoryReqs             float64
@@ -21,18 +24,26 @@ type NodeUsage struct {
 
 type NodeWithUsageData struct {
 	Name                           string             `json:"name"`
-	FractionCpuReqs                float64            `json:"cpu_reqs"`
-	FractionCpuLimits              float64            `json:"cpu_limits"`
-	FractionMemoryReqs             float64            `json:"memory_reqs"`
-	FractionMemoryLimits           float64            `json:"memory_limits"`
-	FractionEphemeralStorageReqs   float64            `json:"ephemeral_storage_reqs"`
-	FractionEphemeralStorageLimits float64            `json:"ephemeral_storage_limits"`
+	Labels                         map[string]string  `json:"labels"`
+	CpuReqs                        string             `json:"cpu_reqs"`
+	MemoryReqs                     string             `json:"memory_reqs"`
+	EphemeralStorageReqs           string             `json:"ephemeral_storage_reqs"`
+	FractionCpuReqs                float64            `json:"fraction_cpu_reqs"`
+	FractionCpuLimits              float64            `json:"fraction_cpu_limits"`
+	FractionMemoryReqs             float64            `json:"fraction_memory_reqs"`
+	FractionMemoryLimits           float64            `json:"fraction_memory_limits"`
+	FractionEphemeralStorageReqs   float64            `json:"fraction_ephemeral_storage_reqs"`
+	FractionEphemeralStorageLimits float64            `json:"fraction_ephemeral_storage_limits"`
 	Condition                      []v1.NodeCondition `json:"node_conditions"`
 }
 
 func (nu *NodeUsage) Externalize(node v1.Node) *NodeWithUsageData {
 	return &NodeWithUsageData{
 		Name:                           node.Name,
+		Labels:                         node.Labels,
+		CpuReqs:                        nu.cpuReqs,
+		MemoryReqs:                     nu.memoryReqs,
+		EphemeralStorageReqs:           nu.ephemeralStorageReqs,
 		FractionCpuReqs:                nu.fractionCpuReqs,
 		FractionCpuLimits:              nu.fractionCpuLimits,
 		FractionMemoryReqs:             nu.fractionMemoryReqs,
@@ -72,3 +83,23 @@ func getPodsForNode(clientset kubernetes.Interface, nodeName string) *v1.PodList
 
 	return podList
 }
+
+type NodeDetails struct {
+	NodeWithUsageData
+	AllocatableCpu    int64  `json:"allocatable_cpu"`
+	AllocatableMemory string `json:"allocatable_memory"`
+}
+
+func DescribeNode(clientset kubernetes.Interface, nodeName string) *NodeDetails {
+	node, _ := clientset.CoreV1().Nodes().Get(context.TODO(), nodeName, metav1.GetOptions{})
+
+	podList := getPodsForNode(clientset, node.Name)
+	nodeUsage := DescribeNodeResource(podList, node)
+	extNodeUsage := nodeUsage.Externalize(*node)
+
+	return &NodeDetails{
+		NodeWithUsageData: *extNodeUsage,
+		AllocatableCpu:    node.Status.Allocatable.Cpu().MilliValue(),
+		AllocatableMemory: node.Status.Allocatable.Memory().String(),
+	}
+}

+ 111 - 0
server/api/k8s_handler.go

@@ -1073,6 +1073,75 @@ func (app *App) HandleStreamControllerStatus(w http.ResponseWriter, r *http.Requ
 	}
 }
 
+func (app *App) HandleStreamHelmReleases(w http.ResponseWriter, r *http.Request) {
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get session to retrieve correct kubeconfig
+	_, err = app.Store.Get(r, app.ServerConf.CookieName)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get the filter options
+	form := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
+		},
+	}
+
+	form.PopulateK8sOptionsFromQueryParams(vals, app.Repo.Cluster)
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new agent
+	var agent *kubernetes.Agent
+
+	if app.ServerConf.IsTesting {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	}
+
+	upgrader.CheckOrigin = func(r *http.Request) bool { return true }
+
+	// upgrade to websocket.
+	conn, err := upgrader.Upgrade(w, r, nil)
+
+	if err != nil {
+		app.handleErrorUpgradeWebsocket(err, w)
+	}
+
+	selectors := ""
+	if vals["selectors"] != nil {
+		selectors = vals["selectors"][0]
+	}
+
+	var chartList []string
+
+	if vals["charts"] != nil {
+		chartList = vals["charts"]
+	}
+
+	err = agent.StreamHelmReleases(conn, chartList, selectors)
+
+	if err != nil {
+		app.handleErrorWebsocketWrite(err, w)
+		return
+	}
+}
+
 // HandleDetectPrometheusInstalled detects a prometheus installation in the target cluster
 func (app *App) HandleDetectPrometheusInstalled(w http.ResponseWriter, r *http.Request) {
 	vals, err := url.ParseQuery(r.URL.RawQuery)
@@ -1317,3 +1386,45 @@ func (app *App) HandleListNodes(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 }
+
+func (app *App) HandleGetNode(w http.ResponseWriter, r *http.Request) {
+	cluster_id, err := strconv.ParseUint(chi.URLParam(r, "cluster_id"), 0, 64)
+	node_name := chi.URLParam(r, "node_name")
+
+	if err != nil || cluster_id == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	cluster, err := app.Repo.Cluster.ReadCluster(uint(cluster_id))
+
+	if err != nil {
+		app.handleErrorRead(err, ErrProjectDataRead, w)
+		return
+	}
+
+	form := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
+			Cluster:           cluster,
+		},
+	}
+
+	var agent *kubernetes.Agent
+
+	if app.ServerConf.IsTesting {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, _ = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	}
+
+	nodeWithUsageData := nodes.DescribeNode(agent.Clientset, node_name)
+
+	w.WriteHeader(http.StatusOK)
+
+	if err := json.NewEncoder(w).Encode(nodeWithUsageData); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}

+ 28 - 0
server/router/router.go

@@ -613,6 +613,20 @@ func New(a *api.App) *chi.Mux {
 				),
 			)
 
+			r.Method(
+				"GET",
+				"/projects/{project_id}/clusters/{cluster_id}/node/{node_name}",
+				auth.DoesUserHaveProjectAccess(
+					auth.DoesUserHaveClusterAccess(
+						requestlog.NewHandler(a.HandleGetNode, l),
+						mw.URLParam,
+						mw.URLParam,
+					),
+					mw.URLParam,
+					mw.ReadAccess,
+				),
+			)
+
 			r.Method(
 				"POST",
 				"/projects/{project_id}/clusters/{cluster_id}",
@@ -1300,6 +1314,20 @@ func New(a *api.App) *chi.Mux {
 				),
 			)
 
+			r.Method(
+				"GET",
+				"/projects/{project_id}/k8s/helm_releases",
+				auth.DoesUserHaveProjectAccess(
+					auth.DoesUserHaveClusterAccess(
+						requestlog.NewHandler(a.HandleStreamHelmReleases, l),
+						mw.URLParam,
+						mw.QueryParam,
+					),
+					mw.URLParam,
+					mw.ReadAccess,
+				),
+			)
+
 			r.Method(
 				"GET",
 				"/projects/{project_id}/k8s/pods",