From 80391b867c7804d6401417ed07f8504cfcc7370d Mon Sep 17 00:00:00 2001 From: Daniel Valdivia <18384552+dvaldivia@users.noreply.github.com> Date: Mon, 30 May 2022 20:06:42 -0700 Subject: [PATCH] Create Tenant Namespace Field move to Thunks (#2052) Signed-off-by: Daniel Valdivia <18384552+dvaldivia@users.noreply.github.com> --- .../Steps/TenantResources/NameTenantMain.tsx | 197 +------- .../TenantResources/NamespaceSelector.tsx | 86 ++++ .../Steps/helpers/AddNamespaceModal.tsx | 77 +-- .../Tenants/AddTenant/createTenantSlice.ts | 438 ++++++------------ .../AddTenant/thunks/namespaceThunks.ts | 102 ++++ 5 files changed, 356 insertions(+), 544 deletions(-) create mode 100644 portal-ui/src/screens/Console/Tenants/AddTenant/Steps/TenantResources/NamespaceSelector.tsx create mode 100644 portal-ui/src/screens/Console/Tenants/AddTenant/thunks/namespaceThunks.ts diff --git a/portal-ui/src/screens/Console/Tenants/AddTenant/Steps/TenantResources/NameTenantMain.tsx b/portal-ui/src/screens/Console/Tenants/AddTenant/Steps/TenantResources/NameTenantMain.tsx index 859b9653c..2e113e422 100644 --- a/portal-ui/src/screens/Console/Tenants/AddTenant/Steps/TenantResources/NameTenantMain.tsx +++ b/portal-ui/src/screens/Console/Tenants/AddTenant/Steps/TenantResources/NameTenantMain.tsx @@ -14,54 +14,33 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import React, { - Fragment, - useCallback, - useEffect, - useMemo, - useState, -} from "react"; +import React, { Fragment, useCallback, useEffect } from "react"; import { useDispatch, useSelector } from "react-redux"; import { Theme } from "@mui/material/styles"; import createStyles from "@mui/styles/createStyles"; import withStyles from "@mui/styles/withStyles"; import get from "lodash/get"; -import debounce from "lodash/debounce"; import Grid from "@mui/material/Grid"; import { formFieldStyles, modalBasic, wizardCommon, } from "../../../../Common/FormComponents/common/styleLibrary"; - -import { - getLimitSizes, - IQuotaElement, - IQuotas, -} from "../../../ListTenants/utils"; import { AppState } from "../../../../../../store"; -import { commonFormValidation } from "../../../../../../utils/validationFunctions"; -import { clearValidationError } from "../../../utils"; -import { ErrorResponseHandler } from "../../../../../../common/types"; -import api from "../../../../../../common/api"; import InputBoxWrapper from "../../../../Common/FormComponents/InputBoxWrapper/InputBoxWrapper"; import SelectWrapper from "../../../../Common/FormComponents/SelectWrapper/SelectWrapper"; -import AddIcon from "../../../../../../icons/AddIcon"; -import AddNamespaceModal from "../helpers/AddNamespaceModal"; import SizePreview from "../SizePreview"; import TenantSize from "./TenantSize"; import { Paper, SelectChangeEvent } from "@mui/material"; import { IMkEnvs, mkPanelConfigurations } from "./utils"; -import { setModalErrorSnackMessage } from "../../../../../../systemSlice"; import { isPageValid, - setLimitSize, - setStorageClassesList, setStorageType, setTenantName, updateAddField, } from "../../createTenantSlice"; import { selFeatures } from "../../../../consoleSlice"; +import NamespaceSelector from "./NamespaceSelector"; const styles = (theme: Theme) => createStyles({ @@ -110,9 +89,6 @@ interface INameTenantMainScreen { const NameTenantMain = ({ classes, formToRender }: INameTenantMainScreen) => { const dispatch = useDispatch(); - const namespace = useSelector( - (state: AppState) => state.createTenant.fields.nameTenant.namespace - ); const selectedStorageClass = useSelector( (state: AppState) => state.createTenant.fields.nameTenant.selectedStorageClass @@ -126,13 +102,6 @@ const NameTenantMain = ({ classes, formToRender }: INameTenantMainScreen) => { ); const features = useSelector(selFeatures); - const [validationErrors, setValidationErrors] = useState({}); - const [emptyNamespace, setEmptyNamespace] = useState(true); - const [loadingNamespaceInfo, setLoadingNamespaceInfo] = - useState(false); - const [showCreateButton, setShowCreateButton] = useState(false); - const [openAddNSConfirm, setOpenAddNSConfirm] = useState(false); - // Common const updateField = useCallback( (field: string, value: string) => { @@ -143,157 +112,17 @@ const NameTenantMain = ({ classes, formToRender }: INameTenantMainScreen) => { [dispatch] ); - // Storage classes retrieval - const getNamespaceInformation = useCallback(() => { - setShowCreateButton(false); - // Empty tenantValidation - api - .invoke("GET", `/api/v1/namespaces/${namespace}/tenants`) - .then((res: any[]) => { - const tenantsList = get(res, "tenants", []); - - if (tenantsList && tenantsList.length > 0) { - setEmptyNamespace(false); - setLoadingNamespaceInfo(false); - return; - } - setEmptyNamespace(true); - - // Storagequotas retrieval - api - .invoke( - "GET", - `/api/v1/namespaces/${namespace}/resourcequotas/${namespace}-storagequota` - ) - .then((res: IQuotas) => { - const elements: IQuotaElement[] = get(res, "elements", []); - dispatch(setLimitSize(getLimitSizes(res))); - - const newStorage = elements.map((storageClass: any) => { - const name = get(storageClass, "name", "").split( - ".storageclass.storage.k8s.io/requests.storage" - )[0]; - - return { label: name, value: name }; - }); - - dispatch(setStorageClassesList(newStorage)); - - const stExists = newStorage.findIndex( - (storageClass) => storageClass.value === selectedStorageClass - ); - - if (newStorage.length > 0 && stExists === -1) { - updateField("selectedStorageClass", newStorage[0].value); - } else if (newStorage.length === 0) { - updateField("selectedStorageClass", ""); - dispatch(setStorageClassesList([])); - } - setLoadingNamespaceInfo(false); - }) - .catch((err: ErrorResponseHandler) => { - setLoadingNamespaceInfo(false); - setShowCreateButton(true); - updateField("selectedStorageClass", ""); - dispatch(setStorageClassesList([])); - console.error("Namespace error: ", err); - }); - }) - .catch((err: ErrorResponseHandler) => { - dispatch( - setModalErrorSnackMessage({ - errorMessage: "Error validating if namespace already has tenants", - detailedError: err.detailedError, - }) - ); - }); - }, [namespace, dispatch, updateField, selectedStorageClass]); - - const debounceNamespace = useMemo( - () => debounce(getNamespaceInformation, 500), - [getNamespaceInformation] - ); - - useEffect(() => { - if (namespace !== "") { - debounceNamespace(); - setLoadingNamespaceInfo(true); - - // Cancel previous debounce calls during useEffect cleanup. - return debounceNamespace.cancel; - } - }, [debounceNamespace, namespace]); - // Validation useEffect(() => { - let customNamespaceError = false; - let errorMessage = ""; - - if (!emptyNamespace && !loadingNamespaceInfo) { - customNamespaceError = true; - errorMessage = "You can only create one tenant per namespace"; - } else if ( - storageClasses.length < 1 && - emptyNamespace && - !loadingNamespaceInfo - ) { - customNamespaceError = true; - errorMessage = "Please enter a valid namespace"; - } - - const commonValidation = commonFormValidation([ - { - fieldKey: "namespace", - required: true, - value: namespace, - customValidation: customNamespaceError, - customValidationMessage: errorMessage, - }, - ]); - const isValid = - !("namespace" in commonValidation) && - ((formToRender === IMkEnvs.default && storageClasses.length > 0) || - (formToRender !== IMkEnvs.default && selectedStorageType !== "")); + (formToRender === IMkEnvs.default && storageClasses.length > 0) || + (formToRender !== IMkEnvs.default && selectedStorageType !== ""); dispatch(isPageValid({ pageName: "nameTenant", valid: isValid })); - - setValidationErrors(commonValidation); - }, [ - storageClasses, - namespace, - dispatch, - emptyNamespace, - loadingNamespaceInfo, - selectedStorageType, - formToRender, - ]); - - const frmValidationCleanup = (fieldName: string) => { - setValidationErrors(clearValidationError(validationErrors, fieldName)); - }; - - const addNamespace = () => { - setOpenAddNSConfirm(true); - }; - - const closeAddNamespace = (refresh: boolean) => { - setOpenAddNSConfirm(false); - - if (refresh) { - debounceNamespace(); - } - }; + }, [storageClasses, dispatch, selectedStorageType, formToRender]); return ( - {openAddNSConfirm && ( - - )} @@ -310,21 +139,7 @@ const NameTenantMain = ({ classes, formToRender }: INameTenantMainScreen) => { - ) => { - updateField("namespace", e.target.value); - frmValidationCleanup("namespace"); - }} - label="Namespace" - value={namespace} - error={validationErrors["namespace"] || ""} - overlayId={"add-namespace"} - overlayIcon={showCreateButton ? : null} - overlayAction={addNamespace} - required - /> + {formToRender === IMkEnvs.default ? ( diff --git a/portal-ui/src/screens/Console/Tenants/AddTenant/Steps/TenantResources/NamespaceSelector.tsx b/portal-ui/src/screens/Console/Tenants/AddTenant/Steps/TenantResources/NamespaceSelector.tsx new file mode 100644 index 000000000..19d89a8fc --- /dev/null +++ b/portal-ui/src/screens/Console/Tenants/AddTenant/Steps/TenantResources/NamespaceSelector.tsx @@ -0,0 +1,86 @@ +// This file is part of MinIO Console Server +// Copyright (c) 2022 MinIO, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import React, { Fragment, useEffect, useMemo } from "react"; +import AddIcon from "../../../../../../icons/AddIcon"; +import InputBoxWrapper from "../../../../Common/FormComponents/InputBoxWrapper/InputBoxWrapper"; +import { openAddNSModal, setNamespace } from "../../createTenantSlice"; +import { useDispatch, useSelector } from "react-redux"; +import { AppState } from "../../../../../../store"; +import AddNamespaceModal from "../helpers/AddNamespaceModal"; +import debounce from "lodash/debounce"; +import { IMkEnvs } from "./utils"; +import { validateNamespaceAsync } from "../../thunks/namespaceThunks"; + +const NamespaceSelector = ({ formToRender }: { formToRender?: IMkEnvs }) => { + const dispatch = useDispatch(); + + const namespace = useSelector( + (state: AppState) => state.createTenant.fields.nameTenant.namespace + ); + + const showNSCreateButton = useSelector( + (state: AppState) => state.createTenant.showNSCreateButton + ); + + const namespaceError = useSelector( + (state: AppState) => state.createTenant.validationErrors["namespace"] + ); + const openAddNSConfirm = useSelector( + (state: AppState) => state.createTenant.addNSOpen + ); + + const debounceNamespace = useMemo( + () => + debounce(() => { + dispatch(validateNamespaceAsync()); + }, 500), + [dispatch] + ); + + useEffect(() => { + if (namespace !== "") { + debounceNamespace(); + // Cancel previous debounce calls during useEffect cleanup. + return debounceNamespace.cancel; + } + }, [debounceNamespace, namespace]); + + const addNamespace = () => { + dispatch(openAddNSModal()); + }; + + return ( + + {openAddNSConfirm && } + ) => { + dispatch(setNamespace(e.target.value)); + }} + label="Namespace" + value={namespace} + error={namespaceError || ""} + overlayId={"add-namespace"} + overlayIcon={showNSCreateButton ? : null} + overlayAction={addNamespace} + required + /> + + ); +}; +export default NamespaceSelector; diff --git a/portal-ui/src/screens/Console/Tenants/AddTenant/Steps/helpers/AddNamespaceModal.tsx b/portal-ui/src/screens/Console/Tenants/AddTenant/Steps/helpers/AddNamespaceModal.tsx index 813a5f39c..3e52b7729 100644 --- a/portal-ui/src/screens/Console/Tenants/AddTenant/Steps/helpers/AddNamespaceModal.tsx +++ b/portal-ui/src/screens/Console/Tenants/AddTenant/Steps/helpers/AddNamespaceModal.tsx @@ -14,24 +14,23 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import React, { useEffect, useState } from "react"; -import { useDispatch } from "react-redux"; +import React from "react"; +import { useDispatch, useSelector } from "react-redux"; import { DialogContentText, LinearProgress } from "@mui/material"; import { Theme } from "@mui/material/styles"; import createStyles from "@mui/styles/createStyles"; -import withStyles from "@mui/styles/withStyles"; import { deleteDialogStyles, modalBasic, } from "../../../../Common/FormComponents/common/styleLibrary"; - -import { ErrorResponseHandler } from "../../../../../../common/types"; -import api from "../../../../../../common/api"; import ConfirmDialog from "../../../../Common/ModalWrapper/ConfirmDialog"; import { ConfirmModalIcon } from "../../../../../../icons"; -import { setErrorSnackMessage } from "../../../../../../systemSlice"; +import { AppState } from "../../../../../../store"; +import { closeAddNSModal } from "../../createTenantSlice"; +import makeStyles from "@mui/styles/makeStyles"; +import { createNamespaceAsync } from "../../thunks/namespaceThunks"; -const styles = (theme: Theme) => +const useStyles = makeStyles((theme: Theme) => createStyles({ wrapText: { maxWidth: "200px", @@ -40,50 +39,22 @@ const styles = (theme: Theme) => }, ...modalBasic, ...deleteDialogStyles, - }); + }) +); -interface IAddNamespace { - classes: any; - namespace: string; - addNamespaceOpen: boolean; - closeAddNamespaceModalAndRefresh: (reloadNamespaceData: boolean) => void; -} - -const AddNamespaceModal = ({ - classes, - namespace, - addNamespaceOpen, - closeAddNamespaceModalAndRefresh, -}: IAddNamespace) => { +const AddNamespaceModal = () => { const dispatch = useDispatch(); - const [addNamespaceLoading, setAddNamespaceLoading] = - useState(false); + const classes = useStyles(); - useEffect(() => { - if (addNamespaceLoading) { - api - .invoke("POST", "/api/v1/namespace", { - name: namespace, - }) - .then((res) => { - setAddNamespaceLoading(false); - closeAddNamespaceModalAndRefresh(true); - }) - .catch((err: ErrorResponseHandler) => { - setAddNamespaceLoading(false); - dispatch(setErrorSnackMessage(err)); - }); - } - }, [ - addNamespaceLoading, - closeAddNamespaceModalAndRefresh, - namespace, - dispatch, - ]); - - const addNamespace = () => { - setAddNamespaceLoading(true); - }; + const namespace = useSelector( + (state: AppState) => state.createTenant.fields.nameTenant.namespace + ); + const addNamespaceLoading = useSelector( + (state: AppState) => state.createTenant.addNSLoading + ); + const addNamespaceOpen = useSelector( + (state: AppState) => state.createTenant.addNSOpen + ); return ( } isLoading={addNamespaceLoading} - onConfirm={addNamespace} + onConfirm={() => { + dispatch(createNamespaceAsync()); + }} onClose={() => { - closeAddNamespaceModalAndRefresh(false); + dispatch(closeAddNSModal()); }} confirmationContent={ @@ -114,4 +87,4 @@ const AddNamespaceModal = ({ ); }; -export default withStyles(styles)(AddNamespaceModal); +export default AddNamespaceModal; diff --git a/portal-ui/src/screens/Console/Tenants/AddTenant/createTenantSlice.ts b/portal-ui/src/screens/Console/Tenants/AddTenant/createTenantSlice.ts index db83c0554..9e26bc64d 100644 --- a/portal-ui/src/screens/Console/Tenants/AddTenant/createTenantSlice.ts +++ b/portal-ui/src/screens/Console/Tenants/AddTenant/createTenantSlice.ts @@ -15,7 +15,12 @@ // along with this program. If not, see . import { createSlice, PayloadAction } from "@reduxjs/toolkit"; -import { KeyPair, Opts } from "../ListTenants/utils"; +import { + getLimitSizes, + IQuotaElement, + KeyPair, + Opts, +} from "../ListTenants/utils"; import { ITolerationEffect, ITolerationModel, @@ -37,6 +42,11 @@ import { NewServiceAccount } from "../../Common/CredentialsPrompt/types"; import { createTenantAsync } from "./thunks/createTenantThunk"; import { commonFormValidation } from "../../../../utils/validationFunctions"; import { flipValidPageInState } from "./sliceUtils"; +import { + createNamespaceAsync, + namespaceResourcesAsync, + validateNamespaceAsync, +} from "./thunks/namespaceThunks"; export interface ICreateTenant { addingTenant: boolean; @@ -52,6 +62,12 @@ export interface ICreateTenant { // after creation states createdAccount: NewServiceAccount | null; showNewCredentials: boolean; + //namespace logic + emptyNamespace: boolean; + loadingNamespaceInfo: boolean; + showNSCreateButton: boolean; + addNSOpen: boolean; + addNSLoading: boolean; } const initialState: ICreateTenant = { @@ -353,6 +369,11 @@ const initialState: ICreateTenant = { ], createdAccount: null, showNewCredentials: false, + emptyNamespace: true, + loadingNamespaceInfo: false, + showNSCreateButton: false, + addNSOpen: false, + addNSLoading: false, }; export const createTenantSlice = createSlice({ @@ -668,306 +689,7 @@ export const createTenantSlice = createSlice({ encoded_cert: action.payload.value, }; }, - resetAddTenantForm: (state) => { - state.addingTenant = false; - state.page = 0; - // We can assume all the other pages are valid with default configuration except for 'nameTenant' - // because the user still have to choose a namespace and a name for the tenant - state.validPages = [ - "tenantSize", - "configure", - "affinity", - "identityProvider", - "security", - "encryption", - ]; - state.validationErrors = {}; - state.storageClasses = []; - state.limitSize = {}; - state.fields = { - nameTenant: { - tenantName: "", - namespace: "", - selectedStorageClass: "", - selectedStorageType: "", - }, - configure: { - customImage: false, - imageName: "", - customDockerhub: false, - imageRegistry: "", - imageRegistryUsername: "", - imageRegistryPassword: "", - exposeMinIO: true, - exposeConsole: true, - tenantCustom: false, - logSearchEnabled: true, - prometheusEnabled: true, - logSearchVolumeSize: "5", - logSearchSizeFactor: "Gi", - logSearchSelectedStorageClass: "default", - logSearchImage: "", - kesImage: "", - logSearchPostgresImage: "", - logSearchPostgresInitImage: "", - prometheusVolumeSize: "5", - prometheusSizeFactor: "Gi", - prometheusSelectedStorageClass: "default", - prometheusImage: "", - prometheusSidecarImage: "", - prometheusInitImage: "", - setDomains: false, - consoleDomain: "", - minioDomains: [""], - tenantSecurityContext: { - runAsUser: "1000", - runAsGroup: "1000", - fsGroup: "1000", - runAsNonRoot: true, - }, - logSearchSecurityContext: { - runAsUser: "1000", - runAsGroup: "1000", - fsGroup: "1000", - runAsNonRoot: true, - }, - logSearchPostgresSecurityContext: { - runAsUser: "999", - runAsGroup: "999", - fsGroup: "999", - runAsNonRoot: true, - }, - prometheusSecurityContext: { - runAsUser: "1000", - runAsGroup: "1000", - fsGroup: "1000", - runAsNonRoot: true, - }, - }, - identityProvider: { - idpSelection: "Built-in", - accessKeys: [getRandomString(16)], - secretKeys: [getRandomString(32)], - openIDConfigurationURL: "", - openIDClientID: "", - openIDSecretID: "", - openIDCallbackURL: "", - openIDClaimName: "", - openIDScopes: "", - ADURL: "", - ADSkipTLS: false, - ADServerInsecure: false, - ADGroupSearchBaseDN: "", - ADGroupSearchFilter: "", - ADUserDNs: [""], - ADLookupBindDN: "", - ADLookupBindPassword: "", - ADUserDNSearchBaseDN: "", - ADUserDNSearchFilter: "", - ADServerStartTLS: false, - }, - security: { - enableAutoCert: true, - enableCustomCerts: false, - enableTLS: true, - }, - encryption: { - enableEncryption: false, - encryptionType: "vault", - gemaltoEndpoint: "", - gemaltoToken: "", - gemaltoDomain: "", - gemaltoRetry: "0", - awsEndpoint: "", - awsRegion: "", - awsKMSKey: "", - awsAccessKey: "", - awsSecretKey: "", - awsToken: "", - vaultEndpoint: "", - vaultEngine: "", - vaultNamespace: "", - vaultPrefix: "", - vaultAppRoleEngine: "", - vaultId: "", - vaultSecret: "", - vaultRetry: "0", - vaultPing: "0", - azureEndpoint: "", - azureTenantID: "", - azureClientID: "", - azureClientSecret: "", - gcpProjectID: "", - gcpEndpoint: "", - gcpClientEmail: "", - gcpClientID: "", - gcpPrivateKeyID: "", - gcpPrivateKey: "", - enableCustomCertsForKES: false, - replicas: "1", - kesSecurityContext: { - runAsUser: "1000", - runAsGroup: "1000", - fsGroup: "1000", - runAsNonRoot: true, - }, - }, - tenantSize: { - volumeSize: "1024", - sizeFactor: "Gi", - drivesPerServer: "4", - nodes: "4", - memoryNode: "2", - ecParity: "", - ecParityChoices: [], - cleanECChoices: [], - untouchedECField: true, - distribution: { - error: "", - nodes: 0, - persistentVolumes: 0, - disks: 0, - }, - ecParityCalc: { - error: 0, - defaultEC: "", - erasureCodeSet: 0, - maxEC: "", - rawCapacity: "0", - storageFactors: [], - }, - limitSize: {}, - cpuToUse: "0", - // resource request - resourcesSpecifyLimit: false, - resourcesCPURequestError: "", - resourcesCPURequest: "", - resourcesCPULimitError: "", - resourcesCPULimit: "", - resourcesMemoryRequestError: "", - resourcesMemoryRequest: "", - resourcesMemoryLimitError: "", - resourcesMemoryLimit: "", - resourcesSize: { - error: "", - memoryRequest: 0, - memoryLimit: 0, - cpuRequest: 0, - cpuLimit: 0, - }, - maxAllocatableResources: { - min_allocatable_mem: 0, - min_allocatable_cpu: 0, - cpu_priority: { - max_allocatable_cpu: 0, - max_allocatable_mem: 0, - }, - mem_priority: { - max_allocatable_cpu: 0, - max_allocatable_mem: 0, - }, - }, - maxCPUsUse: "0", - maxMemorySize: "0", - integrationSelection: { - driveSize: { driveSize: "0", sizeUnit: "B" }, - CPU: 0, - typeSelection: "", - memory: 0, - drivesPerServer: 0, - storageClass: "", - }, - }, - affinity: { - nodeSelectorLabels: "", - podAffinity: "default", - withPodAntiAffinity: true, - }, - }; - state.certificates = { - minioCertificates: [ - { - id: Date.now().toString(), - key: "", - cert: "", - encoded_key: "", - encoded_cert: "", - }, - ], - caCertificates: [ - { - id: Date.now().toString(), - key: "", - cert: "", - encoded_key: "", - encoded_cert: "", - }, - ], - consoleCaCertificates: [ - { - id: Date.now().toString(), - key: "", - cert: "", - encoded_key: "", - encoded_cert: "", - }, - ], - consoleCertificate: { - id: "console_cert_pair", - key: "", - cert: "", - encoded_key: "", - encoded_cert: "", - }, - serverCertificate: { - id: "encryptionServerCertificate", - key: "", - cert: "", - encoded_key: "", - encoded_cert: "", - }, - clientCertificate: { - id: "encryptionClientCertificate", - key: "", - cert: "", - encoded_key: "", - encoded_cert: "", - }, - vaultCertificate: { - id: "encryptionVaultCertificate", - key: "", - cert: "", - encoded_key: "", - encoded_cert: "", - }, - vaultCA: { - id: "encryptionVaultCA", - key: "", - cert: "", - encoded_key: "", - encoded_cert: "", - }, - gemaltoCA: { - id: "encryptionGemaltoCA", - key: "", - cert: "", - encoded_key: "", - encoded_cert: "", - }, - }; - state.nodeSelectorPairs = [{ key: "", value: "" }]; - state.tolerations = [ - { - key: "", - tolerationSeconds: { seconds: 0 }, - value: "", - effect: ITolerationEffect.NoSchedule, - operator: ITolerationOperator.Equal, - }, - ]; - state.createdAccount = null; - state.showNewCredentials = false; - }, + resetAddTenantForm: () => initialState, setKeyValuePairs: (state, action: PayloadAction) => { state.nodeSelectorPairs = action.payload; }, @@ -1093,6 +815,49 @@ export const createTenantSlice = createSlice({ flipValidPageInState(state, "nameTenant", isValid); }, + setNamespace: (state, action: PayloadAction) => { + state.fields.nameTenant.namespace = action.payload; + delete state.validationErrors["namespace"]; + + let customNamespaceError = false; + let errorMessage = ""; + + if ( + state.storageClasses.length < 1 && + state.emptyNamespace && + !state.loadingNamespaceInfo + ) { + customNamespaceError = true; + errorMessage = "Please enter a valid namespace"; + } + + const commonValidation = commonFormValidation([ + { + fieldKey: "namespace", + required: true, + value: action.payload, + customValidation: customNamespaceError, + customValidationMessage: errorMessage, + }, + ]); + + let isValid = false; + if ("namespace" in commonValidation) { + isValid = true; + state.validationErrors["namespace"] = commonValidation["namespace"]; + } + + flipValidPageInState(state, "nameTenant", isValid); + }, + showNSCreate: (state, action: PayloadAction) => { + state.showNSCreateButton = action.payload; + }, + openAddNSModal: (state) => { + state.addNSOpen = true; + }, + closeAddNSModal: (state) => { + state.addNSOpen = false; + }, }, extraReducers: (builder) => { builder @@ -1108,6 +873,73 @@ export const createTenantSlice = createSlice({ state.addingTenant = false; state.createdAccount = action.payload; state.showNewCredentials = true; + }) + .addCase(validateNamespaceAsync.pending, (state, action) => { + state.loadingNamespaceInfo = true; + state.showNSCreateButton = false; + delete state.validationErrors["namespace"]; + }) + .addCase(validateNamespaceAsync.rejected, (state, action) => { + state.loadingNamespaceInfo = false; + state.showNSCreateButton = true; + }) + .addCase(validateNamespaceAsync.fulfilled, (state, action) => { + state.showNSCreateButton = false; + state.emptyNamespace = action.payload; + if (!state.emptyNamespace) { + state.validationErrors["namespace"] = + "You can only create one tenant per namespace"; + } + }) + .addCase(namespaceResourcesAsync.pending, (state, action) => { + state.loadingNamespaceInfo = true; + }) + .addCase(namespaceResourcesAsync.rejected, (state, action) => { + state.loadingNamespaceInfo = false; + state.showNSCreateButton = true; + state.fields.nameTenant.selectedStorageClass = ""; + state.storageClasses = []; + + state.validationErrors["namespace"] = "Please enter a valid namespace"; + }) + .addCase(namespaceResourcesAsync.fulfilled, (state, action) => { + state.loadingNamespaceInfo = false; + + const elements: IQuotaElement[] = get(action.payload, "elements", []); + state.limitSize = getLimitSizes(action.payload!); + + const newStorage = elements.map((storageClass: any) => { + const name = get(storageClass, "name", "").split( + ".storageclass.storage.k8s.io/requests.storage" + )[0]; + + return { label: name, value: name }; + }); + + state.storageClasses = newStorage; + + const stExists = newStorage.findIndex( + (storageClass) => + storageClass.value === state.fields.nameTenant.selectedStorageClass + ); + + if (newStorage.length > 0 && stExists === -1) { + state.fields.nameTenant.selectedStorageClass = newStorage[0].value; + } else if (newStorage.length === 0) { + state.fields.nameTenant.selectedStorageClass = ""; + state.storageClasses = []; + } + }) + .addCase(createNamespaceAsync.pending, (state, action) => { + state.addNSLoading = true; + }) + .addCase(createNamespaceAsync.rejected, (state, action) => { + state.addNSLoading = false; + }) + .addCase(createNamespaceAsync.fulfilled, (state, action) => { + state.addNSLoading = false; + state.addNSOpen = false; + delete state.validationErrors["namespace"]; }); }, }); @@ -1150,6 +982,10 @@ export const { removeIDPADUsrAtIndex, setIDP, setTenantName, + setNamespace, + showNSCreate, + openAddNSModal, + closeAddNSModal, } = createTenantSlice.actions; export default createTenantSlice.reducer; diff --git a/portal-ui/src/screens/Console/Tenants/AddTenant/thunks/namespaceThunks.ts b/portal-ui/src/screens/Console/Tenants/AddTenant/thunks/namespaceThunks.ts new file mode 100644 index 000000000..9e284978b --- /dev/null +++ b/portal-ui/src/screens/Console/Tenants/AddTenant/thunks/namespaceThunks.ts @@ -0,0 +1,102 @@ +// This file is part of MinIO Console Server +// Copyright (c) 2022 MinIO, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { createAsyncThunk } from "@reduxjs/toolkit"; +import { AppState } from "../../../../../store"; +import { + setErrorSnackMessage, + setModalErrorSnackMessage, +} from "../../../../../systemSlice"; +import api from "../../../../../common/api"; +import { ErrorResponseHandler } from "../../../../../common/types"; +import { IQuotas } from "../../ListTenants/utils"; +import get from "lodash/get"; + +export const validateNamespaceAsync = createAsyncThunk( + "createTenant/validateNamespaceAsync", + async (_, { getState, rejectWithValue, dispatch }) => { + const state = getState() as AppState; + const namespace = state.createTenant.fields.nameTenant.namespace; + + return api + .invoke("GET", `/api/v1/namespaces/${namespace}/tenants`) + .then((res: any[]): boolean => { + const tenantsList = get(res, "tenants", []); + let nsEmpty = true; + if (tenantsList && tenantsList.length > 0) { + return false; + } + if (nsEmpty) { + dispatch(namespaceResourcesAsync()); + } + // it's true it's empty + return nsEmpty; + }) + .catch((err) => { + dispatch( + setModalErrorSnackMessage({ + errorMessage: "Error validating if namespace already has tenants", + detailedError: err.detailedError, + }) + ); + return rejectWithValue(false); + }); + } +); + +export const namespaceResourcesAsync = createAsyncThunk( + "createTenant/namespaceResourcesAsync", + async (_, { getState, rejectWithValue }) => { + const state = getState() as AppState; + + const namespace = state.createTenant.fields.nameTenant.namespace; + + return api + .invoke( + "GET", + `/api/v1/namespaces/${namespace}/resourcequotas/${namespace}-storagequota` + ) + .then((res: IQuotas): IQuotas => { + return res; + }) + .catch((err) => { + console.error("Namespace quota error: ", err); + return rejectWithValue(null); + }); + } +); + +export const createNamespaceAsync = createAsyncThunk( + "createTenant/createNamespaceAsync", + async (_, { getState, rejectWithValue, dispatch }) => { + const state = getState() as AppState; + const namespace = state.createTenant.fields.nameTenant.namespace; + + return api + .invoke("POST", "/api/v1/namespace", { + name: namespace, + }) + .then((res) => { + // revalidate the name to have the storage classes populated + dispatch(validateNamespaceAsync()); + return true; + }) + .catch((err: ErrorResponseHandler) => { + dispatch(setErrorSnackMessage(err)); + rejectWithValue(false); + }); + } +);