Jelajahi Sumber

Merge pull request #757 from jnfrati/0.5.0-cluster-dashboard-node-list

[0.5.0] Cluster dashboard - Cluster nodes list
abelanger5 5 tahun lalu
induk
melakukan
e68bca22cc

+ 14 - 0
dashboard/package-lock.json

@@ -677,6 +677,15 @@
         "@types/react-router": "*"
       }
     },
+    "@types/react-table": {
+      "version": "7.7.1",
+      "resolved": "https://registry.npmjs.org/@types/react-table/-/react-table-7.7.1.tgz",
+      "integrity": "sha512-oed13swLIS4Ffyo4jAjl9lGbYMaY0uavKoI9GNMvf2R6vh8JfpRUpizQ90X1VI4WrhfaMb/HMsN7TTBvkGOQXQ==",
+      "dev": true,
+      "requires": {
+        "@types/react": "*"
+      }
+    },
     "@types/react-transition-group": {
       "version": "4.4.0",
       "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.0.tgz",
@@ -6282,6 +6291,11 @@
         "tiny-warning": "^1.0.0"
       }
     },
+    "react-table": {
+      "version": "7.7.0",
+      "resolved": "https://registry.npmjs.org/react-table/-/react-table-7.7.0.tgz",
+      "integrity": "sha512-jBlj70iBwOTvvImsU9t01LjFjy4sXEtclBovl3mTiqjz23Reu0DKnRza4zlLtOPACx6j2/7MrQIthIK1Wi+LIA=="
+    },
     "react-transition-group": {
       "version": "4.4.1",
       "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.1.tgz",

+ 2 - 0
dashboard/package.json

@@ -42,6 +42,7 @@
     "react-dom": "^16.13.1",
     "react-modal": "^3.11.2",
     "react-router-dom": "^5.2.0",
+    "react-table": "^7.7.0",
     "semver": "^7.3.5",
     "styled-components": "^5.2.0"
   },
@@ -64,6 +65,7 @@
     "@types/react-modal": "^3.10.6",
     "@types/react-router": "^5.1.8",
     "@types/react-router-dom": "^5.1.5",
+    "@types/react-table": "^7.7.1",
     "@types/semver": "^7.3.5",
     "@types/styled-components": "^5.1.3",
     "file-loader": "^6.1.0",

+ 120 - 0
dashboard/react-table.d.ts

@@ -0,0 +1,120 @@
+import {
+  UseColumnOrderInstanceProps,
+  UseColumnOrderState,
+  UseExpandedHooks,
+  UseExpandedInstanceProps,
+  UseExpandedOptions,
+  UseExpandedRowProps,
+  UseExpandedState,
+  UseFiltersColumnOptions,
+  UseFiltersColumnProps,
+  UseFiltersInstanceProps,
+  UseFiltersOptions,
+  UseFiltersState,
+  UseGlobalFiltersColumnOptions,
+  UseGlobalFiltersInstanceProps,
+  UseGlobalFiltersOptions,
+  UseGlobalFiltersState,
+  UseGroupByCellProps,
+  UseGroupByColumnOptions,
+  UseGroupByColumnProps,
+  UseGroupByHooks,
+  UseGroupByInstanceProps,
+  UseGroupByOptions,
+  UseGroupByRowProps,
+  UseGroupByState,
+  UsePaginationInstanceProps,
+  UsePaginationOptions,
+  UsePaginationState,
+  UseResizeColumnsColumnOptions,
+  UseResizeColumnsColumnProps,
+  UseResizeColumnsOptions,
+  UseResizeColumnsState,
+  UseRowSelectHooks,
+  UseRowSelectInstanceProps,
+  UseRowSelectOptions,
+  UseRowSelectRowProps,
+  UseRowSelectState,
+  UseRowStateCellProps,
+  UseRowStateInstanceProps,
+  UseRowStateOptions,
+  UseRowStateRowProps,
+  UseRowStateState,
+  UseSortByColumnOptions,
+  UseSortByColumnProps,
+  UseSortByHooks,
+  UseSortByInstanceProps,
+  UseSortByOptions,
+  UseSortByState
+} from 'react-table'
+
+declare module 'react-table' {
+  // take this file as-is, or comment out the sections that don't apply to your plugin configuration
+  
+  export interface TableOptions<D extends object = {}>
+    extends UseExpandedOptions<D>,
+      UseFiltersOptions<D>,
+      UseGlobalFiltersOptions<D>,
+      UseGroupByOptions<D>,
+      UsePaginationOptions<D>,
+      UseResizeColumnsOptions<D>,
+      UseRowSelectOptions<D>,
+      UseRowStateOptions<D>,
+      UseSortByOptions<D>,
+      // note that having Record here allows you to add anything to the options, this matches the spirit of the
+      // underlying js library, but might be cleaner if it's replaced by a more specific type that matches your
+      // feature set, this is a safe default.
+      Record<string, any> {}
+
+  export interface Hooks<D extends object = {}>
+    extends UseExpandedHooks<D>,
+      UseGroupByHooks<D>,
+      UseRowSelectHooks<D>,
+      UseSortByHooks<D> {}
+
+  export interface TableInstance<D extends object = {}>
+    extends UseColumnOrderInstanceProps<D>,
+      UseExpandedInstanceProps<D>,
+      UseFiltersInstanceProps<D>,
+      UseGlobalFiltersInstanceProps<D>,
+      UseGroupByInstanceProps<D>,
+      UsePaginationInstanceProps<D>,
+      UseRowSelectInstanceProps<D>,
+      UseRowStateInstanceProps<D>,
+      UseSortByInstanceProps<D> {}
+
+  export interface TableState<D extends object = {}>
+    extends UseColumnOrderState<D>,
+      UseExpandedState<D>,
+      UseFiltersState<D>,
+      UseGlobalFiltersState<D>,
+      UseGroupByState<D>,
+      UsePaginationState<D>,
+      UseResizeColumnsState<D>,
+      UseRowSelectState<D>,
+      UseRowStateState<D>,
+      UseSortByState<D> {}
+
+  export interface ColumnInterface<D extends object = {}>
+    extends UseFiltersColumnOptions<D>,
+      UseGlobalFiltersColumnOptions<D>,
+      UseGroupByColumnOptions<D>,
+      UseResizeColumnsColumnOptions<D>,
+      UseSortByColumnOptions<D> {}
+
+  export interface ColumnInstance<D extends object = {}>
+    extends UseFiltersColumnProps<D>,
+      UseGroupByColumnProps<D>,
+      UseResizeColumnsColumnProps<D>,
+      UseSortByColumnProps<D> {}
+
+  export interface Cell<D extends object = {}, V = any>
+    extends UseGroupByCellProps<D>,
+      UseRowStateCellProps<D> {}
+
+  export interface Row<D extends object = {}>
+    extends UseExpandedRowProps<D>,
+      UseGroupByRowProps<D>,
+      UseRowSelectRowProps<D>,
+      UseRowStateRowProps<D> {}
+}

