diff --git a/web/src/Root.tsx b/web/src/Root.tsx index 2e7930a438..684fe64170 100644 --- a/web/src/Root.tsx +++ b/web/src/Root.tsx @@ -58,6 +58,7 @@ import SnapshotDetails from "@components/snapshots/SnapshotDetails"; import SnapshotRestore from "@components/snapshots/SnapshotRestore"; import AppSnapshots from "@components/snapshots/AppSnapshots"; import AppSnapshotRestore from "@components/snapshots/AppSnapshotRestore"; +import HelmVMViewNode from "@components/apps/HelmVMViewNode"; // react-query client const queryClient = new QueryClient(); @@ -579,10 +580,11 @@ const Root = () => { state.adminConsoleMetadata?.isKurl ? ( ) : ( - + ) } /> + } /> } diff --git a/web/src/components/apps/HelmVMClusterManagement.jsx b/web/src/components/apps/HelmVMClusterManagement.jsx index 4d1712f258..5d2ef854af 100644 --- a/web/src/components/apps/HelmVMClusterManagement.jsx +++ b/web/src/components/apps/HelmVMClusterManagement.jsx @@ -1,96 +1,167 @@ -import React, { Component, Fragment } from "react"; import classNames from "classnames"; import dayjs from "dayjs"; +import React, { useEffect, useReducer } from "react"; +import Modal from "react-modal"; +import { useQuery } from "react-query"; + import { KotsPageTitle } from "@components/Head"; -import CodeSnippet from "../shared/CodeSnippet"; -import HelmVMNodeRow from "./HelmVMNodeRow"; -import Loader from "../shared/Loader"; import { rbacRoles } from "../../constants/rbac"; -import { Utilities } from "../../utilities/utilities"; import { Repeater } from "../../utilities/repeater"; +import { Utilities } from "../../utilities/utilities"; +import Icon from "../Icon"; import ErrorModal from "../modals/ErrorModal"; -import Modal from "react-modal"; +import CodeSnippet from "../shared/CodeSnippet"; +import Loader from "../shared/Loader"; +import HelmVMNodeRow from "./HelmVMNodeRow"; import "@src/scss/components/apps/HelmVMClusterManagement.scss"; -import Icon from "../Icon"; -export class HelmVMClusterManagement extends Component { - state = { - generating: false, - command: "", - expiry: null, - displayAddNode: false, - selectedNodeType: "primary", - generateCommandErrMsg: "", - helmvm: null, - getNodeStatusJob: new Repeater(), - deletNodeError: "", - confirmDeleteNode: "", - showConfirmDrainModal: false, - nodeNameToDrain: "", - drainingNodeName: null, - drainNodeSuccessful: false, - }; - - componentDidMount() { - this.getNodeStatus(); - this.state.getNodeStatusJob.start(this.getNodeStatus, 1000); - } +const testData = { + isHelmVMEnabled: true, + ha: false, + nodes: [ + { + name: "test-helmvm-node", + isConnected: true, + isReady: true, + isPrimaryNode: true, + canDelete: false, + kubeletVersion: "v1.28.2", + cpu: { + capacity: 8, + available: 7.466876775, + }, + memory: { + capacity: 31.33294677734375, + available: 24.23790740966797, + }, + pods: { + capacity: 110, + available: 77, + }, + labels: [ + "beta.kubernetes.io/arch:amd64", + "beta.kubernetes.io/os:linux", + "node-role.kubernetes.io/master:", + "node.kubernetes.io/exclude-from-external-load-balancers:", + "kubernetes.io/arch:amd64", + "kubernetes.io/hostname:laverya-kurl", + "kubernetes.io/os:linux", + "node-role.kubernetes.io/control-plane:", + ], + conditions: { + memoryPressure: false, + diskPressure: false, + pidPressure: false, + ready: true, + }, + }, + { + name: "test-helmvm-worker", + isConnected: true, + isReady: true, + isPrimaryNode: false, + canDelete: false, + kubeletVersion: "v1.28.2", + cpu: { + capacity: 4, + available: 3.761070507, + }, + memory: { + capacity: 15.50936508178711, + available: 11.742542266845703, + }, + pods: { + capacity: 110, + available: 94, + }, + labels: [ + "beta.kubernetes.io/arch:amd64", + "beta.kubernetes.io/os:linux", + "kubernetes.io/arch:amd64", + "kubernetes.io/os:linux", + "kurl.sh/cluster:true", + ], + conditions: { + memoryPressure: false, + diskPressure: false, + pidPressure: false, + ready: true, + }, + }, + ], +}; - componentWillUnmount() { - this.state.getNodeStatusJob.stop(); - } +const HelmVMClusterManagement = () => { + const [state, setState] = useReducer( + (state, newState) => ({ ...state, ...newState }), + { + generating: false, + command: "", + expiry: null, + displayAddNode: false, + selectedNodeType: "primary", + generateCommandErrMsg: "", + helmvm: null, + deletNodeError: "", + confirmDeleteNode: "", + showConfirmDrainModal: false, + nodeNameToDrain: "", + drainingNodeName: null, + drainNodeSuccessful: false, + } + ); - getNodeStatus = async () => { - try { - const res = await fetch(`${process.env.API_ENDPOINT}/helmvm/nodes`, { - headers: { - Accept: "application/json", - }, - credentials: "include", - method: "GET", - }); - if (!res.ok) { - if (res.status === 401) { - Utilities.logoutUser(); - return; - } - console.log( - "failed to get node status list, unexpected status code", - res.status - ); + const { data: nodes, isLoading: nodesLoading } = useQuery({ + queryKey: "helmVmNodes", + queryFn: async () => { + return ( + await fetch(`${process.env.API_ENDPOINT}/helmvm/nodes`, { + headers: { + Accept: "application/json", + }, + credentials: "include", + method: "GET", + }) + ).json(); + }, + onError: (err) => { + if (err.status === 401) { + Utilities.logoutUser(); return; } - const response = await res.json(); - this.setState({ - helmvm: response, + console.log( + "failed to get node status list, unexpected status code", + err.status + ); + }, + onSuccess: (data) => { + setState({ // if cluster doesn't support ha, then primary will be disabled. Force into secondary - selectedNodeType: !response.ha - ? "secondary" - : this.state.selectedNodeType, + selectedNodeType: !data.ha ? "secondary" : state.selectedNodeType, }); - return response; - } catch (err) { - console.log(err); - throw err; - } - }; + }, + config: { + refetchInterval: 1000, + retry: false, + }, + }); - deleteNode = (name) => { - this.setState({ + const deleteNode = (name) => { + setState({ confirmDeleteNode: name, }); }; - cancelDeleteNode = () => { - this.setState({ + const cancelDeleteNode = () => { + setState({ confirmDeleteNode: "", }); }; - reallyDeleteNode = () => { - const name = this.state.confirmDeleteNode; - this.cancelDeleteNode(); + const reallyDeleteNode = () => { + const name = state.confirmDeleteNode; + cancelDeleteNode(); fetch(`${process.env.API_ENDPOINT}/helmvm/nodes/${name}`, { headers: { @@ -106,7 +177,7 @@ export class HelmVMClusterManagement extends Component { Utilities.logoutUser(); return; } - this.setState({ + setState({ deleteNodeError: `Delete failed with status ${res.status}`, }); } @@ -116,8 +187,8 @@ export class HelmVMClusterManagement extends Component { }); }; - generateWorkerAddNodeCommand = async () => { - this.setState({ + const generateWorkerAddNodeCommand = async () => { + setState({ generating: true, command: "", expiry: null, @@ -137,13 +208,13 @@ export class HelmVMClusterManagement extends Component { ) .then(async (res) => { if (!res.ok) { - this.setState({ + setState({ generating: false, generateCommandErrMsg: `Failed to generate command with status ${res.status}`, }); } else { const data = await res.json(); - this.setState({ + setState({ generating: false, command: data.command, expiry: data.expiry, @@ -152,22 +223,22 @@ export class HelmVMClusterManagement extends Component { }) .catch((err) => { console.log(err); - this.setState({ + setState({ generating: false, generateCommandErrMsg: err ? err.message : "Something went wrong", }); }); }; - onDrainNodeClick = (name) => { - this.setState({ + const onDrainNodeClick = (name) => { + setState({ showConfirmDrainModal: true, nodeNameToDrain: name, }); }; - drainNode = async (name) => { - this.setState({ showConfirmDrainModal: false, drainingNodeName: name }); + const drainNode = async (name) => { + setState({ showConfirmDrainModal: false, drainingNodeName: name }); fetch(`${process.env.API_ENDPOINT}/helmvm/nodes/${name}/drain`, { headers: { "Content-Type": "application/json", @@ -177,9 +248,9 @@ export class HelmVMClusterManagement extends Component { method: "POST", }) .then(async (res) => { - this.setState({ drainNodeSuccessful: true }); + setState({ drainNodeSuccessful: true }); setTimeout(() => { - this.setState({ + setState({ drainingNodeName: null, drainNodeSuccessful: false, }); @@ -187,15 +258,15 @@ export class HelmVMClusterManagement extends Component { }) .catch((err) => { console.log(err); - this.setState({ + setState({ drainingNodeName: null, drainNodeSuccessful: false, }); }); }; - generatePrimaryAddNodeCommand = async () => { - this.setState({ + const generatePrimaryAddNodeCommand = async () => { + setState({ generating: true, command: "", expiry: null, @@ -215,13 +286,13 @@ export class HelmVMClusterManagement extends Component { ) .then(async (res) => { if (!res.ok) { - this.setState({ + setState({ generating: false, generateCommandErrMsg: `Failed to generate command with status ${res.status}`, }); } else { const data = await res.json(); - this.setState({ + setState({ generating: false, command: data.command, expiry: data.expiry, @@ -230,257 +301,299 @@ export class HelmVMClusterManagement extends Component { }) .catch((err) => { console.log(err); - this.setState({ + setState({ generating: false, generateCommandErrMsg: err ? err.message : "Something went wrong", }); }); }; - onAddNodeClick = () => { - this.setState( + const onAddNodeClick = () => { + setState( { displayAddNode: true, }, async () => { - await this.generateWorkerAddNodeCommand(); + await generateWorkerAddNodeCommand(); } ); }; - onSelectNodeType = (event) => { + const onSelectNodeType = (event) => { const value = event.currentTarget.value; - this.setState( + setState( { selectedNodeType: value, }, async () => { - if (this.state.selectedNodeType === "secondary") { - await this.generateWorkerAddNodeCommand(); + if (state.selectedNodeType === "secondary") { + await generateWorkerAddNodeCommand(); } else { - await this.generatePrimaryAddNodeCommand(); + await generatePrimaryAddNodeCommand(); } } ); }; - ackDeleteNodeError = () => { - this.setState({ deleteNodeError: "" }); + const ackDeleteNodeError = () => { + setState({ deleteNodeError: "" }); }; - render() { - const { helmvm } = this.state; - const { displayAddNode, generateCommandErrMsg } = this.state; - - if (!helmvm) { - return ( -
- -
- ); - } + const { displayAddNode, generateCommandErrMsg } = state; + if (nodesLoading) { return ( -
- -
-
-
-

- Your nodes -

-
- {helmvm?.nodes && - helmvm?.nodes.map((node, i) => ( - - ))} -
+
+ +
+ ); + } + + return ( +
+ +
+
+
+

+ Cluster Nodes +

+

+ This section lists the nodes that are configured and shows the + status/health of each. To add additional nodes to this cluster, + click the "Add node" button at the bottom of this page. +

+
+ {(nodes?.nodes || testData?.nodes) && + (nodes?.nodes || testData?.nodes).map((node, i) => ( + + ))}
- {helmvm?.isHelmVMEnabled && - Utilities.sessionRolesHasOneOf([rbacRoles.CLUSTER_ADMIN]) ? ( - !displayAddNode ? ( -
-
+ {(nodes?.isHelmVMEnabled || testData.isHelmVMEnabled) && + Utilities.sessionRolesHasOneOf([rbacRoles.CLUSTER_ADMIN]) ? ( + !displayAddNode ? ( +
+ +
+ ) : ( +
+
+

Add a node - +

- ) : ( -
-
-

- Add a Node -

-
-
-
+
+ +
-
+ +
+
+

+ Primary Node +

+

+ Provides high availability +

+
+ +
+
+ +
+
+ +
+
+

+ Secondary Node +

+

+ Optimal for running application workloads +

+
+
- {this.state.generating && ( -
- -
- )} - {!this.state.generating && this.state.command?.length > 0 ? ( - -

- Run this command on the node you wish to join the - cluster -

- - Command has been copied to your clipboard - - } - > - {[this.state.command.join(" \\\n ")]} - - {this.state.expiry && ( - - {`Expires on ${dayjs(this.state.expiry).format( - "MMM Do YYYY, h:mm:ss a z" - )} UTC${(-1 * new Date().getTimezoneOffset()) / 60}`} - - )} -
- ) : ( - - {generateCommandErrMsg && ( -
- - {generateCommandErrMsg} - -
- )} -
- )}
- ) - ) : null} + {state.generating && ( +
+ +
+ )} + {!state.generating && state.command?.length > 0 ? ( + <> +

+ Run this command on the node you wish to join the cluster +

+ + Command has been copied to your clipboard + + } + > + {[state.command.join(" \\\n ")]} + + {state.expiry && ( + + {`Expires on ${dayjs(state.expiry).format( + "MMM Do YYYY, h:mm:ss a z" + )} UTC${(-1 * new Date().getTimezoneOffset()) / 60}`} + + )} + + ) : ( + <> + {generateCommandErrMsg && ( +
+ + {generateCommandErrMsg} + +
+ )} + + )} +
+ ) + ) : null} +
+
+ {state.deleteNodeError && ( + + )} + +
+

