]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Cluster Creation Add multiple hosts at once 43584/head
authorAashish Sharma <aashishsharma@localhost.localdomain>
Thu, 23 Sep 2021 10:52:56 +0000 (16:22 +0530)
committerAashish Sharma <aashishsharma@localhost.localdomain>
Mon, 25 Oct 2021 14:23:43 +0000 (19:53 +0530)
Add multiple hosts at once in cluster creation wizard

Fixes: https://tracker.ceph.com/issues/52759
Signed-off-by: Aashish Sharma <aasharma@redhat.com>
src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/02-create-cluster-add-host.e2e-spec.ts
src/pybind/mgr/dashboard/frontend/package-lock.json
src/pybind/mgr/dashboard/frontend/package.json
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.ts

index a35bd6c10748ee3e6dbd9ce5eda9fc8c5afa979f..8ea195f89e7aa1f31b1e40b526387e2529ad8c90 100644 (file)
@@ -9,12 +9,15 @@ describe('Create cluster add host page', () => {
   const hostnames = [
     'ceph-node-00.cephlab.com',
     'ceph-node-01.cephlab.com',
-    'ceph-node-02.cephlab.com'
+    'ceph-node-02.cephlab.com',
+    'ceph-node-[01-02].cephlab.com'
   ];
-  const addHost = (hostname: string, exist?: boolean) => {
+  const addHost = (hostname: string, exist?: boolean, pattern?: boolean) => {
     cy.get('.btn.btn-accent').first().click({ force: true });
     createClusterHostPage.add(hostname, exist, false);
-    createClusterHostPage.checkExist(hostname, true);
+    if (!pattern) {
+      createClusterHostPage.checkExist(hostname, true);
+    }
   };
 
   beforeEach(() => {
@@ -35,6 +38,9 @@ describe('Create cluster add host page', () => {
 
     addHost(hostnames[1], false);
     addHost(hostnames[2], false);
+    createClusterHostPage.delete(hostnames[1]);
+    createClusterHostPage.delete(hostnames[2]);
+    addHost(hostnames[3], false, true);
   });
 
   it('should delete a host and add it back', () => {
index b07688bf3b45f026b54c26709f28a23b615124e9..40e999c7d36b2c1416bbd26213968d1a064673db 100644 (file)
         "@babel/types": "^7.3.0"
       }
     },
+    "@types/brace-expansion": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@types/brace-expansion/-/brace-expansion-1.1.0.tgz",
+      "integrity": "sha512-SaU/Kgp6z40CiF9JxlsrSrBEa+8YIry9IiCPhhYSNekeEhIAkY7iyu9aZ+5dSQIdo7mf86MUVvxWYm5GAzB/0g==",
+      "dev": true
+    },
     "@types/chart.js": {
       "version": "2.9.34",
       "resolved": "https://registry.npmjs.org/@types/chart.js/-/chart.js-2.9.34.tgz",
       "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
       "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
     },
-    "fsevents": {
-      "dev": true,
-      "optional": true,
-      "version": "2.1.3"
-    },
     "function-bind": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
           }
         },
         "fsevents": {
-          "dev": true,
-          "optional": true,
           "version": "2.1.3"
         },
         "glob-parent": {
           }
         },
         "fsevents": {
-          "dev": true,
-          "optional": true,
           "version": "2.1.3"
         },
         "glob-parent": {
           }
         },
         "fsevents": {
-          "dev": true,
-          "optional": true,
           "version": "2.1.3"
         },
         "glob-parent": {
index 2978a3f3128e52fb7e3d69ad55d7ed7ad441eedc..aa5c44b80353d95fb5243aac640c7392f9284ba4 100644 (file)
     "@angular/language-service": "11.2.14",
     "@applitools/eyes-cypress": "^3.22.0",
     "@compodoc/compodoc": "1.1.11",
+    "@types/brace-expansion": "^1.1.0",
     "@types/jest": "26.0.14",
     "@types/lodash": "4.14.161",
     "@types/node": "12.12.62",
index a3477b9bd4102318bf80b08beb36a741d31a6c02..8ff4cfa5c179e00c64da029dcfa5882e8202d059 100644 (file)
           <!-- Hostname -->
           <div class="form-group row">
             <label class="cd-col-form-label required"
-                   for="hostname"
-                   i18n>Hostname</label>
+                   for="hostname">
+            <ng-container i18n>Hostname</ng-container>
+            <cd-helper>
+              <p i18n>To add multiple hosts at once, you can enter:</p>
+              <ul>
+                <li i18n>a comma-separated list of hostnames <samp>(e.g.: example-01,example-02,example-03)</samp>,</li>
+                <li i18n>a range expression <samp>(e.g.: example-[01-03].ceph)</samp>,</li>
+                <li i18n>a comma separated range expression <samp>(e.g.: example-[01-05].lab.com,example2-[1-4].lab.com,example3-[001-006].lab.com)</samp></li>
+              </ul>
+            </cd-helper>
+            </label>
             <div class="cd-col-form-input">
               <input class="form-control"
                      type="text"
@@ -25,7 +34,8 @@
                      id="hostname"
                      name="hostname"
                      formControlName="hostname"
-                     autofocus>
+                     autofocus
+                     (keyup)="checkHostNameValue()">
               <span class="invalid-feedback"
                     *ngIf="hostForm.showError('hostname', formDir, 'required')"
                     i18n>This field is required.</span>
@@ -36,7 +46,8 @@
           </div>
 
           <!-- Address -->
-          <div class="form-group row">
+          <div class="form-group row"
+               *ngIf="!hostPattern">
             <label class="cd-col-form-label"
                    for="addr"
                    i18n>Nework address</label>
index ed3daf1e4b49b75c60d76028d7f768e6b14a573e..ed85d96cb1baba33e1ebf5bd546c214df46c24e4 100644 (file)
@@ -81,4 +81,88 @@ describe('HostFormComponent', () => {
     component.submit();
     expect(component.status).toBe('maintenance');
   });
+
+  it('should expand the hostname correctly', () => {
+    component.hostForm.get('hostname').setValue('ceph-node-00.cephlab.com');
+    fixture.detectChanges();
+    component.submit();
+    expect(component.hostnameArray).toStrictEqual(['ceph-node-00.cephlab.com']);
+
+    component.hostnameArray = [];
+
+    component.hostForm.get('hostname').setValue('ceph-node-[00-10].cephlab.com');
+    fixture.detectChanges();
+    component.submit();
+    expect(component.hostnameArray).toStrictEqual([
+      'ceph-node-00.cephlab.com',
+      'ceph-node-01.cephlab.com',
+      'ceph-node-02.cephlab.com',
+      'ceph-node-03.cephlab.com',
+      'ceph-node-04.cephlab.com',
+      'ceph-node-05.cephlab.com',
+      'ceph-node-06.cephlab.com',
+      'ceph-node-07.cephlab.com',
+      'ceph-node-08.cephlab.com',
+      'ceph-node-09.cephlab.com',
+      'ceph-node-10.cephlab.com'
+    ]);
+
+    component.hostnameArray = [];
+
+    component.hostForm.get('hostname').setValue('ceph-node-00.cephlab.com,ceph-node-1.cephlab.com');
+    fixture.detectChanges();
+    component.submit();
+    expect(component.hostnameArray).toStrictEqual([
+      'ceph-node-00.cephlab.com',
+      'ceph-node-1.cephlab.com'
+    ]);
+
+    component.hostnameArray = [];
+
+    component.hostForm
+      .get('hostname')
+      .setValue('ceph-mon-[01-05].lab.com,ceph-osd-[1-4].lab.com,ceph-rgw-[001-006].lab.com');
+    fixture.detectChanges();
+    component.submit();
+    expect(component.hostnameArray).toStrictEqual([
+      'ceph-mon-01.lab.com',
+      'ceph-mon-02.lab.com',
+      'ceph-mon-03.lab.com',
+      'ceph-mon-04.lab.com',
+      'ceph-mon-05.lab.com',
+      'ceph-osd-1.lab.com',
+      'ceph-osd-2.lab.com',
+      'ceph-osd-3.lab.com',
+      'ceph-osd-4.lab.com',
+      'ceph-rgw-001.lab.com',
+      'ceph-rgw-002.lab.com',
+      'ceph-rgw-003.lab.com',
+      'ceph-rgw-004.lab.com',
+      'ceph-rgw-005.lab.com',
+      'ceph-rgw-006.lab.com'
+    ]);
+
+    component.hostnameArray = [];
+
+    component.hostForm
+      .get('hostname')
+      .setValue('ceph-(mon-[00-04],osd-[001-005],rgw-[1-3]).lab.com');
+    fixture.detectChanges();
+    component.submit();
+    expect(component.hostnameArray).toStrictEqual([
+      'ceph-mon-00.lab.com',
+      'ceph-mon-01.lab.com',
+      'ceph-mon-02.lab.com',
+      'ceph-mon-03.lab.com',
+      'ceph-mon-04.lab.com',
+      'ceph-osd-001.lab.com',
+      'ceph-osd-002.lab.com',
+      'ceph-osd-003.lab.com',
+      'ceph-osd-004.lab.com',
+      'ceph-osd-005.lab.com',
+      'ceph-rgw-1.lab.com',
+      'ceph-rgw-2.lab.com',
+      'ceph-rgw-3.lab.com'
+    ]);
+  });
 });