+ 0 - 0
dashboard/src/components/Helper.tsx


+ 210 - 0
dashboard/src/components/Table.tsx

@@ -0,0 +1,210 @@
+import React from "react";
+import styled from "styled-components";
+import { Column, Row, useGlobalFilter, useTable } from "react-table";
+import InputRow from "./values-form/InputRow";
+import Loading from "components/Loading";
+
+const GlobalFilter: React.FunctionComponent<any> = ({ setGlobalFilter }) => {
+  const [value, setValue] = React.useState("");
+  const onChange = (value: string) => {
+    setValue(value)
+    setGlobalFilter(value || undefined);
+  };
+
+  return (
+    <SearchRow>
+      <i className="material-icons">search</i>
+      <SearchInput
+        value={value}
+        onChange={(e: any) => {
+          onChange(e.target.value);
+        }}
+        placeholder="Search"
+      />
+    </SearchRow>
+  );
+};
+
+export type TableProps = {
+  columns: Column<any>[];
+  data: any[];
+  onRowClick?: (row: Row) => void;
+  isLoading: boolean;
+  disableGlobalFilter?: boolean;
+};
+
+const Table: React.FC<TableProps> = ({
+  columns: columnsData,
+  data,
+  onRowClick,
+  isLoading,
+  disableGlobalFilter = false,
+}) => {
+  const {
+    getTableProps,
+    getTableBodyProps,
+    rows,
+    setGlobalFilter,
+    prepareRow,
+    headerGroups,
+    visibleColumns,
+  } = useTable(
+    {
+      columns: columnsData,
+      data,
+    },
+    useGlobalFilter
+  );
+
+  const renderRows = () => {
+    if (isLoading) {
+      return (
+        <StyledTr disableHover={true} selected={false}>
+          <StyledTd colSpan={visibleColumns.length}>
+            <Loading />
+          </StyledTd>
+        </StyledTr>
+      );
+    }
+
+    if (!rows.length) {
+      return (
+        <StyledTr disableHover={true} selected={false}>
+          <StyledTd colSpan={visibleColumns.length}>No data available</StyledTd>
+        </StyledTr>
+      );
+    }
+    return (
+      <>
+        {rows.map((row) => {
+          prepareRow(row);
+
+          return (
+            <StyledTr
+              {...row.getRowProps()}
+              onClick={() => onRowClick && onRowClick(row)}
+              selected={false}
+            >
+              {row.cells.map((cell) => (
+                <StyledTd {...cell.getCellProps()}>
+                  {cell.render("Cell")}
+                </StyledTd>
+              ))}
+            </StyledTr>
+          );
+        })}
+      </>
+    );
+  };
+
+  return (
+    <TableWrapper>
+      {!disableGlobalFilter && <GlobalFilter setGlobalFilter={setGlobalFilter} />}
+      <StyledTable {...getTableProps()}>
+        <StyledTHead>
+          {headerGroups.map((headerGroup) => (
+            <StyledTr
+              {...headerGroup.getHeaderGroupProps()}
+              disableHover={true}
+            >
+              {headerGroup.headers.map((column) => (
+                <StyledTh {...column.getHeaderProps()}>
+                  {column.render("Header")}
+                </StyledTh>
+              ))}
+            </StyledTr>
+          ))}
+        </StyledTHead>
+        <tbody {...getTableBodyProps()}>{renderRows()}</tbody>
+      </StyledTable>
+    </TableWrapper>
+  );
+};
+
+export default Table;
+
+const TableWrapper = styled.div`
+  padding-bottom: 20px;
+`;
+
+export const StyledTr = styled.tr`
+  line-height: 2.2em;
+  background: ${(props: { disableHover?: boolean; selected?: boolean }) =>
+    props.selected ? "#ffffff11" : ""};
+  :hover {
+    background: ${(props: { disableHover?: boolean; selected?: boolean }) =>
+      props.disableHover ? "" : "#ffffff22"};
+  }
+`;
+
+export const StyledTd = styled.td`
+  font-size: 13px;
+  color: #ffffff;
+  :first-child{
+    padding-left: 10px;
+  }
+  :last-child{
+    padding-right: 10px;
+  }
+`;
+
+export const StyledTHead = styled.thead`
+  width: 100%;
+`;
+
+export const StyledTh = styled.th`
+  text-align: left;
+  font-size: 13px;
+  font-weight: 500;
+  color: #aaaabb;
+  :first-child{
+    padding-left: 10px;
+  }
+  :last-child{
+    padding-right: 10px;
+  }
+`;
+
+export const StyledTable = styled.table`
+  width: 100%;
+  min-width: 500px;
+  border-collapse: collapse;
+`;
+
+
+const SearchInput = styled.input`
+  outline: none;
+  border: none;
+  font-size: 13px;
+  background: none;
+  width: 100%;
+  color: white;
+  padding: 0;
+  height: 20px;
+`;
+
+const SearchRow = styled.div`
+  display: flex;
+  width: 100%;
+  font-size: 13px;
+  color: #ffffff55;
+  border-radius: 4px; 
+  user-select: none;
+  align-items: center;
+  padding: 10px 0px;
+  min-width: 300px;
+  max-width: min-content;  
+  background: #ffffff11;
+  margin-bottom: 7px;
+  margin-top: 7px;
+  i {
+    width: 18px;
+    height: 18px;
+    margin-left: 12px;
+    margin-right: 12px;
+    font-size: 20px;
+  }
+
+`;
+
+