+ Deleting this node may cause data loss. Are you sure you want to + proceed? +

+
+ +
- {this.state.deleteNodeError && ( - - )} +
+ {state.showConfirmDrainModal && ( + setState({ + showConfirmDrainModal: false, + nodeNameToDrain: "", + }) + } shouldReturnFocusAfterClose={false} - contentLabel="Confirm Delete Node" + contentLabel="Confirm Drain Node" ariaHideApp={false} - className="Modal" + className="Modal MediumSize" >
+

+ Are you sure you want to drain {state.nodeNameToDrain}? +

- Deleting this node may cause data loss. Are you sure you want to - proceed? + Draining this node may cause data loss. If you want to delete{" "} + {state.nodeNameToDrain} you must disconnect it after it has been + drained.

- {this.state.showConfirmDrainModal && ( - - this.setState({ - showConfirmDrainModal: false, - nodeNameToDrain: "", - }) - } - shouldReturnFocusAfterClose={false} - contentLabel="Confirm Drain Node" - ariaHideApp={false} - className="Modal MediumSize" - > -
-

- Are you sure you want to drain {this.state.nodeNameToDrain}? -

-

- Draining this node may cause data loss. If you want to delete{" "} - {this.state.nodeNameToDrain} you must disconnect it after it has - been drained. -

-
- - -
-
-
- )} -
- ); - } -} + )} +
+ ); +}; export default HelmVMClusterManagement; diff --git a/web/src/components/apps/HelmVMNodeRow.jsx b/web/src/components/apps/HelmVMNodeRow.jsx index 93f2c5489c..e7dac9025d 100644 --- a/web/src/components/apps/HelmVMNodeRow.jsx +++ b/web/src/components/apps/HelmVMNodeRow.jsx @@ -1,15 +1,20 @@ -import React from "react"; import classNames from "classnames"; -import Loader from "../shared/Loader"; +import React from "react"; +import { Link } from "react-router-dom"; + import { rbacRoles } from "../../constants/rbac"; import { getPercentageStatus, Utilities } from "../../utilities/utilities"; import Icon from "../Icon"; +import Loader from "../shared/Loader"; -export default function HelmVMNodeRow(props) { - const { node } = props; - +export default function HelmVMNodeRow({ + node, + drainNode, + drainNodeSuccessful, + drainingNodeName, + deleteNode, +}) { const DrainDeleteNode = () => { - const { drainNode, drainNodeSuccessful, drainingNodeName } = props; if (drainNode && Utilities.sessionRolesHasOneOf(rbacRoles.DRAIN_NODE)) { if ( !drainNodeSuccessful && @@ -44,9 +49,7 @@ export default function HelmVMNodeRow(props) {
+
+
+ ); +}; + +export default HelmVMViewNode; diff --git a/web/tailwind.config.js b/web/tailwind.config.js index 1ea5c980de..d89f3af2a5 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -13,17 +13,60 @@ module.exports = { "teal-muted-dark": "#577981", "teal-medium": "#097992", gray: { - 100: "#dfdfdf", + 100: "#dedede", 200: "#c4c8ca", 300: "#b3b3b3", + 410: "#9b9b9b", 400: "#959595", 500: "#717171", 600: "#585858", 700: "#4f4f4f", - 800: "#323232" + 800: "#323232", + 900: "#2c2c2c", + }, + blue: { + 50: "#ecf4fe", + 75: "#b3d2fc", + 200: "#65a4f8", + 300: "#4591f7", + 400: "#3066ad", + }, + green: { + 50: "#e7f7f3", + 75: "#9cdfcf", + 100: "#73d2bb", + 200: "#37bf9e", + 300: "#0eb28a", + 400: "#0a7d61", + 500: "#096d54", + }, + indigo: { + 100: "#f0f1ff", + 200: "#c2c7fd", + 300: "#a9b0fd", + 400: "#838efc", + 500: "#6a77fb", + 600: "#4a53b0", + 700: "#414999", }, neutral: { - 700: "#4A4A4A" + 700: "#4A4A4A", + }, + teal: { + 300: "#4db9c0", + 400: "#38a3a8", + }, + pink: { + 50: "#fff0f3", + 100: "#ffc1cf", + 200: "#fea7bc", + 300: "#fe819f", + 400: "#fe678b", + 500: "#b24861", + 600: "#9b3f55", + }, + purple: { + 400: "#7242b0", }, error: "#bc4752", "error-xlight": "#fbedeb", @@ -34,26 +77,26 @@ module.exports = { "warning-bright": "#ec8f39", "info-bright": "#76bbca", "disabled-teal": "#76a6cf", - "dark-neon-green": "#38cc97" + "dark-neon-green": "#38cc97", }, extend: { borderRadius: { xs: "0.125rem", sm: "0.187rem", - variants: ["first", "last"] + variants: ["first", "last"], }, fontFamily: { - sans: ["Open Sans", ...defaultTheme.fontFamily.sans] - } - } + sans: ["Open Sans", ...defaultTheme.fontFamily.sans], + }, + }, }, corePlugins: { - preflight: false + preflight: false, }, plugins: [ plugin(function ({ addVariant }) { addVariant("is-enabled", "&:not([disabled])"); addVariant("is-disabled", "&[disabled]"); - }) - ] + }), + ], };