diff --git a/qa/tasks/mgr/dashboard/test_auth.py b/qa/tasks/mgr/dashboard/test_auth.py index 8fc7cd1992e..98566344444 100644 --- a/qa/tasks/mgr/dashboard/test_auth.py +++ b/qa/tasks/mgr/dashboard/test_auth.py @@ -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 index 00000000000..14f8542796c --- /dev/null +++ b/qa/tasks/mgr/dashboard/test_cluster.py @@ -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) diff --git a/src/pybind/mgr/dashboard/controllers/auth.py b/src/pybind/mgr/dashboard/controllers/auth.py index 353d5d72bb9..196f027b293 100644 --- a/src/pybind/mgr/dashboard/controllers/auth.py +++ b/src/pybind/mgr/dashboard/controllers/auth.py @@ -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 index 00000000000..5ec49e39b1c --- /dev/null +++ b/src/pybind/mgr/dashboard/controllers/cluster.py @@ -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 index 00000000000..5615b0369d0 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/cluster-welcome-page.po.ts @@ -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 index 00000000000..58844e39afe --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/create-cluster-review.po.ts @@ -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 index 00000000000..bd0470b8670 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/06-cluster-welcome-page.e2e-spec.ts @@ -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 index 00000000000..a472810e6e6 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/08-create-cluster-review.e2e-spec.ts @@ -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(); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts index ebbe6f6651c..099b31efbda 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts @@ -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' }, diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts index cc58c38b8dc..a2c1e6d2f89 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts @@ -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 index 00000000000..661c13fc931 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.html @@ -0,0 +1,29 @@ +
+
+
+ + Ceph +

Welcome to {{ projectConstants.projectName }}

+ +
+

Please proceed to complete the cluster creation

+
+ + +
+
+
+
+
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 index 00000000000..e69de29bb2d 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 index 00000000000..7e061b2e25c --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.spec.ts @@ -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; + 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 index 00000000000..239a4f13ca7 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster.component.ts @@ -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` + ); + }); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.spec.ts index 15a7275739f..3cbfab4ebaa 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.spec.ts @@ -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; + 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']); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.ts index 868ba66a002..77bafd99c82 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.ts @@ -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 index 00000000000..758f670eec6 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cluster.service.spec.ts @@ -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 index 00000000000..6b435d6ffed --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cluster.service.ts @@ -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 { + return this.http.get(`${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' } } + ); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts index 05d6b5c53a9..5b668ad9000 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts @@ -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 { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/module-status-guard.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/module-status-guard.service.spec.ts index 0948fc878a9..ebacc06c151 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/module-status-guard.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/module-status-guard.service.spec.ts @@ -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'); + })); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/module-status-guard.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/module-status-guard.service.ts index 171f34adfe6..3162afd2329 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/module-status-guard.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/module-status-guard.service.ts @@ -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, diff --git a/src/pybind/mgr/dashboard/openapi.yaml b/src/pybind/mgr/dashboard/openapi.yaml index 844457b0a72..f03102599b0 100644 --- a/src/pybind/mgr/dashboard/openapi.yaml +++ b/src/pybind/mgr/dashboard/openapi.yaml @@ -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 index 00000000000..aad517a21d6 --- /dev/null +++ b/src/pybind/mgr/dashboard/services/cluster.py @@ -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))