]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Create Cluster Workflow welcome screen and e2e tests
authorAvan Thakkar <athakkar@localhost.localdomain>
Tue, 1 Jun 2021 12:55:15 +0000 (18:25 +0530)
committerNizamudeen A <nia@redhat.com>
Wed, 13 Oct 2021 10:22:14 +0000 (15:52 +0530)
A module option called CLUSTER_STATUS has two option. INSTALLED
AND POST_INSTALLED. When CLUSTER_STATUS is INSTALLED it will allow to show the
create-cluster-wizard after login the initial time.  After the cluster
creation is succesfull this option is set to POST_INSTALLED
Also has the e2e codes for the Review Section

Fixes: https://tracker.ceph.com/issues/50336
Signed-off-by: Avan Thakkar <athakkar@redhat.com>
Signed-off-by: Nizamudeen A <nia@redhat.com>
23 files changed:
qa/tasks/mgr/dashboard/test_auth.py
qa/tasks/mgr/dashboard/test_cluster.py [new file with mode: 0644]
src/pybind/mgr/dashboard/controllers/auth.py
src/pybind/mgr/dashboard/controllers/cluster.py [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/cluster-welcome-page.po.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/create-cluster-review.po.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/06-cluster-welcome-page.e2e-spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/08-create-cluster-review.e2e-spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/cluster.service.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/api/cluster.service.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/module-status-guard.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/module-status-guard.service.ts
src/pybind/mgr/dashboard/openapi.yaml
src/pybind/mgr/dashboard/services/cluster.py [new file with mode: 0644]

index 8fc7cd1992e6dce79511411b91d1b6f9fafb479f..98566344444f7a8dc531f53bb3adbd428c96bdd9 100644 (file)
@@ -335,7 +335,8 @@ class AuthTest(DashboardTestCase):
         self.assertStatus(200)
         data = self.jsonBody()
         self.assertSchema(data, JObj(sub_elems={
-            "login_url": JLeaf(str)
+            "login_url": JLeaf(str),
+            "cluster_status": JLeaf(str)
         }, allow_unknown=False))
         self.logout()
 
@@ -345,6 +346,7 @@ class AuthTest(DashboardTestCase):
         self.assertStatus(200)
         data = self.jsonBody()
         self.assertSchema(data, JObj(sub_elems={
-            "login_url": JLeaf(str)
+            "login_url": JLeaf(str),
+            "cluster_status": JLeaf(str)
         }, allow_unknown=False))
         self.logout(set_cookies=True)
diff --git a/qa/tasks/mgr/dashboard/test_cluster.py b/qa/tasks/mgr/dashboard/test_cluster.py
new file mode 100644 (file)
index 0000000..14f8542
--- /dev/null
@@ -0,0 +1,23 @@
+from .helper import DashboardTestCase, JLeaf, JObj
+
+
+class ClusterTest(DashboardTestCase):
+
+    def setUp(self):
+        super().setUp()
+        self.reset_session()
+
+    def test_get_status(self):
+        data = self._get('/api/cluster', version='0.1')
+        self.assertStatus(200)
+        self.assertSchema(data, JObj(sub_elems={
+            "status": JLeaf(str)
+        }, allow_unknown=False))
+
+    def test_update_status(self):
+        req = {'status': 'POST_INSTALLED'}
+        self._put('/api/cluster', req, version='0.1')
+        self.assertStatus(200)
+        data = self._get('/api/cluster', version='0.1')
+        self.assertStatus(200)
+        self.assertEqual(data, req)
index 353d5d72bb9e83e7b7e6db7adb3faf004937d150..196f027b293ee5ecaa29a863777d7cb326b0c552 100644 (file)
@@ -7,6 +7,7 @@ import sys
 from .. import mgr
 from ..exceptions import InvalidCredentialsError, UserDoesNotExist
 from ..services.auth import AuthManager, JwtManager
+from ..services.cluster import ClusterModel
 from ..settings import Settings
 from . import APIDoc, APIRouter, ControllerAuthMixin, EndpointDoc, RESTController, allow_empty_body
 
@@ -117,4 +118,5 @@ class Auth(RESTController, ControllerAuthMixin):
                 }
         return {
             'login_url': self._get_login_url(),
+            'cluster_status': ClusterModel.from_db().dict()['status']
         }