+ 2 - 1
dashboard/src/components/values-form/InputRow.tsx

@@ -11,6 +11,7 @@ type PropsType = {
   width?: string;
   disabled?: boolean;
   isRequired?: boolean;
+  className?: string;
 };
 
 type StateType = {
@@ -33,7 +34,7 @@ export default class InputRow extends Component<PropsType, StateType> {
   render() {
     let { label, value, type, unit, placeholder, width } = this.props;
     return (
-      <StyledInputRow>
+      <StyledInputRow className={this.props.className}>
         {label && (
           <Label>
             {label} <Required>{this.props.isRequired ? " *" : null}</Required>

+ 4 - 0
dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx

@@ -17,6 +17,7 @@ import ExpandedChartWrapper from "./expanded-chart/ExpandedChartWrapper";
 import { RouteComponentProps, withRouter } from "react-router";
 
 import api from "shared/api";
+import {Dashboard} from "./dashboard/Dashboard";
 
 type PropsType = RouteComponentProps & {
   currentCluster: ClusterType;
@@ -194,6 +195,9 @@ class ClusterDashboard extends Component<PropsType, StateType> {
         <Route path={["/jobs", "/applications", "/env-groups"]}>
           {this.renderContents()}
         </Route>
+        <Route path={["/cluster-dashboard"]}>
+          <Dashboard />
+        </Route>
       </Switch>
     );
   }

+ 91 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/ClusterSettings.tsx

@@ -0,0 +1,91 @@
+import React, { useContext } from 'react'
+import styled from 'styled-components';
+import Heading from 'components/values-form/Heading';
+import Helper from "components/values-form/Helper";
+import { Context } from 'shared/Context';
+
+export const ClusterSettings = () => {
+  const context = useContext(Context);
+
+  let helperText = <Helper>
+    Delete this cluster and underlying infrastructure. To
+    ensure that everything has been properly destroyed, please visit
+    your cloud provider's console. Instructions to properly delete all
+    resources can be found
+    <a
+      target="none"
+      href="https://docs.getporter.dev/docs/deleting-dangling-resources"
+    >
+      {" "}
+      here
+    </a>.
+  </Helper>
+
+  if (!context.currentCluster?.infra_id || !context.currentCluster?.service) {
+    helperText = <Helper>
+      Remove this cluster from Porter. Since this cluster was not provisioned by Porter, deleting the
+      cluster will only detach this cluster from your project. To delete the cluster itself, you must 
+      do so manually. This operation cannot be undone. 
+    </Helper>
+  }
+
+  return (
+    <div>
+      <StyledSettingsSection showSource={false}>
+          <Heading>Delete Cluster</Heading>
+          {helperText}
+          <Button
+            color="#b91133"
+            onClick={() => context.setCurrentModal("UpdateClusterModal")}
+          >
+            Delete Cluster
+          </Button>
+        </StyledSettingsSection>
+    </div>
+  )
+}
+
+
+const StyledSettingsSection = styled.div<{ showSource: boolean }>`
+  margin-top: 35px;
+  width: 100%;
+  background: #ffffff11;
+  padding: 0 35px;
+  padding-bottom: 50px;
+  position: relative;
+  border-radius: 5px;
+  overflow: auto;
+  height: ${(props) => (props.showSource ? "calc(100% - 55px)" : "100%")};
+`;
+
+const Button = styled.button`
+  height: 35px;
+  font-size: 13px;
+  margin-top: 6px;
+  margin-bottom: 30px;
+  font-weight: 500;
+  font-family: "Work Sans", sans-serif;
+  color: white;
+  padding: 6px 20px 7px 20px;
+  text-align: left;
+  border: 0;
+  border-radius: 5px;
+  background: ${(props) => (!props.disabled ? props.color : "#aaaabb")};
+  box-shadow: ${(props) =>
+    !props.disabled ? "0 2px 5px 0 #00000030" : "none"};
+  cursor: ${(props) => (!props.disabled ? "pointer" : "default")};
+  user-select: none;
+  :focus {
+    outline: 0;
+  }
+  :hover {
+    filter: ${(props) => (!props.disabled ? "brightness(120%)" : "")};
+  }
+`;
+
+const Warning = styled.div`
+  font-size: 13px;
+  color: ${(props: { highlight: boolean; makeFlush?: boolean }) =>
+    props.highlight ? "#f5cb42" : ""};
+  margin-bottom: 20px;
+`;

+ 146 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx

@@ -0,0 +1,146 @@
+import React, { useContext, useState } from "react";
+import styled from "styled-components";
+
+import { Context } from "shared/Context";
+import TabSelector from "components/TabSelector";
+
+import NodeList from "./NodeList";
+import { ClusterSettings } from "./ClusterSettings";
+
+
+type TabEnum = "nodes" | "settings";
+
+const tabOptions: {
+  label: string;
+  value: TabEnum
+}[] = [
+  { label: "Nodes", value: "nodes" },
+  { label: "Settings", value: "settings"}
+];
+
+export const Dashboard: React.FC = ({ children }) => {
+  const [currentTab, setCurrentTab] = useState<TabEnum>("nodes");
+  const context = useContext(Context);
+  const renderTab = (cluster: any) => {
+    switch (currentTab) {
+      case "settings": 
+        return <ClusterSettings />
+      case "nodes":
+      default:
+        return <NodeList />;
+    }
+  };
+
+  return (
+    
+    <>
+      <TitleSection>
+        <DashboardIcon>
+          <i className="material-icons">device_hub</i>
+        </DashboardIcon>
+        <Title>{context.currentCluster.name}</Title>
+      </TitleSection>
+
+      <InfoSection>
+        <TopRow>
+          <InfoLabel>
+            <i className="material-icons">info</i> Info
+          </InfoLabel>
+        </TopRow>
+        <Description>Cluster dashboard for {context.currentCluster.name}</Description>
+      </InfoSection>
+
+      <TabSelector
+        options={tabOptions}
+        currentTab={currentTab}
+        setCurrentTab={(value: TabEnum) =>
+          setCurrentTab(value)
+        }
+      />
+
+      {renderTab(context.currentCluster)}
+    </>
+  );
+};
+
+const DashboardIcon = styled.div`
+  height: 45px;
+  min-width: 45px;
+  width: 45px;
+  border-radius: 5px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #676c7c;
+  border: 2px solid #8e94aa;
+  > i {
+    font-size: 22px;
+  }
+`;
+
+const TopRow = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const Description = styled.div`
+  color: #aaaabb;
+  margin-top: 13px;
+  margin-left: 2px;
+  font-size: 13px;
+`;
+
+const InfoLabel = styled.div`
+  width: 72px;
+  height: 20px;
+  display: flex;
+  align-items: center;
+  color: #7a838f;
+  font-size: 13px;
+  > i {
+    color: #8b949f;
+    font-size: 18px;
+    margin-right: 5px;
+  }
+`;
+
+const InfoSection = styled.div`
+  margin-top: 20px;
+  font-family: "Work Sans", sans-serif;
+  margin-left: 0px;
+  margin-bottom: 35px;
+`;
+
+const Title = styled.div`
+  font-size: 20px;
+  font-weight: 500;
+  font-family: "Work Sans", sans-serif;
+  margin-left: 18px;
+  color: #ffffff;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const TitleSection = styled.div`
+  height: 80px;
+  margin-top: 10px;
+  margin-bottom: 10px;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  padding-left: 0px;
+
+  > i {
+    margin-left: 10px;
+    cursor: pointer;
+    font-size 18px;
+    color: #858FAAaa;
+    padding: 5px;
+    border-radius: 100px;
+    :hover {
+      background: #ffffff11;
+    }
+    margin-bottom: -3px;
+  }
+`;

+ 175 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/NodeList.tsx

@@ -0,0 +1,175 @@
+import React, { useContext, useEffect, useMemo, useState } from "react";
+
+import Table from "components/Table";
+import { Column } from "react-table";
+import styled from "styled-components";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { NodeStatusModal } from "./NodeStatusModal";
+
+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 columns = useMemo<Column<any>[]>(
+    () => [
+      {
+        Header: "Node name",
+        accessor: "name",
+      },
+      {
+        Header: "CPU Usage",
+        accessor: "cpu_usage",
+      },
+      {
+        Header: "RAM Usage",
+        accessor: "ram_usage",
+      },
+      {
+        Header: () => <StatusHeader>Node Condition</StatusHeader>,
+        accessor: "is_node_healthy",
+        Cell: ({ row }) => {
+          return (
+            <StatusButtonWrapper>
+              <StatusButton
+                success={row.values.is_node_healthy}
+                onClick={() => triggerPopUp(row.original)}
+              >
+                {row.values.is_node_healthy ? "Healthy" : "Unhealthy"}
+              </StatusButton>
+            </StatusButtonWrapper>
+          );
+        },
+      },
+    ],
+    []
+  );
+
+  const data = useMemo(() => {
+    const percentFormatter = (number: number) =>
+      `${Number(number).toFixed(2)}%`;
+
+    return nodeList
+      .map((node) => {
+        return {
+          name: node.name,
+          cpu_usage: percentFormatter(node.cpu_reqs),
+          ram_usage: percentFormatter(node.memory_reqs),
+          node_conditions: node.node_conditions,
+          is_node_healthy: node.node_conditions.reduce(
+            (prevValue: boolean, current: any) => {
+              if (current.type !== "Ready" && current.status !== "False") {
+                return false;
+              }
+              if (current.type === "Ready" && current.status !== "True") {
+                return false;
+              }
+              return prevValue;
+            },
+            true
+          ),
+        };
+      })
+      .sort((firstEl, secondElement) =>
+        firstEl.is_node_healthy === secondElement.is_node_healthy
+          ? 0
+          : firstEl.is_node_healthy
+          ? 1
+          : -1
+      );
+  }, [nodeList]);
+
+  useEffect(() => {
+    const { currentCluster, currentProject } = context;
+    setLoading(true);
+    api
+      .getClusterNodes(
+        "<token>",
+        {},
+        {
+          cluster_id: currentCluster.id,
+          project_id: currentProject.id,
+        }
+      )
+      .then(({ data }) => {
+        if (data) {
+          setNodeList(data);
+        }
+      })
+      .catch(() => {
+        console.log({ error: true });
+      })
+      .finally(() => setLoading(false));
+  }, [context, setNodeList]);
+
+  return (
+    <NodeListWrapper>
+      <StyledChart>
+        <Table columns={columns} data={data} isLoading={loading} />
+      </StyledChart>
+      {selectedNode && (
+        <NodeStatusModal node={selectedNode} onClose={() => triggerPopUp()} />
+      )}
+    </NodeListWrapper>
+  );
+};
+
+export default NodeList;
+
+const NodeListWrapper = styled.div`
+  margin-top: 35px;
+`;
+
+const StyledChart = styled.div`
+  background: #26282f;
+  padding: 14px;
+  border-radius: 5px;
+  box-shadow: 0 5px 8px 0px #00000033;
+  position: relative;
+  border: 2px solid #9eb4ff00;
+  width: 100%;
+  height: 100%;
+  :not(:last-child) {
+    margin-bottom: 25px;
+  }
+`;
+
+const StatusHeader = styled.div`
+  width: 100%;
+  text-align: center;
+`;
+
+const StatusButtonWrapper = styled.div`
+  width: 100%;
+  display: flex;
+  justify-content: center;
+`;
+
+const StatusButton = styled.div`
+  cursor: pointer;
+  display: flex;
+  border-radius: 3px;
+  align-items: center;
+  justify-content: center;
+  font-weight: 500;
+  height: 21px;
+  font-size: 13px;
+  width: 70px;
+  background: ${(props: { success: boolean }) =>
+    props.success ? "#616FEEcc" : "#ed5f85"};
+  :hover {
+    background: ${(props: { success: boolean }) =>
+      props.success ? "#405eddbb" : "#e83162"};
+  }
+`;

+ 61 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/NodeStatusModal.tsx

@@ -0,0 +1,61 @@
+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,
+  node,
+  width = "800px",
+  height = "min-content",
+}) => {
+
+  const columns = useMemo<Column<any>[]>(
+    () => [
+      {
+        Header: "Type",
+        accessor: "type",
+      },
+      {
+        Header: "Status",
+        accessor: "status",
+      },
+      {
+        Header: "Reason",
+        accessor: "reason",
+      },
+      {
+        Header: "Message",
+        accessor: "message",
+      },
+    ],
+    []
+  );
+
+  const data = useMemo(() => {
+    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>
+    </div>
+  );
+};
+
+const TableWrapper = styled.div`
+  margin-top: 14px;
+`

+ 1 - 1
dashboard/src/main/home/dashboard/ClusterList.tsx

@@ -162,7 +162,7 @@ class Templates extends Component<PropsType, StateType> {
           <TemplateBlock
             onClick={() => {
               this.context.setCurrentCluster(cluster);
-              pushFiltered(this.props, "/applications", ["project_id"], {
+              pushFiltered(this.props, "/cluster-dashboard", ["project_id"], {
                 cluster: cluster.name,
               });
             }}

+ 4 - 3
dashboard/src/main/home/sidebar/ClusterSection.tsx

@@ -8,6 +8,7 @@ import { ClusterType } from "shared/types";
 
 import Drawer from "./Drawer";
 import { RouteComponentProps, withRouter } from "react-router";
+import { pushFiltered } from "shared/routing";
 
 type PropsType = RouteComponentProps & {
   forceCloseDrawer: boolean;
@@ -172,7 +173,7 @@ class ClusterSection extends Component<PropsType, StateType> {
       return (
         <ClusterSelector isSelected={false}>
           <LinkWrapper
-            onClick={() => this.context.setCurrentModal("UpdateClusterModal")}
+            onClick={() => pushFiltered(this.props, "/cluster-dashboard", [])}
           >
             <ClusterIcon>
               <i className="material-icons">device_hub</i>
@@ -275,7 +276,7 @@ const ClusterName = styled.div`
   width: 130px;
   margin-left: 3px;
   font-weight: 400;
-  color: #ffffff44;
+  color: #ffffff;
 `;
 
 const DropdownIcon = styled.span`
@@ -319,7 +320,7 @@ const ClusterIcon = styled.div`
     margin-bottom: 0px;
     margin-left: 17px;
     margin-right: 10px;
-    color: #ffffff44;
+    color: #ffffff;
   }
 `;
 

+ 1 - 1
dashboard/src/main/home/sidebar/Drawer.tsx

@@ -35,7 +35,7 @@ class Drawer extends Component<PropsType, StateType> {
             active={cluster.name === currentCluster.name}
             onClick={() => {
               setCurrentCluster(cluster, () => {
-                pushFiltered(this.props, "/applications", ["project_id"], {
+                pushFiltered(this.props, "/cluster-dashboard", ["project_id"], {
                   cluster: cluster.name,
                 });
               });

+ 34 - 10
dashboard/src/shared/Context.tsx

@@ -3,7 +3,7 @@ import React, { Component } from "react";
 import { ProjectType, ClusterType, CapabilityType } from "shared/types";
 import { pushQueryParams } from "shared/routing";
 
-const Context = React.createContext({});
+const Context = React.createContext<GlobalContextType>({} as GlobalContextType);
 
 const { Provider } = Context;
 const ContextConsumer = Context.Consumer;
@@ -13,7 +13,31 @@ type PropsType = {
   location: any;
 };
 
-type StateType = any;
+type StateType = GlobalContextType;
+
+export interface GlobalContextType {
+  currentModal: string;
+  currentModalData: any;
+  setCurrentModal: (currentModal: string, currentModalData?: any) => void;
+  currentError: string | null;
+  setCurrentError: (currentError: string) => void;
+  currentCluster: ClusterType;
+  setCurrentCluster: (currentCluster: ClusterType, callback?: any) => void;
+  currentProject: ProjectType | null;
+  setCurrentProject: (
+    currentProject: ProjectType,
+    callback?: () => void
+  ) => void;
+  projects: ProjectType[];
+  setProjects: (projects: ProjectType[]) => void;
+  user: any;
+  setUser: (userId: number, email: string) => void;
+  devOpsMode: boolean;
+  setDevOpsMode: (devOpsMode: boolean) => void;
+  capabilities: CapabilityType;
+  setCapabilities: (capabilities: CapabilityType) => void;
+  clearContext: () => void;
+}
 
 /**
  * Component managing a universal (application-wide) data store.
@@ -27,13 +51,13 @@ type StateType = any;
  * 4) As a rule of thumb, Context should not be used for UI-related state
  */
 class ContextProvider extends Component<PropsType, StateType> {
-  state = {
-    currentModal: null as string | null,
-    currentModalData: null as any,
+  state: GlobalContextType = {
+    currentModal: null,
+    currentModalData: null,
     setCurrentModal: (currentModal: string, currentModalData?: any) => {
       this.setState({ currentModal, currentModalData });
     },
-    currentError: null as string | null,
+    currentError: null,
     setCurrentError: (currentError: string) => {
       this.setState({ currentError });
     },
@@ -54,7 +78,7 @@ class ContextProvider extends Component<PropsType, StateType> {
         callback && callback();
       });
     },
-    currentProject: null as ProjectType | null,
+    currentProject: null,
     setCurrentProject: (currentProject: ProjectType, callback?: any) => {
       pushQueryParams(this.props, { project_id: currentProject.id.toString() });
       if (currentProject) {
@@ -66,12 +90,12 @@ class ContextProvider extends Component<PropsType, StateType> {
         callback && callback();
       });
     },
-    projects: [] as ProjectType[],
+    projects: [],
     setProjects: (projects: ProjectType[]) => {
       projects.sort((a: any, b: any) => (a.name > b.name ? 1 : -1));
       this.setState({ projects });
     },
-    user: null as any,
+    user: null,
     setUser: (userId: number, email: string) => {
       this.setState({ user: { userId, email } });
     },
@@ -79,7 +103,7 @@ class ContextProvider extends Component<PropsType, StateType> {
     setDevOpsMode: (devOpsMode: boolean) => {
       this.setState({ devOpsMode });
     },
-    capabilities: null as CapabilityType,
+    capabilities: null,
     setCapabilities: (capabilities: CapabilityType) => {
       this.setState({ capabilities });
     },

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

@@ -399,6 +399,16 @@ const getCluster = baseApi<
   return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}`;
 });
 
+const getClusterNodes = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+  }
+>("GET", (pathParams) => {
+  return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}/nodes`
+})
+
 const getGitRepoList = baseApi<
   {},
   {
@@ -880,6 +890,7 @@ export default {
   getClusterIntegrations,
   getClusters,
   getCluster,
+  getClusterNodes,
   getConfigMap,
   getGitRepoList,
   getGitRepos,

+ 122 - 0
internal/kubernetes/nodes/helpers.go

@@ -0,0 +1,122 @@
+package nodes
+
+import (
+	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/api/resource"
+)
+
+func getPodsTotalRequestsAndLimits(podList *corev1.PodList) (reqs map[corev1.ResourceName]resource.Quantity, limits map[corev1.ResourceName]resource.Quantity) {
+	reqs, limits = map[corev1.ResourceName]resource.Quantity{}, map[corev1.ResourceName]resource.Quantity{}
+	for _, pod := range podList.Items {
+		podReqs, podLimits := podRequestsAndLimits(&pod)
+		for podReqName, podReqValue := range podReqs {
+			if value, ok := reqs[podReqName]; !ok {
+				reqs[podReqName] = podReqValue.DeepCopy()
+			} else {
+				value.Add(podReqValue)
+				reqs[podReqName] = value
+			}
+		}
+		for podLimitName, podLimitValue := range podLimits {
+			if value, ok := limits[podLimitName]; !ok {
+				limits[podLimitName] = podLimitValue.DeepCopy()
+			} else {
+				value.Add(podLimitValue)
+				limits[podLimitName] = value
+			}
+		}
+	}
+	return
+}
+
+func podRequestsAndLimits(pod *corev1.Pod) (reqs, limits corev1.ResourceList) {
+	reqs, limits = corev1.ResourceList{}, corev1.ResourceList{}
+	for _, container := range pod.Spec.Containers {
+		addResourceList(reqs, container.Resources.Requests)
+		addResourceList(limits, container.Resources.Limits)
+	}
+	// init containers define the minimum of any resource
+	for _, container := range pod.Spec.InitContainers {
+		maxResourceList(reqs, container.Resources.Requests)
+		maxResourceList(limits, container.Resources.Limits)
+	}
+
+	// Add overhead for running a pod to the sum of requests and to non-zero limits:
+	if pod.Spec.Overhead != nil {
+		addResourceList(reqs, pod.Spec.Overhead)
+
+		for name, quantity := range pod.Spec.Overhead {
+			if value, ok := limits[name]; ok && !value.IsZero() {
+				value.Add(quantity)
+				limits[name] = value
+			}
+		}
+	}
+	return
+}
+
+// addResourceList adds the resources in newList to list
+func addResourceList(list, new corev1.ResourceList) {
+	for name, quantity := range new {
+		if value, ok := list[name]; !ok {
+			list[name] = quantity.DeepCopy()
+		} else {
+			value.Add(quantity)
+			list[name] = value
+		}
+	}
+}
+
+// maxResourceList sets list to the greater of list/newList for every resource
+// either list
+func maxResourceList(list, new corev1.ResourceList) {
+	for name, quantity := range new {
+		if value, ok := list[name]; !ok {
+			list[name] = quantity.DeepCopy()
+			continue
+		} else {
+			if quantity.Cmp(value) > 0 {
+				list[name] = quantity.DeepCopy()
+			}
+		}
+	}
+}
+
+// Returns the summatory of resources requested and their limits by a list of pods on a specific node in fraction values.
+func DescribeNodeResource(nodeNonTerminatedPodsList *corev1.PodList, node *corev1.Node) *NodeUsage {
+	allocatable := node.Status.Capacity
+	if len(node.Status.Allocatable) > 0 {
+		allocatable = node.Status.Allocatable
+	}
+
+	reqs, limits := getPodsTotalRequestsAndLimits(nodeNonTerminatedPodsList)
+	cpuReqs, cpuLimits, memoryReqs, memoryLimits, ephemeralstorageReqs, ephemeralstorageLimits :=
+		reqs[corev1.ResourceCPU], limits[corev1.ResourceCPU], reqs[corev1.ResourceMemory], limits[corev1.ResourceMemory], reqs[corev1.ResourceEphemeralStorage], limits[corev1.ResourceEphemeralStorage]
+	fractionCpuReqs := float64(0)
+	fractionCpuLimits := float64(0)
+	if allocatable.Cpu().MilliValue() != 0 {
+		fractionCpuReqs = float64(cpuReqs.MilliValue()) / float64(allocatable.Cpu().MilliValue()) * 100
+		fractionCpuLimits = float64(cpuLimits.MilliValue()) / float64(allocatable.Cpu().MilliValue()) * 100
+	}
+	fractionMemoryReqs := float64(0)
+	fractionMemoryLimits := float64(0)
+	if allocatable.Memory().Value() != 0 {
+		fractionMemoryReqs = float64(memoryReqs.Value()) / float64(allocatable.Memory().Value()) * 100
+		fractionMemoryLimits = float64(memoryLimits.Value()) / float64(allocatable.Memory().Value()) * 100
+	}
+	fractionEphemeralStorageReqs := float64(0)
+	fractionEphemeralStorageLimits := float64(0)
+	if allocatable.StorageEphemeral().Value() != 0 {
+		fractionEphemeralStorageReqs = float64(ephemeralstorageReqs.Value()) / float64(allocatable.StorageEphemeral().Value()) * 100
+		fractionEphemeralStorageLimits = float64(ephemeralstorageLimits.Value()) / float64(allocatable.StorageEphemeral().Value()) * 100
+	}
+
+	return &NodeUsage{
+		fractionCpuReqs,
+		fractionCpuLimits,
+		fractionMemoryReqs,
+		fractionMemoryLimits,
+		fractionEphemeralStorageReqs,
+		fractionEphemeralStorageLimits,
+	}
+}

+ 77 - 0
internal/kubernetes/nodes/nodes.go

@@ -0,0 +1,77 @@
+package nodes
+
+import (
+	"context"
+	"fmt"
+	"sync"
+
+	v1 "k8s.io/api/core/v1"
+	"k8s.io/client-go/kubernetes"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+type NodeUsage struct {
+	fractionCpuReqs                float64
+	fractionCpuLimits              float64
+	fractionMemoryReqs             float64
+	fractionMemoryLimits           float64
+	fractionEphemeralStorageReqs   float64
+	fractionEphemeralStorageLimits float64
+}
+
+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"`
+	Condition                      []v1.NodeCondition `json:"node_conditions"`
+}
+
+func (nu *NodeUsage) Externalize(node v1.Node) *NodeWithUsageData {
+	return &NodeWithUsageData{
+		Name:                           node.Name,
+		FractionCpuReqs:                nu.fractionCpuReqs,
+		FractionCpuLimits:              nu.fractionCpuLimits,
+		FractionMemoryReqs:             nu.fractionMemoryReqs,
+		FractionMemoryLimits:           nu.fractionMemoryLimits,
+		FractionEphemeralStorageReqs:   nu.fractionEphemeralStorageReqs,
+		FractionEphemeralStorageLimits: nu.fractionEphemeralStorageLimits,
+		Condition:                      node.Status.Conditions,
+	}
+}
+
+func GetNodesUsage(clientset kubernetes.Interface) []*NodeWithUsageData {
+	nodeList, _ := clientset.CoreV1().Nodes().List(context.TODO(), metav1.ListOptions{})
+
+	extNodeList := make([]*NodeWithUsageData, len(nodeList.Items))
+	var wg sync.WaitGroup
+	for i := range nodeList.Items {
+		index := i
+		currentNode := &nodeList.Items[index]
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			podList := getPodsForNode(clientset, currentNode.Name)
+			nodeUsage := DescribeNodeResource(podList, currentNode)
+
+			extNodeList[index] = nodeUsage.Externalize(*currentNode)
+		}()
+	}
+	wg.Wait()
+
+	return extNodeList
+}
+
+func getPodsForNode(clientset kubernetes.Interface, nodeName string) *v1.PodList {
+	fmt.Printf("%s", nodeName)
+
+	podList, _ := clientset.CoreV1().Pods("").List(context.TODO(), metav1.ListOptions{
+		FieldSelector: "spec.nodeName=" + nodeName + ",status.phase=Running",
+	})
+
+	return podList
+}

+ 43 - 0
server/api/k8s_handler.go

@@ -5,12 +5,14 @@ import (
 	"fmt"
 	"net/http"
 	"net/url"
+	"strconv"
 
 	"github.com/go-chi/chi"
 	"github.com/gorilla/schema"
 	"github.com/gorilla/websocket"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/kubernetes/nodes"
 	"github.com/porter-dev/porter/internal/kubernetes/prometheus"
 	v1 "k8s.io/api/core/v1"
 	"k8s.io/client-go/tools/clientcmd"
@@ -1222,3 +1224,44 @@ func (app *App) HandleGetTemporaryKubeconfig(w http.ResponseWriter, r *http.Requ
 		return
 	}
 }
+
+func (app *App) HandleListNodes(w http.ResponseWriter, r *http.Request) {
+	id, err := strconv.ParseUint(chi.URLParam(r, "cluster_id"), 0, 64)
+
+	if err != nil || id == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	cluster, err := app.Repo.Cluster.ReadCluster(uint(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)
+	}
+
+	nodeWithUsageList := nodes.GetNodesUsage(agent.Clientset)
+
+	w.WriteHeader(http.StatusOK)
+
+	if err := json.NewEncoder(w).Encode(nodeWithUsageList); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}

+ 14 - 0
server/router/router.go

@@ -599,6 +599,20 @@ func New(a *api.App) *chi.Mux {
 				),
 			)
 
+			r.Method(
+				"GET",
+				"/projects/{project_id}/clusters/{cluster_id}/nodes",
+				auth.DoesUserHaveProjectAccess(
+					auth.DoesUserHaveClusterAccess(
+						requestlog.NewHandler(a.HandleListNodes, l),
+						mw.URLParam,
+						mw.URLParam,
+					),
+					mw.URLParam,
+					mw.ReadAccess,
+				),
+			)
+
 			r.Method(
 				"POST",
 				"/projects/{project_id}/clusters/{cluster_id}",