From 6bc357b8fb601570597457bb9ae5f96269f963d5 Mon Sep 17 00:00:00 2001 From: Ernesto Puerta Date: Thu, 31 Jan 2019 17:32:32 +0100 Subject: [PATCH] mgr/dashboard: feature-toggles: add front-end Add front-end behaviours to feature toggles: - In navigation pane, drop-down menu items are displayed/hidden accordingly. - In main dashboard page, info cards are displayed/hidded. - Routes are also enabled/disabled. When disabled, they redirect to 404. Fixes: http://tracker.ceph.com/issues/37530 Signed-off-by: Ernesto Puerta --- .../frontend/src/app/app-routing.module.ts | 8 +- .../dashboard/health/health.component.html | 8 +- .../dashboard/health/health.component.spec.ts | 13 +++ .../ceph/dashboard/health/health.component.ts | 9 +- .../navigation/navigation.component.html | 16 ++-- .../navigation/navigation.component.ts | 9 +- .../feature-toggles-guard.service.spec.ts | 72 ++++++++++++++++ .../services/feature-toggles-guard.service.ts | 36 ++++++++ .../services/feature-toggles.service.spec.ts | 55 ++++++++++++ .../services/feature-toggles.service.ts | 29 +++++++ .../frontend/src/locale/messages.xlf | 84 +++++++++---------- .../mgr/dashboard/plugins/feature_toggles.py | 12 +-- 12 files changed, 290 insertions(+), 61 deletions(-) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/services/feature-toggles-guard.service.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/services/feature-toggles-guard.service.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/services/feature-toggles.service.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/services/feature-toggles.service.ts 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 87664ea740e..edfee3dbd10 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 @@ -35,6 +35,7 @@ import { ForbiddenComponent } from './core/forbidden/forbidden.component'; import { NotFoundComponent } from './core/not-found/not-found.component'; import { BreadcrumbsResolver, IBreadcrumb } from './shared/models/breadcrumbs'; import { AuthGuardService } from './shared/services/auth-guard.service'; +import { FeatureTogglesGuardService } from './shared/services/feature-toggles-guard.service'; import { ModuleStatusGuardService } from './shared/services/module-status-guard.service'; export class PerformanceCounterBreadcrumbsResolver extends BreadcrumbsResolver { @@ -152,6 +153,7 @@ const routes: Routes = [ }, { path: 'rbd', + canActivate: [FeatureTogglesGuardService], data: { breadcrumbs: 'Images' }, children: [ { path: '', component: RbdImagesComponent }, @@ -173,11 +175,13 @@ const routes: Routes = [ { path: 'mirroring', component: RbdMirroringComponent, + canActivate: [FeatureTogglesGuardService], data: { breadcrumbs: 'Mirroring' } }, // iSCSI { path: 'iscsi', + canActivate: [FeatureTogglesGuardService], data: { breadcrumbs: 'iSCSI' }, children: [ { @@ -206,7 +210,7 @@ const routes: Routes = [ { path: 'cephfs', component: CephfsListComponent, - canActivate: [AuthGuardService], + canActivate: [FeatureTogglesGuardService, AuthGuardService], data: { breadcrumbs: 'Filesystems' } }, // Object Gateway @@ -218,7 +222,7 @@ const routes: Routes = [ }, { path: 'rgw', - canActivateChild: [ModuleStatusGuardService, AuthGuardService], + canActivateChild: [FeatureTogglesGuardService, ModuleStatusGuardService, AuthGuardService], data: { moduleStatusGuardConfig: { apiPath: 'rgw', diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/health/health.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/health/health.component.html index cde3a60f940..e9df42074ac 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/health/health.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/health/health.component.html @@ -1,3 +1,4 @@ +
+ *ngIf="enabled_feature.rgw && healthData.rgw != null"> {{ healthData.rgw }} total @@ -111,7 +112,7 @@ link="/block/iscsi" class="col-sm-6 col-md-4 col-lg-3" contentClass="content-medium content-highlight" - *ngIf="healthData.iscsi_daemons != null"> + *ngIf="enabled_feature.iscsi && healthData.iscsi_daemons != null"> {{ healthData.iscsi_daemons }} total @@ -266,3 +267,4 @@
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/health/health.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/health/health.component.spec.ts index aee287159ec..8bb61cf2cd8 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/health/health.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/health/health.component.spec.ts @@ -11,6 +11,7 @@ import { configureTestBed, i18nProviders } from '../../../../testing/unit-test-h import { HealthService } from '../../../shared/api/health.service'; import { Permissions } from '../../../shared/models/permissions'; import { AuthStorageService } from '../../../shared/services/auth-storage.service'; +import { FeatureTogglesService } from '../../../shared/services/feature-toggles.service'; import { SharedModule } from '../../../shared/shared.module'; import { PgCategoryService } from '../../shared/pg-category.service'; import { HealthPieColor } from '../health-pie/health-pie-color.enum'; @@ -45,6 +46,17 @@ describe('HealthComponent', () => { return new Permissions({ log: ['read'] }); } }; + const fakeFeatureTogglesService = { + get: () => { + return of({ + rbd: true, + mirroring: true, + iscsi: true, + cephfs: true, + rgw: true + }); + } + }; configureTestBed({ imports: [SharedModule, HttpClientTestingModule, PopoverModule.forRoot()], @@ -60,6 +72,7 @@ describe('HealthComponent', () => { providers: [ i18nProviders, { provide: AuthStorageService, useValue: fakeAuthStorageService }, + { provide: FeatureTogglesService, useValue: fakeFeatureTogglesService }, PgCategoryService ] }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/health/health.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/health/health.component.ts index 9d42bc90f5a..713a05ea6c9 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/health/health.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard/health/health.component.ts @@ -6,6 +6,10 @@ import * as _ from 'lodash'; import { HealthService } from '../../../shared/api/health.service'; import { Permissions } from '../../../shared/models/permissions'; import { AuthStorageService } from '../../../shared/services/auth-storage.service'; +import { + FeatureTogglesMap$, + FeatureTogglesService +} from '../../../shared/services/feature-toggles.service'; import { PgCategoryService } from '../../shared/pg-category.service'; import { HealthPieColor } from '../health-pie/health-pie-color.enum'; @@ -18,14 +22,17 @@ export class HealthComponent implements OnInit, OnDestroy { healthData: any; interval: number; permissions: Permissions; + enabled_feature$: FeatureTogglesMap$; constructor( private healthService: HealthService, private i18n: I18n, private authStorageService: AuthStorageService, - private pgCategoryService: PgCategoryService + private pgCategoryService: PgCategoryService, + private featureToggles: FeatureTogglesService ) { this.permissions = this.authStorageService.getPermissions(); + this.enabled_feature$ = this.featureToggles.get(); } ngOnInit() { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html index 7083d73690a..307966699c6 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html @@ -1,3 +1,4 @@ + + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.ts index 4c304646a57..018df3f14bd 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.ts @@ -3,6 +3,10 @@ import { Component, OnInit } from '@angular/core'; import { PrometheusService } from '../../../shared/api/prometheus.service'; import { Permissions } from '../../../shared/models/permissions'; import { AuthStorageService } from '../../../shared/services/auth-storage.service'; +import { + FeatureTogglesMap$, + FeatureTogglesService +} from '../../../shared/services/feature-toggles.service'; import { SummaryService } from '../../../shared/services/summary.service'; @Component({ @@ -16,13 +20,16 @@ export class NavigationComponent implements OnInit { isCollapsed = true; prometheusConfigured = false; + enabled_feature$: FeatureTogglesMap$; constructor( private authStorageService: AuthStorageService, private prometheusService: PrometheusService, - private summaryService: SummaryService + private summaryService: SummaryService, + private featureToggles: FeatureTogglesService ) { this.permissions = this.authStorageService.getPermissions(); + this.enabled_feature$ = this.featureToggles.get(); } ngOnInit() { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/feature-toggles-guard.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/feature-toggles-guard.service.spec.ts new file mode 100644 index 00000000000..980b59d4bb8 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/feature-toggles-guard.service.spec.ts @@ -0,0 +1,72 @@ +import { Component, NgZone } from '@angular/core'; +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { ActivatedRouteSnapshot, Router, Routes } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { of as observableOf } from 'rxjs'; + +import { configureTestBed } from '../../../testing/unit-test-helper'; + +import { FeatureTogglesGuardService } from './feature-toggles-guard.service'; +import { FeatureTogglesService } from './feature-toggles.service'; + +describe('FeatureTogglesGuardService', () => { + let service: FeatureTogglesGuardService; + let fakeFeatureTogglesService: FeatureTogglesService; + let router: Router; + let ngZone: NgZone; + + @Component({ selector: 'cd-cephfs', template: '' }) + class CephfsComponent {} + + @Component({ selector: 'cd-404', template: '' }) + class NotFoundComponent {} + + const routes: Routes = [ + { path: 'cephfs', component: CephfsComponent }, + { path: '404', component: NotFoundComponent } + ]; + + configureTestBed({ + imports: [RouterTestingModule.withRoutes(routes)], + providers: [ + { provide: FeatureTogglesService, useValue: { get: null } }, + FeatureTogglesGuardService + ], + declarations: [CephfsComponent, NotFoundComponent] + }); + + beforeEach(() => { + service = TestBed.get(FeatureTogglesGuardService); + fakeFeatureTogglesService = TestBed.get(FeatureTogglesService); + ngZone = TestBed.get(NgZone); + router = TestBed.get(Router); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + function testCanActivate(path, feature_toggles_map) { + let result: boolean; + spyOn(fakeFeatureTogglesService, 'get').and.returnValue(observableOf(feature_toggles_map)); + + ngZone.run(() => { + service + .canActivate({ routeConfig: { path: path } }, null) + .subscribe((val) => (result = val)); + }); + tick(); + + return result; + } + + it('should allow the feature if enabled', fakeAsync(() => { + expect(testCanActivate('cephfs', { cephfs: true })).toBe(true); + expect(router.url).toBe('/'); + })); + + it('should redirect to 404 if disable', fakeAsync(() => { + expect(testCanActivate('cephfs', { cephfs: false })).toBe(false); + expect(router.url).toBe('/404'); + })); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/feature-toggles-guard.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/feature-toggles-guard.service.ts new file mode 100644 index 00000000000..9114ffd6326 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/feature-toggles-guard.service.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@angular/core'; +import { + ActivatedRouteSnapshot, + CanActivate, + CanActivateChild, + Router, + RouterStateSnapshot +} from '@angular/router'; + +import { map } from 'rxjs/operators'; + +import { FeatureTogglesMap, FeatureTogglesService } from './feature-toggles.service'; +import { ServicesModule } from './services.module'; + +@Injectable({ + providedIn: ServicesModule +}) +export class FeatureTogglesGuardService implements CanActivate, CanActivateChild { + constructor(private router: Router, private featureToggles: FeatureTogglesService) {} + + canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { + return this.featureToggles.get().pipe( + map((enabledFeatures: FeatureTogglesMap) => { + if (enabledFeatures[route.routeConfig.path] === false) { + this.router.navigate(['404']); + return false; + } + return true; + }) + ); + } + + canActivateChild(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { + return this.canActivate(route.parent, state); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/feature-toggles.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/feature-toggles.service.spec.ts new file mode 100644 index 00000000000..486ca590b2b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/feature-toggles.service.spec.ts @@ -0,0 +1,55 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { discardPeriodicTasks, fakeAsync, TestBed, tick } from '@angular/core/testing'; + +import { configureTestBed } from '../../../testing/unit-test-helper'; + +import { FeatureTogglesService } from './feature-toggles.service'; + +describe('FeatureTogglesService', () => { + let httpTesting: HttpTestingController; + let service: FeatureTogglesService; + + configureTestBed({ + providers: [FeatureTogglesService], + imports: [HttpClientTestingModule] + }); + + beforeEach(() => { + service = TestBed.get(FeatureTogglesService); + httpTesting = TestBed.get(HttpTestingController); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should fetch HTTP endpoint once and only once', fakeAsync(() => { + const mockFeatureTogglesMap = [ + { + rbd: true, + mirroring: true, + iscsi: true, + cephfs: true, + rgw: true + } + ]; + + service + .get() + .subscribe((featureTogglesMap) => expect(featureTogglesMap).toEqual(mockFeatureTogglesMap)); + tick(); + + // Second subscription shouldn't trigger a new HTTP request + service + .get() + .subscribe((featureTogglesMap) => expect(featureTogglesMap).toEqual(mockFeatureTogglesMap)); + + const req = httpTesting.expectOne(service.API_URL); + req.flush(mockFeatureTogglesMap); + discardPeriodicTasks(); + })); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/feature-toggles.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/feature-toggles.service.ts new file mode 100644 index 00000000000..fbd435fa769 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/feature-toggles.service.ts @@ -0,0 +1,29 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +import { Observable, timer } from 'rxjs'; +import { flatMap, shareReplay } from 'rxjs/operators'; +import { ServicesModule } from './services.module'; + +export type FeatureTogglesMap = Map; +export type FeatureTogglesMap$ = Observable; + +@Injectable({ + providedIn: ServicesModule +}) +export class FeatureTogglesService { + readonly API_URL: string = 'api/feature_toggles'; + readonly REFRESH_INTERVAL: number = 20000; + private featureToggleMap$: FeatureTogglesMap$; + + constructor(private http: HttpClient) { + this.featureToggleMap$ = timer(0, this.REFRESH_INTERVAL).pipe( + flatMap(() => this.http.get(this.API_URL)), + shareReplay(1) + ); + } + + get(): FeatureTogglesMap$ { + return this.featureToggleMap$; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/locale/messages.xlf b/src/pybind/mgr/dashboard/frontend/src/locale/messages.xlf index 666cc701a58..6ddc58e45d3 100644 --- a/src/pybind/mgr/dashboard/frontend/src/locale/messages.xlf +++ b/src/pybind/mgr/dashboard/frontend/src/locale/messages.xlf @@ -6,79 +6,79 @@ Toggle navigation app/core/navigation/navigation/navigation.component.html - 15 + 16 Dashboard app/core/navigation/navigation/navigation.component.html - 34 + 35 Cluster app/core/navigation/navigation/navigation.component.html - 46 + 47 Hosts app/core/navigation/navigation/navigation.component.html - 56 + 57 app/ceph/dashboard/health/health.component.html - 81 + 82 Monitors app/core/navigation/navigation/navigation.component.html - 63 + 64 app/ceph/dashboard/health/health.component.html - 48 + 49 OSDs app/core/navigation/navigation/navigation.component.html - 70 + 71 app/ceph/dashboard/health/health.component.html - 57 + 58 Configuration app/core/navigation/navigation/navigation.component.html - 77 + 78 CRUSH map app/core/navigation/navigation/navigation.component.html - 84 + 85 Logs app/core/navigation/navigation/navigation.component.html - 91 + 92 Alerts app/core/navigation/navigation/navigation.component.html - 97 + 98 Pools app/core/navigation/navigation/navigation.component.html - 107 + 108 app/ceph/block/mirroring/overview/overview.component.html @@ -86,7 +86,7 @@ app/ceph/dashboard/health/health.component.html - 190 + 191 app/ceph/cephfs/cephfs-detail/cephfs-detail.component.html @@ -96,13 +96,13 @@ Block app/core/navigation/navigation/navigation.component.html - 119 + 122 Images app/core/navigation/navigation/navigation.component.html - 128 + 131 app/ceph/block/iscsi-target-form/iscsi-target-form.component.html @@ -132,31 +132,31 @@ Mirroring app/core/navigation/navigation/navigation.component.html - 136 + 139 iSCSI app/core/navigation/navigation/navigation.component.html - 148 + 151 Filesystems app/core/navigation/navigation/navigation.component.html - 159 + 162 Object Gateway app/core/navigation/navigation/navigation.component.html - 170 + 173 Daemons app/core/navigation/navigation/navigation.component.html - 179 + 182 app/ceph/block/iscsi/iscsi.component.html @@ -170,7 +170,7 @@ Users app/core/navigation/navigation/navigation.component.html - 185 + 188 app/core/auth/user-tabs/user-tabs.component.html @@ -180,7 +180,7 @@ Buckets app/core/navigation/navigation/navigation.component.html - 191 + 194 Retrieving data for @@ -1199,7 +1199,7 @@ app/ceph/dashboard/health/health.component.html - 3 + 4 Cluster ID @@ -2979,79 +2979,79 @@ Cluster Status app/ceph/dashboard/health/health.component.html - 15 + 16 Manager Daemons app/ceph/dashboard/health/health.component.html - 69 + 70 Object Gateways app/ceph/dashboard/health/health.component.html - 90 + 91 Metadata Servers app/ceph/dashboard/health/health.component.html - 98 + 99 iSCSI Gateways app/ceph/dashboard/health/health.component.html - 109 + 110 Client IOPS app/ceph/dashboard/health/health.component.html - 125 + 126 Client Throughput app/ceph/dashboard/health/health.component.html - 134 + 135 Client Read/Write app/ceph/dashboard/health/health.component.html - 143 + 144 Client Recovery app/ceph/dashboard/health/health.component.html - 161 + 162 Scrub app/ceph/dashboard/health/health.component.html - 170 + 171 Performance app/ceph/dashboard/health/health.component.html - 119 + 120 Raw Capacity app/ceph/dashboard/health/health.component.html - 200 + 201 Objects app/ceph/dashboard/health/health.component.html - 213 + 214 app/ceph/block/rbd-details/rbd-details.component.html @@ -3061,25 +3061,25 @@ PGs per OSD app/ceph/dashboard/health/health.component.html - 222 + 223 PG Status app/ceph/dashboard/health/health.component.html - 231 + 232 Capacity app/ceph/dashboard/health/health.component.html - 181 + 182 See Logs for more details. app/ceph/dashboard/health/health.component.html - 265 + 266 Move an image to trash diff --git a/src/pybind/mgr/dashboard/plugins/feature_toggles.py b/src/pybind/mgr/dashboard/plugins/feature_toggles.py index 5174f3f6d8e..6c5a8ad109f 100644 --- a/src/pybind/mgr/dashboard/plugins/feature_toggles.py +++ b/src/pybind/mgr/dashboard/plugins/feature_toggles.py @@ -62,9 +62,9 @@ except ImportError: class Features(Enum): - RBD_IMAGES = 'rbd_images' - RBD_MIRRORING = 'rbd_mirroring' - RBD_ISCSI = 'rbd_iscsi' + RBD = 'rbd' + MIRRORING = 'mirroring' + ISCSI = 'iscsi' CEPHFS = 'cephfs' RGW = 'rgw' @@ -78,9 +78,9 @@ class Actions(Enum): PREDISABLED_FEATURES = set() Feature2Endpoint = { - Features.RBD_IMAGES: ["/api/block/image"], - Features.RBD_MIRRORING: ["/api/block/mirroring"], - Features.RBD_ISCSI: ["/api/tcmuiscsi"], + Features.RBD: ["/api/block/image"], + Features.MIRRORING: ["/api/block/mirroring"], + Features.ISCSI: ["/api/tcmuiscsi"], Features.CEPHFS: ["/api/cephfs"], Features.RGW: ["/api/rgw"], } -- 2.39.5