diff --git a/web/console/config/resource/k8sConfig/cronjobs.ts b/web/console/config/resource/k8sConfig/cronjobs.ts index 71766f772d..9fe186ba26 100644 --- a/web/console/config/resource/k8sConfig/cronjobs.ts +++ b/web/console/config/resource/k8sConfig/cronjobs.ts @@ -15,15 +15,15 @@ * WARRANTIES OF ANY KIND, either express or implied. See the License for the * specific language governing permissions and limitations under the License. */ +import { t } from '@tencent/tea-app/lib/i18n'; +import { DetailField, DetailInfo } from '../../../src/modules/common/models'; import { - defaulNotExistedValue, commonActionField, commonDetailInfo, dataFormatConfig, + defaulNotExistedValue, generateResourceInfo } from '../common'; -import { DetailField, DetailInfo } from '../../../src/modules/common/models'; -import { t, Trans } from '@tencent/tea-app/lib/i18n'; const displayField = Object.assign( {}, @@ -74,6 +74,11 @@ const displayField = Object.assign( name: t('删除'), actionType: 'delete', isInMoreOp: false + }, + { + name: t('更新调度策略'), + actionType: 'modifyNodeAffinity', + isInMoreOp: true } ] } diff --git a/web/console/config/resource/k8sConfig/daemonset.ts b/web/console/config/resource/k8sConfig/daemonset.ts index 2dd0ae7a98..5e6ef1ffb5 100644 --- a/web/console/config/resource/k8sConfig/daemonset.ts +++ b/web/console/config/resource/k8sConfig/daemonset.ts @@ -15,17 +15,17 @@ * WARRANTIES OF ANY KIND, either express or implied. See the License for the * specific language governing permissions and limitations under the License. */ +import { t } from '@tencent/tea-app/lib/i18n'; import { DetailField, DetailInfo } from '../../../src/modules/common/models'; -import { t, Trans } from '@tencent/tea-app/lib/i18n'; import { - commonDisplayField, - defaulNotExistedValue, commonActionField, commonDetailInfo, + commonDisplayField, dataFormatConfig, - workloadCommonTabList, - generateResourceInfo + defaulNotExistedValue, + generateResourceInfo, + workloadCommonTabList } from '../common'; /** displayField,列表展示的细节 */ @@ -58,6 +58,16 @@ const displayField = Object.assign({}, commonDisplayField, { name: t('删除'), actionType: 'delete', isInMoreOp: false + }, + { + name: t('设置更新策略'), + actionType: 'modifyStrategy', + isInMoreOp: true + }, + { + name: t('更新调度策略'), + actionType: 'modifyNodeAffinity', + isInMoreOp: true } ] } diff --git a/web/console/config/resource/k8sConfig/deployment.ts b/web/console/config/resource/k8sConfig/deployment.ts index 6e2f87dab1..89875e0825 100644 --- a/web/console/config/resource/k8sConfig/deployment.ts +++ b/web/console/config/resource/k8sConfig/deployment.ts @@ -15,18 +15,18 @@ * WARRANTIES OF ANY KIND, either express or implied. See the License for the * specific language governing permissions and limitations under the License. */ -import { DetailField, DisplayField, DetailInfo } from '../../../src/modules/common/models'; +import { t } from '@tencent/tea-app/lib/i18n'; +import { DetailField, DetailInfo, DisplayField } from '../../../src/modules/common/models'; +import { cloneDeep } from '../../../src/modules/common/utils'; import { - commonDisplayField, - defaulNotExistedValue, - workloadCommonTabList, commonActionField, commonDetailInfo, + commonDisplayField, dataFormatConfig, + defaulNotExistedValue, generateResourceInfo, + workloadCommonTabList } from '../common'; -import { cloneDeep } from '../../../src/modules/common/utils'; -import { t, Trans } from '@tencent/tea-app/lib/i18n'; /** resource table 当中展示的数据 * commonDisplayField 使用公共的展示 @@ -38,7 +38,7 @@ const userDefinedDisplayField: DisplayField = { dataFormat: dataFormatConfig['replicas'], width: '20%', headTitle: t('运行/期望Pod数量'), - noExsitedValue: '0', + noExsitedValue: '0' }, operator: { dataField: [''], @@ -48,27 +48,37 @@ const userDefinedDisplayField: DisplayField = { tips: '', operatorList: [ { - name: t('更新实例数量页'), + name: t('更新Pod数量'), actionType: 'modifyPod', - isInMoreOp: false, + isInMoreOp: false }, { name: t('更新镜像'), actionType: 'modifyRegistry', - isInMoreOp: false, + isInMoreOp: false + }, + { + name: t('设置更新策略'), + actionType: 'modifyStrategy', + isInMoreOp: true + }, + { + name: t('更新调度策略'), + actionType: 'modifyNodeAffinity', + isInMoreOp: true }, { name: t('编辑YAML'), actionType: 'modify', - isInMoreOp: true, + isInMoreOp: true }, { name: t('删除'), actionType: 'delete', - isInMoreOp: true, - }, - ], - }, + isInMoreOp: true + } + ] + } }; const displayField = Object.assign({}, commonDisplayField, userDefinedDisplayField); @@ -85,35 +95,35 @@ const detailBasicInfo: DetailInfo = { dataField: ['name'], dataFormat: dataFormatConfig['text'], label: t('名称'), - noExsitedValue: defaulNotExistedValue, + noExsitedValue: defaulNotExistedValue }, namespace: { dataField: ['namespace'], dataFormat: dataFormatConfig['text'], label: 'Namespace', - noExsitedValue: defaulNotExistedValue, + noExsitedValue: defaulNotExistedValue }, description: { dataField: ['annotations.description'], dataFormat: dataFormatConfig['text'], label: t('描述'), - noExsitedValue: defaulNotExistedValue, + noExsitedValue: defaulNotExistedValue }, createdTime: { dataField: ['creationTimestamp'], dataFormat: dataFormatConfig['time'], label: t('创建时间'), tips: '', - noExsitedValue: defaulNotExistedValue, + noExsitedValue: defaulNotExistedValue }, label: { dataField: ['labels'], dataFormat: dataFormatConfig['labels'], label: 'Labels', tips: '', - noExsitedValue: defaulNotExistedValue, - }, - }, + noExsitedValue: defaulNotExistedValue + } + } }, spec: { dataField: ['spec'], @@ -123,28 +133,28 @@ const detailBasicInfo: DetailInfo = { dataFormat: dataFormatConfig['labels'], label: 'Selector', tips: '', - noExsitedValue: defaulNotExistedValue, + noExsitedValue: defaulNotExistedValue }, updateStrategy: { dataField: ['strategy.type'], dataFormat: dataFormatConfig['text'], label: t('更新策略'), tips: '', - noExsitedValue: defaulNotExistedValue, + noExsitedValue: defaulNotExistedValue }, replicas: { dataField: ['replicas'], dataFormat: dataFormatConfig['text'], label: t('副本数'), - noExsitedValue: '0', + noExsitedValue: '0' }, networkType: { dataField: ['template', 'metadata', 'annotations', 'k8s.v1.cni.cncf.io/networks'], dataFormat: dataFormatConfig['text'], label: t('网络模式'), - noExsitedValue: '-', - }, - }, + noExsitedValue: '-' + } + } }, status: { dataField: ['status'], @@ -153,22 +163,22 @@ const detailBasicInfo: DetailInfo = { dataField: ['readyReplicas'], dataFormat: dataFormatConfig['text'], label: t('运行副本数'), - noExsitedValue: '0', - }, - }, - }, - }, + noExsitedValue: '0' + } + } + } + } }; -let tabList = cloneDeep(workloadCommonTabList); +const tabList = cloneDeep(workloadCommonTabList); tabList.splice(1, 0, { id: 'history', - label: t('修订历史'), + label: t('修订历史') }); /** 详情页面的相关配置 */ const detailField: DetailField = { tabList, - detailInfo: Object.assign({}, commonDetailInfo(), detailBasicInfo), + detailInfo: Object.assign({}, commonDetailInfo(), detailBasicInfo) }; /** deployment的配置 */ @@ -177,11 +187,11 @@ export const deployment = (k8sVersion: string) => { k8sVersion, resourceName: 'deployment', requestType: { - list: 'deployments', + list: 'deployments' }, isRelevantToNamespace: true, displayField, actionField, - detailField, + detailField }); }; diff --git a/web/console/config/resource/k8sConfig/statefulset.ts b/web/console/config/resource/k8sConfig/statefulset.ts index 3a9822f99c..7d9935bfe8 100644 --- a/web/console/config/resource/k8sConfig/statefulset.ts +++ b/web/console/config/resource/k8sConfig/statefulset.ts @@ -15,17 +15,17 @@ * WARRANTIES OF ANY KIND, either express or implied. See the License for the * specific language governing permissions and limitations under the License. */ +import { t } from '@tencent/tea-app/lib/i18n'; import { DetailField, DetailInfo } from '../../../src/modules/common/models'; import { - commonDisplayField, - defaulNotExistedValue, commonActionField, - dataFormatConfig, commonDetailInfo, - workloadCommonTabList, - generateResourceInfo + commonDisplayField, + dataFormatConfig, + defaulNotExistedValue, + generateResourceInfo, + workloadCommonTabList } from '../common'; -import { t, Trans } from '@tencent/tea-app/lib/i18n'; const displayField = Object.assign({}, commonDisplayField, { runningReplicas: { @@ -56,6 +56,16 @@ const displayField = Object.assign({}, commonDisplayField, { name: t('删除'), actionType: 'delete', isInMoreOp: false + }, + { + name: t('设置更新策略'), + actionType: 'modifyStrategy', + isInMoreOp: true + }, + { + name: t('更新调度策略'), + actionType: 'modifyNodeAffinity', + isInMoreOp: true } ] } diff --git a/web/console/helpers/Validator.ts b/web/console/helpers/Validator.ts index 880cd7ac2d..e0447ec33b 100644 --- a/web/console/helpers/Validator.ts +++ b/web/console/helpers/Validator.ts @@ -15,8 +15,8 @@ * WARRANTIES OF ANY KIND, either express or implied. See the License for the * specific language governing permissions and limitations under the License. */ -import { Validation } from 'src/modules/common'; import { ControllerFieldState, UseFormStateReturn } from 'react-hook-form'; +import { Validation } from 'src/modules/common'; export interface Rule { /**标签名 */ @@ -159,10 +159,10 @@ export function getReactHookFormStatusWithMessage({ status?: 'error' | 'success'; message?: string; } { - console.log('getReactHookFormStatus:', fieldState, formState); if (!fieldState.isTouched && !fieldState.isDirty && !formState.isSubmitted) { return {}; } + return fieldState.invalid ? { status: 'error', diff --git a/web/console/helpers/index.ts b/web/console/helpers/index.ts index bd3366c93e..c12622b2d5 100644 --- a/web/console/helpers/index.ts +++ b/web/console/helpers/index.ts @@ -15,27 +15,29 @@ * WARRANTIES OF ANY KIND, either express or implied. See the License for the * specific language governing permissions and limitations under the License. */ +export { RouteState, Router } from './Router'; +export { Validate, getReactHookFormStatusWithMessage, isValidateSuccess } from './Validator'; +export * from './appUtil'; +export { getCookie } from './cookieUtil'; +export * from './csrf'; +export { dateFormatter } from './dateFormatter'; +export { dateFormat } from './dateUtil'; export { downloadCrt, downloadKubeconfig, getKubectlConfig } from './downloadCrt'; -export { ResetStoreAction, generateResetableReducer } from './reduxStore'; -export { isValidateSuccess, Validate, getReactHookFormStatusWithMessage } from './Validator'; +export { downloadCsv } from './downloadCsv'; +export * from './format'; +export { getScrollBarSize } from './getScrollBarSize'; +export * from './path'; export { + ConsoleModuleMapProps, + Method, + operationResult, reduceNetworkRequest, reduceNetworkWorkflow, - operationResult, - Method, requestMethodForAction, - ConsoleModuleMapProps, setConsoleAPIAddress } from './reduceNetwork'; -export { dateFormatter } from './dateFormatter'; -export { downloadCsv } from './downloadCsv'; -export { Router, RouteState } from './Router'; +export { ResetStoreAction, generateResetableReducer } from './reduxStore'; export { assureRegion } from './regionLint'; -export { getScrollBarSize } from './getScrollBarSize'; -export { dateFormat } from './dateUtil'; -export * from './appUtil'; -export { getCookie } from './cookieUtil'; -export { reduceK8sQueryString, reduceK8sRestfulPath, reduceNs, parseQueryString, cutNsStartClusterId } from './urlUtil'; export * from './request'; -export * from './format'; -export * from './csrf'; +export { satisfyClusterVersion } from './satisfyClusterVersion'; +export { cutNsStartClusterId, parseQueryString, reduceK8sQueryString, reduceK8sRestfulPath, reduceNs } from './urlUtil'; diff --git a/web/console/helpers/path.ts b/web/console/helpers/path.ts new file mode 100644 index 0000000000..f27e93fe99 --- /dev/null +++ b/web/console/helpers/path.ts @@ -0,0 +1,4 @@ +export function getParamByUrl(key: string) { + const searchParams = new URL(window.location.href)?.searchParams; + return searchParams?.get(key); +} diff --git a/web/console/helpers/satisfyClusterVersion.ts b/web/console/helpers/satisfyClusterVersion.ts new file mode 100644 index 0000000000..027369d790 --- /dev/null +++ b/web/console/helpers/satisfyClusterVersion.ts @@ -0,0 +1,28 @@ +type CompareType = 'gt' | 'lt' | 'ge' | 'le' | 'eq'; +/** + * 判断集群版本是否符合要求 + * @param clusterVersion: string 当前的集群版本 + * @param targetClusterVersion: string 目标对比集群版本 + * @param type: lt | gt | ge | le | eq 对比的类型,默认为ge + */ +export const satisfyClusterVersion = (clusterVersion = '', targetClusterVersion = '', type: CompareType = 'ge') => { + const [major, version] = (clusterVersion ?? '').split('.'); + const [minMajor, minVersion] = (targetClusterVersion ?? '').split('.'); + + const majorNum = +major || 0, + versionNum = +version || 0, + minMajorNum = +minMajor || 0, + minVersionNum = +minVersion || 0; + + if (type === 'ge') { + return majorNum > minMajorNum || (majorNum === minMajorNum && versionNum >= minVersionNum); + } else if (type === 'gt') { + return majorNum > minMajorNum || (majorNum === minMajorNum && versionNum > minVersionNum); + } else if (type === 'lt') { + return majorNum < minMajorNum || (majorNum === minMajorNum && versionNum < minVersionNum); + } else if (type === 'le') { + return majorNum < minMajorNum || (majorNum === minMajorNum && versionNum <= minVersionNum); + } else if (type === 'eq') { + return majorNum === minMajorNum && versionNum === minVersionNum; + } +}; diff --git a/web/console/package-lock.json b/web/console/package-lock.json index 047a3f9173..f6c49e6da3 100644 --- a/web/console/package-lock.json +++ b/web/console/package-lock.json @@ -1209,6 +1209,11 @@ } } }, + "@hookform/resolvers": { + "version": "3.0.1", + "resolved": "https://mirrors.tencent.com/npm/@hookform%2fresolvers/-/resolvers-3.0.1.tgz", + "integrity": "sha512-n5oOt0cLw9mQNW3/k9zWaPsNWQcc0k6Jpc7XUrg2Q/AqqsHp3IVa1juqHCxczXI6uXHBa69ILc4pdtsRGyuzsw==" + }, "@hypnosphi/create-react-context": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/@hypnosphi/create-react-context/-/create-react-context-0.3.1.tgz", @@ -9618,9 +9623,9 @@ "integrity": "sha512-Q8ZP1B0vyAsJ3E8T8QEbje6ECdvD7xkNUd95iMoZuuXAuAtjEHoj8fL8SWxm3V/tG1GPwMkmKS/DPTYlVaaMVg==" }, "react-hook-form": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.10.1.tgz", - "integrity": "sha512-YRSkOZ0DJKjJ+PuCB6caFbFSbz6I4JQcGXVb/OeEIVE3YuXXYYJ/YQwodRy8HsZ5WA+DryoThWIeZZzPd5f9lQ==" + "version": "7.43.9", + "resolved": "https://mirrors.tencent.com/npm/react-hook-form/-/react-hook-form-7.43.9.tgz", + "integrity": "sha512-AUDN3Pz2NSeoxQ7Hs6OhQhDr6gtF9YRuutGDwPQqhSUAHJSgGl2VeY3qN19MG0SucpjgDiuMJ4iC5T5uB+eaNQ==" }, "react-i18next": { "version": "9.0.2", @@ -11710,9 +11715,9 @@ "dev": true }, "typescript": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.3.5.tgz", - "integrity": "sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA==", + "version": "5.0.4", + "resolved": "https://mirrors.tencent.com/npm/typescript/-/typescript-5.0.4.tgz", + "integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==", "dev": true }, "uglify-js": { @@ -13064,6 +13069,11 @@ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true + }, + "zod": { + "version": "3.21.4", + "resolved": "https://mirrors.tencent.com/npm/zod/-/zod-3.21.4.tgz", + "integrity": "sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==" } } } diff --git a/web/console/package.json b/web/console/package.json index 32e2c049ae..1892300861 100644 --- a/web/console/package.json +++ b/web/console/package.json @@ -28,6 +28,7 @@ }, "license": "Apache-2.0", "dependencies": { + "@hookform/resolvers": "^3.0.1", "@novnc/novnc": "^1.3.0", "ahooks": "^3.3.10", "axios": "^0.21.1", @@ -60,7 +61,7 @@ "react-codemirror2": "^5.1.0", "react-dom": "^18.0.0", "react-final-form-hooks": "^2.0.2", - "react-hook-form": "^7.10.1", + "react-hook-form": "^7.43.9", "react-i18next": "^9.0.2", "react-redux": "^7.1.3", "react-transition-group": "^2.5.2", @@ -74,7 +75,8 @@ "ts-optchain": "^0.1.7", "use-immer": "^0.4.1", "uuid": "^8.3.1", - "validator": "^13.5.2" + "validator": "^13.5.2", + "zod": "^3.21.4" }, "devDependencies": { "@babel/core": "^7.0.0", @@ -124,7 +126,7 @@ "style-loader": "^2.0.0", "thread-loader": "^3.0.4", "ts-loader": "^9.2.1", - "typescript": "^4.3.5", + "typescript": "^5.0.4", "url-loader": "^4.1.1", "webpack": "^5.37.0", "webpack-bundle-analyzer": "^4.4.1", diff --git a/web/console/src/modules/cluster/actions/resourceActions.ts b/web/console/src/modules/cluster/actions/resourceActions.ts index 4909d7a508..2dd8b3d668 100644 --- a/web/console/src/modules/cluster/actions/resourceActions.ts +++ b/web/console/src/modules/cluster/actions/resourceActions.ts @@ -21,7 +21,7 @@ import { ResourceInfo } from '../../common/models'; import { includes } from '../../common/utils'; import { IsResourceShowLoadingIcon } from '../components/resource/resourceTableOperation/ResourceTablePanel'; import * as ActionType from '../constants/ActionType'; -import { FFReduxActionName, PollEventName, ResourceNeedJudgeLoading } from '../constants/Config'; +import { FFReduxActionName, ResourceNeedJudgeLoading } from '../constants/Config'; import { Resource, ResourceFilter, RootState } from '../models'; import { router } from '../router'; import * as WebAPI from '../WebAPI'; diff --git a/web/console/src/modules/cluster/components/resource/resourceEdition/UpdateResourcePanel.tsx b/web/console/src/modules/cluster/components/resource/resourceEdition/UpdateResourcePanel.tsx index 69463c11fd..939a37b51a 100644 --- a/web/console/src/modules/cluster/components/resource/resourceEdition/UpdateResourcePanel.tsx +++ b/web/console/src/modules/cluster/components/resource/resourceEdition/UpdateResourcePanel.tsx @@ -19,16 +19,15 @@ import * as React from 'react'; import { connect } from 'react-redux'; import { bindActionCreators } from '@tencent/ff-redux'; -import { t, Trans } from '@tencent/tea-app/lib/i18n'; +import { t } from '@tencent/tea-app/lib/i18n'; import { allActions } from '../../../actions'; import { router } from '../../../router'; import { RootProps } from '../../ClusterApp'; -import { - UpdateServiceAccessTypePanel -} from '../resourceTableOperation/UpdateServiceAccessTypePanel'; +import { UpdateServiceAccessTypePanel } from '../resourceTableOperation/UpdateServiceAccessTypePanel'; import { UpdateWorkloadPodNumPanel } from '../resourceTableOperation/UpdateWorkloadPodNumPanel'; import { UpdateWorkloadRegistryPanel } from '../resourceTableOperation/UpdateWorkloadRegistryPanel'; +import { WorkloadUpdatePanel } from '../resourceTableOperation/workloadUpdate'; import { EditLbcfBackGroupPanel } from './EditLbcfBackGroupPanel'; import { SubHeaderPanel } from './SubHeaderPanel'; @@ -38,7 +37,7 @@ const mapDispatchToProps = dispatch => @connect(state => state, mapDispatchToProps) export class UpdateResourcePanel extends React.Component { render() { - let { route } = this.props, + const { route } = this.props, urlParams = router.resolve(route); let headTitle = ''; @@ -47,9 +46,11 @@ export class UpdateResourcePanel extends React.Component { let content: JSX.Element; // 判断当前的资源 - let resourceType = urlParams['resourceName'], + const resourceType = urlParams['resourceName'], updateType = urlParams['tab']; + const { clusterVersion } = this.props; + if (resourceType === 'svc' && updateType === 'modifyType') { content = ; headTitle = t('更新访问方式'); @@ -71,6 +72,11 @@ export class UpdateResourcePanel extends React.Component { } else if (resourceType === 'lbcf' && updateType === 'updateBG') { content = ; headTitle = t('更新后端负载'); + } else if ( + ['deployment', 'statefulset', 'daemonset', 'cronjob'].includes(resourceType) && + ['modifyStrategy', 'modifyNodeAffinity'].includes(updateType) + ) { + return ; } return ( diff --git a/web/console/src/modules/cluster/components/resource/resourceTableOperation/ResourceTablePanel.tsx b/web/console/src/modules/cluster/components/resource/resourceTableOperation/ResourceTablePanel.tsx index a98566ebc9..8119a2eac3 100644 --- a/web/console/src/modules/cluster/components/resource/resourceTableOperation/ResourceTablePanel.tsx +++ b/web/console/src/modules/cluster/components/resource/resourceTableOperation/ResourceTablePanel.tsx @@ -25,7 +25,7 @@ import { Bubble, TableColumn, Text } from '@tea/component'; import { selectable } from '@tea/component/table/addons/selectable'; import { TablePanel } from '@tencent/ff-component'; import { bindActionCreators, uuid } from '@tencent/ff-redux'; -import { t, Trans } from '@tencent/tea-app/lib/i18n'; +import { Trans, t } from '@tencent/tea-app/lib/i18n'; import { dateFormatter } from '../../../../../../helpers'; import { Clip, HeadBubble, LinkButton } from '../../../../common/components'; @@ -129,7 +129,7 @@ const mapDispatchToProps = dispatch => @connect(state => state, mapDispatchToProps) export class ResourceTablePanel extends React.Component { componentWillUnmount() { - let { actions } = this?.props; + const { actions } = this?.props; // 离开页面的话,清空当前的轮询操作 actions?.resource?.clearPolling(); // 离开页面的话,清空当前的多选 @@ -257,17 +257,17 @@ export class ResourceTablePanel extends React.Component { * @param fieldInfo: 配置文件 */ private _renderOperationCell(resource: Resource, fieldInfo: DisplayFiledProps) { - let { route, actions, subRoot, namespaceSelection } = this.props, + const { route, actions, subRoot, namespaceSelection } = this.props, urlParams = router.resolve(route), { clusterId } = route.queries, { resourceOption, resourceName } = subRoot, { ffResourceList } = resourceOption; // 操作列表的list - let operatorList = fieldInfo?.operatorList; + const operatorList = fieldInfo?.operatorList; // 更多按钮的 pop方向 - let resourceIndex = ffResourceList?.list?.data?.records?.findIndex(c => c.id === resource.id); - let direction: 'down' | 'up' = + const resourceIndex = ffResourceList?.list?.data?.records?.findIndex(c => c.id === resource.id); + const direction: 'down' | 'up' = resourceIndex < ffResourceList?.list?.data?.recordCount - 2 || ffResourceList?.list?.data?.recordCount < 4 ? 'down' : 'up'; @@ -401,19 +401,23 @@ export class ResourceTablePanel extends React.Component { ); }; - let btns = []; + const btns = []; operatorList?.forEach(operatorItem => { if (operatorItem?.actionType === 'modify') { btns.push(renderModifyButton(operatorItem)); } else if (operatorItem.actionType === 'delete') { btns.push(renderDeleteButton(operatorItem)); } else if ( - operatorItem?.actionType === 'modifyPod' || - operatorItem?.actionType === 'modifyRule' || - operatorItem?.actionType === 'modifyType' || - operatorItem?.actionType === 'modifyRegistry' || - operatorItem?.actionType === 'createBG' || - operatorItem?.actionType === 'updateBG' + [ + 'modifyNodeAffinity', + 'modifyStrategy', + 'modifyPod', + 'modifyRule', + 'modifyType', + 'modifyRegistry', + 'createBG', + 'updateBG' + ].includes(operatorItem?.actionType) ) { btns.push(renderUpdateResourcePart(operatorItem)); } @@ -428,7 +432,7 @@ export class ResourceTablePanel extends React.Component { /** 展示ip的内容 */ private _reduceIPCell(ipInfo: any, clipId: string, resource: Resource) { - let { resourceName } = this?.props?.subRoot; + const { resourceName } = this?.props?.subRoot; let ipArray = ipInfo; // 如果ipArray 不是一个数组 if (typeof ipArray !== 'object') { @@ -446,8 +450,8 @@ export class ResourceTablePanel extends React.Component { if (isNginxIngress) { content =
-
; } else { - let [clusterIP, ingressIP] = ipArray; - let isShowLoading = IsResourceShowLoadingIcon(resourceName, resource); + const [clusterIP, ingressIP] = ipArray; + const isShowLoading = IsResourceShowLoadingIcon(resourceName, resource); content = (
{ingressIP && ( @@ -479,9 +483,9 @@ export class ResourceTablePanel extends React.Component { /** 展示status */ private _reduceStatus(showData: any, resource: Resource) { - let { resourceName } = this.props.subRoot; + const { resourceName } = this.props.subRoot; - let statusMap = ResourceStatus?.[resourceName]; + const statusMap = ResourceStatus?.[resourceName]; return (
@@ -501,7 +505,7 @@ export class ResourceTablePanel extends React.Component { /** 展示映射的字段 */ private _reduceMapText(showData: any, fieldInfo: DisplayFiledProps) { - let { mapTextConfig } = fieldInfo; + const { mapTextConfig } = fieldInfo; return ( @@ -512,7 +516,7 @@ export class ResourceTablePanel extends React.Component { /** 展示副本的相关 */ private _reduceReplicas(showData: any, resource: Resource) { - let { resourceName } = this.props.subRoot; + const { resourceName } = this.props.subRoot; return ( @@ -542,9 +546,9 @@ export class ResourceTablePanel extends React.Component { }${rule.path}`; }; - let finalRules = [...httpRules, ...httpsRules]; + const finalRules = [...httpRules, ...httpsRules]; - let finalRulesLength = finalRules.length; + const finalRulesLength = finalRules.length; return finalRules.length ? ( { /** 展示ingress的后端服务 */ private _reduceIngressRule_standalone(showData: any) { - let httpRules = showData !== '-' ? showData : []; - let finalRules = + const httpRules = showData !== '-' ? showData : []; + const finalRules = httpRules?.map(item => { return { protocol: 'http', @@ -593,7 +597,7 @@ export class ResourceTablePanel extends React.Component { return `${rule?.protocol}://${rule?.host}${rule?.path}`; }; - let finalRulesLength = finalRules?.length; + const finalRulesLength = finalRules?.length; return finalRules.length ? ( { ); } private _reducebackendGroups(showData) { - let backendGroups = showData, + const backendGroups = showData, backendGroupsLength = backendGroups !== '-' ? backendGroups.length : 0; return backendGroupsLength ? ( { /** 展示时间 */ private _reduceTime(showData: any, direction: 'bottom' | 'top') { - let time = dateFormatter(new Date(showData), 'YYYY-MM-DD HH:mm:ss'); + const time = dateFormatter(new Date(showData), 'YYYY-MM-DD HH:mm:ss'); - let [year, currentTime] = time.split(' '); + const [year, currentTime] = time.split(' '); return ( @@ -669,8 +673,8 @@ export class ResourceTablePanel extends React.Component { } private _reduceResourceLimit(showData) { - let resourceLimitKeys = showData !== '-' ? Object.keys(showData) : []; - let content = resourceLimitKeys.map((item, index) => ( + const resourceLimitKeys = showData !== '-' ? Object.keys(showData) : []; + const content = resourceLimitKeys.map((item, index) => ( {`${resourceLimitTypeToText[item]}:${ resourceTypeToUnit[item] === 'MiB' ? valueLabels1024(showData[item], K8SUNIT.Mi) @@ -701,7 +705,7 @@ export class ResourceTablePanel extends React.Component { /** 根据 fieldInfo的 dataFormat来决定显示的bodyCell的具体内容 */ private _renderBodyCell(resource: Resource, fieldInfo: DisplayFiledProps, clipId: string) { - let { subRoot } = this.props, + const { subRoot } = this.props, { resourceOption } = subRoot, { ffResourceList } = resourceOption; @@ -710,8 +714,8 @@ export class ResourceTablePanel extends React.Component { // fieldInfo当中的 dataField是一个数组,可以同时输入多个值 let showData: any = []; fieldInfo?.dataField?.forEach(item => { - let dataFieldIns = item.split('.'); - let data: any = this._getFinalData(dataFieldIns, resource); + const dataFieldIns = item.split('.'); + const data: any = this._getFinalData(dataFieldIns, resource); // 如果返回的为 '' ,即找不到这个对象,则使用配置文件所设定的默认值 showData.push(data === '' ? fieldInfo?.noExsitedValue : data); }); @@ -719,8 +723,8 @@ export class ResourceTablePanel extends React.Component { showData = showData.length === 1 ? showData[0] : showData; // 这里是当列表有 bubble等情况的时候,判断当前行属于第几行 - let resourceIndex = ffResourceList?.list?.data?.records?.findIndex(item => item.id === resource.id); - let direction: 'top' | 'bottom' = + const resourceIndex = ffResourceList?.list?.data?.records?.findIndex(item => item.id === resource.id); + const direction: 'top' | 'bottom' = ffResourceList?.list?.data?.recordCount < 4 || resourceIndex < ffResourceList?.list?.data?.recordCount - 2 ? 'top' : 'bottom'; @@ -758,13 +762,13 @@ export class ResourceTablePanel extends React.Component { { resourceOption, resourceInfo, resourceName } = subRoot, { ffResourceList, resourceMultipleSelection } = resourceOption; - let addons = []; + const addons = []; - let displayField = resourceInfo?.displayField ?? {}; + const displayField = resourceInfo?.displayField ?? {}; // 根据 displayField当中的key来决定展示什么内容 - let showField = []; + const showField = []; Object.keys(displayField).forEach(item => { - let fieldInfo = displayField?.[item]; + const fieldInfo = displayField?.[item]; // 操作的按钮现在都换成在tablePanel当中去展示 if (fieldInfo?.dataFormat === 'operator') return; @@ -782,7 +786,7 @@ export class ResourceTablePanel extends React.Component { ); return; } - let columnInfo: TableColumn = { + const columnInfo: TableColumn = { key: item + uuid(), header: fieldInfo?.headTitle, width: fieldInfo?.width, @@ -790,9 +794,9 @@ export class ResourceTablePanel extends React.Component { }; if (fieldInfo.headCell) { - let style: React.CSSProperties = { display: 'block' }; + const style: React.CSSProperties = { display: 'block' }; - let headBubbleText = ( + const headBubbleText = ( {fieldInfo?.headCell?.map((item, index) => ( @@ -842,7 +846,7 @@ export class ResourceTablePanel extends React.Component { /** 链接的跳转 */ private _handleClickForNavigate(resource: Resource) { - let { actions, route } = this.props, + const { actions, route } = this.props, urlParams = router.resolve(route); // 选择当前的具体的resouce diff --git a/web/console/src/modules/cluster/components/resource/resourceTableOperation/workloadUpdate/constants.ts b/web/console/src/modules/cluster/components/resource/resourceTableOperation/workloadUpdate/constants.ts new file mode 100644 index 0000000000..45a65ad322 --- /dev/null +++ b/web/console/src/modules/cluster/components/resource/resourceTableOperation/workloadUpdate/constants.ts @@ -0,0 +1,38 @@ +export enum WorkloadKindEnum { + Deployment = 'deployment', + + StatefulSet = 'statefulset', + + DaemonSet = 'daemonset', + + Cronjob = 'cronjob' +} + +export enum UpdateTypeEnum { + ModifyStrategy = 'modifyStrategy', + + ModifyNodeAffinity = 'modifyNodeAffinity' +} + +export interface IWrokloadUpdatePanelProps { + kind: WorkloadKindEnum; + + updateType: UpdateTypeEnum; + + clusterVersion: string; +} + +export const updateType2text = { + [UpdateTypeEnum.ModifyStrategy]: '设置更新策略', + + [UpdateTypeEnum.ModifyNodeAffinity]: '更新调度策略' +}; + +export interface IModifyPanelProps { + kind: WorkloadKindEnum; + resource: any; + title: React.ReactNode; + baseInfo: React.ReactNode; + onCancel: () => void; + onUpdate: (data: any) => void; +} diff --git a/web/console/src/modules/cluster/components/resource/resourceTableOperation/workloadUpdate/index.tsx b/web/console/src/modules/cluster/components/resource/resourceTableOperation/workloadUpdate/index.tsx new file mode 100644 index 0000000000..eb59401e64 --- /dev/null +++ b/web/console/src/modules/cluster/components/resource/resourceTableOperation/workloadUpdate/index.tsx @@ -0,0 +1,102 @@ +import { t } from '@/tencent/tea-app/lib/i18n'; +import { getParamByUrl } from '@helper'; +import { router } from '@src/modules/cluster/router'; +import { workloadApi } from '@src/webApi'; +import { useRequest } from 'ahooks'; +import React from 'react'; +import { Breadcrumb, Form } from 'tea-component'; +import { IWrokloadUpdatePanelProps, UpdateTypeEnum, updateType2text } from './constants'; +import { ModifyNodeAffinityPanel } from './modifyNodeAffinityPanel'; +import { ModifyStrategyPanel } from './modifyStrategyPanel'; + +export const WorkloadUpdatePanel = ({ kind, updateType, clusterVersion }: IWrokloadUpdatePanelProps) => { + const clusterId = getParamByUrl('clusterId')!; + const namespace = getParamByUrl('np')!; + const resourceId = getParamByUrl('resourceIns')!; + + const { data: resource } = useRequest( + () => { + return workloadApi.fetchWorkloadResource({ namespace, clusterId, resourceId, kind, clusterVersion }); + }, + { + ready: Boolean(namespace && resourceId && clusterId && kind) + } + ); + + async function handleUpdate(data: any) { + await workloadApi.updateWorkloadResource({ + clusterId, + namespace, + resourceId, + kind, + data, + clusterVersion + }); + + goBackToListPanel(); + } + + function goBackToListPanel() { + router.navigate({ sub: 'sub', mode: 'list', type: 'resource', resourceName: kind }, { clusterId, np: namespace }); + } + + const title = ( + + + router.navigate({}, {})}>集群 + + + + {clusterId} + + + {`${kind}:${resourceId}(${namespace})`} + + {updateType2text[updateType]} + + ); + + const baseInfo = ( +
+ 基本信息 + + + {clusterId} + + + + {namespace} + + + + {`${resourceId} (${kind})`} + +
+ ); + + return ( + <> + {updateType === UpdateTypeEnum.ModifyStrategy && ( + + )} + + {updateType === UpdateTypeEnum.ModifyNodeAffinity && ( + + )} + + ); +}; diff --git a/web/console/src/modules/cluster/components/resource/resourceTableOperation/workloadUpdate/modifyNodeAffinityPanel/affinityRulePanel.tsx b/web/console/src/modules/cluster/components/resource/resourceTableOperation/workloadUpdate/modifyNodeAffinityPanel/affinityRulePanel.tsx new file mode 100644 index 0000000000..587e210dae --- /dev/null +++ b/web/console/src/modules/cluster/components/resource/resourceTableOperation/workloadUpdate/modifyNodeAffinityPanel/affinityRulePanel.tsx @@ -0,0 +1,179 @@ +import { t } from '@/tencent/tea-app/lib/i18n'; +import { getReactHookFormStatusWithMessage } from '@helper'; +import React from 'react'; +import { Controller, useFieldArray, useFormContext, useFormState } from 'react-hook-form'; +import { Button, Form, Input, InputNumber, Justify, Select } from 'tea-component'; +import { AddRuleButton } from './appendAffinityRuleButton'; +import { + AffinityTypeEnum, + NodeAffinityFormType, + NodeAffinityOperatorEnum, + affinityRuleOperatorList +} from './constants'; + +export function AffinityRulePanel({ subName }: { subName: AffinityTypeEnum }) { + const showWeight = subName === AffinityTypeEnum.Attempt; + + const { control } = useFormContext(); + + const { + fields: rules, + append, + remove + } = useFieldArray({ + control, + name: `affinityRules.${subName}` + }); + + return ( + <> + {rules.map(({ id }, index) => ( +
+ + remove(index)} />} /> + + {showWeight && ( + ( + + + + )} + /> + )} + + + + ))} + + {subName === AffinityTypeEnum.Force && } + + {subName === AffinityTypeEnum.Attempt && ( + + )} + + ); +} + +const SubRulePanel = ({ ruleIndex, subName }: { ruleIndex: number; subName: AffinityTypeEnum }) => { + const { control, trigger, watch } = useFormContext(); + + const prePath: + | `affinityRules.force.${number}.subRules` + | `affinityRules.attempt.${number}.subRules` = `affinityRules.${subName}.${ruleIndex}.subRules`; + + const { + fields: subRules, + append: appendSubRule, + remove: removeSubRule, + update + } = useFieldArray({ + control, + name: prePath + }); + + function needDisabledValue(operator) { + return operator === NodeAffinityOperatorEnum.Exists || operator === NodeAffinityOperatorEnum.DoesNotExist; + } + + const { errors } = useFormState({ control }); + + const disabled = Boolean(errors?.affinityRules?.[subName]?.[ruleIndex]?.subRules); + + return ( + <> + {subRules.map(({ id }, index) => ( + + ( + + + + )} + /> + + ( + + + + )} + /> + + + + + ); +}; diff --git a/web/console/src/modules/cluster/components/resource/resourceTableOperation/workloadUpdate/modifyNodeAffinityPanel/appendAffinityRuleButton.tsx b/web/console/src/modules/cluster/components/resource/resourceTableOperation/workloadUpdate/modifyNodeAffinityPanel/appendAffinityRuleButton.tsx new file mode 100644 index 0000000000..2724de234f --- /dev/null +++ b/web/console/src/modules/cluster/components/resource/resourceTableOperation/workloadUpdate/modifyNodeAffinityPanel/appendAffinityRuleButton.tsx @@ -0,0 +1,346 @@ +import { t } from '@/tencent/tea-app/lib/i18n'; +import { getParamByUrl, getReactHookFormStatusWithMessage } from '@helper'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { nodeApi } from '@src/webApi'; +import { useRequest } from 'ahooks'; +import React, { useState } from 'react'; +import { Controller, FormProvider, useFieldArray, useForm, useFormContext, useFormState } from 'react-hook-form'; +import { + Alert, + Button, + Form, + Input, + InputNumber, + Justify, + Modal, + Radio, + RadioGroup, + Select, + Table, + TableColumn, + Transfer +} from 'tea-component'; +import { + AppendAffinityRuleFormType, + NodeAffinityOperatorEnum, + ScheduleTypeEnum, + affinityRuleOperatorList, + appendAffinityRuleSchema, + defaultAppendAffinityRuleFormData +} from './constants'; + +export function AddRuleButton({ append }) { + const [visible, setVisible] = useState(false); + + const [type, setType] = useState(ScheduleTypeEnum.ScheduleByLabel); + + const [nodeKeys, setNodeKeys] = useState([]); + + const formProps = useForm({ + mode: 'onBlur', + defaultValues: defaultAppendAffinityRuleFormData, + resolver: zodResolver(appendAffinityRuleSchema) + }); + + const { handleSubmit, reset } = formProps; + + function onCancel() { + setVisible(false); + reset(); + } + + function handleOk() { + if (type === ScheduleTypeEnum.ScheduleByNode && nodeKeys.length >= 1) { + append({ + weight: 1, + subRules: nodeKeys.map(key => ({ + key: 'kubernetes.io/hostname', + operator: NodeAffinityOperatorEnum.In, + value: key + })) + }); + + onCancel(); + } + + if (type === ScheduleTypeEnum.ScheduleByLabel) { + handleSubmit(({ rules }) => { + append(rules); + + onCancel(); + })(); + } + } + + return ( + <> + + + setVisible(false)}> + + +
+ + setType(type)}> + 指定节点调度 + 自定义Label规则 + + + + {type === ScheduleTypeEnum.ScheduleByLabel && ( + + + + )} + + {type === ScheduleTypeEnum.ScheduleByNode && ( + + + + )} +
+
+
+ + + + + + +
+ + ); +} + +const { scrollable, selectable, removeable } = Table.addons; + +function NodeTransferPanel({ nodeKeys, setNodeKeys }) { + const clusterId = getParamByUrl('clusterId')!; + + const { data: nodeList = [] } = useRequest( + async () => { + const rsp = await nodeApi.fetchNodeList({ clusterId }); + + console.log('NodeTransferPanel--->', rsp); + + return rsp?.items ?? []; + }, + { ready: Boolean(clusterId) } + ); + + const columns: TableColumn[] = [ + { + key: 'metadata.name', + header: 'ID/节点名' + }, + + { + key: 'ip', + header: 'IP地址', + render: ({ metadata }) => metadata?.name + } + ]; + + return ( + <> + {nodeKeys.length < 1 && 节点不能为空,请选择节点} + + setNodeKeys(keys), + rowSelect: true + }) + ]} + /> + + } + rightCell={ + +
nodeKeys.includes(item?.metadata?.name))} + recordKey="metadata.name" + addons={[ + removeable({ + onRemove: key => { + console.log('remove--->', key); + + setNodeKeys(items => items.filter(item => item !== key)); + } + }) + ]} + /> + + } + /> + + ); +} + +export function AffinityRulePanel() { + const showWeight = false; + + const { control } = useFormContext(); + + const { + fields: rules, + append, + remove + } = useFieldArray({ + control, + name: `rules` + }); + + return ( + <> + {rules.map(({ id }, index) => ( +
+ + remove(index)} />} /> + + {showWeight && ( + ( + + + + )} + /> + )} + + + + ))} + + + + ); +} + +const SubRulePanel = ({ ruleIndex }: { ruleIndex: number }) => { + const { control, trigger, watch } = useFormContext(); + + const { + fields: subRules, + append: appendSubRule, + remove: removeSubRule, + update + } = useFieldArray({ + control, + name: `rules.${ruleIndex}.subRules` + }); + + function needDisabledValue(operator) { + return operator === NodeAffinityOperatorEnum.Exists || operator === NodeAffinityOperatorEnum.DoesNotExist; + } + + const { errors } = useFormState({ control }); + + const disabled = Boolean(errors?.rules?.[ruleIndex]?.subRules); + + return ( + <> + {subRules.map(({ id }, index) => ( + + ( + + + + )} + /> + + ( + + + + )} + /> + + + + + ); +}; + +// TODO 泛型组件 diff --git a/web/console/src/modules/cluster/components/resource/resourceTableOperation/workloadUpdate/modifyNodeAffinityPanel/constants.ts b/web/console/src/modules/cluster/components/resource/resourceTableOperation/workloadUpdate/modifyNodeAffinityPanel/constants.ts new file mode 100644 index 0000000000..386b7138b2 --- /dev/null +++ b/web/console/src/modules/cluster/components/resource/resourceTableOperation/workloadUpdate/modifyNodeAffinityPanel/constants.ts @@ -0,0 +1,242 @@ +import { t } from '@/tencent/tea-app/lib/i18n'; +import validateJS from 'validator'; +import { z } from 'zod'; + +/** 节点亲和性调度的方式 */ +export enum NodeAffinityTypeEnum { + /** 不使用调度策略 */ + Unset = 'unset', + + /** 指定节点调度 */ + Node = 'node', + + /** 自定义调度规则 */ + Rule = 'rule' +} + +export enum TolerationTypeEnum { + UnSet = 'UnSet', + + Set = 'Set' +} + +/** 节点亲和性调度 亲和性调度操作符 */ +export enum NodeAffinityOperatorEnum { + In = 'In', + NotIn = 'NotIn', + Exists = 'Exists', + DoesNotExist = 'DoesNotExist', + Gt = 'Gt', + Lt = 'Lt' +} + +export const affinityRuleOperatorList = [ + { + value: NodeAffinityOperatorEnum.In, + tooltip: t('Label的value在列表中') + }, + { + value: NodeAffinityOperatorEnum.NotIn, + tooltip: t('Label的value不在列表中') + }, + { + value: NodeAffinityOperatorEnum.Exists, + tooltip: t('Label的key存在') + }, + { + value: NodeAffinityOperatorEnum.DoesNotExist, + tooltip: t('Labe的key不存在') + }, + { + value: NodeAffinityOperatorEnum.Gt, + tooltip: t('Label的值大于列表值(字符串匹配)') + }, + { + value: NodeAffinityOperatorEnum.Lt, + tooltip: t('Label的值小于列表值(字符串匹配)') + } +]; + +export const affinityRuleSchema = z.array( + z.object({ + weight: z + .number() + .int(t('权重必须为整数')) + .gte(0, { message: t('权重必须在1~100之前') }) + .lte(100, { message: t('权重必须在1~100之前') }), + subRules: z.array( + z + .object({ + key: z + .string() + .min(1, { message: t('标签名不能为空') }) + .max(63, { message: t('标签名长度不能超过63个字符') }) + .regex(/^([A-Za-z0-9][-A-Za-z0-9_./]*)?[A-Za-z0-9]$/, { message: '标签名格式不正确' }), + operator: z.nativeEnum(NodeAffinityOperatorEnum), + value: z.string() + }) + .superRefine(({ operator, value }, ctx) => { + console.log('superRefine', value, operator); + + const values = value.split(';'); + + let message = ''; + + if (operator === NodeAffinityOperatorEnum.Exists || operator === NodeAffinityOperatorEnum.DoesNotExist) + return; + + const value0 = values?.[0]; + + if (!value0) { + message = t('自定义规则不能为空'); + } else { + if (operator === NodeAffinityOperatorEnum.Lt || operator === NodeAffinityOperatorEnum.Gt) { + if (values.length > 1) { + message = t('Gt和Lt操作符只支持一个value值'); + } else if (!validateJS.isNumeric(value0)) { + message = t('Gt和Lt操作符value值格式必须为数字'); + } + } else if (values.some(item => !item)) { + message = t('标签值不能为空'); + } else if (values.some(item => item?.length > 63)) { + message = t('标签值长度不能超过63个字符'); + } else if (values.some(item => !validateJS.matches(item, /^([A-Za-z0-9][-A-Za-z0-9_./]*)?[A-Za-z0-9]$/))) { + message = t('标签格式不正确'); + } + } + + if (message) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message, + path: ['value'] + }); + } + }) + ) + }) +); + +export const generateDefaultRules = () => [ + { + weight: 1, + subRules: [ + { + key: '', + operator: NodeAffinityOperatorEnum.In, + value: '' + } + ] + } +]; + +export enum TolerationOperatorEnum { + Equal = 'Equal', + Exists = 'Exists' +} + +export const tolerationOperatorOptions = [ + { + value: TolerationOperatorEnum.Equal + }, + + { + value: TolerationOperatorEnum.Exists + } +]; + +export enum TolerationEffectEnum { + All = 'All', + NoSchedule = 'NoSchedule', + PreferNoSchedule = 'PreferNoSchedule', + NoExecute = 'NoExecute' +} + +export enum AffinityTypeEnum { + Force = 'force', + Attempt = 'attempt' +} + +export const tolerationEffectOptions = [ + { + value: TolerationEffectEnum.All, + text: '匹配全部' + }, + + { + value: TolerationEffectEnum.NoSchedule + }, + + { + value: TolerationEffectEnum.PreferNoSchedule + }, + + { + value: TolerationEffectEnum.NoExecute + } +]; + +const tolerationSchema = z.array( + z + .object({ + key: z.string(), + operator: z.nativeEnum(TolerationOperatorEnum), + value: z.string(), + effect: z.nativeEnum(TolerationEffectEnum), + time: z.number().min(0) + }) + .superRefine(({ key, operator, value, effect, time }, ctx) => { + if (operator === TolerationOperatorEnum.Equal && !key) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['key'], + message: 'key 不能为空' + }); + } + + if (operator === TolerationOperatorEnum.Equal && !value) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['value'], + message: 'value 不能为空' + }); + } + }) +); + +export const nodeAffinitySchema = z.object({ + affinityRules: z.object({ + [AffinityTypeEnum.Force]: affinityRuleSchema, + [AffinityTypeEnum.Attempt]: affinityRuleSchema + }), + tolerationRules: tolerationSchema, + nodeAffinityType: z.nativeEnum(NodeAffinityTypeEnum), + tolerationType: z.nativeEnum(TolerationTypeEnum) +}); + +export type NodeAffinityFormType = z.infer; + +export const defaultNodeAffinityFormData: NodeAffinityFormType = { + affinityRules: { + force: [], + attempt: [] + }, + tolerationRules: [], + nodeAffinityType: NodeAffinityTypeEnum.Unset, + tolerationType: TolerationTypeEnum.UnSet +}; + +export enum ScheduleTypeEnum { + ScheduleByNode = 'scheduleByNode', + ScheduleByLabel = 'scheduleByLabel' +} + +export const appendAffinityRuleSchema = z.object({ + rules: affinityRuleSchema +}); + +export type AppendAffinityRuleFormType = z.infer; + +export const defaultAppendAffinityRuleFormData: AppendAffinityRuleFormType = { + rules: generateDefaultRules() +}; diff --git a/web/console/src/modules/cluster/components/resource/resourceTableOperation/workloadUpdate/modifyNodeAffinityPanel/index.tsx b/web/console/src/modules/cluster/components/resource/resourceTableOperation/workloadUpdate/modifyNodeAffinityPanel/index.tsx new file mode 100644 index 0000000000..d56f3df307 --- /dev/null +++ b/web/console/src/modules/cluster/components/resource/resourceTableOperation/workloadUpdate/modifyNodeAffinityPanel/index.tsx @@ -0,0 +1,225 @@ +import { t } from '@/tencent/tea-app/lib/i18n'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { TeaFormLayout } from '@src/modules/common/layouts/TeaFormLayout'; +import React, { useEffect } from 'react'; +import { Controller, FormProvider, useForm } from 'react-hook-form'; +import { Button, Form, Radio } from 'tea-component'; +import { IModifyPanelProps, WorkloadKindEnum } from '../constants'; +import { AffinityRulePanel } from './affinityRulePanel'; +import { + AffinityTypeEnum, + NodeAffinityFormType, + NodeAffinityTypeEnum, + TolerationEffectEnum, + TolerationOperatorEnum, + TolerationTypeEnum, + defaultNodeAffinityFormData, + nodeAffinitySchema +} from './constants'; +import { TolerationRulePanel } from './tolerationRulePanel'; + +export const ModifyNodeAffinityPanel = ({ title, baseInfo, onCancel, kind, onUpdate, resource }: IModifyPanelProps) => { + const isCronjob = kind === WorkloadKindEnum.Cronjob; + + const useFormReturn = useForm({ + mode: 'onBlur', + defaultValues: defaultNodeAffinityFormData, + resolver: zodResolver(nodeAffinitySchema) + }); + + const { handleSubmit, control, watch, reset } = useFormReturn; + + useEffect(() => { + if (!resource) return; + + let originNodeAffinityInfo = resource?.spec?.template?.spec?.affinity?.nodeAffinity; + + let tolerationInfo = resource?.spec?.template?.spec?.tolerations; + + if (isCronjob) { + originNodeAffinityInfo = resource?.spec?.jobTemplate?.spec?.template?.spec?.affinity?.nodeAffinity; + + tolerationInfo = resource?.spec?.jobTemplate?.spec?.template?.spec?.tolerations; + } + + const forceAffinityRules = + originNodeAffinityInfo?.requiredDuringSchedulingIgnoredDuringExecution?.nodeSelectorTerms?.map(item => ({ + weight: 1, + subRules: + item?.matchExpressions?.map(r => ({ + key: r?.key ?? '', + operator: r?.operator, + value: r?.values?.join(';') ?? '' + })) ?? [] + })) ?? []; + + const attemptAffinityRules = + originNodeAffinityInfo?.preferredDuringSchedulingIgnoredDuringExecution?.map(item => ({ + weight: item?.weight ?? '', + subRules: + item?.preference?.matchExpressions?.map(r => ({ + key: r?.key ?? '', + operator: r?.operator, + value: r?.values?.join(';') ?? '' + })) ?? [] + })) ?? []; + + const data: NodeAffinityFormType = { + nodeAffinityType: originNodeAffinityInfo ? NodeAffinityTypeEnum.Rule : NodeAffinityTypeEnum.Unset, + affinityRules: { + force: forceAffinityRules, + attempt: attemptAffinityRules + }, + + tolerationType: tolerationInfo ? TolerationTypeEnum.Set : TolerationTypeEnum.UnSet, + + tolerationRules: + tolerationInfo?.map(item => ({ + key: item?.key ?? '', + operator: item?.operator, + value: item?.value ?? '', + effect: item?.effect ?? TolerationEffectEnum.All, + time: item?.tolerationSeconds ?? 0 + })) ?? [] + }; + + reset(data); + }, [resource, isCronjob, reset]); + + function onSubmit({ nodeAffinityType, affinityRules, tolerationType, tolerationRules }: NodeAffinityFormType) { + const templateContent = { + spec: { + tolerations: + tolerationType === TolerationTypeEnum.Set + ? tolerationRules.map(({ key, operator, value, effect, time }) => ({ + key: key || undefined, + operator, + value: operator === TolerationOperatorEnum.Exists ? undefined : value, + effect: effect === TolerationEffectEnum.All ? undefined : effect, + tolerationSeconds: effect === TolerationEffectEnum.NoExecute ? time : undefined + })) + : null, + + affinity: { + nodeAffinity: + nodeAffinityType === NodeAffinityTypeEnum.Unset + ? null + : { + requiredDuringSchedulingIgnoredDuringExecution: affinityRules.force.length + ? { + nodeSelectorTerms: affinityRules.force.map(({ subRules }) => ({ + matchExpressions: subRules.map(({ key, operator, value }) => ({ + key, + operator, + values: value ? value.split(';') : undefined + })) + })) + } + : null, + + preferredDuringSchedulingIgnoredDuringExecution: affinityRules.attempt.length + ? affinityRules.attempt.map(({ weight, subRules }) => ({ + weight, + preference: { + matchExpressions: subRules.map(({ key, operator, value }) => ({ + key, + operator, + values: value ? value.split(';') : undefined + })) + } + })) + : null + } + } + } + }; + + const jsonData = { + spec: { + template: isCronjob ? undefined : templateContent, + jobTemplate: isCronjob + ? { + spec: { + template: templateContent + } + } + : undefined + } + }; + + onUpdate(jsonData); + } + + return ( + + + + + + } + > + {baseInfo} + +
+ + +
+ ( + + + 不使用调度策略 + 自定义调度规则 + + + )} + /> + + {watch('nodeAffinityType') === NodeAffinityTypeEnum.Rule && ( + <> + + + + + + + + + )} + + ( + + + 不使用容忍调度 + 使用容忍调度 + + + )} + /> + + {watch('tolerationType') === TolerationTypeEnum.Set && ( + + + + )} + +
+
+ ); +}; diff --git a/web/console/src/modules/cluster/components/resource/resourceTableOperation/workloadUpdate/modifyNodeAffinityPanel/tolerationRulePanel.tsx b/web/console/src/modules/cluster/components/resource/resourceTableOperation/workloadUpdate/modifyNodeAffinityPanel/tolerationRulePanel.tsx new file mode 100644 index 0000000000..4efd9e106f --- /dev/null +++ b/web/console/src/modules/cluster/components/resource/resourceTableOperation/workloadUpdate/modifyNodeAffinityPanel/tolerationRulePanel.tsx @@ -0,0 +1,171 @@ +import { getReactHookFormStatusWithMessage } from '@helper'; +import { ValidateProvider } from '@src/modules/common/components'; +import React from 'react'; +import { Controller, useFieldArray, useFormContext } from 'react-hook-form'; +import { Button, Input, InputNumber, Select, Table, TableColumn } from 'tea-component'; +import { + NodeAffinityFormType, + TolerationEffectEnum, + TolerationOperatorEnum, + tolerationEffectOptions, + tolerationOperatorOptions +} from './constants'; + +export const TolerationRulePanel = () => { + const { control, watch } = useFormContext(); + + const { fields, remove, append } = useFieldArray({ + control, + name: 'tolerationRules' + }); + + const rulesWatch = watch('tolerationRules'); + + const columns: TableColumn[] = [ + { + key: 'key', + header: '标签名', + render(_, __, index) { + return ( + ( + + + + )} + /> + ); + } + }, + + { + key: 'operator', + header: '操作符', + render(_, __, index) { + return ( + ( + + + + )} + /> + ); + } + }, + + { + key: 'effect', + header: '效果', + render(_, __, index) { + return ( + ( + +
+ + ); +}; diff --git a/web/console/src/modules/cluster/components/resource/resourceTableOperation/workloadUpdate/modifyStrategyPanel/constants.ts b/web/console/src/modules/cluster/components/resource/resourceTableOperation/workloadUpdate/modifyStrategyPanel/constants.ts new file mode 100644 index 0000000000..8a161d29dd --- /dev/null +++ b/web/console/src/modules/cluster/components/resource/resourceTableOperation/workloadUpdate/modifyStrategyPanel/constants.ts @@ -0,0 +1,73 @@ +import { t } from '@/tencent/tea-app/lib/i18n'; +import { WorkloadKindEnum } from '../constants'; + +/** 滚动更新的策略选择 */ +export enum RollingUpdateTypeEnum { + /** 启动新的pod,停止旧的pod */ + CreatePod = 'createPod', + + /** 停止旧的pod,启动新的pod */ + DestroyPod = 'destroyPod', + + /** 用户自定义 */ + UserDefined = 'userDefined' +} + +export enum RegistryUpdateTypeEnum { + /** 滚动更新 */ + RollingUpdate = 'RollingUpdate', + + /** 快速更新 */ + Recreate = 'Recreate', + + /** OnDelete */ + OnDelete = 'OnDelete' +} + +export const updateStrategyOptions = [ + { + text: t('启动新的Pod,停止旧的Pod'), + value: RollingUpdateTypeEnum.CreatePod + }, + + { + text: t('停止旧的Pod,启动新的Pod'), + value: RollingUpdateTypeEnum.DestroyPod + }, + + { + text: t('自定义'), + value: RollingUpdateTypeEnum.UserDefined + } +]; + +export const getUpdateTypeOptionsForKind = (kind: WorkloadKindEnum) => { + const fullOptions = [ + { + text: t('滚动更新(推荐)'), + value: RegistryUpdateTypeEnum.RollingUpdate + }, + + { + text: t('快速更新'), + value: RegistryUpdateTypeEnum.Recreate + }, + + { + text: 'OnDelete', + value: RegistryUpdateTypeEnum.OnDelete + } + ]; + + return fullOptions.filter(({ value }) => { + if (kind === WorkloadKindEnum.Deployment && value !== RegistryUpdateTypeEnum.OnDelete) return true; + + if ( + (kind === WorkloadKindEnum.StatefulSet || kind === WorkloadKindEnum.DaemonSet) && + value !== RegistryUpdateTypeEnum.Recreate + ) + return true; + + return false; + }); +}; diff --git a/web/console/src/modules/cluster/components/resource/resourceTableOperation/workloadUpdate/modifyStrategyPanel/index.tsx b/web/console/src/modules/cluster/components/resource/resourceTableOperation/workloadUpdate/modifyStrategyPanel/index.tsx new file mode 100644 index 0000000000..e7c9f0781e --- /dev/null +++ b/web/console/src/modules/cluster/components/resource/resourceTableOperation/workloadUpdate/modifyStrategyPanel/index.tsx @@ -0,0 +1,250 @@ +import { t } from '@/tencent/tea-app/lib/i18n'; +import { TeaFormLayout } from '@src/modules/common/layouts/TeaFormLayout'; +import React, { useEffect } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { Button, Form, InputNumber, Radio, Select } from 'tea-component'; +import { IModifyPanelProps, WorkloadKindEnum } from '../constants'; +import { + RegistryUpdateTypeEnum, + RollingUpdateTypeEnum, + getUpdateTypeOptionsForKind, + updateStrategyOptions +} from './constants'; + +export const ModifyStrategyPanel = ({ kind, resource, title, baseInfo, onCancel, onUpdate }: IModifyPanelProps) => { + const isDeployment = kind === WorkloadKindEnum.Deployment; + const isStatefulSet = kind === WorkloadKindEnum.StatefulSet; + const isDaemonSet = kind === WorkloadKindEnum.DaemonSet; + + const { handleSubmit, watch, control, reset } = useForm({ + mode: 'onBlur', + + defaultValues: { + updateType: RegistryUpdateTypeEnum.RollingUpdate, + updateInterval: 0, + updateStrategy: RollingUpdateTypeEnum.UserDefined, + maxSurge: 25, + maxUnavailable: 25, + batchSize: 1, + partition: 0 + } + }); + + const updateTypeWatch = watch('updateType'); + const updateStrategyWatch = watch('updateStrategy'); + + useEffect(() => { + if (!resource) return; + + const updateType = isDeployment ? resource?.spec?.strategy?.type : resource?.spec?.updateStrategy?.type; + + // 如果只有maxSurge 有值,而maxUnavailable为0,则为启动新的pod,停止旧的pod + const minReadySeconds = resource?.spec?.minReadySeconds ?? 0, + partition = resource?.spec?.updateStrategy?.rollingUpdate?.partition ?? 0; + + let maxSurge = resource?.spec?.strategy?.rollingUpdate?.maxSurge ?? 0, + maxUnavailable = resource?.spec?.strategy?.rollingUpdate?.maxUnavailable ?? 0, + rollingUpdateStrategy = RollingUpdateTypeEnum.CreatePod, + batchSize = 1; + + if (maxSurge === 0 && Number.isInteger(maxUnavailable)) { + rollingUpdateStrategy = RollingUpdateTypeEnum.DestroyPod; + batchSize = maxUnavailable; + maxSurge = '25%'; + maxUnavailable = '25%'; + } else if (maxUnavailable === 0 && Number.isInteger(maxSurge)) { + rollingUpdateStrategy = RollingUpdateTypeEnum.CreatePod; + batchSize = maxSurge; + maxSurge = '25%'; + maxUnavailable = '25%'; + } else { + rollingUpdateStrategy = RollingUpdateTypeEnum.UserDefined; + } + + if (isDaemonSet) { + maxUnavailable = resource?.spec?.updateStrategy?.rollingUpdate?.maxUnavailable ?? 0; + } + + const newConfig = { + updateType, + updateInterval: minReadySeconds, + updateStrategy: rollingUpdateStrategy, + maxSurge: parseInt(maxSurge), + maxUnavailable: parseInt(maxUnavailable), + batchSize, + partition + }; + + reset(newConfig); + }, [resource, isDaemonSet, isDeployment, isStatefulSet, reset]); + + function onSubmit({ updateType, updateInterval, updateStrategy, maxSurge, maxUnavailable, batchSize, partition }) { + const isRollingUpdate = updateType === RegistryUpdateTypeEnum.RollingUpdate; + + const isUserDefined = updateStrategy === RollingUpdateTypeEnum.UserDefined; + const isCreatePod = updateStrategy === RollingUpdateTypeEnum.CreatePod; + + const data = { + spec: { + minReadySeconds: isStatefulSet ? undefined : isRollingUpdate ? updateInterval : 0, + + strategy: isDeployment + ? { + type: updateType, + rollingUpdate: isRollingUpdate + ? { + maxSurge: isUserDefined ? `${maxSurge}%` : isCreatePod ? batchSize : 0, + + maxUnavailable: isUserDefined ? `${maxUnavailable}%` : isCreatePod ? 0 : batchSize + } + : null + } + : undefined, + + updateStrategy: isDeployment + ? undefined + : { + type: updateType, + rollingUpdate: isRollingUpdate + ? isStatefulSet + ? { partition } + : maxUnavailable + ? { maxUnavailable } + : undefined + : null + } + } + }; + + onUpdate(data); + } + + return ( + + + + + + } + > + {baseInfo} + +
+ +
+ ( + +