Ver Fonte

Merge pull request #784 from jnfrati/0.5.0-expanded-node-view

[0.5.0] - Expanded node view on cluster dashboard
abelanger5 há 4 anos atrás
pai
commit
89cb4a9589

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

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

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

@@ -1,26 +1,19 @@
 import React, { useContext, useEffect, useMemo, useState } from "react";
 import React, { useContext, useEffect, useMemo, useState } from "react";
 
 
 import Table from "components/Table";
 import Table from "components/Table";
-import { Column } from "react-table";
+import { Column, Row } from "react-table";
 import styled from "styled-components";
 import styled from "styled-components";
 import api from "shared/api";
 import api from "shared/api";
 import { Context } from "shared/Context";
 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 NodeList: React.FC = () => {
   const context = useContext(Context);
   const context = useContext(Context);
   const [nodeList, setNodeList] = useState([]);
   const [nodeList, setNodeList] = useState([]);
   const [loading, setLoading] = useState<boolean>(false);
   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>[]>(
   const columns = useMemo<Column<any>[]>(
     () => [
     () => [
@@ -28,6 +21,10 @@ const NodeList: React.FC = () => {
         Header: "Node name",
         Header: "Node name",
         accessor: "name",
         accessor: "name",
       },
       },
+      {
+        Header: "Machine type",
+        accessor: "machine_type",
+      },
       {
       {
         Header: "CPU Usage",
         Header: "CPU Usage",
         accessor: "cpu_usage",
         accessor: "cpu_usage",
@@ -42,10 +39,7 @@ const NodeList: React.FC = () => {
         Cell: ({ row }) => {
         Cell: ({ row }) => {
           return (
           return (
             <StatusButtonWrapper>
             <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"}
                 {row.values.is_node_healthy ? "Healthy" : "Unhealthy"}
               </StatusButton>
               </StatusButton>
             </StatusButtonWrapper>
             </StatusButtonWrapper>
@@ -60,12 +54,17 @@ const NodeList: React.FC = () => {
     const percentFormatter = (number: number) =>
     const percentFormatter = (number: number) =>
       `${Number(number).toFixed(2)}%`;
       `${Number(number).toFixed(2)}%`;
 
 
+    const getMachineType = (labels: any) => {
+      return (labels && labels["node.kubernetes.io/instance-type"]) || "N/A";
+    };
+
     return nodeList
     return nodeList
       .map((node) => {
       .map((node) => {
         return {
         return {
           name: node.name,
           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,
           node_conditions: node.node_conditions,
           is_node_healthy: node.node_conditions.reduce(
           is_node_healthy: node.node_conditions.reduce(
             (prevValue: boolean, current: any) => {
             (prevValue: boolean, current: any) => {
@@ -113,14 +112,27 @@ const NodeList: React.FC = () => {
       .finally(() => setLoading(false));
       .finally(() => setLoading(false));
   }, [context, setNodeList]);
   }, [context, setNodeList]);
 
 
+  const handleOnRowClick = (row: any) => {
+    pushFiltered(
+      {
+        history,
+        location,
+      },
+      `/cluster-dashboard/node-view/${row.original.name}`,
+      []
+    );
+  };
+
   return (
   return (
     <NodeListWrapper>
     <NodeListWrapper>
       <StyledChart>
       <StyledChart>
-        <Table columns={columns} data={data} isLoading={loading} />
+        <Table
+          columns={columns}
+          data={data}
+          isLoading={loading}
+          onRowClick={handleOnRowClick}
+        />
       </StyledChart>
       </StyledChart>
-      {selectedNode && (
-        <NodeStatusModal node={selectedNode} onClose={() => triggerPopUp()} />
-      )}
     </NodeListWrapper>
     </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 React, { useMemo } from "react";
-import Modal from "../../modals/Modal";
 import Table from "components/Table";
 import Table from "components/Table";
 import { Column } from "react-table";
 import { Column } from "react-table";
 import styled from "styled-components";
 import styled from "styled-components";
 
 
 type NodeStatusModalProps = {
 type NodeStatusModalProps = {
-  onClose: () => void;
   node: any;
   node: any;
-  width?: string;
-  height?: string;
 };
 };
 
 
-export const NodeStatusModal: React.FunctionComponent<NodeStatusModalProps> = ({
-  onClose,
+export const ConditionsTable: React.FunctionComponent<NodeStatusModalProps> = ({
   node,
   node,
-  width = "800px",
-  height = "min-content",
 }) => {
 }) => {
   const columns = useMemo<Column<any>[]>(
   const columns = useMemo<Column<any>[]>(
     () => [
     () => [
@@ -35,27 +28,32 @@ export const NodeStatusModal: React.FunctionComponent<NodeStatusModalProps> = ({
         Header: "Message",
         Header: "Message",
         accessor: "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 || [];
     return node?.node_conditions || [];
   }, [node]);
   }, [node]);
 
 
   return (
   return (
     <div>
     <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>
     </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;

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

@@ -894,7 +894,7 @@ const Dot = styled.div`
 const InfoWrapper = styled.div`
 const InfoWrapper = styled.div`
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
-  margin-left: 6px;
+  margin-left: 3px;
   margin-top: 22px;
   margin-top: 22px;
 `;
 `;
 
 

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

@@ -421,7 +421,7 @@ const TitleSection = styled.div`
       align-items: center;
       align-items: center;
       margin-bottom: -2px;
       margin-bottom: -2px;
       font-size: 18px;
       font-size: 18px;
-      margin-left: 18px;
+      margin-left: 15px;
       color: #858faaaa;
       color: #858faaaa;
       :hover {
       :hover {
         color: #aaaabb;
         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`;
   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<
 const getGitRepoList = baseApi<
   {},
   {},
   {
   {
@@ -951,6 +964,7 @@ export default {
   getClusters,
   getClusters,
   getCluster,
   getCluster,
   getClusterNodes,
   getClusterNodes,
+  getClusterNode,
   getConfigMap,
   getConfigMap,
   getGitRepoList,
   getGitRepoList,
   getGitRepos,
   getGitRepos,

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

@@ -112,11 +112,14 @@ func DescribeNodeResource(nodeNonTerminatedPodsList *corev1.PodList, node *corev
 	}
 	}
 
 
 	return &NodeUsage{
 	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 {
 type NodeUsage struct {
+	cpuReqs                        string
+	memoryReqs                     string
+	ephemeralStorageReqs           string
 	fractionCpuReqs                float64
 	fractionCpuReqs                float64
 	fractionCpuLimits              float64
 	fractionCpuLimits              float64
 	fractionMemoryReqs             float64
 	fractionMemoryReqs             float64
@@ -21,18 +24,26 @@ type NodeUsage struct {
 
 
 type NodeWithUsageData struct {
 type NodeWithUsageData struct {
 	Name                           string             `json:"name"`
 	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"`
 	Condition                      []v1.NodeCondition `json:"node_conditions"`
 }
 }
 
 
 func (nu *NodeUsage) Externalize(node v1.Node) *NodeWithUsageData {
 func (nu *NodeUsage) Externalize(node v1.Node) *NodeWithUsageData {
 	return &NodeWithUsageData{
 	return &NodeWithUsageData{
 		Name:                           node.Name,
 		Name:                           node.Name,
+		Labels:                         node.Labels,
+		CpuReqs:                        nu.cpuReqs,
+		MemoryReqs:                     nu.memoryReqs,
+		EphemeralStorageReqs:           nu.ephemeralStorageReqs,
 		FractionCpuReqs:                nu.fractionCpuReqs,
 		FractionCpuReqs:                nu.fractionCpuReqs,
 		FractionCpuLimits:              nu.fractionCpuLimits,
 		FractionCpuLimits:              nu.fractionCpuLimits,
 		FractionMemoryReqs:             nu.fractionMemoryReqs,
 		FractionMemoryReqs:             nu.fractionMemoryReqs,
@@ -72,3 +83,23 @@ func getPodsForNode(clientset kubernetes.Interface, nodeName string) *v1.PodList
 
 
 	return 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(),
+	}
+}

+ 42 - 0
server/api/k8s_handler.go

@@ -1317,3 +1317,45 @@ func (app *App) HandleListNodes(w http.ResponseWriter, r *http.Request) {
 		return
 		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
+	}
+}

+ 14 - 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(
 			r.Method(
 				"POST",
 				"POST",
 				"/projects/{project_id}/clusters/{cluster_id}",
 				"/projects/{project_id}/clusters/{cluster_id}",