mgr/dashboard: Create Cluster Workflow welcome screen and e2e tests

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>
This commit is contained in:
Avan Thakkar 2021-06-01 18:25:15 +05:30 committed by Nizamudeen A
parent f41eae16af
commit b9f38cadc4
23 changed files with 520 additions and 9 deletions

View 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)

View File

@ -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)

View 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']
}

View File

@ -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()

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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();
});
});

View File

@ -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();
});
});
});

View 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' },

View 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 {}

View File

@ -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>

View File

@ -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);
});
});

View File

@ -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`
);
});
}
}

View 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']);
});
});

View 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]);
});
}

View File

@ -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' });
}));
});

View File

@ -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' } }
);
}
}

View 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 {

View 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');
}));
});

View 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,

View 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

View File

@ -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))