|
|
@@ -1,4 +1,5 @@
|
|
|
-import React, { useContext, useMemo } from "react";
|
|
|
+import React, { useContext, useMemo, useState } from "react";
|
|
|
+import axios from "axios";
|
|
|
import _ from "lodash";
|
|
|
import { Controller, useFieldArray, useFormContext } from "react-hook-form";
|
|
|
import styled from "styled-components";
|
|
|
@@ -8,10 +9,12 @@ import Container from "components/porter/Container";
|
|
|
import Expandable from "components/porter/Expandable";
|
|
|
import Image from "components/porter/Image";
|
|
|
import Input from "components/porter/Input";
|
|
|
+import Modal from "components/porter/Modal";
|
|
|
import PorterOperatorComponent from "components/porter/PorterOperatorComponent";
|
|
|
import Select from "components/porter/Select";
|
|
|
import Spacer from "components/porter/Spacer";
|
|
|
import Text from "components/porter/Text";
|
|
|
+import TrashDelete from "components/porter/TrashDelete";
|
|
|
import {
|
|
|
type ClientClusterContract,
|
|
|
type ClientMachineType,
|
|
|
@@ -21,6 +24,8 @@ import { Context } from "shared/Context";
|
|
|
import chip from "assets/computer-chip.svg";
|
|
|
import world from "assets/world.svg";
|
|
|
|
|
|
+import { useClusterContext } from "../ClusterContextProvider";
|
|
|
+
|
|
|
type Props = {
|
|
|
availableMachineTypes: ClientMachineType[];
|
|
|
isDefaultExpanded?: boolean;
|
|
|
@@ -31,8 +36,23 @@ const NodeGroups: React.FC<Props> = ({
|
|
|
isDefaultExpanded = true,
|
|
|
isCreating = false,
|
|
|
}) => {
|
|
|
- const { control } = useFormContext<ClientClusterContract>();
|
|
|
+ const {
|
|
|
+ control,
|
|
|
+ formState: { errors },
|
|
|
+ } = useFormContext<ClientClusterContract>();
|
|
|
const { currentProject } = useContext(Context);
|
|
|
+ const [nodeGroupDeletionId, setNodeGroupDeletionId] = useState("");
|
|
|
+ const [nodeGroupDeletionConfirmation, setNodeGroupDeletionConfirmation] =
|
|
|
+ useState("");
|
|
|
+ const [nodeGroupDeletionError, setNodeGroupDeletionError] = useState("");
|
|
|
+
|
|
|
+ // if the NodeGroups component is used outside of a ClusterContextProvider (e.g. at cluster creation), these functions will be no-ops
|
|
|
+ let deleteNodeGroup = async (_: string): Promise<void> => {};
|
|
|
+ try {
|
|
|
+ const { deleteNodeGroup: deleteNodeGroupImpl } = useClusterContext();
|
|
|
+ deleteNodeGroup = deleteNodeGroupImpl;
|
|
|
+ } catch (err) {}
|
|
|
+
|
|
|
const {
|
|
|
fields: nodeGroups,
|
|
|
append,
|
|
|
@@ -41,6 +61,24 @@ const NodeGroups: React.FC<Props> = ({
|
|
|
control,
|
|
|
name: "cluster.config.nodeGroups",
|
|
|
});
|
|
|
+
|
|
|
+ const [showCreateNodeGroupModal, setShowCreateNodeGroupModal] =
|
|
|
+ useState(false);
|
|
|
+ const [nodeGroupName, setNodeGroupName] = useState("");
|
|
|
+
|
|
|
+ const onRemove = (index: number): void => {
|
|
|
+ const id = nodeGroups[index].nodeGroupId;
|
|
|
+
|
|
|
+ setNodeGroupDeletionError("");
|
|
|
+ setNodeGroupDeletionConfirmation("");
|
|
|
+
|
|
|
+ if (id) {
|
|
|
+ setNodeGroupDeletionId(id);
|
|
|
+ } else {
|
|
|
+ remove(index);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
const displayableNodeGroups = useMemo(() => {
|
|
|
const dng = _.groupBy(
|
|
|
nodeGroups.map((ng, idx) => {
|
|
|
@@ -54,6 +92,10 @@ const NodeGroups: React.FC<Props> = ({
|
|
|
return dng;
|
|
|
}, [nodeGroups]);
|
|
|
|
|
|
+ const nodeGroupDeletionName =
|
|
|
+ nodeGroups.find((ng) => ng.nodeGroupId === nodeGroupDeletionId)
|
|
|
+ ?.nodeGroupName || "";
|
|
|
+
|
|
|
return (
|
|
|
<NodeGroupContainer>
|
|
|
{displayableNodeGroups.APPLICATION?.map((ng) => {
|
|
|
@@ -180,6 +222,169 @@ const NodeGroups: React.FC<Props> = ({
|
|
|
</Expandable>
|
|
|
);
|
|
|
})}
|
|
|
+ {displayableNodeGroups.USER?.map((ng) => {
|
|
|
+ return (
|
|
|
+ <Controller
|
|
|
+ key={ng.nodeGroup.id}
|
|
|
+ name={`cluster.config.nodeGroups.${ng.idx}`}
|
|
|
+ control={control}
|
|
|
+ render={({ field: { value, onChange } }) => (
|
|
|
+ <>
|
|
|
+ <Expandable
|
|
|
+ preExpanded={isDefaultExpanded}
|
|
|
+ header={
|
|
|
+ <Container row spaced={true}>
|
|
|
+ <Container row>
|
|
|
+ <Image src={world} />
|
|
|
+ <Spacer inline x={1} />
|
|
|
+ {value.nodeGroupName}
|
|
|
+ </Container>
|
|
|
+ <TrashDelete
|
|
|
+ handleDelete={() => {
|
|
|
+ onRemove(ng.idx);
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </Container>
|
|
|
+ }
|
|
|
+ >
|
|
|
+ <Input
|
|
|
+ label={"Node group name"}
|
|
|
+ width="300px"
|
|
|
+ type="text"
|
|
|
+ disabled={false}
|
|
|
+ value={value.nodeGroupName || ""}
|
|
|
+ setValue={(newNodeGroupName: string) => {
|
|
|
+ // Define the regular expression to match non-lowercase alphanumeric and non-hyphen characters
|
|
|
+ onChange({
|
|
|
+ ...value,
|
|
|
+ nodeGroupName: newNodeGroupName,
|
|
|
+ });
|
|
|
+ }}
|
|
|
+ error={
|
|
|
+ errors.cluster?.config?.nodeGroups?.[ng.idx]
|
|
|
+ ?.nodeGroupName?.message
|
|
|
+ }
|
|
|
+ placeholder="ex: node-group-name"
|
|
|
+ />
|
|
|
+ <Spacer y={1} />
|
|
|
+
|
|
|
+ {ng.nodeGroup.nodeGroupId && (
|
|
|
+ <>
|
|
|
+ <Input
|
|
|
+ placeholder={"node-group-id"}
|
|
|
+ label={"Node group id"}
|
|
|
+ width="300px"
|
|
|
+ type="text"
|
|
|
+ disabled={true}
|
|
|
+ value={ng.nodeGroup.nodeGroupId}
|
|
|
+ />
|
|
|
+ <Spacer y={1} />
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+ <Select
|
|
|
+ width="300px"
|
|
|
+ options={availableMachineTypes.map((t) => ({
|
|
|
+ value: t.name,
|
|
|
+ label: t.displayName,
|
|
|
+ }))}
|
|
|
+ value={value.instanceType}
|
|
|
+ setValue={(newInstanceType: string) => {
|
|
|
+ onChange({
|
|
|
+ ...value,
|
|
|
+ instanceType: newInstanceType,
|
|
|
+ });
|
|
|
+ }}
|
|
|
+ label="Machine type"
|
|
|
+ />
|
|
|
+ {isCreating ? (
|
|
|
+ <>
|
|
|
+ <Spacer y={1} />
|
|
|
+ <PorterOperatorComponent>
|
|
|
+ <>
|
|
|
+ <Text color="helper">
|
|
|
+ Minimum number of application nodes
|
|
|
+ </Text>
|
|
|
+ <Spacer y={0.5} />
|
|
|
+ <Input
|
|
|
+ width="75px"
|
|
|
+ type="number"
|
|
|
+ disabled={false}
|
|
|
+ value={value.minInstances.toString()}
|
|
|
+ setValue={(newMinInstances: string) => {
|
|
|
+ onChange({
|
|
|
+ ...value,
|
|
|
+ minInstances: parseInt(newMinInstances),
|
|
|
+ });
|
|
|
+ }}
|
|
|
+ placeholder="ex: 1"
|
|
|
+ />
|
|
|
+ <Spacer y={1} />
|
|
|
+ <Text color="helper">
|
|
|
+ Maximum number of application nodes
|
|
|
+ </Text>
|
|
|
+ <Spacer y={0.5} />
|
|
|
+ <Input
|
|
|
+ width="75px"
|
|
|
+ type="number"
|
|
|
+ disabled={false}
|
|
|
+ value={value.maxInstances.toString()}
|
|
|
+ setValue={(newMaxInstances: string) => {
|
|
|
+ onChange({
|
|
|
+ ...value,
|
|
|
+ maxInstances: parseInt(newMaxInstances),
|
|
|
+ });
|
|
|
+ }}
|
|
|
+ placeholder="ex: 10"
|
|
|
+ />
|
|
|
+ </>
|
|
|
+ </PorterOperatorComponent>
|
|
|
+ </>
|
|
|
+ ) : (
|
|
|
+ <>
|
|
|
+ <Spacer y={1} />
|
|
|
+ <Text color="helper">
|
|
|
+ Minimum number of application nodes
|
|
|
+ </Text>
|
|
|
+ <Spacer y={0.5} />
|
|
|
+ <Input
|
|
|
+ width="75px"
|
|
|
+ type="number"
|
|
|
+ disabled={false}
|
|
|
+ value={value.minInstances.toString()}
|
|
|
+ setValue={(newMinInstances: string) => {
|
|
|
+ onChange({
|
|
|
+ ...value,
|
|
|
+ minInstances: parseInt(newMinInstances),
|
|
|
+ });
|
|
|
+ }}
|
|
|
+ placeholder="ex: 1"
|
|
|
+ />
|
|
|
+ <Spacer y={1} />
|
|
|
+ <Text color="helper">
|
|
|
+ Maximum number of application nodes
|
|
|
+ </Text>
|
|
|
+ <Spacer y={0.5} />
|
|
|
+ <Input
|
|
|
+ width="75px"
|
|
|
+ type="number"
|
|
|
+ disabled={false}
|
|
|
+ value={value.maxInstances.toString()}
|
|
|
+ setValue={(newMaxInstances: string) => {
|
|
|
+ onChange({
|
|
|
+ ...value,
|
|
|
+ maxInstances: parseInt(newMaxInstances),
|
|
|
+ });
|
|
|
+ }}
|
|
|
+ placeholder="ex: 10"
|
|
|
+ />
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+ </Expandable>
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+ />
|
|
|
+ );
|
|
|
+ })}
|
|
|
<PorterOperatorComponent>
|
|
|
<>
|
|
|
{displayableNodeGroups.MONITORING?.map((ng) => {
|
|
|
@@ -468,6 +673,18 @@ const NodeGroups: React.FC<Props> = ({
|
|
|
</Expandable>
|
|
|
);
|
|
|
})}
|
|
|
+ {currentProject?.advanced_infra_enabled && (
|
|
|
+ <Button
|
|
|
+ alt
|
|
|
+ onClick={() => {
|
|
|
+ setNodeGroupName("");
|
|
|
+ setShowCreateNodeGroupModal(true);
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <I className="material-icons">add</I>
|
|
|
+ Add an additional node group
|
|
|
+ </Button>
|
|
|
+ )}
|
|
|
{currentProject?.gpu_enabled &&
|
|
|
(displayableNodeGroups.CUSTOM ?? []).length === 0 &&
|
|
|
availableMachineTypes.filter((t) => t.isGPU).length > 0 && (
|
|
|
@@ -491,6 +708,95 @@ const NodeGroups: React.FC<Props> = ({
|
|
|
Add a GPU node group
|
|
|
</Button>
|
|
|
)}
|
|
|
+ {nodeGroupDeletionId && (
|
|
|
+ <Modal
|
|
|
+ closeModal={() => {
|
|
|
+ setNodeGroupDeletionId("");
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <Text color="helper">
|
|
|
+ Are you sure you want to delete this node group?
|
|
|
+ </Text>
|
|
|
+ <Spacer y={1} />
|
|
|
+ <Input
|
|
|
+ placeholder={nodeGroupDeletionName}
|
|
|
+ value={nodeGroupDeletionConfirmation}
|
|
|
+ setValue={setNodeGroupDeletionConfirmation}
|
|
|
+ width="100%"
|
|
|
+ height="40px"
|
|
|
+ />
|
|
|
+ <Spacer y={1} />
|
|
|
+ <Button
|
|
|
+ disabled={nodeGroupDeletionConfirmation !== nodeGroupDeletionName}
|
|
|
+ color={"red"}
|
|
|
+ onClick={() => {
|
|
|
+ deleteNodeGroup(nodeGroupDeletionId)
|
|
|
+ .then(() => {
|
|
|
+ setNodeGroupDeletionConfirmation("");
|
|
|
+ setNodeGroupDeletionError("");
|
|
|
+ setNodeGroupDeletionId("");
|
|
|
+ })
|
|
|
+ .catch((err) => {
|
|
|
+ setNodeGroupDeletionError(
|
|
|
+ (axios.isAxiosError(err) && err.response?.data?.error) ||
|
|
|
+ "An error occurred while deleting your node group. Please try again."
|
|
|
+ );
|
|
|
+ });
|
|
|
+ }}
|
|
|
+ status={nodeGroupDeletionError ? "error" : ""}
|
|
|
+ errorText={nodeGroupDeletionError}
|
|
|
+ width={"140px"}
|
|
|
+ >
|
|
|
+ Confirm deletion
|
|
|
+ </Button>
|
|
|
+ </Modal>
|
|
|
+ )}
|
|
|
+ {showCreateNodeGroupModal && (
|
|
|
+ <Modal
|
|
|
+ closeModal={() => {
|
|
|
+ setShowCreateNodeGroupModal(false);
|
|
|
+ }}
|
|
|
+ width="800px"
|
|
|
+ >
|
|
|
+ <Text size={16}>Add a new node group</Text>
|
|
|
+ <Spacer y={1} />
|
|
|
+ <Text color="helper">Name this node group:</Text>
|
|
|
+ <Spacer y={0.5} />
|
|
|
+ <Input
|
|
|
+ value={nodeGroupName}
|
|
|
+ setValue={(e) => {
|
|
|
+ // Define the regular expression to match non-lowercase alphanumeric and non-hyphen characters
|
|
|
+ const regex = /[^a-z0-9-]/g;
|
|
|
+ setNodeGroupName(e.replace(regex, ""));
|
|
|
+ }}
|
|
|
+ placeholder="new-node-group"
|
|
|
+ width="100%"
|
|
|
+ />
|
|
|
+ <Spacer y={1} />
|
|
|
+ <Button
|
|
|
+ type="button"
|
|
|
+ onClick={() => {
|
|
|
+ append({
|
|
|
+ nodeGroupType: "USER",
|
|
|
+ nodeGroupName,
|
|
|
+ instanceType: availableMachineTypes.map((t) => ({
|
|
|
+ value: t.name,
|
|
|
+ label: t.displayName,
|
|
|
+ }))[0].value,
|
|
|
+ minInstances: 1,
|
|
|
+ maxInstances: 10,
|
|
|
+ });
|
|
|
+ setShowCreateNodeGroupModal(false);
|
|
|
+ }}
|
|
|
+ disabled={
|
|
|
+ nodeGroupName === "" ||
|
|
|
+ nodeGroups.some((ng) => ng.nodeGroupName === nodeGroupName)
|
|
|
+ }
|
|
|
+ >
|
|
|
+ <I className="material-icons">add</I> Add node group
|
|
|
+ </Button>
|
|
|
+ </Modal>
|
|
|
+ )}
|
|
|
</NodeGroupContainer>
|
|
|
);
|
|
|
};
|
|
|
@@ -504,13 +810,12 @@ const NodeGroupContainer = styled.div`
|
|
|
`;
|
|
|
|
|
|
const I = styled.i`
|
|
|
- font-size: 20px;
|
|
|
- cursor: pointer;
|
|
|
- padding: 5px;
|
|
|
- color: #aaaabb;
|
|
|
- :hover {
|
|
|
- color: white;
|
|
|
- }
|
|
|
+ color: white;
|
|
|
+ font-size: 14px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ margin-right: 7px;
|
|
|
+ justify-content: center;
|
|
|
`;
|
|
|
|
|
|
const ActionButton = styled.button`
|