Browse Source

Clusterstatus (#4285)

jusrhee 2 năm trước cách đây
mục cha
commit
1c725f16b5

+ 15 - 18
dashboard/src/lib/clusters/types.ts

@@ -239,6 +239,16 @@ export type PreflightCheck = {
   resolution?: PreflightCheckResolution;
 };
 
+// Node
+export const nodeValidator = z.object({
+  name: z.string(),
+  labels: z.record(z.string()),
+});
+export type ClientNode = {
+  nodeGroupType: NodeGroupType;
+  instanceType: string;
+};
+
 // Cluster
 export const clusterValidator = z.object({
   id: z.number(),
@@ -340,44 +350,31 @@ export const contractValidator = z.object({
 });
 // this is the type of the object that is returned from the getContract API, but only the base64_contract field is editable by the user
 export type APIContract = z.infer<typeof contractValidator>;
-const eksNodeGroupTypeValidator = z.enum([
-  "UNKNOWN",
-  "SYSTEM",
-  "MONITORING",
-  "APPLICATION",
-  "CUSTOM",
-]);
-const gkeNodeGroupTypeValidator = z.enum([
+const nodeGroupTypeValidator = z.enum([
   "UNKNOWN",
   "SYSTEM",
   "MONITORING",
   "APPLICATION",
   "CUSTOM",
 ]);
+type NodeGroupType = z.infer<typeof nodeGroupTypeValidator>;
 const eksNodeGroupValidator = z.object({
   instanceType: z.string(),
   minInstances: z.number(),
   maxInstances: z.number(),
-  nodeGroupType: eksNodeGroupTypeValidator,
+  nodeGroupType: nodeGroupTypeValidator,
 });
 const gkeNodeGroupValidator = z.object({
   instanceType: z.string(),
   minInstances: z.number(),
   maxInstances: z.number(),
-  nodeGroupType: gkeNodeGroupTypeValidator,
+  nodeGroupType: nodeGroupTypeValidator,
 });
-const aksNodeGroupTypeValidator = z.enum([
-  "UNKNOWN",
-  "SYSTEM",
-  "MONITORING",
-  "APPLICATION",
-  "CUSTOM",
-]);
 const aksNodeGroupValidator = z.object({
   instanceType: z.string(),
   minInstances: z.number(),
   maxInstances: z.number(),
-  nodeGroupType: aksNodeGroupTypeValidator,
+  nodeGroupType: nodeGroupTypeValidator,
 });
 
 const cidrRangeValidator = z

+ 70 - 0
dashboard/src/lib/hooks/useCluster.ts

@@ -19,10 +19,12 @@ import {
   clusterValidator,
   contractValidator,
   createContractResponseValidator,
+  nodeValidator,
   preflightCheckValidator,
   type APIContract,
   type ClientCluster,
   type ClientClusterContract,
+  type ClientNode,
   type ClientPreflightCheck,
   type ClusterState,
   type ContractCondition,
@@ -443,6 +445,74 @@ export const useUpdateCluster = ({
   };
 };
 
+type TUseClusterNodeList = {
+  nodes: ClientNode[];
+  isLoading: boolean;
+};
+export const useClusterNodeList = ({
+  clusterId,
+}: {
+  clusterId: number | undefined;
+}): TUseClusterNodeList => {
+  const { currentProject } = useContext(Context);
+
+  const clusterNodesReq = useQuery(
+    ["getClusterNodes", currentProject?.id, clusterId],
+    async () => {
+      if (
+        !currentProject?.id ||
+        currentProject.id === -1 ||
+        !clusterId ||
+        clusterId === -1
+      ) {
+        return;
+      }
+
+      const res = await api.getClusterNodes(
+        "<token>",
+        {},
+        { project_id: currentProject.id, cluster_id: clusterId }
+      );
+
+      const parsed = await z.array(nodeValidator).parseAsync(res.data);
+      return parsed
+        .map((n) => {
+          const nodeGroupType = match(n.labels["porter.run/workload-kind"])
+            .with("application", () => "APPLICATION" as const)
+            .with("system", () => "SYSTEM" as const)
+            .with("monitoring", () => "MONITORING" as const)
+            .with("custom", () => "CUSTOM" as const)
+            .otherwise(() => "UNKNOWN" as const);
+          if (nodeGroupType === "UNKNOWN") {
+            return undefined;
+          }
+          const instanceType = n.labels["node.kubernetes.io/instance-type"];
+          if (!instanceType) {
+            return undefined;
+          }
+          return {
+            nodeGroupType,
+            instanceType,
+          };
+        })
+        .filter(valueExists);
+    },
+    {
+      refetchInterval: 3000,
+      enabled:
+        !!currentProject &&
+        currentProject.id !== -1 &&
+        !!clusterId &&
+        clusterId !== -1,
+    }
+  );
+
+  return {
+    nodes: clusterNodesReq.data ?? [],
+    isLoading: clusterNodesReq.isLoading,
+  };
+};
+
 const getErrorMessageFromNetworkCall = (
   err: unknown,
   networkCallDescription: string

+ 6 - 1
dashboard/src/main/home/infrastructure-dashboard/ClusterContextProvider.tsx

@@ -12,8 +12,9 @@ import { updateExistingClusterContract } from "lib/clusters";
 import {
   type ClientCluster,
   type ClientClusterContract,
+  type ClientNode,
 } from "lib/clusters/types";
-import { useCluster } from "lib/hooks/useCluster";
+import { useCluster, useClusterNodeList } from "lib/hooks/useCluster";
 
 import api from "shared/api";
 import { Context } from "shared/Context";
@@ -21,6 +22,7 @@ import notFound from "assets/not-found.png";
 
 type ClusterContextType = {
   cluster: ClientCluster;
+  nodes: ClientNode[];
   projectId: number;
   isClusterUpdating: boolean;
   updateClusterVanityName: (name: string) => void;
@@ -55,6 +57,8 @@ const ClusterContextProvider: React.FC<ClusterContextProviderProps> = ({
     refetchInterval: 3000,
   });
 
+  const { nodes } = useClusterNodeList({ clusterId });
+
   const paramsExist =
     !!clusterId && !!currentProject && currentProject.id !== -1;
 
@@ -163,6 +167,7 @@ const ClusterContextProvider: React.FC<ClusterContextProviderProps> = ({
     <ClusterContext.Provider
       value={{
         cluster,
+        nodes,
         projectId: currentProject.id,
         isClusterUpdating,
         updateClusterVanityName,

+ 14 - 15
dashboard/src/main/home/infrastructure-dashboard/ClusterHeader.tsx

@@ -1,18 +1,22 @@
-import React from "react";
+import React, { useMemo } from "react";
 import styled from "styled-components";
 
 import Container from "components/porter/Container";
 import Icon from "components/porter/Icon";
 import Spacer from "components/porter/Spacer";
-import StatusDot from "components/porter/StatusDot";
 import Text from "components/porter/Text";
 
 import { readableDate } from "shared/string_utils";
 
 import { useClusterContext } from "./ClusterContextProvider";
+import ClusterStatus from "./ClusterStatus";
 
 const ClusterHeader: React.FC = () => {
-  const { cluster } = useClusterContext();
+  const { cluster, isClusterUpdating, nodes } = useClusterContext();
+
+  const applicationNodes = useMemo(() => {
+    return nodes.filter((n) => n.nodeGroupType === "APPLICATION");
+  }, [nodes]);
 
   return (
     <>
@@ -23,18 +27,6 @@ const ClusterHeader: React.FC = () => {
       </Container>
       <Spacer y={0.5} />
       <CreatedAtContainer>
-        <Container row>
-          <Spacer inline x={0.2} />
-          <StatusDot
-            status={cluster.status === "READY" ? "available" : "pending"}
-            heightPixels={8}
-          />
-          <Spacer inline x={0.7} />
-          <Text color="helper">
-            {cluster.status === "READY" ? "Running" : "Updating"}
-          </Text>
-          <Spacer inline x={1} />
-        </Container>
         <div style={{ flexShrink: 0 }}>
           <Text color="#aaaabb66">
             Updated {readableDate(cluster.contract.updated_at)}
@@ -42,6 +34,13 @@ const ClusterHeader: React.FC = () => {
         </div>
         <Spacer y={0.5} />
       </CreatedAtContainer>
+      {isClusterUpdating ||
+        (applicationNodes.length !== 0 && (
+          <>
+            <Spacer y={0.5} />
+            <ClusterStatus />
+          </>
+        ))}
     </>
   );
 };

+ 50 - 0
dashboard/src/main/home/infrastructure-dashboard/ClusterStatus.tsx

@@ -0,0 +1,50 @@
+import React, { useMemo } from "react";
+import pluralize from "pluralize";
+import styled from "styled-components";
+
+import Container from "components/porter/Container";
+import Spacer from "components/porter/Spacer";
+import StatusDot from "components/porter/StatusDot";
+import Text from "components/porter/Text";
+
+import { useClusterContext } from "./ClusterContextProvider";
+
+const ClusterStatus: React.FC = () => {
+  const { nodes, isClusterUpdating } = useClusterContext();
+
+  const applicationNodes = useMemo(() => {
+    return nodes.filter((n) => n.nodeGroupType === "APPLICATION");
+  }, [nodes]);
+
+  return (
+    <Container row style={{ flexShrink: 0 }}>
+      <Spacer inline x={0.2} />
+      {isClusterUpdating ? (
+        <>
+          <StatusDot status={"pending"} heightPixels={8} />
+          <Spacer inline x={0.7} />
+          <Text color="helper">Updating</Text>
+        </>
+      ) : (
+        applicationNodes.length !== 0 && (
+          <>
+            <StatusDot status={"available"} heightPixels={8} />
+            <Spacer inline x={0.7} />
+            <Text color="helper">
+              Applications using {applicationNodes.length}{" "}
+              <Code>{applicationNodes[0].instanceType}</Code>{" "}
+              {pluralize("instance", applicationNodes.length)}
+            </Text>
+          </>
+        )
+      )}
+    </Container>
+  );
+};
+
+export default ClusterStatus;
+
+const Code = styled.span`
+  font-family: monospace;
+  font-size: 12px;
+`;

+ 30 - 16
dashboard/src/main/home/onboarding/Onboarding.tsx

@@ -1,19 +1,23 @@
-import Loading from "components/Loading";
-import ProvisionerFlow from "components/ProvisionerFlow";
 import React, { useContext, useEffect, useState } from "react";
-import api from "shared/api";
-import { Context } from "shared/Context";
 import styled from "styled-components";
 import { devtools } from "valtio/utils";
-import Routes from "./Routes";
-import { OFState } from "./state";
-import { useSteps } from "./state/StepHandler";
-import { type Onboarding as OnboardingSaveType } from "./types";
 
+import Loading from "components/Loading";
+import Banner from "components/porter/Banner";
+import Link from "components/porter/Link";
+import Spacer from "components/porter/Spacer";
+import ProvisionerFlow from "components/ProvisionerFlow";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
 import bolt from "assets/bolt.svg";
 
 import DashboardHeader from "../cluster-dashboard/DashboardHeader";
 import CreateClusterForm from "../infrastructure-dashboard/forms/CreateClusterForm";
+import Routes from "./Routes";
+import { OFState } from "./state";
+import { useSteps } from "./state/StepHandler";
+import { type Onboarding as OnboardingSaveType } from "./types";
 
 const Onboarding = () => {
   const context = useContext(Context);
@@ -154,9 +158,13 @@ const Onboarding = () => {
   }, [context?.currentProject?.id]);
 
   const renderOnboarding = () => {
-    if (context?.currentProject?.simplified_view_enabled && context?.currentProject?.capi_provisioner_enabled && context?.currentProject?.beta_features_enabled) {
+    if (
+      context?.currentProject?.simplified_view_enabled &&
+      context?.currentProject?.capi_provisioner_enabled &&
+      context?.currentProject?.beta_features_enabled
+    ) {
       return <CreateClusterForm />;
-  } else if (context?.currentProject?.capi_provisioner_enabled) {
+    } else if (context?.currentProject?.capi_provisioner_enabled) {
       return (
         <Wrapper>
           <DashboardHeader
@@ -166,23 +174,29 @@ const Onboarding = () => {
             disableLineBreak
             capitalize={false}
           />
-          <Br />
+          <Banner>
+            Don't want to link your own cloud account? Immediately deploy your
+            apps on the <Spacer inline width="5px" />
+            <Link to="https://sandbox.porter.run" hasunderline>
+              Porter sandbox
+            </Link>
+            .
+          </Banner>
+          <Spacer y={1} />
           <ProvisionerFlow />
           <Div />
         </Wrapper>
-      )
+      );
     } else {
       return (
         <StyledOnboarding>
           {isLoading ? <Loading /> : <Routes />}
         </StyledOnboarding>
-      )
+      );
     }
   };
 
-  return (
-    <>{renderOnboarding()}</>
-  );
+  return <>{renderOnboarding()}</>;
 };
 
 export default Onboarding;