]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Pool create / edit
authorStephan Müller <smueller@suse.com>
Fri, 29 Jun 2018 12:13:37 +0000 (14:13 +0200)
committerStephan Müller <smueller@suse.com>
Tue, 9 Oct 2018 13:56:15 +0000 (15:56 +0200)
You can create/edit pools through the UI if you have the right
permissions.

You can specify the following:
* Name - can't be duplicated
* Type - replicated or erasure
* Crush rule set
  * Validates if you can use it
  * A popover tells which crush steps are used
* Replica size - depends on your selected rule and the amount of OSDs
* Erasure code profile
* PGs - will be recalculated on form changes (type, replica size,
    erasure profile, crush rule) only if not set before
* EC overwrites flag
* Compression - Algorithm / Min/max blob size / mode / ratio
* Application metadata - Predefined and custom applications as badges

Fixes: https://tracker.ceph.com/issues/36355
Signed-off-by: Stephan Müller <smueller@suse.com>
13 files changed:
src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form-data.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form-info.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-list/pool-list.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/pool.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/pool.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-manager-message.service.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-manager-message.service.ts [new file with mode: 0644]

index efe4ed58db89d92be2fee9679d0dc8cf63d60fc1..1b60de3b670bf61fab5a55efd1bafdc0ed212b2e 100644 (file)
@@ -12,6 +12,7 @@ import { MonitorComponent } from './ceph/cluster/monitor/monitor.component';
 import { OsdListComponent } from './ceph/cluster/osd/osd-list/osd-list.component';
 import { DashboardComponent } from './ceph/dashboard/dashboard/dashboard.component';
 import { PerformanceCounterComponent } from './ceph/performance-counter/performance-counter/performance-counter.component';
+import { PoolFormComponent } from './ceph/pool/pool-form/pool-form.component';
 import { PoolListComponent } from './ceph/pool/pool-list/pool-list.component';
 import { Rgw501Component } from './ceph/rgw/rgw-501/rgw-501.component';
 import { RgwBucketFormComponent } from './ceph/rgw/rgw-bucket-form/rgw-bucket-form.component';