diff --git a/src/pybind/mgr/dashboard/controllers/cluster.py b/src/pybind/mgr/dashboard/controllers/cluster.py
new file mode 100644 (file)
index 0000000..5ec49e3
--- /dev/null
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+
+from ..security import Scope
+from ..services.cluster import ClusterModel
+from . import ApiController, ControllerDoc, EndpointDoc, RESTController
+
+
+@ApiController('/cluster', Scope.CONFIG_OPT)
+@ControllerDoc("Get Cluster Details", "Cluster")
+class Cluster(RESTController):
+    @RESTController.MethodMap(version='0.1')
+    @EndpointDoc("Get the cluster status")
+    def list(self):
+        return ClusterModel.from_db().dict()
+
+    @RESTController.MethodMap(version='0.1')
+    @EndpointDoc("Update the cluster status",
+                 parameters={'status': (str, 'Cluster Status')})
+    def singleton_set(self, status: str):
+        ClusterModel(status).to_db()
diff --git a/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/cluster-welcome-page.po.ts b/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/cluster-welcome-page.po.ts
new file mode 100644 (file)
index 0000000..5615b03
--- /dev/null
@@ -0,0 +1,22 @@
+import { PageHelper } from '../page-helper.po';
+import { NotificationSidebarPageHelper } from '../ui/notification.po';
+
+export class CreateClusterWelcomePageHelper extends PageHelper {
+  pages = {
+    index: { url: '#/create-cluster', id: 'cd-create-cluster' }
+  };
+
+  createCluster() {
+    cy.get('cd-create-cluster').should('contain.text', 'Welcome to Ceph');
+    cy.get('[name=create-cluster]').click();
+  }
+
+  doSkip() {
+    cy.get('[name=skip-cluster-creation]').click();
+
+    cy.get('cd-dashboard').should('exist');
+    const notification = new NotificationSidebarPageHelper();
+    notification.open();
+    notification.getNotifications().should('contain', 'Cluster creation skipped by user');
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/create-cluster-review.po.ts b/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/create-cluster-review.po.ts
new file mode 100644 (file)
index 0000000..58844e3
--- /dev/null
@@ -0,0 +1,11 @@
+import { PageHelper } from '../page-helper.po';
+
+export class CreateClusterReviewPageHelper extends PageHelper {
+  pages = {
+    index: { url: '#/create-cluster', id: 'cd-create-cluster-review' }
+  };
+
+  checkDefaultHostName() {
+    this.getTableCell(1, 'ceph-node-00.cephlab.com').should('exist');
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/06-cluster-welcome-page.e2e-spec.ts b/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/06-cluster-welcome-page.e2e-spec.ts
new file mode 100644 (file)
index 0000000..bd0470b
--- /dev/null
@@ -0,0 +1,19 @@
+import { CreateClusterWelcomePageHelper } from '../cluster/cluster-welcome-page.po';
+
+describe('Create cluster page', () => {
+  const createCluster = new CreateClusterWelcomePageHelper();
+
+  beforeEach(() => {
+    cy.login();
+    Cypress.Cookies.preserveOnce('token');
+    createCluster.navigateTo();
+  });
+
+  it('should fail to create cluster', () => {
+    createCluster.createCluster();
+  });
+
+  it('should skip to dashboard landing page', () => {
+    createCluster.doSkip();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/08-create-cluster-review.e2e-spec.ts b/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/08-create-cluster-review.e2e-spec.ts
new file mode 100644 (file)
index 0000000..a472810
--- /dev/null
@@ -0,0 +1,61 @@
+import { CreateClusterWelcomePageHelper } from 'cypress/integration/cluster/cluster-welcome-page.po';
+import { CreateClusterReviewPageHelper } from 'cypress/integration/cluster/create-cluster-review.po';
+
+describe('Create Cluster Review page', () => {
+  const reviewPage = new CreateClusterReviewPageHelper();
+  const createCluster = new CreateClusterWelcomePageHelper();
+
+  beforeEach(() => {
+    cy.login();
+    Cypress.Cookies.preserveOnce('token');
+    createCluster.navigateTo();
+    createCluster.createCluster();
+
+    cy.get('button[aria-label="Next"]').click();
+  });
+
+  describe('navigation link and title test', () => {
+    it('should check if nav-link and title contains Review', () => {
+      cy.get('.nav-link').should('contain.text', 'Review');
+
+      cy.get('.title').should('contain.text', 'Review');
+    });
+  });
+
+  describe('fields check', () => {
+    it('should check cluster resources table is present', () => {
+      // check for table header 'Status'
+      reviewPage.getLegends().its(0).should('have.text', 'Cluster Resources');
+
+      // check for fields in table
+      reviewPage.getStatusTables().should('contain.text', 'Hosts');
+    });
+
+    it('should check Hosts Per Label and Host Details tables are present', () => {
+      // check for there to be two tables
+      reviewPage.getDataTables().should('have.length', 2);
+
+      // check for table header 'Hosts Per Label'
+      reviewPage.getLegends().its(1).should('have.text', 'Hosts Per Label');
+
+      // check for table header 'Host Details'
+      reviewPage.getLegends().its(2).should('have.text', 'Host Details');
+
+      // verify correct columns on Hosts Per Label table
+      reviewPage.getDataTableHeaders(0).contains('Label');
+
+      reviewPage.getDataTableHeaders(0).contains('Number of Hosts');
+
+      // verify correct columns on Host Details table
+      reviewPage.getDataTableHeaders(1).contains('Host Name');
+
+      reviewPage.getDataTableHeaders(1).contains('Labels');
+    });
+
+    it('should check hosts count and default host name are present', () => {
+      reviewPage.getStatusTables().should('contain.text', '1');
+
+      reviewPage.checkDefaultHostName();
+    });
+  });
+});
index ebbe6f6651c1c3f9b4390d7989e7030040ce4c2a..099b31efbda5033950cdde84a6b872d35b3c7a39 100644 (file)
@@ -6,6 +6,7 @@ import _ from 'lodash';
 import { CephfsListComponent } from './ceph/cephfs/cephfs-list/cephfs-list.component';
 import { ConfigurationFormComponent } from './ceph/cluster/configuration/configuration-form/configuration-form.component';
 import { ConfigurationComponent } from './ceph/cluster/configuration/configuration.component';
+import { CreateClusterComponent } from './ceph/cluster/create-cluster/create-cluster.component';
 import { CrushmapComponent } from './ceph/cluster/crushmap/crushmap.component';
 import { HostFormComponent } from './ceph/cluster/hosts/host-form/host-form.component';
 import { HostsComponent } from './ceph/cluster/hosts/hosts.component';
@@ -89,6 +90,19 @@ const routes: Routes = [
       { path: 'error', component: ErrorComponent },
 
       // Cluster
+      {
+        path: 'create-cluster',
+        component: CreateClusterComponent,
+        canActivate: [ModuleStatusGuardService],
+        data: {
+          moduleStatusGuardConfig: {
+            apiPath: 'orchestrator',
+            redirectTo: 'dashboard',
+            backend: 'cephadm'
+          },
+          breadcrumbs: 'Create Cluster'
+        }
+      },
       {
         path: 'hosts',
         data: { breadcrumbs: 'Cluster/Hosts' },
index cc58c38b8dc9c8cdaf6cf0081913a727465bed69..a2c1e6d2f89ecd8c93791490036c7014e3caa231 100644 (file)
@@ -21,6 +21,7 @@ import { CephSharedModule } from '../shared/ceph-shared.module';
 import { ConfigurationDetailsComponent } from './configuration/configuration-details/configuration-details.component';
 import { ConfigurationFormComponent } from './configuration/configuration-form/configuration-form.component';
 import { ConfigurationComponent } from './configuration/configuration.component';
+import { CreateClusterComponent } from './create-cluster/create-cluster.component';
 import { CrushmapComponent } from './crushmap/crushmap.component';
 import { HostDetailsComponent } from './hosts/host-details/host-details.component';
 import { HostFormComponent } from './hosts/host-form/host-form.component';
@@ -112,7 +113,8 @@ import { TelemetryComponent } from './telemetry/telemetry.component';
     PrometheusTabsComponent,
     ServiceFormComponent,
     OsdFlagsIndivModalComponent,
-    PlacementPipe
+    PlacementPipe,
+    CreateClusterComponent
   ]
 })
 export class ClusterModule {}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.html
new file mode 100644 (file)
index 0000000..661c13f
--- /dev/null
@@ -0,0 +1,29 @@
+<div class="container h-75">
+  <div class="row h-100 justify-content-center align-items-center">
+    <div class="blank-page">
+      <!-- htmllint img-req-src="false" -->
+      <img [src]="projectConstants.cephLogo"
+           alt="Ceph"
+           class="img-fluid mx-auto d-block">
+      <h3 class="text-center m-2"
+          i18n>Welcome to {{ projectConstants.projectName }}</h3>
+
+      <div class="m-4">
+        <h4 class="text-center"
+            i18n>Please proceed to complete the cluster creation</h4>
+        <div class="offset-md-3">
+          <button class="btn btn-accent m-3"
+                  name="create-cluster"
+                  [routerLink]="'/dashboard'"
+                  (click)="createCluster()"
+                  i18n>Create Cluster</button>
+          <button class="btn btn-light"
+                  name="skip-cluster-creation"
+                  [routerLink]="'/dashboard'"
+                  (click)="skipClusterCreation()"
+                  i18n>Skip</button>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.spec.ts
new file mode 100644 (file)
index 0000000..7e061b2
--- /dev/null
@@ -0,0 +1,45 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { ToastrModule } from 'ngx-toastr';
+
+import { ClusterService } from '~/app/shared/api/cluster.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { CreateClusterComponent } from './create-cluster.component';
+
+describe('CreateClusterComponent', () => {
+  let component: CreateClusterComponent;
+  let fixture: ComponentFixture<CreateClusterComponent>;
+  let clusterService: ClusterService;
+
+  configureTestBed({
+    declarations: [CreateClusterComponent],
+    imports: [HttpClientTestingModule, RouterTestingModule, ToastrModule.forRoot(), SharedModule]
+  });
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(CreateClusterComponent);
+    component = fixture.componentInstance;
+    clusterService = TestBed.inject(ClusterService);
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  it('should have the heading "Welcome to Ceph Dashboard"', () => {
+    const heading = fixture.debugElement.query(By.css('h3')).nativeElement;
+    expect(heading.innerHTML).toBe('Welcome to Ceph Dashboard');
+  });
+
+  it('should call updateStatus when cluster creation is skipped', () => {
+    const clusterServiceSpy = spyOn(clusterService, 'updateStatus').and.callThrough();
+    expect(clusterServiceSpy).not.toHaveBeenCalled();
+    component.skipClusterCreation();
+    expect(clusterServiceSpy).toHaveBeenCalledTimes(1);
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.ts
new file mode 100644 (file)
index 0000000..239a4f1
--- /dev/null
@@ -0,0 +1,44 @@
+import { Component } from '@angular/core';
+
+import { ClusterService } from '~/app/shared/api/cluster.service';
+import { AppConstants } from '~/app/shared/constants/app.constants';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { Permission } from '~/app/shared/models/permissions';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { NotificationService } from '~/app/shared/services/notification.service';
+
+@Component({
+  selector: 'cd-create-cluster',
+  templateUrl: './create-cluster.component.html',
+  styleUrls: ['./create-cluster.component.scss']
+})
+export class CreateClusterComponent {
+  permission: Permission;
+  orchStatus = false;
+  featureAvailable = false;
+  projectConstants: typeof AppConstants = AppConstants;
+
+  constructor(
+    private authStorageService: AuthStorageService,
+    private clusterService: ClusterService,
+    private notificationService: NotificationService
+  ) {
+    this.permission = this.authStorageService.getPermissions().configOpt;
+  }
+
+  createCluster() {
+    this.notificationService.show(
+      NotificationType.error,
+      $localize`Cluster creation feature not implemented`
+    );
+  }
+
+  skipClusterCreation() {
+    this.clusterService.updateStatus('POST_INSTALLED').subscribe(() => {
+      this.notificationService.show(
+        NotificationType.info,
+        $localize`Cluster creation skipped by user`
+      );
+    });
+  }
+}
index 15a7275739f36d08c088bd0ba1238bbf6182b8f2..3cbfab4ebaac35d674410189a7b196bd1ba8e1eb 100644 (file)
@@ -1,7 +1,11 @@
 import { HttpClientTestingModule } from '@angular/common/http/testing';
 import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { Router } from '@angular/router';
 import { RouterTestingModule } from '@angular/router/testing';
 
+import { of } from 'rxjs';
+
+import { AuthService } from '~/app/shared/api/auth.service';
 import { configureTestBed } from '~/testing/unit-test-helper';
 import { AuthModule } from '../auth.module';
 import { LoginComponent } from './login.component';
@@ -9,6 +13,8 @@ import { LoginComponent } from './login.component';
 describe('LoginComponent', () => {
   let component: LoginComponent;
   let fixture: ComponentFixture<LoginComponent>;
+  let routerNavigateSpy: jasmine.Spy;
+  let authServiceLoginSpy: jasmine.Spy;
 
   configureTestBed({
     imports: [RouterTestingModule, HttpClientTestingModule, AuthModule]
@@ -17,6 +23,10 @@ describe('LoginComponent', () => {
   beforeEach(() => {
     fixture = TestBed.createComponent(LoginComponent);
     component = fixture.componentInstance;
+    routerNavigateSpy = spyOn(TestBed.inject(Router), 'navigate');
+    routerNavigateSpy.and.returnValue(true);
+    authServiceLoginSpy = spyOn(TestBed.inject(AuthService), 'login');
+    authServiceLoginSpy.and.returnValue(of(null));
     fixture.detectChanges();
   });
 
@@ -29,4 +39,20 @@ describe('LoginComponent', () => {
     component.ngOnInit();
     expect(component['modalService'].hasOpenModals()).toBeFalsy();
   });
+
+  it('should not show create cluster wizard if cluster creation was successful', () => {
+    component.postInstalled = true;
+    component.login();
+
+    expect(routerNavigateSpy).toHaveBeenCalledTimes(1);
+    expect(routerNavigateSpy).toHaveBeenCalledWith(['/']);
+  });
+
+  it('should show create cluster wizard if cluster creation was failed', () => {
+    component.postInstalled = false;
+    component.login();
+
+    expect(routerNavigateSpy).toHaveBeenCalledTimes(1);
+    expect(routerNavigateSpy).toHaveBeenCalledWith(['/create-cluster']);
+  });
 });
index 868ba66a002489cbd1e7ab3f48cfb2e8e46c7861..77bafd99c82e069bd4c91d135b0b618acd127493 100644 (file)
@@ -17,6 +17,7 @@ export class LoginComponent implements OnInit {
   model = new Credentials();
   isLoginActive = false;
   returnUrl: string;
+  postInstalled = false;
 
   constructor(
     private authService: AuthService,
@@ -43,6 +44,7 @@ export class LoginComponent implements OnInit {
       }
       this.authService.check(token).subscribe((login: any) => {
         if (login.login_url) {
+          this.postInstalled = login.cluster_status === 'POST_INSTALLED';
           if (login.login_url === '#/login') {
             this.isLoginActive = true;
           } else {
@@ -63,7 +65,11 @@ export class LoginComponent implements OnInit {
 
   login() {
     this.authService.login(this.model).subscribe(() => {
-      const url = _.get(this.route.snapshot.queryParams, 'returnUrl', '/');
+      const urlPath = this.postInstalled ? '/' : '/create-cluster';
+      let url = _.get(this.route.snapshot.queryParams, 'returnUrl', urlPath);
+      if (!this.postInstalled && this.route.snapshot.queryParams['returnUrl'] === '/dashboard') {
+        url = '/create-cluster';
+      }
       this.router.navigate([url]);
     });
   }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cluster.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cluster.service.spec.ts
new file mode 100644 (file)
index 0000000..758f670
--- /dev/null
@@ -0,0 +1,42 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { fakeAsync, TestBed } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { ClusterService } from './cluster.service';
+
+describe('ClusterService', () => {
+  let service: ClusterService;
+  let httpTesting: HttpTestingController;
+
+  configureTestBed({
+    imports: [HttpClientTestingModule],
+    providers: [ClusterService]
+  });
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({});
+    service = TestBed.inject(ClusterService);
+    httpTesting = TestBed.inject(HttpTestingController);
+  });
+
+  afterEach(() => {
+    httpTesting.verify();
+  });
+
+  it('should be created', () => {
+    expect(service).toBeTruthy();
+  });
+
+  it('should call getStatus', () => {
+    service.getStatus().subscribe();
+    const req = httpTesting.expectOne('api/cluster');
+    expect(req.request.method).toBe('GET');
+  });
+
+  it('should update cluster status', fakeAsync(() => {
+    service.updateStatus('fakeStatus').subscribe();
+    const req = httpTesting.expectOne('api/cluster');
+    expect(req.request.method).toBe('PUT');
+    expect(req.request.body).toEqual({ status: 'fakeStatus' });
+  }));
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cluster.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cluster.service.ts
new file mode 100644 (file)
index 0000000..6b435d6
--- /dev/null
@@ -0,0 +1,27 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+import { Observable } from 'rxjs';
+
+@Injectable({
+  providedIn: 'root'
+})
+export class ClusterService {
+  baseURL = 'api/cluster';
+
+  constructor(private http: HttpClient) {}
+
+  getStatus(): Observable<string> {
+    return this.http.get<string>(`${this.baseURL}`, {
+      headers: { Accept: 'application/vnd.ceph.api.v0.1+json' }
+    });
+  }
+
+  updateStatus(status: string) {
+    return this.http.put(
+      `${this.baseURL}`,
+      { status: status },
+      { headers: { Accept: 'application/vnd.ceph.api.v0.1+json' } }
+    );
+  }
+}
index 05d6b5c53a9ada733670502178c1c865389cd0fb..5b668ad9000deb1f893fe16def1deac443615547 100644 (file)
@@ -7,6 +7,7 @@ export class AppConstants {
   public static readonly projectName = 'Ceph Dashboard';
   public static readonly license = 'Free software (LGPL 2.1).';
   public static readonly copyright = 'Copyright(c) ' + environment.year + ' Ceph contributors.';
+  public static readonly cephLogo = 'assets/Ceph_Logo.svg';
 }
 
 export enum URLVerbs {
index 0948fc878a9e0eb77bed08c016ba60d06e2bcc29..ebacc06c151922fadff365bc24bd4b881359e6a3 100644 (file)
@@ -7,6 +7,7 @@ import { RouterTestingModule } from '@angular/router/testing';
 import { of as observableOf } from 'rxjs';
 
 import { configureTestBed } from '~/testing/unit-test-helper';
+import { MgrModuleService } from '../api/mgr-module.service';
 import { ModuleStatusGuardService } from './module-status-guard.service';
 
 describe('ModuleStatusGuardService', () => {
@@ -15,6 +16,7 @@ describe('ModuleStatusGuardService', () => {
   let router: Router;
   let route: ActivatedRouteSnapshot;
   let ngZone: NgZone;
+  let mgrModuleService: MgrModuleService;
 
   @Component({ selector: 'cd-foo', template: '' })
   class FooComponent {}
@@ -25,9 +27,16 @@ describe('ModuleStatusGuardService', () => {
 
   const routes: Routes = [{ path: '**', component: FooComponent }];
 
-  const testCanActivate = (getResult: {}, activateResult: boolean, urlResult: string) => {
+  const testCanActivate = (
+    getResult: {},
+    activateResult: boolean,
+    urlResult: string,
+    backend = 'cephadm'
+  ) => {
     let result: boolean;
     spyOn(httpClient, 'get').and.returnValue(observableOf(getResult));
+    const test = { orchestrator: backend };
+    spyOn(mgrModuleService, 'getConfig').and.returnValue(observableOf(test));
     ngZone.run(() => {
       service.canActivateChild(route).subscribe((resp) => {
         result = resp;
@@ -48,13 +57,15 @@ describe('ModuleStatusGuardService', () => {
   beforeEach(() => {
     service = TestBed.inject(ModuleStatusGuardService);
     httpClient = TestBed.inject(HttpClient);
+    mgrModuleService = TestBed.inject(MgrModuleService);
     router = TestBed.inject(Router);
     route = new ActivatedRouteSnapshot();
     route.url = [];
     route.data = {
       moduleStatusGuardConfig: {
         apiPath: 'bar',
-        redirectTo: '/foo'
+        redirectTo: '/foo',
+        backend: 'rook'
       }
     };
     ngZone = TestBed.inject(NgZone);
@@ -76,4 +87,8 @@ describe('ModuleStatusGuardService', () => {
   it('should test canActivateChild with status unavailable', fakeAsync(() => {
     testCanActivate(null, false, '/foo');
   }));
+
+  it('should redirect normally if the backend provided matches the current backend', fakeAsync(() => {
+    testCanActivate({ available: true, message: 'foo' }, true, '/', 'rook');
+  }));
 });
index 171f34adfe6b48e09f6ba6aeceeabb08032509e3..3162afd232933219142ed022e8d961786602a2b6 100644 (file)
@@ -5,7 +5,8 @@ import { ActivatedRouteSnapshot, CanActivate, CanActivateChild, Router } from '@
 import { of as observableOf } from 'rxjs';
 import { catchError, map } from 'rxjs/operators';
 
-import { Icons } from '../enum/icons.enum';
+import { MgrModuleService } from '~/app/shared/api/mgr-module.service';
+import { Icons } from '~/app/shared/enum/icons.enum';
 
 /**
  * This service checks if a route can be activated by executing a
@@ -39,7 +40,11 @@ export class ModuleStatusGuardService implements CanActivate, CanActivateChild {
   // TODO: Hotfix - remove ALLOWLIST'ing when a generic ErrorComponent is implemented
   static readonly ALLOWLIST: string[] = ['501'];
 
-  constructor(private http: HttpClient, private router: Router) {}
+  constructor(
+    private http: HttpClient,
+    private router: Router,
+    private mgrModuleService: MgrModuleService
+  ) {}
 
   canActivate(route: ActivatedRouteSnapshot) {
     return this.doCheck(route);
@@ -54,9 +59,15 @@ export class ModuleStatusGuardService implements CanActivate, CanActivateChild {
       return observableOf(true);
     }
     const config = route.data['moduleStatusGuardConfig'];
+    let backendCheck = false;
+    if (config.backend) {
+      this.mgrModuleService.getConfig('orchestrator').subscribe((resp) => {
+        backendCheck = config.backend === resp['orchestrator'];
+      });
+    }
     return this.http.get(`api/${config.apiPath}/status`).pipe(
       map((resp: any) => {
-        if (!resp.available) {
+        if (!resp.available && !backendCheck) {
           this.router.navigate([config.redirectTo || ''], {
             state: {
               header: config.header,
index 844457b0a72d3902310b5b34d12b3885bd7262dd..f03102599b0dfda9433fb5b0bf524145d87038c0 100644 (file)
@@ -2038,6 +2038,67 @@ paths:
       - jwt: []
       tags:
       - Cephfs
+  /api/cluster:
+    get:
+      parameters: []
+      responses:
+        '200':
+          content:
+            application/vnd.ceph.api.v0.1+json:
+              type: object
+          description: OK
+        '400':
+          description: Operation exception. Please check the response body for details.
+        '401':
+          description: Unauthenticated access. Please login first.
+        '403':
+          description: Unauthorized access. Please check your permissions.
+        '500':
+          description: Unexpected error. Please check the response body for the stack
+            trace.
+      security:
+      - jwt: []
+      summary: Get the cluster status
+      tags:
+      - Cluster
+    put:
+      parameters: []
+      requestBody:
+        content:
+          application/json:
+            schema:
+              properties:
+                status:
+                  description: Cluster Status
+                  type: string
+              required:
+              - status
+              type: object
+      responses:
+        '200':
+          content:
+            application/vnd.ceph.api.v0.1+json:
+              type: object
+          description: Resource updated.
+        '202':
+          content:
+            application/vnd.ceph.api.v0.1+json:
+              type: object
+          description: Operation is still executing. Please check the task queue.
+        '400':
+          description: Operation exception. Please check the response body for details.
+        '401':
+          description: Unauthenticated access. Please login first.
+        '403':
+          description: Unauthorized access. Please check your permissions.
+        '500':
+          description: Unexpected error. Please check the response body for the stack
+            trace.
+      security:
+      - jwt: []
+      summary: Update the cluster status
+      tags:
+      - Cluster
   /api/cluster_conf:
     get:
       parameters: []
@@ -10388,6 +10449,8 @@ tags:
   name: Auth
 - description: Cephfs Management API
   name: Cephfs
+- description: Get Cluster Details
+  name: Cluster
 - description: Manage Cluster Configurations
   name: ClusterConfiguration
 - description: Crush Rule Management API
diff --git a/src/pybind/mgr/dashboard/services/cluster.py b/src/pybind/mgr/dashboard/services/cluster.py
new file mode 100644 (file)
index 0000000..aad517a
--- /dev/null
@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+from enum import Enum
+
+from .. import mgr
+
+
+class ClusterModel:
+
+    class Status(Enum):
+        INSTALLED = 0
+        POST_INSTALLED = 1
+
+    status: Status
+
+    def __init__(self, status=Status.INSTALLED.name):
+        self.status = self.Status[status]
+
+    def dict(self):
+        return {'status': self.status.name}
+
+    def to_db(self):
+        mgr.set_store('cluster/status', self.status.name)
+
+    @classmethod
+    def from_db(cls):
+        return cls(status=mgr.get_store('cluster/status', cls.Status.INSTALLED.name))