index 704b659127f263c8959c9bb95ecba4c999566593..6e84da688e581dedb2ea18e049e037ea6696cc06 100644 (file)
@@ -3,6 +3,7 @@ import { FormControl, Validators } from '@angular/forms';
 import { Router } from '@angular/router';
 
 import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import expand from 'brace-expansion';
 
 import { HostService } from '~/app/shared/api/host.service';
 import { SelectMessages } from '~/app/shared/components/select/select-messages.model';
@@ -23,10 +24,12 @@ export class HostFormComponent extends CdForm implements OnInit {
   action: string;
   resource: string;
   hostnames: string[];
+  hostnameArray: string[] = [];
   addr: string;
   status: string;
   allLabels: string[];
   pageURL: string;
+  hostPattern = false;
 
   messages = new SelectMessages({
     empty: $localize`There are no labels.`,
@@ -59,6 +62,12 @@ export class HostFormComponent extends CdForm implements OnInit {
     });
   }
 
+  // check if hostname is a single value or pattern to hide network address field
+  checkHostNameValue() {
+    const hostNames = this.hostForm.get('hostname').value;
+    hostNames.match(/[()\[\]{},]/g) ? (this.hostPattern = true) : (this.hostPattern = false);
+  }
+
   private createForm() {
     this.hostForm = new CdFormGroup({
       hostname: new FormControl('', {
@@ -77,30 +86,77 @@ export class HostFormComponent extends CdForm implements OnInit {
     });
   }
 
+  private isCommaSeparatedPattern(hostname: string) {
+    // eg. ceph-node-01.cephlab.com,ceph-node-02.cephlab.com
+    return hostname.includes(',');
+  }
+
+  private isRangeTypePattern(hostname: string) {
+    // check if it is a range expression or comma separated range expression
+    // eg. ceph-mon-[01-05].lab.com,ceph-osd-[02-08].lab.com,ceph-rgw-[01-09]
+    return hostname.includes('[') && hostname.includes(']') && !hostname.match(/(?![^(]*\)),/g);
+  }
+
+  private replaceBraces(hostname: string) {
+    // pattern to replace range [0-5] to [0..5](valid expression for brace expansion)
+    // replace any kind of brackets with curly braces
+    return hostname
+      .replace(/(?<=\d)\s*-\s*(?=\d)/g, '..')
+      .replace(/\(/g, '{')
+      .replace(/\)/g, '}')
+      .replace(/\[/g, '{')
+      .replace(/]/g, '}');
+  }
+
+  // expand hostnames in case hostname is a pattern
+  private checkHostNamePattern(hostname: string) {
+    if (this.isRangeTypePattern(hostname)) {
+      const hostnameRange = this.replaceBraces(hostname);
+      this.hostnameArray = expand(hostnameRange);
+    } else if (this.isCommaSeparatedPattern(hostname)) {
+      let hostArray = [];
+      hostArray = hostname.split(',');
+      hostArray.forEach((host: string) => {
+        if (this.isRangeTypePattern(host)) {
+          const hostnameRange = this.replaceBraces(host);
+          this.hostnameArray = this.hostnameArray.concat(expand(hostnameRange));
+        } else {
+          this.hostnameArray.push(host);
+        }
+      });
+    } else {
+      // single hostname
+      this.hostnameArray.push(hostname);
+    }
+  }
+
   submit() {
     const hostname = this.hostForm.get('hostname').value;
+    this.checkHostNamePattern(hostname);
     this.addr = this.hostForm.get('addr').value;
     this.status = this.hostForm.get('maintenance').value ? 'maintenance' : '';
     this.allLabels = this.hostForm.get('labels').value;
     if (this.pageURL !== 'hosts' && !this.allLabels.includes('_no_schedule')) {
       this.allLabels.push('_no_schedule');
     }
-    this.taskWrapper
-      .wrapTaskAroundCall({
-        task: new FinishedTask('host/' + URLVerbs.ADD, {
-          hostname: hostname
-        }),
-        call: this.hostService.create(hostname, this.addr, this.allLabels, this.status)
-      })
-      .subscribe({
-        error: () => {
-          this.hostForm.setErrors({ cdSubmitButton: true });
-        },
-        complete: () => {
-          this.pageURL === 'hosts'
-            ? this.router.navigate([this.pageURL, { outlets: { modal: null } }])
-            : this.activeModal.close();
-        }
-      });
+    this.hostnameArray.forEach((hostName: string) => {
+      this.taskWrapper
+        .wrapTaskAroundCall({
+          task: new FinishedTask('host/' + URLVerbs.ADD, {
+            hostname: hostName
+          }),
+          call: this.hostService.create(hostName, this.addr, this.allLabels, this.status)
+        })
+        .subscribe({
+          error: () => {
+            this.hostForm.setErrors({ cdSubmitButton: true });
+          },
+          complete: () => {
+            this.pageURL === 'hosts'
+              ? this.router.navigate([this.pageURL, { outlets: { modal: null } }])
+              : this.activeModal.close();
+          }
+        });
+    });
   }
 }