@@ -92,9 +93,14 @@ const routes: Routes = [
   // Pools
   {
     path: 'pool',
-    component: PoolListComponent,
     canActivate: [AuthGuardService],
-    data: { breadcrumbs: 'Pools' }
+    canActivateChild: [AuthGuardService],
+    data: { breadcrumbs: 'Pools' },
+    children: [
+      { path: '', component: PoolListComponent },
+      { path: 'add', component: PoolFormComponent, data: { breadcrumbs: 'Add' } },
+      { path: 'edit/:name', component: PoolFormComponent, data: { breadcrumbs: 'Edit' } }
+    ]
   },
   // Block
   {
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form-data.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form-data.ts
new file mode 100644 (file)
index 0000000..b10599e
--- /dev/null
@@ -0,0 +1,33 @@
+import { Validators } from '@angular/forms';
+
+import { SelectBadgesMessages } from '../../../shared/components/select-badges/select-badges-messages.model';
+import { SelectBadgesOption } from '../../../shared/components/select-badges/select-badges-option.model';
+import { Pool } from '../pool';
+
+export class PoolFormData {
+  poolTypes = ['erasure', 'replicated'];
+  applications = {
+    selected: [],
+    available: [
+      new SelectBadgesOption(false, 'cephfs', ''),
+      new SelectBadgesOption(false, 'rbd', ''),
+      new SelectBadgesOption(false, 'rgw', '')
+    ],
+    validators: [Validators.pattern('[A-Za-z0-9_]+'), Validators.maxLength(128)],
+    messages: new SelectBadgesMessages({
+      empty: 'No applications added',
+      selectionLimit: {
+        text: 'Applications limit reached',
+        tooltip: 'A pool can only have up to four applications definitions.'
+      },
+      customValidations: {
+        pattern: `Allowed characters '_a-zA-Z0-9'`,
+        maxlength: 'Maximum length is 128 characters'
+      },
+      filter: 'Filter or add applications',
+      add: 'Add application'
+    })
+  };
+  pgs = 1;
+  pool: Pool; // Only available during edit mode
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form-info.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form-info.ts
new file mode 100644 (file)
index 0000000..931083b
--- /dev/null
@@ -0,0 +1,11 @@
+import { CrushRule } from '../../../shared/models/crush-rule';
+
+export class PoolFormInfo {
+  pool_names: string[];
+  osd_count: number;
+  is_all_bluestore: boolean;
+  compression_algorithms: string[];
+  compression_modes: string[];
+  crush_rules_replicated: CrushRule[];
+  crush_rules_erasure: CrushRule[];
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.html
new file mode 100644 (file)
index 0000000..7299a5c
--- /dev/null
@@ -0,0 +1,482 @@
+<div class="col-sm-12 col-lg-6">
+  <h1 *ngIf="!(info && ecProfiles)"
+      i18n
+      class="jumbotron">
+    <i class="fa fa-lg fa-pulse fa-spinner text-primary"></i>
+    Loading...
+  </h1>
+  <form name="form"
+        *ngIf="info && ecProfiles"
+        class="form-horizontal"
+        #formDir="ngForm"
+        [formGroup]="form"
+        novalidate>
+    <div class="panel panel-default">
+      <div class="panel-heading">
+        <h3 class="panel-title">
+          <span i18n>{{ editing ? 'Edit' : 'Add' }} pool</span>
+        </h3>
+      </div>
+
+      <div class="panel-body">
+        <!-- Name -->
+        <div class="form-group"
+             [ngClass]="{'has-error': form.showError('name', formDir)}">
+          <label i18n
+                 class="control-label col-sm-3"
+                 for="name">
+            Name
+            <span class="required"></span>
+          </label>
+          <div class="col-sm-9">
+            <input id="name"
+                   name="name"
+                   type="text"
+                   class="form-control"
+                   placeholder="Name..."
+                   i18n-placeholder
+                   formControlName="name"
+                   autofocus>
+            <span i18n
+                  class="help-block"
+                  *ngIf="form.showError('name', formDir, 'required')">
+              This field is required!
+            </span>
+            <span i18n
+                  class="help-block"
+                  *ngIf="form.showError('name', formDir, 'uniqueName')">
+              The chosen Ceph pool name is already in use.
+            </span>
+          </div>
+        </div>
+
+        <!-- Pool type selection -->
+        <div class="form-group"
+             [ngClass]="{'has-error': form.showError('poolType', formDir)}">
+          <label i18n
+                 class="control-label col-sm-3"
+                 for="poolType">
+            Pool type
+            <span class="required"></span>
+          </label>
+          <div class="col-sm-9">
+            <select class="form-control"
+                    id="poolType"
+                    formControlName="poolType"
+                    name="poolType">
+              <option ngValue=""
+                      i18n>
+                -- Select a pool type --
+              </option>
+              <option *ngFor="let poolType of data.poolTypes"
+                      [value]="poolType">
+                {{ poolType }}
+              </option>
+            </select>
+            <span i18n
+                  class="help-block"
+                  *ngIf="form.showError('poolType', formDir, 'required')">
+              This field is required!
+            </span>
+          </div>
+        </div>
+
+        <div *ngIf="form.getValue('poolType')">
+          <!-- Pg number -->
+          <div class="form-group"
+               [ngClass]="{'has-error': form.showError('pgNum', formDir)}">
+            <label i18n
+                   class="control-label col-sm-3"
+                   for="pgNum">
+              Placement groups
+              <span class="required"></span>
+            </label>
+            <div class="col-sm-9">
+              <input class="form-control"
+                     id="pgNum"
+                     name="pgNum"
+                     formControlName="pgNum"
+                     min="1"
+                     type="number"
+                     (focus)="externalPgChange = false"
+                     (keyup)="pgKeyUp($event)"
+                     (blur)="pgUpdate()"
+                     required>
+              <span i18n
+                    class="help-block"
+                    *ngIf="form.showError('pgNum', formDir, 'required')">
+                This field is required!
+              </span>
+              <span i18n
+                    class="help-block"
+                    *ngIf="form.showError('pgNum', formDir, 'min')">
+                At least one placement group is needed!
+              </span>
+              <span i18n
+                    class="help-block"
+                    *ngIf="form.showError('pgNum', formDir, '34')">
+                Your cluster can't handle this many PGs. Please recalculate the PG amount needed.
+              </span>
+              <span i18n
+                    class="help-block"
+                    *ngIf="form.showError('pgNum', formDir, 'noDecrease')">
+                You can only increase the number of PGs of an existing pool.
+                Currently your pool has {{ data.pool.pg_num }} PGs.
+              </span>
+              <span class="help-block">
+                <a i18n
+                   target="_blank"
+                   href="http://ceph.com/pgcalc">Calculation help</a>
+              </span>
+              <span class="help-block"
+                    i18n
+                    *ngIf="externalPgChange">
+                The current PGs settings were calculated for you, you should make sure the values
+                suite your needs before submit.
+              </span>
+            </div>
+          </div>
+
+          <!-- Crush ruleset selection -->
+          <ng-template #crushSteps>
+            <ng-container *ngIf="form.getValue('crushRule')">
+              <div class="crush-rule-steps">
+                <ol>
+                  <li *ngFor="let step of form.get('crushRule').value.steps">
+                    {{ describeCrushStep(step) }}
+                  </li>
+                </ol>
+              </div>
+            </ng-container>
+          </ng-template>
+          <div class="form-group"
+               [ngClass]="{'has-error': form.showError('crushRule', formDir)}"
+               *ngIf="form.getValue('poolType') && current.rules.length > 0">
+            <label class="control-label col-sm-3"
+                   for="crushSet"
+                   i18n>
+              Crush ruleset
+            </label>
+            <div class="col-sm-9"
+                 [popover]="crushSteps"
+                 popoverTitle="Steps"
+                 triggers="mouseenter:mouseleave">
+              <select class="form-control"
+                      id="crushSet"
+                      formControlName="crushRule"
+                      name="crushSet">
+                <option i18n
+                        [ngValue]="null">
+                  -- Select a crush rule --
+                </option>
+                <option *ngFor="let rule of current.rules"
+                        [ngValue]="rule">
+                  {{ rule.rule_name }}
+                </option>
+              </select>
+              <span class="help-block"
+                    i18n
+                    *ngIf="form.showError('crushRule', formDir, 'tooFewOsds')">
+                The rule can't be used in the current cluster as it has to few OSDs to meet the
+                minimum required OSD by this rule.
+              </span>
+            </div>
+          </div>
+
+          <!-- Replica Size -->
+          <div class="form-group"
+               [ngClass]="{'has-error': form.showError('size', formDir)}"
+               *ngIf="form.getValue('poolType') === 'replicated'">
+            <label i18n
+                   class="control-label col-sm-3"
+                   for="size">
+              Replicated size
+              <span class="required"></span>
+            </label>
+            <div class="col-sm-9">
+              <input class="form-control"
+                     id="size"
+                     [max]="getMaxSize()"
+                     [min]="getMinSize()"
+                     name="size"
+                     type="number"
+                     formControlName="size">
+              <span class="help-block"
+                    *ngIf="form.showError('size', formDir)">
+                <ul class="list-inline">
+                  <li i18n>
+                    Minimum: {{ getMinSize() }}
+                  </li>
+                  <li i18n>
+                    Maximum: {{ getMaxSize() }}
+                  </li>
+                </ul>
+              </span>
+              <span class="help-block"
+                    i18n
+                    *ngIf="form.showError('size', formDir)">
+                The size specified is out of range.
+                A value from {{ getMinSize() }} to {{ getMaxSize() }} is valid.
+              </span>
+            </div>
+          </div>
+
+          <!-- Erasure Profile select -->
+          <div class="form-group"
+               *ngIf="form.getValue('poolType') === 'erasure'">
+            <label i18n
+                   class="control-label col-sm-3"
+                   for="erasureProfile">
+              Erasure code profile
+            </label>
+            <div class="col-sm-9">
+              <select class="form-control"
+                      id="erasureProfile"
+                      name="erasureProfile"
+                      formControlName="erasureProfile">
+                <option *ngIf="!ecProfiles"
+                        ngValue=""
+                        i18n>
+                  Loading...
+                </option>
+                <option *ngIf="ecProfiles && ecProfiles.length === 0"
+                        i18n
+                        [ngValue]="null">
+                  -- No erasure code profile available --
+                </option>
+                <option *ngIf="ecProfiles && ecProfiles.length > 0"
+                        i18n
+                        [ngValue]="null">
+                  -- Select an erasure code profile --
+                </option>
+                <option *ngFor="let ecp of ecProfiles"
+                        [ngValue]="ecp">
+                  {{ ecp.name }}
+                </option>
+              </select>
+            </div>
+          </div>
+
+          <!-- Flags -->
+          <div class="form-group"
+               *ngIf="info.is_all_bluestore && form.getValue('poolType') === 'erasure'">
+            <label i18n
+                   class="control-label col-sm-3">
+              Flags
+            </label>
+            <div class="col-sm-9">
+              <div class="input-group">
+                <div class="checkbox checkbox-primary">
+                  <input id="ec-overwrites"
+                         type="checkbox"
+                         formControlName="ecOverwrites">
+                  <label i18n
+                         for="ec-overwrites">
+                    EC Overwrites
+                  </label>
+                </div>
+              </div>
+            </div>
+          </div>
+
+          <!-- Applications -->
+          <div class="form-group">
+            <label i18n
+                   class="col-sm-3 control-label"
+                   for="applications">
+              Applications
+            </label>
+            <div class="col-sm-9">
+              <span class="form-control no-border full-height">
+                <cd-select-badges
+                  id="applications"
+                  [customBadges]="true"
+                  [customBadgeValidators]="data.applications.validators"
+                  [messages]="data.applications.messages"
+                  [data]="data.applications.selected"
+                  [options]="data.applications.available"
+                  [selectionLimit]="4">
+                </cd-select-badges>
+              </span>
+            </div>
+          </div>
+
+          <!-- Compression -->
+          <div *ngIf="info.is_all_bluestore" formGroupName="compression">
+            <legend i18n>Compression</legend>
+
+            <!-- Compression Mode -->
+            <div class="form-group">
+              <label i18n
+                     class="control-label col-sm-3"
+                     for="mode">
+                Mode
+              </label>
+              <div class="col-sm-9">
+                <select class="form-control"
+                        id="mode"
+                        name="mode"
+                        formControlName="mode">
+                  <option i18n
+                          ngValue="">
+                    -- Select a compression mode --
+                  </option>
+                  <option *ngFor="let mode of info.compression_modes"
+                          [value]="mode">
+                    {{ mode }}
+                  </option>
+                </select>
+              </div>
+            </div>
+            <div *ngIf="activatedCompression()">
+              <!-- Compression algorithm selection -->
+              <div class="form-group"
+                   [ngClass]="{'has-error': form.showError('algorithm', formDir)}">
+                <label i18n
+                       class="control-label col-sm-3"
+                       for="algorithm">
+                  Algorithm
+                </label>
+                <div class="col-sm-9">
+                  <select class="form-control"
+                          id="algorithm"
+                          name="algorithm"
+                          formControlName="algorithm">
+                    <option *ngIf="!info.compression_algorithms"
+                            ngValue=""
+                            i18n>
+                      Loading...
+                    </option>
+                    <option *ngIf="info.compression_algorithms && info.compression_algorithms.length === 0"
+                            i18n
+                            ngValue="">
+                      -- No erasure compression algorithm available --
+                    </option>
+                    <option *ngIf="info.compression_algorithms &&
+                                   info.compression_algorithms.length > 0"
+                            i18n
+                            ngValue="">
+                      -- Select a compression algorithm --
+                    </option>
+                    <option *ngFor="let algorithm of info.compression_algorithms"
+                            [value]="algorithm">
+                      {{ algorithm }}
+                    </option>
+                  </select>
+                </div>
+              </div>
+
+              <!-- Compression min blob size -->
+              <div class="form-group"
+                   [ngClass]="{'has-error': form.showError('minBlobSize', formDir)}">
+                <label i18n
+                       class="control-label col-sm-3"
+                       for="minBlobSize">
+                  Minimum blob size
+                </label>
+                <div class="col-sm-9">
+                  <input id="minBlobSize"
+                         name="minBlobSize"
+                         formControlName="minBlobSize"
+                         type="text"
+                         min="0"
+                         class="form-control"
+                         i18n-placeholder
+                         placeholder="e.g., 128KiB"
+                         defaultUnit="KiB"
+                         cdDimlessBinary>
+                  <span i18n
+                        class="help-block"
+                        *ngIf="form.showError('minBlobSize', formDir, 'min')">
+                    Value should be greater than 0
+                  </span>
+                  <span i18n
+                        class="help-block"
+                        *ngIf="form.showError('minBlobSize', formDir, 'maximum')">
+                    Value should be greater than the maximum blob size
+                  </span>
+                </div>
+              </div>
+
+              <!-- Compression max blob size -->
+              <div class="form-group"
+                   [ngClass]="{'has-error': form.showError('maxBlobSize', formDir)}">
+                <label i18n
+                       class="control-label col-sm-3"
+                       for="maxBlobSize">
+                  Maximum blob size
+                </label>
+                <div class="col-sm-9">
+                  <input id="maxBlobSize"
+                         type="text"
+                         min="0"
+                         formControlName="maxBlobSize"
+                         class="form-control"
+                         i18n-placeholder
+                         placeholder="e.g., 512KiB"
+                         defaultUnit="KiB"
+                         cdDimlessBinary>
+                  <span i18n
+                        class="help-block"
+                        *ngIf="form.showError('maxBlobSize', formDir, 'min')">
+                    Value should be greater than 0
+                  </span>
+                  <span i18n
+                        class="help-block"
+                        *ngIf="form.showError('maxBlobSize', formDir, 'minimum')">
+                    Value should be greater than the minimum blob size
+                  </span>
+                </div>
+              </div>
+
+              <!-- Compression ratio -->
+              <div class="form-group"
+                   [ngClass]="{'has-error': form.showError('ratio', formDir)}">
+                <label i18n
+                       class="control-label col-sm-3"
+                       for="ratio">
+                   Ratio
+                </label>
+                <div class="col-sm-9">
+                  <input id="ratio"
+                         name="ratio"
+                         formControlName="ratio"
+                         type="number"
+                         min="0"
+                         max="1"
+                         step="0.1"
+                         class="form-control"
+                         i18n-placeholder
+                         placeholder="Compression ratio">
+                  <span i18n
+                        class="help-block"
+                        *ngIf="form.showError('ratio', formDir, 'min') || form.showError('ratio', formDir, 'max')">
+                    Value should be between 0.0 and 1.0
+                  </span>
+                </div>
+              </div>
+
+            </div>
+          </div>
+
+        </div>
+      </div>
+
+      <div class="panel-footer">
+        <div class="button-group text-right">
+          <cd-submit-button [form]="formDir"
+                            type="button"
+                            (submitAction)="submit()">
+            <span i18n>{{ editing ? 'Edit' : 'Create' }} pool</span>
+          </cd-submit-button>
+          <button i18n
+                  type="button"
+                  class="btn btn-sm btn-default"
+                  routerLink="/pool">
+            Back
+          </button>
+        </div>
+      </div>
+    </div>
+  </form>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.scss
new file mode 100644 (file)
index 0000000..29a4cf7
--- /dev/null
@@ -0,0 +1,3 @@
+.crush-rule-steps {
+  margin-top: 10px;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.spec.ts
new file mode 100644 (file)
index 0000000..fcb601c
--- /dev/null
@@ -0,0 +1,946 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { AbstractControl } from '@angular/forms';
+import { By } from '@angular/platform-browser';
+import { ActivatedRoute, Router, Routes } from '@angular/router';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { ToastModule } from 'ng2-toastr';
+import { of } from 'rxjs';
+
+import { configureTestBed } from '../../../../testing/unit-test-helper';
+import { NotFoundComponent } from '../../../core/not-found/not-found.component';
+import { ErasureCodeProfileService } from '../../../shared/api/erasure-code-profile.service';
+import { PoolService } from '../../../shared/api/pool.service';
+import { SelectBadgesComponent } from '../../../shared/components/select-badges/select-badges.component';
+import { CdFormGroup } from '../../../shared/forms/cd-form-group';
+import { CrushRule } from '../../../shared/models/crush-rule';
+import { Permission } from '../../../shared/models/permissions';
+import { AuthStorageService } from '../../../shared/services/auth-storage.service';
+import { TaskWrapperService } from '../../../shared/services/task-wrapper.service';
+import { Pool } from '../pool';
+import { PoolModule } from '../pool.module';
+import { PoolFormComponent } from './pool-form.component';
+
+describe('PoolFormComponent', () => {
+  const OSDS = 8;
+  let component: PoolFormComponent;
+  let fixture: ComponentFixture<PoolFormComponent>;
+  let poolService: PoolService;
+  let form: CdFormGroup;
+  let router: Router;
+
+  const hasError = (control: AbstractControl, error: string) => {
+    expect(control.hasError(error)).toBeTruthy();
+  };
+
+  const isValid = (control: AbstractControl) => {
+    expect(control.valid).toBeTruthy();
+  };
+
+  const setValue = (controlName: string, value: any): AbstractControl => {
+    const control = form.get(controlName);
+    control.setValue(value);
+    return control;
+  };
+
+  const setPgNum = (pgs): AbstractControl => {
+    setValue('poolType', 'erasure');
+    const control = setValue('pgNum', pgs);
+    fixture.detectChanges();
+    fixture.debugElement.query(By.css('#pgNum')).nativeElement.dispatchEvent(new Event('blur'));
+    return control;
+  };
+
+  const createCrushRule = ({
+    id = 0,
+    name = 'somePoolName',
+    min = 1,
+    max = 10,
+    type = 'replicated'
+  }: {
+    max?: number;
+    min?: number;
+    id?: number;
+    name?: string;
+    type?: string;
+  }) => {
+    const typeNumber = type === 'erasure' ? 3 : 1;
+    const rule = new CrushRule();
+    rule.max_size = max;
+    rule.min_size = min;
+    rule.rule_id = id;
+    rule.ruleset = typeNumber;
+    rule.rule_name = name;
+    rule.steps = [
+      {
+        item_name: 'default',
+        item: -1,
+        op: 'take'
+      },
+      {
+        num: 0,
+        type: 'osd',
+        op: 'choose_firstn'
+      },
+      {
+        op: 'emit'
+      }
+    ];
+    component.info['crush_rules_' + type].push(rule);
+  };
+
+  const testSubmit = (pool: any, taskName: string, poolServiceMethod: 'create' | 'update') => {
+    spyOn(poolService, poolServiceMethod).and.stub();
+    const taskWrapper = TestBed.get(TaskWrapperService);
+    spyOn(taskWrapper, 'wrapTaskAroundCall').and.callThrough();
+    component.submit();
+    expect(poolService[poolServiceMethod]).toHaveBeenCalledWith(pool);
+    expect(taskWrapper.wrapTaskAroundCall).toHaveBeenCalledWith({
+      task: {
+        name: taskName,
+        metadata: {
+          pool_name: pool.pool
+        }
+      },
+      call: undefined // because of stub
+    });
+  };
+
+  const setUpPoolComponent = () => {
+    fixture = TestBed.createComponent(PoolFormComponent);
+    component = fixture.componentInstance;
+    component.info = {
+      pool_names: [],
+      osd_count: OSDS,
+      is_all_bluestore: true,
+      compression_algorithms: [],
+      compression_modes: [],
+      crush_rules_replicated: [],
+      crush_rules_erasure: []
+    };
+    component.ecProfiles = [];
+    form = component.form;
+  };
+
+  const routes: Routes = [{ path: '404', component: NotFoundComponent }];
+
+  configureTestBed({
+    declarations: [NotFoundComponent],
+    imports: [
+      HttpClientTestingModule,
+      RouterTestingModule.withRoutes(routes),
+      ToastModule.forRoot(),
+      PoolModule
+    ],
+    providers: [
+      ErasureCodeProfileService,
+      SelectBadgesComponent,
+      { provide: ActivatedRoute, useValue: { params: of({ name: 'somePoolName' }) } }
+    ]
+  });
+
+  beforeEach(() => {
+    setUpPoolComponent();
+    poolService = TestBed.get(PoolService);
+    spyOn(poolService, 'getInfo').and.callFake(() => [component.info]);
+    const ecpService = TestBed.get(ErasureCodeProfileService);
+    spyOn(ecpService, 'list').and.callFake(() => [component.ecProfiles]);
+    router = TestBed.get(Router);
+    spyOn(router, 'navigate').and.stub();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  describe('redirect not allowed users', () => {
+    let poolPermissions: Permission;
+    let authStorageService: AuthStorageService;
+
+    const testForRedirect = (times: number) => {
+      component.authenticate();
+      expect(router.navigate).toHaveBeenCalledTimes(times);
+    };
+
+    beforeEach(() => {
+      poolPermissions = {
+        create: false,
+        update: false,
+        read: false,
+        delete: false
+      };
+      authStorageService = TestBed.get(AuthStorageService);
+      spyOn(authStorageService, 'getPermissions').and.callFake(() => ({
+        pool: poolPermissions
+      }));
+    });
+
+    it('navigates to 404 if not allowed', () => {
+      component.authenticate();
+      expect(router.navigate).toHaveBeenCalledWith(['/404']);
+    });
+
+    it('navigates if user is not allowed', () => {
+      testForRedirect(1);
+      poolPermissions.read = true;
+      testForRedirect(2);
+      poolPermissions.delete = true;
+      testForRedirect(3);
+      poolPermissions.update = true;
+      testForRedirect(4);
+      component.editing = true;
+      poolPermissions.update = false;
+      poolPermissions.create = true;
+      testForRedirect(5);
+    });
+
+    it('does not navigate users with right permissions', () => {
+      poolPermissions.read = true;
+      poolPermissions.create = true;
+      testForRedirect(0);
+      component.editing = true;
+      poolPermissions.update = true;
+      testForRedirect(0);
+      poolPermissions.create = false;
+      testForRedirect(0);
+    });
+  });
+
+  describe('pool form validation', () => {
+    beforeEach(() => {
+      component.ngOnInit();
+    });
+
+    it('is invalid at the beginning all sub forms are valid', () => {
+      expect(form.valid).toBeFalsy();
+      ['name', 'poolType', 'pgNum'].forEach((name) => hasError(form.get(name), 'required'));
+      ['crushRule', 'size', 'erasureProfile', 'ecOverwrites'].forEach((name) =>
+        isValid(form.get(name))
+      );
+      expect(component.compressionForm.valid).toBeTruthy();
+    });
+
+    it('validates name', () => {
+      hasError(form.get('name'), 'required');
+      isValid(setValue('name', 'some-name'));
+      component.info.pool_names.push('someExistingPoolName');
+      hasError(setValue('name', 'someExistingPoolName'), 'uniqueName');
+      hasError(setValue('name', 'wrong format with spaces'), 'pattern');
+    });
+
+    it('validates poolType', () => {
+      hasError(form.get('poolType'), 'required');
+      isValid(setValue('poolType', 'erasure'));
+      isValid(setValue('poolType', 'replicated'));
+    });
+
+    it('validates pgNum in creation mode', () => {
+      hasError(form.get('pgNum'), 'required');
+      setValue('poolType', 'erasure');
+      isValid(setPgNum(-28));
+      expect(form.getValue('pgNum')).toBe(1);
+      isValid(setPgNum(15));
+      expect(form.getValue('pgNum')).toBe(16);
+    });
+
+    it('increases pgNum by the power of two for if the value has changed by one', () => {
+      setPgNum('16');
+      expect(setPgNum(17).value).toBe(32);
+      expect(setPgNum(31).value).toBe(16);
+    });
+
+    it('not increases pgNum by more than one but lower than the next pg update change', () => {
+      setPgNum('16');
+      expect(setPgNum('18').value).toBe(16);
+      expect(setPgNum('14').value).toBe(16);
+    });
+
+    it('validates pgNum in edit mode', () => {
+      component.data.pool = new Pool('test');
+      component.data.pool.pg_num = 16;
+      component.editing = true;
+      component.ngOnInit();
+      hasError(setPgNum('8'), 'noDecrease');
+    });
+
+    it('is valid if pgNum, poolType and name are valid', () => {
+      setValue('name', 'some-name');
+      setValue('poolType', 'erasure');
+      setPgNum(1);
+      expect(form.valid).toBeTruthy();
+    });
+
+    it('validates crushRule', () => {
+      isValid(form.get('crushRule'));
+      hasError(setValue('crushRule', { min_size: 20 }), 'tooFewOsds');
+    });
+
+    it('validates size', () => {
+      setValue('poolType', 'replicated');
+      isValid(form.get('size'));
+      setValue('crushRule', {
+        min_size: 2,
+        max_size: 6
+      });
+      hasError(setValue('size', 1), 'min');
+      hasError(setValue('size', 8), 'max');
+      isValid(setValue('size', 6));
+    });
+
+    it('validates compression mode default value', () => {
+      expect(form.getValue('mode')).toBe('none');
+    });
+
+    describe('compression form', () => {
+      beforeEach(() => {
+        setValue('poolType', 'replicated');
+        setValue('mode', 'passive');
+      });
+
+      it('is valid', () => {
+        expect(component.compressionForm.valid).toBeTruthy();
+      });
+
+      it('validates minBlobSize to be only valid between 0 and maxBlobSize', () => {
+        hasError(setValue('minBlobSize', -1), 'min');
+        isValid(setValue('minBlobSize', 0));
+        setValue('maxBlobSize', '2 KiB');
+        hasError(setValue('minBlobSize', '3 KiB'), 'maximum');
+        isValid(setValue('minBlobSize', '1.9 KiB'));
+      });
+
+      it('validates minBlobSize converts numbers', () => {
+        const control = setValue('minBlobSize', '1');
+        fixture.detectChanges();
+        isValid(control);
+        expect(control.value).toBe('1 KiB');
+      });
+
+      it('validates maxBlobSize to be only valid bigger than minBlobSize', () => {
+        hasError(setValue('maxBlobSize', -1), 'min');
+        setValue('minBlobSize', '1 KiB');
+        hasError(setValue('maxBlobSize', '0.5 KiB'), 'minimum');
+        isValid(setValue('maxBlobSize', '1.5 KiB'));
+      });
+
+      it('s valid to only use one blob size', () => {
+        isValid(setValue('minBlobSize', '1 KiB'));
+        isValid(setValue('maxBlobSize', ''));
+        isValid(setValue('minBlobSize', ''));
+        isValid(setValue('maxBlobSize', '1 KiB'));
+      });
+
+      it('dismisses any size error if one of the blob sizes is changed into a valid state', () => {
+        const min = setValue('minBlobSize', '10 KiB');
+        const max = setValue('maxBlobSize', '1 KiB');
+        fixture.detectChanges();
+        max.setValue('');
+        isValid(min);
+        isValid(max);
+        max.setValue('1 KiB');
+        fixture.detectChanges();
+        min.setValue('0.5 KiB');
+        isValid(min);
+        isValid(max);
+      });
+
+      it('validates maxBlobSize converts numbers', () => {
+        const control = setValue('maxBlobSize', '2');
+        fixture.detectChanges();
+        expect(control.value).toBe('2 KiB');
+      });
+
+      it('validates ratio to be only valid between 0 and 1', () => {
+        isValid(form.get('ratio'));
+        hasError(setValue('ratio', -0.1), 'min');
+        isValid(setValue('ratio', 0));
+        isValid(setValue('ratio', 1));
+        hasError(setValue('ratio', 1.1), 'max');
+      });
+    });
+
+    it('validates application metadata name', () => {
+      setValue('poolType', 'replicated');
+      fixture.detectChanges();
+      const selectBadges = fixture.debugElement.query(By.directive(SelectBadgesComponent))
+        .componentInstance;
+      const control = selectBadges.filter;
+      isValid(control);
+      control.setValue('?');
+      hasError(control, 'pattern');
+      control.setValue('Ab3_');
+      isValid(control);
+      control.setValue('a'.repeat(129));
+      hasError(control, 'maxlength');
+    });
+  });
+
+  describe('pool type changes', () => {
+    beforeEach(() => {
+      component.ngOnInit();
+      createCrushRule({ id: 3, min: 1, max: 1, name: 'ep1', type: 'erasure' });
+      createCrushRule({ id: 0, min: 2, max: 4, name: 'rep1', type: 'replicated' });
+      createCrushRule({ id: 1, min: 3, max: 18, name: 'rep2', type: 'replicated' });
+    });
+
+    it('should have a default replicated size of 3', () => {
+      setValue('poolType', 'replicated');
+      expect(form.getValue('size')).toBe(3);
+    });
+
+    describe('replicatedRuleChange', () => {
+      beforeEach(() => {
+        setValue('poolType', 'replicated');
+        setValue('size', 99);
+      });
+
+      it('should not set size if a replicated pool is not set', () => {
+        setValue('poolType', 'erasure');
+        expect(form.getValue('size')).toBe(99);
+        setValue('crushRule', component.info.crush_rules_replicated[1]);
+        expect(form.getValue('size')).toBe(99);
+      });
+
+      it('should set size to maximum if size exceeds maximum', () => {
+        setValue('crushRule', component.info.crush_rules_replicated[0]);
+        expect(form.getValue('size')).toBe(4);
+      });
+
+      it('should set size to minimum if size is lower than minimum', () => {
+        setValue('size', -1);
+        setValue('crushRule', component.info.crush_rules_replicated[0]);
+        expect(form.getValue('size')).toBe(2);
+      });
+    });
+
+    describe('rulesChange', () => {
+      it('has no effect if info is not there', () => {
+        delete component.info;
+        setValue('poolType', 'replicated');
+        expect(component.current.rules).toEqual([]);
+      });
+
+      it('has no effect if pool type is not set', () => {
+        component['rulesChange']();
+        expect(component.current.rules).toEqual([]);
+      });
+
+      it('shows all replicated rules when pool type is "replicated"', () => {
+        setValue('poolType', 'replicated');
+        expect(component.current.rules).toEqual(component.info.crush_rules_replicated);
+        expect(component.current.rules.length).toBe(2);
+      });
+
+      it('shows all erasure code rules when pool type is "erasure"', () => {
+        setValue('poolType', 'erasure');
+        expect(component.current.rules).toEqual(component.info.crush_rules_erasure);
+        expect(component.current.rules.length).toBe(1);
+      });
+
+      it('disables rule field if only one rule exists which is used in the disabled field', () => {
+        setValue('poolType', 'erasure');
+        const control = form.get('crushRule');
+        expect(control.value).toEqual(component.info.crush_rules_erasure[0]);
+        expect(control.disabled).toBe(true);
+      });
+
+      it('does not select the first rule if more than one exist', () => {
+        setValue('poolType', 'replicated');
+        const control = form.get('crushRule');
+        expect(control.value).toEqual(null);
+        expect(control.disabled).toBe(false);
+      });
+
+      it('changing between both types will not leave crushRule in a bad state', () => {
+        setValue('poolType', 'erasure');
+        setValue('poolType', 'replicated');
+        const control = form.get('crushRule');
+        expect(control.value).toEqual(null);
+        expect(control.disabled).toBe(false);
+        setValue('poolType', 'erasure');
+        expect(control.value).toEqual(component.info.crush_rules_erasure[0]);
+        expect(control.disabled).toBe(true);
+      });
+    });
+  });
+
+  describe('getMaxSize and getMinSize', () => {
+    const setCrushRule = ({ min, max }: { min?: number; max?: number }) => {
+      setValue('crushRule', {
+        min_size: min,
+        max_size: max
+      });
+    };
+
+    it('returns nothing if osd count is 0', () => {
+      component.info.osd_count = 0;
+      expect(component.getMinSize()).toBe(undefined);
+      expect(component.getMaxSize()).toBe(undefined);
+    });
+
+    it('returns nothing if info is not there', () => {
+      delete component.info;
+      expect(component.getMinSize()).toBe(undefined);
+      expect(component.getMaxSize()).toBe(undefined);
+    });
+
+    it('returns minimum and maximum of rule', () => {
+      setCrushRule({ min: 2, max: 6 });
+      expect(component.getMinSize()).toBe(2);
+      expect(component.getMaxSize()).toBe(6);
+    });
+
+    it('returns 1 as minimum and the osd count as maximum if no crush rule is available', () => {
+      expect(component.getMinSize()).toBe(1);
+      expect(component.getMaxSize()).toBe(OSDS);
+    });
+
+    it('returns the osd count as maximum if the rule maximum exceeds it', () => {
+      setCrushRule({ max: 100 });
+      expect(component.getMaxSize()).toBe(OSDS);
+    });
+
+    it('should return the osd count as minimum if its lower the the rule minimum', () => {
+      setCrushRule({ min: 10 });
+      expect(component.getMinSize()).toBe(10);
+      const control = form.get('crushRule');
+      expect(control.invalid).toBe(true);
+      hasError(control, 'tooFewOsds');
+    });
+  });
+
+  describe('application metadata', () => {
+    let selectBadges: SelectBadgesComponent;
+
+    const testAddApp = (app?: string, result?: string[]) => {
+      selectBadges.filter.setValue(app);
+      selectBadges.updateFilter();
+      selectBadges.selectOption();
+      expect(component.data.applications.selected).toEqual(result);
+    };
+
+    const testRemoveApp = (app: string, result: string[]) => {
+      selectBadges.removeItem(app);
+      expect(component.data.applications.selected).toEqual(result);
+    };
+
+    const setCurrentApps = (apps: string[]) => {
+      component.data.applications.selected = apps;
+      fixture.detectChanges();
+      selectBadges.ngOnInit();
+      return apps;
+    };
+
+    beforeEach(() => {
+      setValue('poolType', 'replicated');
+      fixture.detectChanges();
+      selectBadges = fixture.debugElement.query(By.directive(SelectBadgesComponent))
+        .componentInstance;
+    });
+
+    it('adds all predefined and a custom applications to the application metadata array', () => {
+      testAddApp('g', ['rgw']);
+      testAddApp('b', ['rbd', 'rgw']);
+      testAddApp('c', ['cephfs', 'rbd', 'rgw']);
+      testAddApp('something', ['cephfs', 'rbd', 'rgw', 'something']);
+    });
+
+    it('only allows 4 apps to be added to the array', () => {
+      const apps = setCurrentApps(['d', 'c', 'b', 'a']);
+      testAddApp('e', apps);
+    });
+
+    it('can remove apps', () => {
+      setCurrentApps(['a', 'b', 'c', 'd']);
+      testRemoveApp('c', ['a', 'b', 'd']);
+      testRemoveApp('a', ['b', 'd']);
+      testRemoveApp('d', ['b']);
+      testRemoveApp('b', []);
+    });
+
+    it('does not remove any app that is not in the array', () => {
+      const apps = ['a', 'b', 'c', 'd'];
+      setCurrentApps(apps);
+      testRemoveApp('e', apps);
+      testRemoveApp('0', apps);
+    });
+  });
+
+  describe('pg number changes', () => {
+    beforeEach(() => {
+      setValue('crushRule', {
+        min_size: 1,
+        max_size: 20
+      });
+      component.ngOnInit();
+      // triggers pgUpdate
+      setPgNum(256);
+    });
+
+    describe('pgCalc', () => {
+      const PGS = 1;
+
+      const getValidCase = () => ({
+        type: 'replicated',
+        osds: OSDS,
+        size: 4,
+        ecp: {
+          k: 2,
+          m: 2
+        },
+        expected: 256
+      });
+
+      const testPgCalc = ({ type, osds, size, ecp, expected }) => {
+        component.info.osd_count = osds;
+        setValue('poolType', type);
+        if (type === 'replicated') {
+          setValue('size', size);
+        } else {
+          setValue('erasureProfile', ecp);
+        }
+        expect(form.getValue('pgNum')).toBe(expected);
+        expect(component.externalPgChange).toBe(PGS !== expected);
+      };
+
+      beforeEach(() => {
+        setPgNum(PGS);
+      });
+
+      it('does not change anything if type is not valid', () => {
+        const test = getValidCase();
+        test.type = '';
+        test.expected = PGS;
+        testPgCalc(test);
+      });
+
+      it('does not change anything if ecp is not valid', () => {
+        const test = getValidCase();
+        test.expected = PGS;
+        test.type = 'erasure';
+        test.ecp = null;
+        testPgCalc(test);
+      });
+
+      it('calculates some replicated values', () => {
+        const test = getValidCase();
+        testPgCalc(test);
+        test.osds = 16;
+        test.expected = 512;
+        testPgCalc(test);
+        test.osds = 8;
+        test.size = 8;
+        test.expected = 128;
+        testPgCalc(test);
+      });
+
+      it('calculates erasure code values even if selection is disabled', () => {
+        component['initEcp']([{ k: 2, m: 2, name: 'bla', plugin: '', technique: '' }]);
+        const test = getValidCase();
+        test.type = 'erasure';
+        testPgCalc(test);
+        expect(form.get('erasureProfile').disabled).toBeTruthy();
+      });
+
+      it('calculates some erasure code values', () => {
+        const test = getValidCase();
+        test.type = 'erasure';
+        testPgCalc(test);
+        test.osds = 16;
+        test.ecp.m = 5;
+        test.expected = 256;
+        testPgCalc(test);
+        test.ecp.k = 5;
+        test.expected = 128;
+        testPgCalc(test);
+      });
+
+      it('should not change a manual set pg number', () => {
+        form.get('pgNum').markAsDirty();
+        const test = getValidCase();
+        test.expected = PGS;
+        testPgCalc(test);
+      });
+    });
+
+    describe('pgUpdate', () => {
+      const testPgUpdate = (pgs, jump, returnValue) => {
+        component['pgUpdate'](pgs, jump);
+        expect(form.getValue('pgNum')).toBe(returnValue);
+      };
+
+      it('updates by value', () => {
+        testPgUpdate(10, undefined, 8);
+        testPgUpdate(22, undefined, 16);
+        testPgUpdate(26, undefined, 32);
+      });
+
+      it('updates by jump -> a magnitude of the power of 2', () => {
+        testPgUpdate(undefined, 1, 512);
+        testPgUpdate(undefined, -1, 256);
+        testPgUpdate(undefined, -2, 64);
+        testPgUpdate(undefined, -10, 1);
+      });
+
+      it('returns 1 as minimum for false numbers', () => {
+        testPgUpdate(-26, undefined, 1);
+        testPgUpdate(0, undefined, 1);
+        testPgUpdate(undefined, -20, 1);
+      });
+
+      it('uses by value and jump', () => {
+        testPgUpdate(330, 0, 256);
+        testPgUpdate(230, 2, 1024);
+        testPgUpdate(230, 3, 2048);
+      });
+    });
+
+    describe('pgKeyUp', () => {
+      const testPgKeyUp = (keyName, returnValue) => {
+        component.pgKeyUp({ key: keyName });
+        expect(form.getValue('pgNum')).toBe(returnValue);
+      };
+
+      it('does nothing with unrelated keys', () => {
+        testPgKeyUp('0', 256);
+        testPgKeyUp(',', 256);
+        testPgKeyUp('a', 256);
+        testPgKeyUp('Space', 256);
+        testPgKeyUp('ArrowLeft', 256);
+        testPgKeyUp('ArrowRight', 256);
+      });
+
+      it('increments by jump with plus or ArrowUp', () => {
+        testPgKeyUp('ArrowUp', 512);
+        testPgKeyUp('ArrowUp', 1024);
+        testPgKeyUp('+', 2048);
+        testPgKeyUp('+', 4096);
+      });
+
+      it('decrement by jump with minus or ArrowDown', () => {
+        testPgKeyUp('ArrowDown', 128);
+        testPgKeyUp('ArrowDown', 64);
+        testPgKeyUp('-', 32);
+        testPgKeyUp('-', 16);
+      });
+    });
+  });
+
+  describe('submit - create', () => {
+    const setMultipleValues = (settings: {}) => {
+      Object.keys(settings).forEach((name) => {
+        setValue(name, settings[name]);
+      });
+    };
+    const testCreate = (pool) => {
+      testSubmit(pool, 'pool/create', 'create');
+    };
+
+    beforeEach(() => {
+      createCrushRule({ name: 'replicatedRule' });
+      createCrushRule({ name: 'erasureRule', type: 'erasure', id: 1 });
+    });
+
+    describe('erasure coded pool', () => {
+      it('minimum requirements', () => {
+        setMultipleValues({
+          name: 'minECPool',
+          poolType: 'erasure',
+          pgNum: 4
+        });
+        testCreate({
+          pool: 'minECPool',
+          pool_type: 'erasure',
+          pg_num: 4
+        });
+      });
+
+      it('with erasure coded profile', () => {
+        const ecp = { name: 'ecpMinimalMock' };
+        setMultipleValues({
+          name: 'ecpPool',
+          poolType: 'erasure',
+          pgNum: 16,
+          size: 2, // Will be ignored
+          erasureProfile: ecp
+        });
+        testCreate({
+          pool: 'ecpPool',
+          pool_type: 'erasure',
+          pg_num: 16,
+          erasure_code_profile: ecp.name
+        });
+      });
+
+      it('with ec_overwrite flag', () => {
+        setMultipleValues({
+          name: 'ecOverwrites',
+          poolType: 'erasure',
+          pgNum: 32,
+          ecOverwrites: true
+        });
+        testCreate({
+          pool: 'ecOverwrites',
+          pool_type: 'erasure',
+          pg_num: 32,
+          flags: ['ec_overwrites']
+        });
+      });
+    });
+
+    describe('replicated coded pool', () => {
+      it('minimum requirements', () => {
+        const ecp = { name: 'ecpMinimalMock' };
+        setMultipleValues({
+          name: 'minRepPool',
+          poolType: 'replicated',
+          size: 2,
+          erasureProfile: ecp, // Will be ignored
+          pgNum: 8
+        });
+        testCreate({
+          pool: 'minRepPool',
+          pool_type: 'replicated',
+          pg_num: 8,
+          size: 2
+        });
+      });
+    });
+
+    it('pool with compression', () => {
+      setMultipleValues({
+        name: 'compression',
+        poolType: 'erasure',
+        pgNum: 64,
+        mode: 'passive',
+        algorithm: 'lz4',
+        minBlobSize: '4 K',
+        maxBlobSize: '4 M',
+        ratio: 0.7
+      });
+      testCreate({
+        pool: 'compression',
+        pool_type: 'erasure',
+        pg_num: 64,
+        compression_mode: 'passive',
+        compression_algorithm: 'lz4',
+        compression_min_blob_size: 4096,
+        compression_max_blob_size: 4194304,
+        compression_required_ratio: 0.7
+      });
+    });
+
+    it('pool with application metadata', () => {
+      setMultipleValues({
+        name: 'apps',
+        poolType: 'erasure',
+        pgNum: 128
+      });
+      component.data.applications.selected = ['cephfs', 'rgw'];
+      testCreate({
+        pool: 'apps',
+        pool_type: 'erasure',
+        pg_num: 128,
+        application_metadata: ['cephfs', 'rgw']
+      });
+    });
+  });
+
+  describe('edit mode', () => {
+    const setUrl = (url) => {
+      Object.defineProperty(router, 'url', { value: url });
+      setUpPoolComponent(); // Renew of component needed because the constructor has to be called
+    };
+
+    let pool: Pool;
+    beforeEach(() => {
+      pool = new Pool('somePoolName');
+      pool.type = 'replicated';
+      pool.size = 3;
+      pool.crush_rule = 'someRule';
+      pool.pg_num = 32;
+      pool.options = {};
+      pool.options.compression_mode = 'passive';
+      pool.options.compression_algorithm = 'lz4';
+      pool.options.compression_min_blob_size = 1024 * 512;
+      pool.options.compression_max_blob_size = 1024 * 1024;
+      pool.options.compression_required_ratio = 0.8;
+      pool.flags_names = 'someFlag1,someFlag2';
+      pool.application_metadata = ['rbd', 'rgw'];
+      createCrushRule({ name: 'someRule' });
+      spyOn(poolService, 'get').and.callFake(() => of(pool));
+    });
+
+    it('is not in edit mode if edit is not included in url', () => {
+      setUrl('/pool/add');
+      expect(component.editing).toBeFalsy();
+    });
+
+    it('is in edit mode if edit is included in url', () => {
+      setUrl('/pool/edit/somePoolName');
+      expect(component.editing).toBeTruthy();
+    });
+
+    describe('after ngOnInit', () => {
+      beforeEach(() => {
+        component.editing = true;
+        component.ngOnInit();
+      });
+
+      it('disabled inputs', () => {
+        const disabled = [
+          'name',
+          'poolType',
+          'crushRule',
+          'size',
+          'erasureProfile',
+          'ecOverwrites'
+        ];
+        disabled.forEach((controlName) => {
+          return expect(form.get(controlName).disabled).toBeTruthy();
+        });
+        const enabled = ['pgNum', 'mode', 'algorithm', 'minBlobSize', 'maxBlobSize', 'ratio'];
+        enabled.forEach((controlName) => {
+          return expect(form.get(controlName).enabled).toBeTruthy();
+        });
+      });
+
+      it('set all control values to the given pool', () => {
+        expect(form.getValue('name')).toBe(pool.pool_name);
+        expect(form.getValue('poolType')).toBe(pool.type);
+        expect(form.getValue('crushRule')).toEqual(component.info.crush_rules_replicated[0]);
+        expect(form.getValue('size')).toBe(pool.size);
+        expect(form.getValue('pgNum')).toBe(pool.pg_num);
+        expect(form.getValue('mode')).toBe(pool.options.compression_mode);
+        expect(form.getValue('algorithm')).toBe(pool.options.compression_algorithm);
+        expect(form.getValue('minBlobSize')).toBe('512 KiB');
+        expect(form.getValue('maxBlobSize')).toBe('1 MiB');
+        expect(form.getValue('ratio')).toBe(pool.options.compression_required_ratio);
+      });
+
+      it('is only be possible to use the same or more pgs like before', () => {
+        isValid(setPgNum(64));
+        hasError(setPgNum(4), 'noDecrease');
+      });
+
+      it(`always provides the application metadata array with submit even if it's empty`, () => {
+        component.data.applications.selected = [];
+        testSubmit(
+          {
+            application_metadata: [],
+            compression_algorithm: 'lz4',
+            compression_max_blob_size: 1048576,
+            compression_min_blob_size: 524288,
+            compression_mode: 'passive',
+            compression_required_ratio: 0.8,
+            pg_num: 32,
+            pool: 'somePoolName'
+          },
+          'pool/edit',
+          'update'
+        );
+      });
+    });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/pool/pool-form/pool-form.component.ts
new file mode 100644 (file)
index 0000000..fc8002c
--- /dev/null
@@ -0,0 +1,530 @@
+import { Component, OnInit } from '@angular/core';
+import { FormControl, Validators } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
+
+import * as _ from 'lodash';
+import { forkJoin } from 'rxjs';
+
+import { ErasureCodeProfileService } from '../../../shared/api/erasure-code-profile.service';
+import { PoolService } from '../../../shared/api/pool.service';
+import { CdFormGroup } from '../../../shared/forms/cd-form-group';
+import { CdValidators } from '../../../shared/forms/cd-validators';
+import { CrushRule } from '../../../shared/models/crush-rule';
+import { CrushStep } from '../../../shared/models/crush-step';
+import { ErasureCodeProfile } from '../../../shared/models/erasure-code-profile';
+import { FinishedTask } from '../../../shared/models/finished-task';
+import { Permission } from '../../../shared/models/permissions';
+import { DimlessBinaryPipe } from '../../../shared/pipes/dimless-binary.pipe';
+import { AuthStorageService } from '../../../shared/services/auth-storage.service';
+import { FormatterService } from '../../../shared/services/formatter.service';
+import { TaskWrapperService } from '../../../shared/services/task-wrapper.service';
+import { Pool } from '../pool';
+import { PoolFormData } from './pool-form-data';
+import { PoolFormInfo } from './pool-form-info';
+
+@Component({
+  selector: 'cd-pool-form',
+  templateUrl: './pool-form.component.html',
+  styleUrls: ['./pool-form.component.scss']
+})
+export class PoolFormComponent implements OnInit {
+  permission: Permission;
+  form: CdFormGroup;
+  compressionForm: CdFormGroup;
+  ecProfiles: ErasureCodeProfile[];
+  info: PoolFormInfo;
+  routeParamsSubscribe: any;
+  editing = false;
+  data = new PoolFormData();
+  externalPgChange = false;
+  current = {
+    rules: []
+  };
+
+  constructor(
+    private dimlessBinaryPipe: DimlessBinaryPipe,
+    private route: ActivatedRoute,
+    private router: Router,
+    private poolService: PoolService,
+    private authStorageService: AuthStorageService,
+    private formatter: FormatterService,
+    private taskWrapper: TaskWrapperService,
+    private ecpService: ErasureCodeProfileService
+  ) {
+    this.editing = this.router.url.startsWith('/pool/edit');
+    this.authenticate();
+    this.createForms();
+  }
+
+  authenticate() {
+    this.permission = this.authStorageService.getPermissions().pool;
+    if (
+      !this.permission.read ||
+      ((!this.permission.update && this.editing) || (!this.permission.create && !this.editing))
+    ) {
+      this.router.navigate(['/404']);
+    }
+  }
+
+  private createForms() {
+    this.compressionForm = new CdFormGroup({
+      mode: new FormControl('none'),
+      algorithm: new FormControl(''),
+      minBlobSize: new FormControl('', {
+        updateOn: 'blur'
+      }),
+      maxBlobSize: new FormControl('', {
+        updateOn: 'blur'
+      }),
+      ratio: new FormControl('', {
+        updateOn: 'blur'
+      })
+    });
+    this.form = new CdFormGroup(
+      {
+        name: new FormControl('', {
+          validators: [
+            Validators.pattern('[A-Za-z0-9_-]+'),
+            Validators.required,
+            CdValidators.custom(
+              'uniqueName',
+              (value) => this.info && this.info.pool_names.indexOf(value) !== -1
+            )
+          ]
+        }),
+        poolType: new FormControl('', {
+          validators: [Validators.required]
+        }),
+        crushRule: new FormControl(null, {
+          validators: [
+            CdValidators.custom(
+              'tooFewOsds',
+              (rule) => this.info && rule && this.info.osd_count < rule.min_size
+            )
+          ]
+        }),
+        size: new FormControl('', {
+          updateOn: 'blur'
+        }),
+        erasureProfile: new FormControl(null),
+        pgNum: new FormControl('', {
+          validators: [Validators.required, Validators.min(1)]
+        }),
+        ecOverwrites: new FormControl(false),
+        compression: this.compressionForm
+      },
+      CdValidators.custom('form', () => null)
+    );
+  }
+
+  ngOnInit() {
+    forkJoin(this.poolService.getInfo(), this.ecpService.list()).subscribe(
+      (data: [PoolFormInfo, ErasureCodeProfile[]]) => {
+        this.initInfo(data[0]);
+        this.initEcp(data[1]);
+        if (this.editing) {
+          this.initEditMode();
+        }
+        this.listenToChanges();
+        this.setComplexValidators();
+      }
+    );
+  }
+
+  private initInfo(info: PoolFormInfo) {
+    info.compression_algorithms = info.compression_algorithms.filter((m) => m.length > 0);
+    this.info = info;
+  }
+
+  private initEcp(ecProfiles: ErasureCodeProfile[]) {
+    if (ecProfiles.length === 1) {
+      const control = this.form.get('erasureProfile');
+      control.setValue(ecProfiles[0]);
+      control.disable();
+    }
+    this.ecProfiles = ecProfiles;
+  }
+
+  private initEditMode() {
+    this.disableForEdit();
+    this.routeParamsSubscribe = this.route.params.subscribe((param: { name: string }) =>
+      this.poolService.get(param.name).subscribe((pool: Pool) => {
+        this.data.pool = pool;
+        this.initEditFormData(pool);
+      })
+    );
+  }
+
+  private disableForEdit() {
+    ['name', 'poolType', 'crushRule', 'size', 'erasureProfile', 'ecOverwrites'].forEach(
+      (controlName) => this.form.get(controlName).disable()
+    );
+  }
+
+  private initEditFormData(pool: Pool) {
+    const transform = {
+      name: 'pool_name',
+      poolType: 'type',
+      crushRule: (p) =>
+        this.info['crush_rules_' + p.type].find(
+          (rule: CrushRule) => rule.rule_name === p.crush_rule
+        ),
+      size: 'size',
+      erasureProfile: (p) => this.ecProfiles.find((ecp) => ecp.name === p.erasure_code_profile),
+      pgNum: 'pg_num',
+      ecOverwrites: (p) => p.flags_names.includes('ec_overwrites'),
+      mode: 'options.compression_mode',
+      algorithm: 'options.compression_algorithm',
+      minBlobSize: (p) => this.dimlessBinaryPipe.transform(p.options.compression_min_blob_size),
+      maxBlobSize: (p) => this.dimlessBinaryPipe.transform(p.options.compression_max_blob_size),
+      ratio: 'options.compression_required_ratio'
+    };
+    Object.keys(transform).forEach((key) => {
+      const attrib = transform[key];
+      const value = _.isFunction(attrib) ? attrib(pool) : _.get(pool, attrib);
+      if (!_.isUndefined(value) && value !== '') {
+        this.form.silentSet(key, value);
+      }
+    });
+    this.data.applications.selected = pool.application_metadata;
+  }
+
+  private listenToChanges() {
+    this.listenToChangesDuringAddEdit();
+    if (!this.editing) {
+      this.listenToChangesDuringAdd();
+    }
+  }
+
+  private listenToChangesDuringAddEdit() {
+    this.form.get('pgNum').valueChanges.subscribe((pgs) => {
+      const change = pgs - this.data.pgs;
+      if (Math.abs(change) === 1) {
+        this.pgUpdate(undefined, change);
+      }
+    });
+  }
+
+  private listenToChangesDuringAdd() {
+    this.form.get('poolType').valueChanges.subscribe((poolType) => {
+      this.form.get('size').updateValueAndValidity();
+      this.rulesChange();
+      if (poolType === 'replicated') {
+        this.replicatedRuleChange();
+      }
+      this.pgCalc();
+    });
+    this.form.get('crushRule').valueChanges.subscribe(() => {
+      if (this.form.getValue('poolType') === 'replicated') {
+        this.replicatedRuleChange();
+      }
+      this.pgCalc();
+    });
+    this.form.get('size').valueChanges.subscribe(() => {
+      this.pgCalc();
+    });
+    this.form.get('erasureProfile').valueChanges.subscribe(() => {
+      this.pgCalc();
+    });
+    this.form.get('mode').valueChanges.subscribe(() => {
+      ['minBlobSize', 'maxBlobSize', 'ratio'].forEach((name) =>
+        this.form.get(name).updateValueAndValidity()
+      );
+    });
+    this.form.get('minBlobSize').valueChanges.subscribe(() => {
+      this.form.get('maxBlobSize').updateValueAndValidity({ emitEvent: false });
+    });
+    this.form.get('maxBlobSize').valueChanges.subscribe(() => {
+      this.form.get('minBlobSize').updateValueAndValidity({ emitEvent: false });
+    });
+  }
+
+  private rulesChange() {
+    const poolType = this.form.getValue('poolType');
+    if (!poolType || !this.info) {
+      this.current.rules = [];
+      return;
+    }
+    const rules = this.info['crush_rules_' + poolType] || [];
+    const control = this.form.get('crushRule');
+    if (rules.length === 1) {
+      control.setValue(rules[0]);
+      control.disable();
+    } else {
+      control.setValue(null);
+      control.enable();
+    }
+    this.current.rules = rules;
+  }
+
+  private replicatedRuleChange() {
+    if (this.form.getValue('poolType') !== 'replicated') {
+      return;
+    }
+    const control = this.form.get('size');
+    let size = this.form.getValue('size') || 3;
+    const min = this.getMinSize();
+    const max = this.getMaxSize();
+    if (size < min) {
+      size = min;
+    } else if (size > max) {
+      size = max;
+    }
+    if (size !== control.value) {
+      this.form.silentSet('size', size);
+    }
+  }
+
+  getMinSize(): number {
+    if (!this.info || this.info.osd_count < 1) {
+      return;
+    }
+    const rule = this.form.getValue('crushRule');
+    if (rule) {
+      return rule.min_size;
+    }
+    return 1;
+  }
+
+  getMaxSize(): number {
+    if (!this.info || this.info.osd_count < 1) {
+      return;
+    }
+    const osds: number = this.info.osd_count;
+    if (this.form.getValue('crushRule')) {
+      const max: number = this.form.get('crushRule').value.max_size;
+      if (max < osds) {
+        return max;
+      }
+    }
+    return osds;
+  }
+
+  private pgCalc() {
+    const poolType = this.form.getValue('poolType');
+    if (!this.info || this.form.get('pgNum').dirty || !poolType) {
+      return;
+    }
+    const pgMax = this.info.osd_count * 100;
+    const pgs =
+      poolType === 'replicated' ? this.replicatedPgCalc(pgMax) : this.erasurePgCalc(pgMax);
+    if (!pgs) {
+      return;
+    }
+    const oldValue = this.data.pgs;
+    this.pgUpdate(pgs);
+    const newValue = this.data.pgs;
+    if (!this.externalPgChange) {
+      this.externalPgChange = oldValue !== newValue;
+    }
+  }
+
+  private replicatedPgCalc(pgs): number {
+    const sizeControl = this.form.get('size');
+    const size = sizeControl.value;
+    if (sizeControl.valid && size > 0) {
+      return pgs / size;
+    }
+  }
+
+  private erasurePgCalc(pgs): number {
+    const ecpControl = this.form.get('erasureProfile');
+    const ecp = ecpControl.value;
+    if ((ecpControl.valid || ecpControl.disabled) && ecp) {
+      return pgs / (ecp.k + ecp.m);
+    }
+  }
+
+  private pgUpdate(pgs?, jump?) {
+    pgs = _.isNumber(pgs) ? pgs : this.form.getValue('pgNum');
+    if (pgs < 1) {
+      pgs = 1;
+    }
+    let power = Math.round(Math.log(pgs) / Math.log(2));
+    if (_.isNumber(jump)) {
+      power += jump;
+    }
+    if (power < 0) {
+      power = 0;
+    }
+    pgs = Math.pow(2, power); // Set size the nearest accurate size.
+    this.data.pgs = pgs;
+    this.form.silentSet('pgNum', pgs);
+  }
+
+  private setComplexValidators() {
+    if (this.editing) {
+      this.form
+        .get('pgNum')
+        .setValidators(
+          CdValidators.custom('noDecrease', (pgs) => this.data.pool && pgs < this.data.pool.pg_num)
+        );
+    } else {
+      CdValidators.validateIf(
+        this.form.get('size'),
+        () => this.form.get('poolType').value === 'replicated',
+        [
+          CdValidators.custom(
+            'min',
+            (value) => this.form.getValue('size') && value < this.getMinSize()
+          ),
+          CdValidators.custom(
+            'max',
+            (value) => this.form.getValue('size') && this.getMaxSize() < value
+          )
+        ]
+      );
+    }
+    this.setCompressionValidators();
+  }
+
+  private setCompressionValidators() {
+    CdValidators.validateIf(this.form.get('minBlobSize'), () => this.activatedCompression(), [
+      Validators.min(0),
+      CdValidators.custom('maximum', (size) =>
+        this.compareBlobSize(size, this.form.getValue('maxBlobSize'))
+      )
+    ]);
+    CdValidators.validateIf(this.form.get('maxBlobSize'), () => this.activatedCompression(), [
+      Validators.min(0),
+      CdValidators.custom('minimum', (size) =>
+        this.compareBlobSize(this.form.getValue('minBlobSize'), size)
+      )
+    ]);
+    CdValidators.validateIf(this.form.get('ratio'), () => this.activatedCompression(), [
+      Validators.min(0),
+      Validators.max(1)
+    ]);
+  }
+
+  private compareBlobSize(minimum, maximum) {
+    return Boolean(
+      minimum && maximum && this.formatter.toBytes(minimum) >= this.formatter.toBytes(maximum)
+    );
+  }
+
+  activatedCompression() {
+    return this.form.getValue('mode') && this.form.get('mode').value.toLowerCase() !== 'none';
+  }
+
+  pgKeyUp($e) {
+    const key = $e.key;
+    const included = (arr: string[]): number => (arr.indexOf(key) !== -1 ? 1 : 0);
+    const jump = included(['ArrowUp', '+']) - included(['ArrowDown', '-']);
+    if (jump) {
+      this.pgUpdate(undefined, jump);
+    }
+  }
+
+  describeCrushStep(step: CrushStep) {
+    return [
+      step.op.replace('_', ' '),
+      step.item_name || '',
+      step.type ? step.num + ' type ' + step.type : ''
+    ].join(' ');
+  }
+
+  submit() {
+    if (this.form.invalid) {
+      this.form.setErrors({ cdSubmitButton: true });
+      return;
+    }
+    const pool = {};
+    this.extendByItemsForSubmit(pool, [
+      { api: 'pool', name: 'name', edit: true },
+      { api: 'pool_type', name: 'poolType' },
+      { api: 'pg_num', name: 'pgNum', edit: true },
+      this.form.getValue('poolType') === 'replicated'
+        ? { api: 'size', name: 'size' }
+        : { api: 'erasure_code_profile', name: 'erasureProfile', attr: 'name' },
+      { api: 'rule_name', name: 'crushRule', attr: 'rule_name' }
+    ]);
+    if (this.info.is_all_bluestore) {
+      this.extendByItemForSubmit(pool, {
+        api: 'flags',
+        name: 'ecOverwrites',
+        fn: () => ['ec_overwrites']
+      });
+      if (this.form.getValue('mode')) {
+        this.extendByItemsForSubmit(pool, [
+          {
+            api: 'compression_mode',
+            name: 'mode',
+            edit: true,
+            fn: (value) => this.activatedCompression() && value
+          },
+          { api: 'compression_algorithm', name: 'algorithm', edit: true },
+          {
+            api: 'compression_min_blob_size',
+            name: 'minBlobSize',
+            fn: this.formatter.toBytes,
+            edit: true
+          },
+          {
+            api: 'compression_max_blob_size',
+            name: 'maxBlobSize',
+            fn: this.formatter.toBytes,
+            edit: true
+          },
+          { api: 'compression_required_ratio', name: 'ratio', edit: true }
+        ]);
+      }
+    }
+    const apps = this.data.applications.selected;
+    if (apps.length > 0 || this.editing) {
+      pool['application_metadata'] = apps;
+    }
+    this.triggerApiTask(pool);
+  }
+
+  private extendByItemsForSubmit(pool, items: any[]) {
+    items.forEach((item) => this.extendByItemForSubmit(pool, item));
+  }
+
+  private extendByItemForSubmit(
+    pool,
+    {
+      api,
+      name,
+      attr,
+      fn,
+      edit
+    }: {
+      api: string;
+      name: string;
+      attr?: string;
+      fn?: Function;
+      edit?: boolean;
+    }
+  ) {
+    if (this.editing && !edit) {
+      return;
+    }
+    const value = this.form.getValue(name);
+    const apiValue = fn ? fn(value) : attr ? _.get(value, attr) : value;
+    if (!value || !apiValue) {
+      return;
+    }
+    pool[api] = apiValue;
+  }
+
+  private triggerApiTask(pool) {
+    this.taskWrapper
+      .wrapTaskAroundCall({
+        task: new FinishedTask('pool/' + (this.editing ? 'edit' : 'create'), {
+          pool_name: pool.pool
+        }),
+        call: this.poolService[this.editing ? 'update' : 'create'](pool)
+      })
+      .subscribe(
+        undefined,
+        (resp) => {
+          if (_.isObject(resp.error) && resp.error.code === '34') {
+            this.form.get('pgNum').setErrors({ '34': true });
+          }
+          this.form.setErrors({ cdSubmitButton: true });
+        },
+        () => this.router.navigate(['/pool'])
+      );
+  }
+}
index 1f3f5f2d284cafea22c6a228072198fffb672748..8037c1748099dbd69e7cfbbe42195542c87a53b8 100644 (file)
@@ -5,13 +5,55 @@
               [columns]="columns"
               selectionType="single"
               (updateSelection)="updateSelection($event)">
-      <tabset cdTableDetail
-              *ngIf="selection.hasSingleSelection">
+      <div class="table-actions">
+        <div class="btn-group"
+             dropdown>
+          <button type="button"
+                  class="btn btn-sm btn-primary"
+                  *ngIf="!selection.hasSingleSelection"
+                  routerLink="/pool/add">
+            <i class="fa fa-fw fa-plus"></i><span i18n>Add</span>
+          </button>
+          <button type="button"
+                  class="btn btn-sm btn-primary"
+                  *ngIf="selection.hasSingleSelection"
+                  [ngClass]="{'disabled': selection.first()?.executing}"
+                  routerLink="/pool/edit/{{ selection.first()?.pool_name }}">
+            <i class="fa fa-fw fa-pencil"></i><span i18n>Edit</span>
+          </button>
+          <button type="button"
+                  dropdownToggle
+                  class="btn btn-sm btn-primary dropdown-toggle dropdown-toggle-split">
+            <span class="caret"></span>
+            <span class="sr-only"></span>
+          </button>
+          <ul *dropdownMenu class="dropdown-menu" role="menu">
+            <li role="menuitem">
+              <a class="dropdown-item"
+                 routerLink="/pool/add">
+                <i class="fa fa-fw fa-plus"></i><span i18n>Add</span>
+              </a>
+            </li>
+            <li role="menuitem"
+                [ngClass]="{'disabled': !selection.hasSingleSelection || selection.first()?.executing}">
+              <a class="dropdown-item"
+                 routerLink="/pool/edit/{{ selection.first()?.pool_name }}">
+                <i class="fa fa-fw fa-pencil"></i><span i18n>Edit</span></a>
+            </li>
+            <li role="menuitem"
+                [ngClass]="{'disabled': !selection.hasSingleSelection || selection.first()?.executing}">
+              <a class="dropdown-item"
+                 (click)="deletePoolModal()">
+                <i class="fa fa-fw fa-trash-o"></i><span i18n>Delete</span>
+              </a>
+            </li>
+          </ul>
+        </div>
+      </div>
+      <tabset cdTableDetail *ngIf="selection.hasSingleSelection">
         <tab i18n-heading
              heading="Details">
-          <cd-table-key-value [data]="selection.first()"
-                              [renderObjects]="true"
-                              [autoReload]="false">
+          <cd-table-key-value [renderObjects]="true" [data]="selection.first()" [autoReload]="false">
           </cd-table-key-value>
         </tab>
         <tab i18n-heading
index 92c24034320cb946436589af211b1910cbc4473e..a4032df0a533d9caf06eb1fb9b6457949f199de6 100644 (file)
@@ -1,15 +1,27 @@
 import { CommonModule } from '@angular/common';
 import { NgModule } from '@angular/core';
+import { ReactiveFormsModule } from '@angular/forms';
+import { RouterModule } from '@angular/router';
 
-import { TabsModule } from 'ngx-bootstrap/tabs';
+import { BsDropdownModule, PopoverModule, TabsModule } from 'ngx-bootstrap';
 
 import { ServicesModule } from '../../shared/services/services.module';
 import { SharedModule } from '../../shared/shared.module';
+import { PoolFormComponent } from './pool-form/pool-form.component';
 import { PoolListComponent } from './pool-list/pool-list.component';
 
 @NgModule({
-  imports: [CommonModule, TabsModule, SharedModule, ServicesModule],
-  exports: [PoolListComponent],
-  declarations: [PoolListComponent]
+  imports: [
+    CommonModule,
+    TabsModule,
+    PopoverModule.forRoot(),
+    SharedModule,
+    RouterModule,
+    ReactiveFormsModule,
+    BsDropdownModule,
+    ServicesModule
+  ],
+  exports: [PoolListComponent, PoolFormComponent],
+  declarations: [PoolListComponent, PoolFormComponent]
 })
 export class PoolModule {}
index 89630df492060a2fe59eb8cd96e3c3a6f7a7a052..c0e4303c1d5b29e32161afd6c626c8613d94800e 100644 (file)
@@ -7,6 +7,7 @@ import { PoolService } from './pool.service';
 describe('PoolService', () => {
   let service: PoolService;
   let httpTesting: HttpTestingController;
+  const apiPath = 'api/pool';
 
   configureTestBed({
     providers: [PoolService],
@@ -28,16 +29,37 @@ describe('PoolService', () => {
 
   it('should call getList', () => {
     service.getList().subscribe();
-    const req = httpTesting.expectOne('api/pool');
+    const req = httpTesting.expectOne(apiPath);
     expect(req.request.method).toBe('GET');
   });
 
+  it('should call getInfo', () => {
+    service.getInfo().subscribe();
+    const req = httpTesting.expectOne(`${apiPath}/_info`);
+    expect(req.request.method).toBe('GET');
+  });
+
+  it('should call create', () => {
+    const pool = { pool: 'somePool' };
+    service.create(pool).subscribe();
+    const req = httpTesting.expectOne(apiPath);
+    expect(req.request.method).toBe('POST');
+    expect(req.request.body).toEqual(pool);
+  });
+
+  it('should call update', () => {
+    service.update({ pool: 'somePool', application_metadata: [] }).subscribe();
+    const req = httpTesting.expectOne(`${apiPath}/somePool`);
+    expect(req.request.method).toBe('PUT');
+    expect(req.request.body).toEqual({ application_metadata: [] });
+  });
+
   it(
     'should call list without parameter',
     fakeAsync(() => {
       let result;
       service.list().then((resp) => (result = resp));
-      const req = httpTesting.expectOne('api/pool?attrs=');
+      const req = httpTesting.expectOne(`${apiPath}?attrs=`);
       expect(req.request.method).toBe('GET');
       req.flush(['foo', 'bar']);
       tick();
@@ -50,7 +72,7 @@ describe('PoolService', () => {
     fakeAsync(() => {
       let result;
       service.list(['foo']).then((resp) => (result = resp));
-      const req = httpTesting.expectOne('api/pool?attrs=foo');
+      const req = httpTesting.expectOne(`${apiPath}?attrs=foo`);
       expect(req.request.method).toBe('GET');
       req.flush(['foo', 'bar']);
       tick();
index 32cc67bdb4cbca0cf50b77d8d0e15cd8ee9c713d..2716a88948634a76f6b24f5ae3d4982df2049ba0 100644 (file)
@@ -9,16 +9,36 @@ import { ApiModule } from './api.module';
   providedIn: ApiModule
 })
 export class PoolService {
+  apiPath = 'api/pool';
+
   constructor(private http: HttpClient) {}
 
+  create(pool) {
+    return this.http.post(this.apiPath, pool, { observe: 'response' });
+  }
+
+  update(pool) {
+    const name = pool.pool;
+    delete pool.pool;
+    return this.http.put(`${this.apiPath}/${name}`, pool, { observe: 'response' });
+  }
+
+  get(poolName) {
+    return this.http.get(`${this.apiPath}/${poolName}`);
+  }
+
   getList() {
-    return this.http.get('api/pool');
+    return this.http.get(this.apiPath);
+  }
+
+  getInfo() {
+    return this.http.get(`${this.apiPath}/_info`);
   }
 
   list(attrs = []) {
     const attrsStr = attrs.join(',');
     return this.http
-      .get(`api/pool?attrs=${attrsStr}`)
+      .get(`${this.apiPath}?attrs=${attrsStr}`)
       .toPromise()
       .then((resp: any) => {
         return resp;
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-manager-message.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-manager-message.service.spec.ts
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-manager-message.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-manager-message.service.ts
new file mode 100644 (file)
index 0000000..e69de29