This is an automated email from the ASF dual-hosted git repository. pingsutw pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/submarine.git
The following commit(s) were added to refs/heads/master by this push: new 5e8f0e9 SUBMARINE-566. [WEB] Create a new experiment through UI 5e8f0e9 is described below commit 5e8f0e90823b927ce0785e545bb83da0323d18ad Author: wang0630 <j2081...@gmail.com> AuthorDate: Thu Jul 23 15:59:40 2020 +0800 SUBMARINE-566. [WEB] Create a new experiment through UI ### What is this PR for? New experiment creation through UI. ### What type of PR is it [Feature] ### Todos * More user-feedback should be added later ### What is the Jira issue? [SUBMARINE-566](https://issues.apache.org/jira/browse/SUBMARINE-566?filter=-1) ### How should this be tested? https://travis-ci.com/github/wang0630/submarine/jobs/364539196 ### Screenshots (if appropriate) ![first](https://user-images.githubusercontent.com/26138982/88267743-d9268e00-cd03-11ea-95d1-a07856c5c0eb.gif) ### Questions: * Does the licenses files need update? No * Is there breaking changes for older versions? No * Does this needs documentation? No Author: wang0630 <j2081...@gmail.com> Closes #354 from wang0630/SUBMARINE-566 and squashes the following commits: f25a54b [wang0630] SUBMARINE-566. [WEB] Create a new experiment through UI f94fa5b [wang0630] Before refactoring the e2e testing dff392a [wang0630] Error handling finished c64448b [wang0630] Fire the request succeed --- .../server/submitter/k8s/K8sSubmitter.java | 2 +- .../apache/submarine/integration/experimentIT.java | 71 ++++----- .../integration/pages/ExperimentPage.java | 172 +++++++++++++++++++++ .../src/app/interfaces/experiment-spec.ts | 29 +++- .../workbench/experiment/experiment.component.html | 2 +- .../workbench/experiment/experiment.component.scss | 4 - .../workbench/experiment/experiment.component.ts | 91 +++++++++-- .../src/app/services/base-api.service.ts | 3 +- .../src/app/services/experiment.service.ts | 23 ++- .../app/services/experiment.validator.service.ts | 4 +- 10 files changed, 332 insertions(+), 69 deletions(-) diff --git a/submarine-server/server-submitter/submitter-k8s/src/main/java/org/apache/submarine/server/submitter/k8s/K8sSubmitter.java b/submarine-server/server-submitter/submitter-k8s/src/main/java/org/apache/submarine/server/submitter/k8s/K8sSubmitter.java index 91db0b9..9b495bf 100644 --- a/submarine-server/server-submitter/submitter-k8s/src/main/java/org/apache/submarine/server/submitter/k8s/K8sSubmitter.java +++ b/submarine-server/server-submitter/submitter-k8s/src/main/java/org/apache/submarine/server/submitter/k8s/K8sSubmitter.java @@ -104,7 +104,7 @@ public class K8sSubmitter implements Submitter { experiment = parseResponseObject(object, ParseOp.PARSE_OP_RESULT); } catch (InvalidSpecException e) { LOG.error("K8s submitter: parse Job object failed by " + e.getMessage(), e); - throw new SubmarineRuntimeException(200, e.getMessage()); + throw new SubmarineRuntimeException(400, e.getMessage()); } catch (ApiException e) { LOG.error("K8s submitter: parse Job object failed by " + e.getMessage(), e); throw new SubmarineRuntimeException(e.getCode(), e.getMessage()); diff --git a/submarine-test/test-e2e/src/test/java/org/apache/submarine/integration/experimentIT.java b/submarine-test/test-e2e/src/test/java/org/apache/submarine/integration/experimentIT.java index a525749..7dce204 100644 --- a/submarine-test/test-e2e/src/test/java/org/apache/submarine/integration/experimentIT.java +++ b/submarine-test/test-e2e/src/test/java/org/apache/submarine/integration/experimentIT.java @@ -17,25 +17,16 @@ package org.apache.submarine.integration; -import org.apache.commons.io.FileUtils; import org.apache.submarine.AbstractSubmarineIT; import org.apache.submarine.WebDriverManager; +import org.apache.submarine.integration.pages.ExperimentPage; import org.openqa.selenium.By; -import org.openqa.selenium.OutputType; -import org.openqa.selenium.TakesScreenshot; -import org.openqa.selenium.WebElement; -import org.openqa.selenium.interactions.Actions; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.testng.Assert; import org.testng.annotations.AfterClass; import org.testng.annotations.BeforeClass; import org.testng.annotations.Test; -import org.testng.Assert; -import org.openqa.selenium.support.ui.WebDriverWait; -import org.openqa.selenium.support.ui.ExpectedConditions; -import sun.rmi.runtime.Log; - -import java.io.File; public class experimentIT extends AbstractSubmarineIT { @@ -43,8 +34,8 @@ public class experimentIT extends AbstractSubmarineIT { @BeforeClass public static void startUp(){ - LOG.info("[Testcase]: experimentIT"); - driver = WebDriverManager.getWebDriver(); + LOG.info("[Testcase]: experimentNew"); + driver = WebDriverManager.getWebDriver(); } @AfterClass @@ -54,6 +45,8 @@ public class experimentIT extends AbstractSubmarineIT { @Test public void experimentNavigation() throws Exception { + // Init the page object + ExperimentPage experimentPage = new ExperimentPage(driver); // Login LOG.info("Login"); pollingWait(By.cssSelector("input[ng-reflect-name='userName']"), MAX_BROWSER_TIMEOUT_SEC).sendKeys("admin"); @@ -68,35 +61,29 @@ public class experimentIT extends AbstractSubmarineIT { // Test create new experiment LOG.info("new experiment"); - pollingWait(By.xpath("//button[@id='openExperiment']"), MAX_BROWSER_TIMEOUT_SEC).click(); - Assert.assertTrue(pollingWait(By.xpath("//form"), MAX_BROWSER_TIMEOUT_SEC).isDisplayed()); - WebDriverWait wait = new WebDriverWait( driver, 15); - // Basic information section - pollingWait(By.name("experimentName"), MAX_BROWSER_TIMEOUT_SEC).sendKeys("e2e test Experiment"); - pollingWait(By.name("description"), MAX_BROWSER_TIMEOUT_SEC).sendKeys("e2e test Project description"); - pollingWait(By.name("namespace"), MAX_BROWSER_TIMEOUT_SEC).sendKeys("e2e namespace"); - pollingWait(By.name("cmd"), MAX_BROWSER_TIMEOUT_SEC).sendKeys("python3 -m e2e cmd"); - pollingWait(By.name("image"), MAX_BROWSER_TIMEOUT_SEC).sendKeys("e2e custom image"); - pollingWait(By.xpath("//button[@id='go']"), MAX_BROWSER_TIMEOUT_SEC).click(); - // env variables section - LOG.info("in env"); - Assert.assertTrue(pollingWait(By.xpath("//button[@id='env-btn']"), MAX_BROWSER_TIMEOUT_SEC).isDisplayed()); - WebElement envBtn = buttonCheck(By.id("env-btn"), MAX_BROWSER_TIMEOUT_SEC); - envBtn.click(); - wait.until(ExpectedConditions.visibilityOfAllElementsLocatedBy(By.xpath("//input[@name='key0' or name='value0']"))); - pollingWait(By.name("key0"), MAX_BROWSER_TIMEOUT_SEC).sendKeys("e2e key"); - pollingWait(By.name("value0"), MAX_BROWSER_TIMEOUT_SEC).sendKeys("e2e value"); + experimentPage.newExperimentButtonClick(); + experimentPage.fillMeta("good-e2e-test", "e2e des", "default", "python /var/tf_mnist/mnist_with_summaries.py --log_dir=/train/log --learning_rate=0.01 --batch_size=150", "gcr.io/kubeflow-ci/tf-mnist-with-summaries:1.0"); + Assert.assertTrue(experimentPage.getGoButton().isEnabled()); + experimentPage.goButtonClick(); + + LOG.info("In env"); + experimentPage.envBtnClick(); + experimentPage.fillEnv("ENV_1", "ENV1"); + Assert.assertTrue(experimentPage.getGoButton().isEnabled()); + experimentPage.goButtonClick(); - pollingWait(By.xpath("//button[@id='go']"), MAX_BROWSER_TIMEOUT_SEC).click(); - // Spec section - LOG.info("in spec"); - WebElement specBtn = wait.until(ExpectedConditions.elementToBeClickable(By.id("spec-btn"))); - specBtn.click(); - pollingWait(By.name("spec0"), MAX_BROWSER_TIMEOUT_SEC).sendKeys("e2e spec"); - pollingWait(By.name("replica0"), MAX_BROWSER_TIMEOUT_SEC).sendKeys("1"); - pollingWait(By.name("cpu0"), MAX_BROWSER_TIMEOUT_SEC).sendKeys("1"); - pollingWait(By.name("memory0"), MAX_BROWSER_TIMEOUT_SEC).sendKeys("512M"); - Assert.assertTrue(pollingWait(By.xpath("//button[@id='go']"), MAX_BROWSER_TIMEOUT_SEC).isEnabled()); -// pollingWait(By.xpath("//button[@id='go']"), MAX_BROWSER_TIMEOUT_SEC).click(); + // Fail due to incorrect spec name + LOG.info("In spec fail"); + experimentPage.fillTfSpec(1, new String[]{"wrong name"}, new int[]{1}, new int[]{1}, new String[]{"512M"}); + Assert.assertTrue(experimentPage.getGoButton().isEnabled()); + experimentPage.goButtonClick(); + Assert.assertTrue(experimentPage.getErrorNotification().isDisplayed()); + // Successful request + LOG.info("In spec success"); + experimentPage.deleteSpec(); + Assert.assertEquals(experimentPage.getSpecs(), 0); + experimentPage.fillTfSpec(2, new String[]{"Ps", "Worker"}, new int[]{1, 1}, new int[]{1, 1}, new String[]{"1024M", "1024M"}); + Assert.assertTrue(experimentPage.getGoButton().isEnabled()); + experimentPage.goButtonClick(); } } diff --git a/submarine-test/test-e2e/src/test/java/org/apache/submarine/integration/pages/ExperimentPage.java b/submarine-test/test-e2e/src/test/java/org/apache/submarine/integration/pages/ExperimentPage.java new file mode 100644 index 0000000..b173ffb --- /dev/null +++ b/submarine-test/test-e2e/src/test/java/org/apache/submarine/integration/pages/ExperimentPage.java @@ -0,0 +1,172 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.submarine.integration.pages; + +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.support.FindBy; +import org.openqa.selenium.support.PageFactory; +import org.openqa.selenium.support.pagefactory.AjaxElementLocatorFactory; +import org.openqa.selenium.support.ui.ExpectedConditions; +import org.openqa.selenium.support.ui.WebDriverWait; + +import java.util.List; + +public class ExperimentPage { + + @FindBy(id = "go") + private WebElement goButton; + + @FindBy(id = "openExperiment") + private WebElement newExperimentButton; + + /* + * For svg/path/g element tag, we must use //*[name = 'svg'] to select + * //svg will fail + */ + @FindBy(xpath = "//*[name() = 'svg' and @data-icon = 'close-circle']") + private List<WebElement> deleteBtns; + + // Meta form + @FindBy(name = "experimentName") + private WebElement experimentName; + + @FindBy(name = "description") + private WebElement description; + + @FindBy(name = "namespace") + private WebElement namespace; + + @FindBy(name = "cmd") + private WebElement cmd; + + @FindBy(name = "image") + private WebElement image; + + // Env form + @FindBy(id = "env-btn") + private WebElement envBtn; + + @FindBy(xpath = "//input[contains(@name, 'key')]") + private WebElement envKey; + + @FindBy(xpath = "//input[contains(@name, 'value')]") + private WebElement envValue; + + // Spec + @FindBy(id = "spec-btn") + private WebElement specBtn; + + @FindBy(xpath = "//input[contains(@name, 'spec')]") + private List<WebElement> specNames; + + @FindBy(xpath = "//input[contains(@name, 'replica')]") + private List<WebElement> replicas; + + @FindBy(xpath = "//input[contains(@name, 'cpu')]") + private List<WebElement> cpus; + + @FindBy(xpath = "//input[contains(@name, 'memory')]") + private List<WebElement> memory; + + // Notification + @FindBy(xpath = "//div[contains(@class, 'ant-message-error')]//span") + private WebElement errorNotification; + + @FindBy(xpath = "//div[contains(@class, 'ant-message-success')]//span") + private WebElement successNotification; + + private WebDriverWait wait; + + public ExperimentPage(WebDriver driver) { + // NoSuchElementException will be thrown if the elements are not found + PageFactory.initElements(new AjaxElementLocatorFactory(driver, 5), this); + wait = new WebDriverWait(driver, 15); + } + + // Getter + public WebElement getGoButton() { + return goButton; + } + + public WebElement getErrorNotification() { + return errorNotification; + } + + + public int getSpecs() { + return specNames.size(); + } + + // button click actions + public void goButtonClick() { + wait.until(ExpectedConditions.elementToBeClickable(goButton)).click(); + } + + public void newExperimentButtonClick() { + wait.until(ExpectedConditions.elementToBeClickable(newExperimentButton)).click(); + } + + public void envBtnClick() { + wait.until(ExpectedConditions.elementToBeClickable(envBtn)).click(); + } + + public void specBtnClick() { + wait.until(ExpectedConditions.elementToBeClickable(specBtn)).click(); + } + + + // Real actions + public void fillMeta(String name, String des, String namespaceStr, String cmdStr, String imageStr) { + experimentName.clear(); + experimentName.sendKeys(name); + description.clear(); + description.sendKeys(des); + namespace.clear(); + namespace.sendKeys(namespaceStr); + cmd.clear(); + cmd.sendKeys(cmdStr); + image.clear(); + image.sendKeys(imageStr); + } + + public void fillEnv(String key, String value) { + envKey.sendKeys(key); + envValue.sendKeys(value); + } + + public void deleteSpec() { + for (WebElement d : deleteBtns) { + d.click(); + } + } + + public void fillTfSpec(int specCount, String[] inputNames, int[] replicaCount, int[] cpuCount, String[] inputMemory) { + for (int i = 0; i < specCount; i++) { + specBtnClick(); + } + + for (int i = 0; i < specCount; i++) { + specNames.get(i).sendKeys(inputNames[i]); + replicas.get(i).sendKeys(Integer.toString(replicaCount[i])); + cpus.get(i).sendKeys(Integer.toString(cpuCount[i])); + memory.get(i).sendKeys(inputMemory[i]); + } + + } +} diff --git a/submarine-workbench/workbench-web-ng/src/app/interfaces/experiment-spec.ts b/submarine-workbench/workbench-web-ng/src/app/interfaces/experiment-spec.ts index 98906bf..2ea061b 100644 --- a/submarine-workbench/workbench-web-ng/src/app/interfaces/experiment-spec.ts +++ b/submarine-workbench/workbench-web-ng/src/app/interfaces/experiment-spec.ts @@ -17,6 +17,31 @@ * under the License. */ -export class ExperimentSpec { - // TODO(pingsutw): After refactor submarine experiment spec, we could start implementing it. +export interface SpecMeta { + name: string; + namespace: string; + framework: string; + cmd: string; + envVars?: { + [key: string]: string; + }; +} + +export interface SpecEnviroment { + image: string; +} + +export interface Specs { + [name: string]: { + replicas: string; + resources: string; + }; +} + +export interface ExperimentSpec { + meta: SpecMeta; + environment: { + image: string; + }; + spec: Specs; } diff --git a/submarine-workbench/workbench-web-ng/src/app/pages/workbench/experiment/experiment.component.html b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/experiment/experiment.component.html index 39dacd9..480b32e 100644 --- a/submarine-workbench/workbench-web-ng/src/app/pages/workbench/experiment/experiment.component.html +++ b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/experiment/experiment.component.html @@ -155,7 +155,7 @@ </nz-steps> </div> <div> - <form [formGroup]="createExperiment"> + <form [formGroup]="experiment"> <div *nzModalFooter> <button nz-button nzType="default" (click)="isVisible = false">Cancel</button> <button id="go" nz-button nzType="primary" [disabled]="checkStatus()" (click)="handleOk()"> diff --git a/submarine-workbench/workbench-web-ng/src/app/pages/workbench/experiment/experiment.component.scss b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/experiment/experiment.component.scss index 1cc8199..57998b8 100644 --- a/submarine-workbench/workbench-web-ng/src/app/pages/workbench/experiment/experiment.component.scss +++ b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/experiment/experiment.component.scss @@ -113,7 +113,3 @@ flex-direction: column; align-items: center; } - -.pg3-form-label { - -} \ No newline at end of file diff --git a/submarine-workbench/workbench-web-ng/src/app/pages/workbench/experiment/experiment.component.ts b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/experiment/experiment.component.ts index 8b07030..37af51c 100644 --- a/submarine-workbench/workbench-web-ng/src/app/pages/workbench/experiment/experiment.component.ts +++ b/submarine-workbench/workbench-web-ng/src/app/pages/workbench/experiment/experiment.component.ts @@ -24,6 +24,7 @@ import { ExperimentInfo } from '@submarine/interfaces/experiment-info'; import { ExperimentService } from '@submarine/services/experiment.service'; import { ExperimentFormService } from '@submarine/services/experiment.validator.service'; import { NzMessageService } from 'ng-zorro-antd'; +import { SpecMeta, Specs, SpecEnviroment, ExperimentSpec } from '@submarine/interfaces/experiment-spec'; @Component({ selector: 'submarine-experiment', @@ -41,7 +42,7 @@ export class ExperimentComponent implements OnInit { searchText = ''; // About new experiment - createExperiment: FormGroup; + experiment: FormGroup; current = 0; okText = 'Next Step'; isVisible = false; @@ -65,7 +66,7 @@ export class ExperimentComponent implements OnInit { ) {} ngOnInit() { - this.createExperiment = new FormGroup({ + this.experiment = new FormGroup({ experimentName: new FormControl(null, Validators.required), description: new FormControl(null, [Validators.required]), // experimentSpec: new FormControl('Adhoc'), @@ -97,28 +98,28 @@ export class ExperimentComponent implements OnInit { // Getters of experiment request form get experimentName() { - return this.createExperiment.get('experimentName'); + return this.experiment.get('experimentName'); } get description() { - return this.createExperiment.get('description'); + return this.experiment.get('description'); } get frameworks() { - return this.createExperiment.get('frameworks'); + return this.experiment.get('frameworks'); } get namespace() { - return this.createExperiment.get('namespace'); + return this.experiment.get('namespace'); } get cmd() { - return this.createExperiment.get('cmd'); + return this.experiment.get('cmd'); } get envs() { - return this.createExperiment.get('envs') as FormArray; + return this.experiment.get('envs') as FormArray; } get image() { - return this.createExperiment.get('image'); + return this.experiment.get('image'); } get specs() { - return this.createExperiment.get('specs') as FormArray; + return this.experiment.get('specs') as FormArray; } /** * Check the validity of the experiment page @@ -141,9 +142,38 @@ export class ExperimentComponent implements OnInit { } } + /** + * Init a new experiment form, clear all status + */ + initExperimentStatus() { + this.isVisible = false; + this.current = 0; + this.okText = 'Next step'; + } + + /** + * Event handler for Next step/Submit button + */ handleOk() { if (this.current === 1) { this.okText = 'Submit'; + } else if (this.current === 2) { + const newSpec = this.constructSpec(); + this.experimentService.createExperiment(newSpec).subscribe({ + next: (result) => { + // Must reconstruct a new array for re-rendering + this.experimentList = [...this.experimentList, result]; + }, + error: (msg) => { + this.nzMessageService.error(`${msg}, please try again`, { + nzPauseOnHover: true + }); + }, + complete: () => { + this.nzMessageService.success('Experiment creation succeeds'); + this.initExperimentStatus(); + } + }); } if (this.current < 2) { @@ -191,6 +221,47 @@ export class ExperimentComponent implements OnInit { } /** + * Construct spec for new experiment creation + */ + constructSpec(): ExperimentSpec { + // Construct the spec + const meta: SpecMeta = { + name: this.experimentName.value, + namespace: this.namespace.value, + framework: this.frameworks.value, + cmd: this.cmd.value, + envVars: {} + }; + for (const env of this.envs.controls) { + if (env.get('key').value) { + meta.envVars[env.get('key').value] = env.get('value').value; + } + } + + const specs: Specs = {}; + for (const spec of this.specs.controls) { + if (spec.get('name').value) { + specs[spec.get('name').value] = { + replicas: spec.get('replicas').value, + resources: `cpu=${spec.get('cpus').value},memory=${spec.get('memory').value}` + }; + } + } + + const enviroment: SpecEnviroment = { + image: this.image.value + }; + + const newExperimentSpec: ExperimentSpec = { + meta: meta, + environment: enviroment, + spec: specs + }; + + return newExperimentSpec; + } + + /** * Delete list items(envs or specs) * * @param arr - The FormArray containing the item diff --git a/submarine-workbench/workbench-web-ng/src/app/services/base-api.service.ts b/submarine-workbench/workbench-web-ng/src/app/services/base-api.service.ts index 86b2710..b9506d8 100644 --- a/submarine-workbench/workbench-web-ng/src/app/services/base-api.service.ts +++ b/submarine-workbench/workbench-web-ng/src/app/services/base-api.service.ts @@ -35,7 +35,8 @@ class HttpError extends Error { this.params = params; if (!environment.production) { - this.logError(); + // comment out because weird this behavior + // this.logError(); } } diff --git a/submarine-workbench/workbench-web-ng/src/app/services/experiment.service.ts b/submarine-workbench/workbench-web-ng/src/app/services/experiment.service.ts index 41876ae..18ab660 100644 --- a/submarine-workbench/workbench-web-ng/src/app/services/experiment.service.ts +++ b/submarine-workbench/workbench-web-ng/src/app/services/experiment.service.ts @@ -22,8 +22,8 @@ import { Injectable } from '@angular/core'; import { Rest } from '@submarine/interfaces'; import { ExperimentInfo } from '@submarine/interfaces/experiment-info'; import { BaseApiService } from '@submarine/services/base-api.service'; -import { of, Observable } from 'rxjs'; -import { switchMap } from 'rxjs/operators'; +import { of, Observable, throwError } from 'rxjs'; +import { switchMap, catchError, map } from 'rxjs/operators'; @Injectable({ providedIn: 'root' @@ -61,12 +61,23 @@ export class ExperimentService { createExperiment(experimentSpec): Observable<ExperimentInfo> { const apiUrl = this.baseApi.getRestApi('/v1/experiment'); return this.httpClient.post<Rest<ExperimentInfo>>(apiUrl, experimentSpec).pipe( - switchMap((res) => { - if (res.success) { - return of(res.result); + map((res) => res.result), // return result directly if succeeding + catchError((e) => { + let message: string; + if (e.error instanceof ErrorEvent) { + // client side error + message = 'Something went wrong with network or workbench'; } else { - throw this.baseApi.createRequestError(res.message, res.code, apiUrl, 'post', experimentSpec); + console.log(e); + if (e.status === 409) { + message = 'You might have a duplicate experiment name'; + } else if (e.status >= 500) { + message = `${e.message}`; + } else { + message = e.error.message; + } } + return throwError(message); }) ); } diff --git a/submarine-workbench/workbench-web-ng/src/app/services/experiment.validator.service.ts b/submarine-workbench/workbench-web-ng/src/app/services/experiment.validator.service.ts index b9b2461..923e98b 100644 --- a/submarine-workbench/workbench-web-ng/src/app/services/experiment.validator.service.ts +++ b/submarine-workbench/workbench-web-ng/src/app/services/experiment.validator.service.ts @@ -56,8 +56,8 @@ export class ExperimentFormService { * @param memory - The memory field in Spec */ memoryValidator: ValidatorFn = (memory: FormControl): ValidationErrors | null => { - // Must match number + digit ex. 512M - return memory.value && /^\d+M$/.test(memory.value) + // Must match number + digit ex. 512M or empty + return !memory.value || /^\d+M$/.test(memory.value) ? null : { memoryPatternError: 'Memory pattern must match number + M ex. 512M' }; }; --------------------------------------------------------------------- To unsubscribe, e-mail: dev-unsubscr...@submarine.apache.org For additional commands, e-mail: dev-h...@submarine.apache.org