diff --git a/electron/app/js/wdtPrepareModel.js b/electron/app/js/wdtPrepareModel.js index dedc7610b..02d9c27f7 100644 --- a/electron/app/js/wdtPrepareModel.js +++ b/electron/app/js/wdtPrepareModel.js @@ -30,7 +30,8 @@ const _vzTargetTypeName = i18n.t('prepare-model-wko-target-type-name'); async function prepareModel(currentWindow, stdoutChannel, stderrChannel, prepareConfig) { const logger = getLogger(); - const { javaHome, oracleHome, projectDirectory, modelsSubdirectory, modelFiles, variableFiles, wdtTargetType } = prepareConfig; + const { javaHome, oracleHome, projectDirectory, modelsSubdirectory, modelFiles, + variableFiles, wdtTargetType, targetDomainLocation } = prepareConfig; const outputDirectory = await fsUtils.createTemporaryDirectory(projectDirectory, 'prepareModel'); const absoluteModelFiles = fsUtils.getAbsolutePathsList(modelFiles, projectDirectory); @@ -38,7 +39,7 @@ async function prepareModel(currentWindow, stdoutChannel, stderrChannel, prepare '-oracle_home', oracleHome, '-model_file', absoluteModelFiles.join(','), '-output_dir', outputDirectory, - '-target', wdtTargetType + '-target', getToolTargetType(wdtTargetType, targetDomainLocation) ]; const absoluteVariableFiles = fsUtils.getAbsolutePathsList(variableFiles, projectDirectory); @@ -50,7 +51,7 @@ async function prepareModel(currentWindow, stdoutChannel, stderrChannel, prepare // const env = { JAVA_HOME: javaHome, - WDT_CUSTOM_CONFIG: getWdtCustomConfigDirectory(prepareConfig) + WDT_CUSTOM_CONFIG: getWdtCustomConfigDirectory() }; getLogger().debug(`Invoking ${getPrepareModelShellScript()} with args ${JSON.stringify(argList)} and environment ${JSON.stringify(env)}`); @@ -75,7 +76,7 @@ async function prepareModel(currentWindow, stdoutChannel, stderrChannel, prepare } } catch (err) { results.isSuccess = false; - results.reason = i18n.t('prepare-model-execution-failed-error-message', { error: getErrorMessage(err) }); + results.reason = i18n.t('prepare-model-execution-failed-error-message', { error: errorUtils.getErrorMessage(err) }); results.error = err; logger.error(results.reason); removeTempDirectory(outputDirectory).then().catch(); @@ -114,7 +115,7 @@ async function prepareModel(currentWindow, stdoutChannel, stderrChannel, prepare await getModelFileContent(currentWindow, updatedModelFiles, updatedVariableFiles, []); } catch (err) { results.isSuccess = false; - results.reason = i18n.t('prepare-model-move-files-failed-error-message', { error: getErrorMessage(err) }); + results.reason = i18n.t('prepare-model-move-files-failed-error-message', { error: errorUtils.getErrorMessage(err) }); results.error = err; logger.error(results.reason); removeTempDirectory(outputDirectory).then().catch(); @@ -128,7 +129,7 @@ async function prepareModel(currentWindow, stdoutChannel, stderrChannel, prepare results['secrets'] = await getJsonSecretsContent(outputDirectory); } catch (err) { results.isSuccess = false; - results.reason = getErrorMessage(err); + results.reason = errorUtils.getErrorMessage(err); results.error = err; logger.error(results.reason); removeTempDirectory(outputDirectory).then().catch(); @@ -139,7 +140,7 @@ async function prepareModel(currentWindow, stdoutChannel, stderrChannel, prepare results['domain'] = await getTargetSpecContent(wdtTargetType, outputDirectory); } catch (err) { results.isSuccess = false; - results.reason = getErrorMessage(err); + results.reason = errorUtils.getErrorMessage(err); results.error = err; logger.error(results.reason); removeTempDirectory(outputDirectory).then().catch(); @@ -167,18 +168,6 @@ async function prepareModel(currentWindow, stdoutChannel, stderrChannel, prepare return Promise.resolve(results); } -function getErrorMessage(err) { - let errorMessage; - if (!err) { - errorMessage = 'Unknown Error'; - } else if (!err.message) { - errorMessage = err.toString(); - } else { - errorMessage = err.message; - } - return errorMessage; -} - async function removeTempDirectory(outputDirectory) { return new Promise(resolve => { if (_deleteTempDirectory) { @@ -438,6 +427,11 @@ async function getVzSpecContent(outputDirectory) { }); } +function getToolTargetType(wdtTargetType, targetDomainLocation) { + const suffix = targetDomainLocation === 'mii' ? '' : `-${targetDomainLocation}`; + return `${wdtTargetType}${suffix}`; +} + function getFileExistsErrorMessage(targetType, fileName, err) { const error = new Error(i18n.t('prepare-model-spec-file-exists-error-message', { targetType: targetType, fileName: fileName, error: errorUtils.getErrorMessage(err) })); diff --git a/electron/app/js/wdtValidateModel.js b/electron/app/js/wdtValidateModel.js index be0e48bfa..761232b7e 100644 --- a/electron/app/js/wdtValidateModel.js +++ b/electron/app/js/wdtValidateModel.js @@ -42,7 +42,7 @@ async function validateModel(currentWindow, stdoutChannel, stderrChannel, valida const env = { JAVA_HOME: javaHome, - WDT_CUSTOM_CONFIG: getWdtCustomConfigDirectory(validateConfig) + WDT_CUSTOM_CONFIG: getWdtCustomConfigDirectory() }; getLogger().debug(`Invoking ${getValidateModelShellScript()} with args ${JSON.stringify(argList)} and environment ${JSON.stringify(env)}`); diff --git a/electron/app/js/wktTools.js b/electron/app/js/wktTools.js index d950dc7e8..17b8cf54b 100644 --- a/electron/app/js/wktTools.js +++ b/electron/app/js/wktTools.js @@ -45,9 +45,8 @@ function getValidateModelShellScript() { return path.join(getWdtDirectory(), 'bin', 'validateModel' + scriptExtension); } -function getWdtCustomConfigDirectory(config) { - const targetDomainLocation = config.targetDomainLocation || 'mii'; - return path.join(getToolsDirectory(), 'wdt-config', targetDomainLocation); +function getWdtCustomConfigDirectory() { + return path.join(getToolsDirectory(), 'wdt-config'); } function isWdtErrorExitCode(exitCode) { diff --git a/electron/app/locales/en/webui.json b/electron/app/locales/en/webui.json index 53a9c1f82..6e055a46d 100644 --- a/electron/app/locales/en/webui.json +++ b/electron/app/locales/en/webui.json @@ -545,6 +545,7 @@ "domain-design-aux-image-registry-pull-email-help": "The image registry user's email address used to create the Kubernetes image pull secret for the auxiliary image.", "domain-design-clusters-title": "Clusters", + "domain-design-clusters-table-aria-label": "Clusters configuration table", "domain-design-no-clusters-message": "No clusters found for this domain. If your project has a model that defines clusters, please run Prepare Model to populate the clusters tables.", "domain-design-clusters-name-heading": "Cluster Name", "domain-design-clusters-replicas-heading": "Replicas", @@ -554,6 +555,8 @@ "domain-design-clusters-memory-request-heading": "Kubernetes Memory Request", "domain-design-edit-cluster-label": "Edit Cluster Settings", "domain-design-cluster-dialog-title": "Edit Cluster Settings", + "domain-design-add-cluster-label": "Add Cluster", + "domain-design-delete-cluster-label": "Delete Cluster", "domain-design-get-domain-status": "Get Domain Status", "domain-design-domain-status-label": "Domain Status: ", @@ -574,6 +577,8 @@ "domain-design-cluster-name-label": "Cluster Name", "domain-design-cluster-name-help": "The cluster name in the domain for which to enter settings.", + "domain-design-cluster-name-is-empty-error": "Cluster Name cannot be empty", + "domain-design-cluster-name-not-unique-error": "{{name}} is already present in cluster list [{{existingNames}}]", "domain-design-cluster-replicas-label": "Replicas", "domain-design-cluster-replicas-help": "The number of managed servers to start for the cluster.", "domain-design-cluster-min-heap-label": "Minimum Heap Size", @@ -867,7 +872,6 @@ "wdt-discoverer-online-discovery-failed-error-prefix": "Unable to discover domain {{adminUrl}} in online mode", "wdt-validator-aborted-error-title": "Validate Model Aborted", - "wdt-validator-domain-in-pv-message": "Validate Model has no meaning when the target domain location is an externally created Kubernetes persistent volume.", "wdt-validator-invalid-java-home-error-prefix": "Unable to validate model due to the Java Home being invalid", "wdt-validator-invalid-oracle-home-error-prefix": "Unable to validate model due to the Oracle Home being invalid", "wdt-validator-project-not-saved-error-prefix": "Unable to validate model because project save failed", @@ -883,7 +887,6 @@ "wdt-validator-validate-complete-message": "Validate Model successfully validated the model.", "wdt-preparer-aborted-error-title": "Prepare Model Aborted", - "wdt-preparer-domain-in-pv-message": "Prepare Model has no meaning when the target domain location is an externally created Kubernetes persistent volume.", "wdt-preparer-invalid-java-home-error-prefix": "Unable to prepare model due to the Java Home being invalid", "wdt-preparer-invalid-oracle-home-error-prefix": "Unable to prepare model due to the Oracle Home being invalid", "wdt-preparer-project-not-saved-error-prefix": "Unable to prepare model because project save failed", diff --git a/tools/wdt-config/dii/targets/vz/target.json b/tools/wdt-config/targets/vz-dii/target.json similarity index 73% rename from tools/wdt-config/dii/targets/vz/target.json rename to tools/wdt-config/targets/vz-dii/target.json index e69df952b..0d3ee40b7 100644 --- a/tools/wdt-config/dii/targets/vz/target.json +++ b/tools/wdt-config/targets/vz-dii/target.json @@ -1,7 +1,6 @@ { "model_filters" : { "discover": [ - { "name": "vz_prep", "path": "@@TARGET_CONFIG_DIR@@/vz_filter.py" }, { "id": "wko_filter" } ] }, @@ -10,6 +9,5 @@ "credentials_output_method" : "script", "exclude_domain_bin_contents": true, "wls_credentials_name" : "__weblogic-credentials__", - "additional_secrets": "runtime-encryption-secret", "additional_output" : "vz-application.yaml" } diff --git a/tools/wdt-config/targets/vz-pv/target.json b/tools/wdt-config/targets/vz-pv/target.json new file mode 100644 index 000000000..fca8c9648 --- /dev/null +++ b/tools/wdt-config/targets/vz-pv/target.json @@ -0,0 +1,14 @@ +{ + "model_filters" : { + "discover": [ + { "id": "wko_filter" } + ] + }, + "variable_injectors" : {"PORT": {},"HOST": {},"URL": {}}, + "validation_method" : "lax", + "credentials_output_method" : "script", + "exclude_domain_bin_contents": true, + "wls_credentials_name" : "__weblogic-credentials__", + "additional_output" : "vz-application.yaml", + "use_persistent_volume" : true +} diff --git a/tools/wdt-config/mii/targets/vz/target.json b/tools/wdt-config/targets/vz/target.json similarity index 85% rename from tools/wdt-config/mii/targets/vz/target.json rename to tools/wdt-config/targets/vz/target.json index 5fe7d02f1..d178160e6 100644 --- a/tools/wdt-config/mii/targets/vz/target.json +++ b/tools/wdt-config/targets/vz/target.json @@ -1,7 +1,6 @@ { "model_filters" : { "discover": [ - { "name": "vz_prep", "path": "@@TARGET_CONFIG_DIR@@/vz_filter.py" }, { "id": "wko_filter" } ] }, diff --git a/tools/wdt-config/dii/targets/wko/target.json b/tools/wdt-config/targets/wko-dii/target.json similarity index 71% rename from tools/wdt-config/dii/targets/wko/target.json rename to tools/wdt-config/targets/wko-dii/target.json index 586042bcc..2f7f8252f 100644 --- a/tools/wdt-config/dii/targets/wko/target.json +++ b/tools/wdt-config/targets/wko-dii/target.json @@ -1,7 +1,6 @@ { "model_filters" : { "discover": [ - { "name": "wko_prep", "path": "@@TARGET_CONFIG_DIR@@/wko_operator_filter.py" }, { "id": "wko_filter" } ] }, @@ -10,6 +9,5 @@ "credentials_output_method" : "json", "exclude_domain_bin_contents": true, "wls_credentials_name" : "__weblogic-credentials__", - "additional_secrets": "runtime-encryption-secret", "additional_output" : "wko-domain.yaml" } diff --git a/tools/wdt-config/targets/wko-pv/target.json b/tools/wdt-config/targets/wko-pv/target.json new file mode 100644 index 000000000..6931524db --- /dev/null +++ b/tools/wdt-config/targets/wko-pv/target.json @@ -0,0 +1,14 @@ +{ + "model_filters" : { + "discover": [ + { "id": "wko_filter" } + ] + }, + "variable_injectors" : {"PORT": {},"HOST": {},"URL": {}}, + "validation_method" : "wktui", + "credentials_output_method" : "json", + "exclude_domain_bin_contents": true, + "wls_credentials_name" : "__weblogic-credentials__", + "additional_output" : "wko-domain.yaml", + "use_persistent_volume" : true +} diff --git a/tools/wdt-config/mii/targets/wko/target.json b/tools/wdt-config/targets/wko/target.json similarity index 83% rename from tools/wdt-config/mii/targets/wko/target.json rename to tools/wdt-config/targets/wko/target.json index e6ffd61ca..173c394eb 100644 --- a/tools/wdt-config/mii/targets/wko/target.json +++ b/tools/wdt-config/targets/wko/target.json @@ -1,7 +1,6 @@ { "model_filters" : { "discover": [ - { "name": "wko_prep", "path": "@@TARGET_CONFIG_DIR@@/wko_operator_filter.py" }, { "id": "wko_filter" } ] }, diff --git a/webui/src/js/models/k8s-domain-definition.js b/webui/src/js/models/k8s-domain-definition.js index e2ee11dad..a5493025e 100644 --- a/webui/src/js/models/k8s-domain-definition.js +++ b/webui/src/js/models/k8s-domain-definition.js @@ -58,10 +58,10 @@ define(['knockout', 'utils/observable-properties', 'utils/common-utilities', 'ut this.auxImagePullPolicy = props.createProperty('IfNotPresent'); this.clusterKeys = [ - 'name', 'maxServers', 'replicas', 'minHeap', 'maxHeap', 'cpuRequest', 'cpuLimit', 'memoryRequest', + 'uid', 'name', 'maxServers', 'replicas', 'minHeap', 'maxHeap', 'cpuRequest', 'cpuLimit', 'memoryRequest', 'memoryLimit', 'disableDebugStdout', 'disableFan', 'useUrandom', 'additionalArguments' ]; - this.clusters = props.createListProperty(this.clusterKeys).persistByKey('name'); + this.clusters = props.createListProperty(this.clusterKeys).persistByKey('uid'); this.modelConfigMapName = props.createProperty('${1}-config-map', this.uid.observable); this.modelConfigMapName.addValidator(...validationHelper.getK8sNameValidators()); @@ -204,19 +204,22 @@ define(['knockout', 'utils/observable-properties', 'utils/common-utilities', 'ut }; this.setClusterRow = (prepareModelCluster) => { - let found; + let cluster; for (const row of this.clusters.observable()) { if (row.name === prepareModelCluster.clusterName) { row.maxServers = prepareModelCluster.replicas; if (row.replicas === undefined || row.replicas > row.maxServers) { row.replicas = row.maxServers; } - found = true; + cluster = row; break; } } - if (!found) { + if (cluster) { + this.clusters.observable.replace(cluster, cluster); + } else { this.clusters.addNewItem({ + uid: utils.getShortUuid(), name: prepareModelCluster.clusterName, maxServers: prepareModelCluster.replicas, replicas: prepareModelCluster.replicas diff --git a/webui/src/js/models/wkt-project.js b/webui/src/js/models/wkt-project.js index c1c6950c7..32f22657a 100644 --- a/webui/src/js/models/wkt-project.js +++ b/webui/src/js/models/wkt-project.js @@ -111,6 +111,30 @@ function (ko, wdtConstructor, imageConstructor, kubectlConstructor, domainConstr } delete wktProjectJson.kubectl.extraPathDirectories; } + + // Version 1.1.1 changes domain clusters to be persisted by UID instead of name + // to allow us to support adding new clusters on the domain page for the + // Domain in PV use case... + // + if ('k8sDomain' in wktProjectJson && 'clusters' in wktProjectJson.k8sDomain) { + const currentClusters = wktProjectJson.k8sDomain.clusters; + const newClusters = {}; + for (const clusterName in currentClusters) { + const cluster = currentClusters[clusterName]; + // This is tricky because the only way to tell if the cluster is in + // the old format is if there is no name field... + // + if (cluster.name) { + break; + } + cluster.name = clusterName; + const uid = utils.getShortUuid(); + newClusters[uid] = cluster; + } + if (Object.keys(newClusters).length > 0) { + wktProjectJson.k8sDomain.clusters = newClusters; + } + } }; this.setFromJson = (wktProjectJson, modelContentsJson) => { diff --git a/webui/src/js/utils/wdt-preparer.js b/webui/src/js/utils/wdt-preparer.js index 156d5bdb1..b8eefff2b 100644 --- a/webui/src/js/utils/wdt-preparer.js +++ b/webui/src/js/utils/wdt-preparer.js @@ -26,12 +26,6 @@ function(WdtActionsBase, project, wktConsole, i18n, projectIo, dialogHelper, val const errPrefix = 'wdt-preparer'; const shouldCloseBusyDialog = !options.skipBusyDialog; - if (this.project.settings.targetDomainLocation.value === 'pv') { - const errMessage = i18n.t('wdt-preparer-domain-in-pv-message'); - await window.api.ipc.invoke('show-info-message', errTitle, errMessage); - return Promise.resolve(false); - } - const validationObject = this.getValidationObject('flow-prepare-model-name'); if (validationObject.hasValidationErrors()) { const validationErrorDialogConfig = validationObject.getValidationErrorDialogConfig(errTitle); diff --git a/webui/src/js/utils/wdt-validator.js b/webui/src/js/utils/wdt-validator.js index c0ec3ab72..c0ff9fafe 100644 --- a/webui/src/js/utils/wdt-validator.js +++ b/webui/src/js/utils/wdt-validator.js @@ -26,12 +26,6 @@ function(WdtActionsBase, project, wktConsole, i18n, projectIo, dialogHelper, val const errPrefix = 'wdt-validator'; const shouldCloseBusyDialog = !options.skipBusyDialog; - if (this.project.settings.targetDomainLocation.value === 'pv') { - const errMessage = i18n.t('wdt-validator-domain-in-pv-message'); - await window.api.ipc.invoke('show-info-message', errTitle, errMessage); - return Promise.resolve(false); - } - const validationObject = this.getValidationObject('flow-validate-model-name'); if (validationObject.hasValidationErrors()) { const validationErrorDialogConfig = validationObject.getValidationErrorDialogConfig(errTitle); diff --git a/webui/src/js/viewModels/cluster-edit-dialog.js b/webui/src/js/viewModels/cluster-edit-dialog.js index d03eafd3f..f90384fd7 100644 --- a/webui/src/js/viewModels/cluster-edit-dialog.js +++ b/webui/src/js/viewModels/cluster-edit-dialog.js @@ -1,6 +1,6 @@ /** * @license - * Copyright (c) 2021, Oracle and/or its affiliates. + * Copyright (c) 2021, 2022, Oracle and/or its affiliates. * Licensed under The Universal Permissive License (UPL), Version 1.0 as shown at https://oss.oracle.com/licenses/upl/ */ 'use strict'; @@ -36,8 +36,21 @@ function(accUtils, ko, i18n, project, ArrayDataProvider, this.project = project; this.cluster = args.cluster; + this.existingClusterNames = args.existingNames; + this.isDomainInPV = args.isDomainInPV; this.i18n = i18n; + this.nameIsUnique = { + validate: (value) => { + const existingNames = this.existingClusterNames; + if (!value) { + throw new Error(this.labelMapper('name-is-empty-error')); + } else if (Array.isArray(existingNames) && existingNames.length > 0 && existingNames.includes(value)) { + throw new Error(this.labelMapper('name-not-unique-error', + { name: value, existingNames: existingNames.join(',')})); + } + }, + }; this.integerConverter = new ojConverterNumber.IntlNumberConverter({ style: 'decimal', roundingMode: 'HALF_DOWN', @@ -45,6 +58,7 @@ function(accUtils, ko, i18n, project, ArrayDataProvider, useGrouping: false }); + // create an observable property for each simple field this['maxServers'] = this.cluster.maxServers; SIMPLE_PROPERTIES.forEach(propertyName => { diff --git a/webui/src/js/viewModels/domain-design-view.js b/webui/src/js/viewModels/domain-design-view.js index c457f3491..e0122a7b4 100644 --- a/webui/src/js/viewModels/domain-design-view.js +++ b/webui/src/js/viewModels/domain-design-view.js @@ -21,6 +21,10 @@ function (project, accUtils, utils, ko, i18n, screenUtils, BufferingDataProvider this.applyAuxImageConfig(newValue); })); + // subscriptions.push(this.project.k8sDomain.clusters.observable.subscribe(() => { + // document.getElementById('clusters-table').refresh(); + // })); + subscriptions.push(project.image.createPrimaryImage.observable.subscribe(() => { document.getElementById('create-image-switch').refresh(); const primaryImageTag = document.getElementById('primary-image-tag'); @@ -267,6 +271,14 @@ function (project, accUtils, utils, ko, i18n, screenUtils, BufferingDataProvider headerText: this.labelMapper('clusters-memory-request-heading'), sortProperty: 'memoryRequest' }, + { + 'className': 'wkt-table-delete-cell', + 'headerClassName': 'wkt-table-add-header', + 'headerTemplate': 'chooseHeaderTemplate', + 'template': 'actionTemplate', + 'sortable': 'disable', + width: viewHelper.BUTTON_COLUMN_WIDTH + }, { 'className': 'wkt-table-delete-cell', 'headerClassName': 'wkt-table-add-header', @@ -287,7 +299,11 @@ function (project, accUtils, utils, ko, i18n, screenUtils, BufferingDataProvider this.handleEditCluster = (event, context) => { const index = context.item.index; const cluster = this.project.k8sDomain.clusters.observable()[index]; - const options = { cluster: cluster }; + const existingClusterNames = this.project.k8sDomain.clusters.observable() + .filter(item => item.name !== cluster.name).map(item => { return item.name; }); + + console.log(`existingClusterNames = ${existingClusterNames}`); + const options = { cluster: cluster, existingNames: existingClusterNames, isDomainInPV: this.isDomainInPV() }; dialogHelper.promptDialog('cluster-edit-dialog', options).then(result => { if (result) { @@ -299,6 +315,7 @@ function (project, accUtils, utils, ko, i18n, screenUtils, BufferingDataProvider } }); if (changed) { + // FIXME - deal with cluster name changes that conflict with existing names... this.project.k8sDomain.clusters.observable.replace(cluster, cluster); } } @@ -310,6 +327,40 @@ function (project, accUtils, utils, ko, i18n, screenUtils, BufferingDataProvider this.clustersEditRow({ rowKey: null }); }; + const generatedClusterNameRegex = /^new-cluster-(\d+)$/; + + this.generateNewClusterName = () => { + let index = 1; + this.project.k8sDomain.clusters.observable().forEach(cluster => { + const match = cluster.name.match(generatedClusterNameRegex); + if (match) { + const indexFound = Number(match[1]); + if (indexFound >= index) { + index = indexFound + 1; + } + } + }); + return `new-cluster-${index}`; + }; + + this.handleAddCluster = () => { + const clusterToAdd = { + uid: utils.getShortUuid(), + name: this.generateNewClusterName(), + // In the case of Domain in PV where the user is adding a cluster definition + // without running PrepareModel, we have no information on the cluster size + // so just set replicas to zero and maxServers to the max value possible. + // + replicas: 0, + maxServers: Number.MAX_SAFE_INTEGER + }; + this.project.k8sDomain.clusters.addNewItem(clusterToAdd); + }; + + this.handleDeleteCluster = (event, context) => { + const index = context.item.index; + this.project.k8sDomain.clusters.observable.splice(index, 1); + }; this.modelHasNoProperties = () => { return this.project.wdtModel.getMergedPropertiesContent().value.length === 0; diff --git a/webui/src/js/views/cluster-edit-dialog.html b/webui/src/js/views/cluster-edit-dialog.html index fe21aa474..4464aaf31 100644 --- a/webui/src/js/views/cluster-edit-dialog.html +++ b/webui/src/js/views/cluster-edit-dialog.html @@ -1,5 +1,5 @@ @@ -13,7 +13,8 @@ diff --git a/webui/src/js/views/domain-design-view.html b/webui/src/js/views/domain-design-view.html index c177f0fb0..c058cff7b 100644 --- a/webui/src/js/views/domain-design-view.html +++ b/webui/src/js/views/domain-design-view.html @@ -277,6 +277,7 @@
+ + + + + + -