From: Tiago Melo Date: Fri, 3 Aug 2018 13:02:46 +0000 (+0100) Subject: mgr/dashboard: Add breadcrumbs component X-Git-Tag: v14.0.1~653^2 X-Git-Url: http://git.apps.os.sepia.ceph.com/?a=commitdiff_plain;h=14e704ab54daa6419294c64d118a7f41150be9de;p=ceph.git mgr/dashboard: Add breadcrumbs component Now we don't need to add a breadcrumb in each new page we create, just add the necessary extra data in the route module and the breadcrumb will be automatically created. I used a modified version of ngx-breadcrumbs from McNull: https://github.com/McNull/ngx-breadcrumbs Fixes: http://tracker.ceph.com/issues/24781 Signed-off-by: Tiago Melo --- 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 0d44e19ec7d9e..b8a6df65dae6b 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 @@ -1,5 +1,5 @@ import { NgModule } from '@angular/core'; -import { RouterModule, Routes } from '@angular/router'; +import { ActivatedRouteSnapshot, RouterModule, Routes } from '@angular/router'; import { IscsiComponent } from './ceph/block/iscsi/iscsi.component'; import { MirroringComponent } from './ceph/block/mirroring/mirroring.component'; @@ -24,103 +24,181 @@ import { UserFormComponent } from './core/auth/user-form/user-form.component'; import { UserListComponent } from './core/auth/user-list/user-list.component'; 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 { ModuleStatusGuardService } from './shared/services/module-status-guard.service'; +export class PerformanceCounterBreadcrumbsResolver extends BreadcrumbsResolver { + resolve(route: ActivatedRouteSnapshot) { + const result: IBreadcrumb[] = []; + + const fromPath = route.queryParams.fromLink || null; + let fromText = ''; + switch (fromPath) { + case '/monitor': + fromText = 'Monitors'; + break; + case '/hosts': + fromText = 'Hosts'; + break; + } + result.push({ text: 'Cluster', path: null }); + result.push({ text: fromText, path: fromPath }); + result.push({ text: 'Performance Counters', path: '' }); + + return result; + } +} + const routes: Routes = [ + // Dashboard { path: '', redirectTo: 'dashboard', pathMatch: 'full' }, { path: 'dashboard', component: DashboardComponent, canActivate: [AuthGuardService] }, - { path: 'hosts', component: HostsComponent, canActivate: [AuthGuardService] }, - { path: 'login', component: LoginComponent }, - { path: 'hosts', component: HostsComponent, canActivate: [AuthGuardService] }, - { path: 'rgw/501/:message', component: Rgw501Component, canActivate: [AuthGuardService] }, + // Cluster + { + path: 'hosts', + component: HostsComponent, + canActivate: [AuthGuardService], + data: { breadcrumbs: 'Cluster/Hosts' } + }, + { + path: 'monitor', + component: MonitorComponent, + canActivate: [AuthGuardService], + data: { breadcrumbs: 'Cluster/Monitors' } + }, + { + path: 'osd', + component: OsdListComponent, + canActivate: [AuthGuardService], + data: { breadcrumbs: 'Cluster/OSDs' } + }, + { + path: 'configuration', + component: ConfigurationComponent, + canActivate: [AuthGuardService], + data: { breadcrumbs: 'Cluster/Configuration Documentation' } + }, + { + path: 'perf_counters/:type/:id', + component: PerformanceCounterComponent, + canActivate: [AuthGuardService], + data: { + breadcrumbs: PerformanceCounterBreadcrumbsResolver + } + }, + // Pools + { + path: 'pool', + component: PoolListComponent, + canActivate: [AuthGuardService], + data: { breadcrumbs: 'Pools' } + }, + // Block + { + path: 'block', + canActivateChild: [AuthGuardService], + canActivate: [AuthGuardService], + data: { breadcrumbs: true, text: 'Block', path: null }, + children: [ + { + path: 'rbd', + data: { breadcrumbs: 'Images' }, + children: [ + { path: '', component: RbdListComponent }, + { path: 'add', component: RbdFormComponent, data: { breadcrumbs: 'Add' } }, + { path: 'edit/:pool/:name', component: RbdFormComponent, data: { breadcrumbs: 'Edit' } }, + { + path: 'clone/:pool/:name/:snap', + component: RbdFormComponent, + data: { breadcrumbs: 'Clone' } + }, + { path: 'copy/:pool/:name', component: RbdFormComponent, data: { breadcrumbs: 'Copy' } }, + { + path: 'copy/:pool/:name/:snap', + component: RbdFormComponent, + data: { breadcrumbs: 'Copy' } + } + ] + }, + { + path: 'mirroring', + component: MirroringComponent, + data: { breadcrumbs: 'Mirroring' } + }, + { path: 'iscsi', component: IscsiComponent, data: { breadcrumbs: 'iSCSI' } } + ] + }, + // Filesystems + { + path: 'cephfs', + component: CephfsListComponent, + canActivate: [AuthGuardService], + data: { breadcrumbs: 'Filesystems' } + }, + // Object Gateway + { + path: 'rgw/501/:message', + component: Rgw501Component, + canActivate: [AuthGuardService], + data: { breadcrumbs: 'Object Gateway' } + }, { path: 'rgw', - canActivateChild: [ModuleStatusGuardService], + canActivateChild: [ModuleStatusGuardService, AuthGuardService], data: { moduleStatusGuardConfig: { apiPath: 'rgw', redirectTo: 'rgw/501' - } + }, + breadcrumbs: true, + text: 'Object Gateway', + path: null }, children: [ - { - path: 'daemon', - component: RgwDaemonListComponent, - canActivate: [AuthGuardService] - }, + { path: 'daemon', component: RgwDaemonListComponent, data: { breadcrumbs: 'Daemons' } }, { path: 'user', - component: RgwUserListComponent, - canActivate: [AuthGuardService] - }, - { - path: 'user/add', - component: RgwUserFormComponent, - canActivate: [AuthGuardService] - }, - { - path: 'user/edit/:uid', - component: RgwUserFormComponent, - canActivate: [AuthGuardService] + data: { breadcrumbs: 'Users' }, + children: [ + { path: '', component: RgwUserListComponent }, + { path: 'add', component: RgwUserFormComponent, data: { breadcrumbs: 'Add' } }, + { path: 'edit/:uid', component: RgwUserFormComponent, data: { breadcrumbs: 'Edit' } } + ] }, { path: 'bucket', - component: RgwBucketListComponent, - canActivate: [AuthGuardService] - }, - { - path: 'bucket/add', - component: RgwBucketFormComponent, - canActivate: [AuthGuardService] - }, - { - path: 'bucket/edit/:bucket', - component: RgwBucketFormComponent, - canActivate: [AuthGuardService] + data: { breadcrumbs: 'Buckets' }, + children: [ + { path: '', component: RgwBucketListComponent }, + { path: 'add', component: RgwBucketFormComponent, data: { breadcrumbs: 'Add' } }, + { path: 'edit/:bucket', component: RgwBucketFormComponent, data: { breadcrumbs: 'Edit' } } + ] } ] }, - { path: 'block/iscsi', component: IscsiComponent, canActivate: [AuthGuardService] }, - { path: 'block/rbd', component: RbdListComponent, canActivate: [AuthGuardService] }, - { path: 'rbd/add', component: RbdFormComponent, canActivate: [AuthGuardService] }, - { path: 'rbd/edit/:pool/:name', component: RbdFormComponent, canActivate: [AuthGuardService] }, - { path: 'pool', component: PoolListComponent, canActivate: [AuthGuardService] }, - { - path: 'rbd/clone/:pool/:name/:snap', - component: RbdFormComponent, - canActivate: [AuthGuardService] - }, + // Administration { - path: 'rbd/copy/:pool/:name', - component: RbdFormComponent, - canActivate: [AuthGuardService] - }, - { - path: 'rbd/copy/:pool/:name/:snap', - component: RbdFormComponent, - canActivate: [AuthGuardService] - }, - { - path: 'perf_counters/:type/:id', - component: PerformanceCounterComponent, - canActivate: [AuthGuardService] + path: 'users', + canActivate: [AuthGuardService], + canActivateChild: [AuthGuardService], + data: { breadcrumbs: 'Administration/Users' }, + children: [ + { path: '', component: UserListComponent }, + { path: 'add', component: UserFormComponent, data: { breadcrumbs: 'Add' } }, + { path: 'edit/:username', component: UserFormComponent, data: { breadcrumbs: 'Edit' } } + ] }, - { path: 'monitor', component: MonitorComponent, canActivate: [AuthGuardService] }, - { path: 'cephfs', component: CephfsListComponent, canActivate: [AuthGuardService] }, - { path: 'configuration', component: ConfigurationComponent, canActivate: [AuthGuardService] }, - { path: 'mirroring', component: MirroringComponent, canActivate: [AuthGuardService] }, - { path: 'users', component: UserListComponent, canActivate: [AuthGuardService] }, - { path: 'users/add', component: UserFormComponent, canActivate: [AuthGuardService] }, - { path: 'users/edit/:username', component: UserFormComponent, canActivate: [AuthGuardService] }, + // System + { path: 'login', component: LoginComponent }, { path: '403', component: ForbiddenComponent }, { path: '404', component: NotFoundComponent }, - { path: 'osd', component: OsdListComponent, canActivate: [AuthGuardService] }, { path: '**', redirectTo: '/404' } ]; @NgModule({ imports: [RouterModule.forRoot(routes, { useHash: true })], - exports: [RouterModule] + exports: [RouterModule], + providers: [PerformanceCounterBreadcrumbsResolver] }) export class AppRoutingModule {} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/app.component.html b/src/pybind/mgr/dashboard/frontend/src/app/app.component.html index 638edaa604f1a..669222afc80e7 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/app.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/app.component.html @@ -1,5 +1,6 @@
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi/iscsi.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi/iscsi.component.html index 68f9326690cd7..b2b42a65254a4 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi/iscsi.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/iscsi/iscsi.component.html @@ -1,13 +1,3 @@ - - Daemons - - -
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.component.html index 6abe5603d5c64..d03f3edfeff43 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-form/rbd-form.component.html @@ -1,13 +1,3 @@ - -
- - - @@ -26,14 +16,14 @@ @@ -57,7 +47,7 @@
  • + routerLink="/block/rbd/add"> Add @@ -66,7 +56,7 @@ *ngIf="permission.update" [ngClass]="{'disabled': !selection.hasSingleSelection || selection.first().executing}"> + routerLink="/block/rbd/edit/{{ selection.first()?.pool_name | encodeUri }}/{{ selection.first()?.name | encodeUri }}"> Edit @@ -75,7 +65,7 @@ *ngIf="permission.create" [ngClass]="{'disabled': !selection.hasSingleSelection || selection.first().executing}"> + routerLink="/block/rbd/copy/{{ selection.first()?.pool_name | encodeUri }}/{{ selection.first()?.name | encodeUri }}"> Copy diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot-list.component.html index a742f21694473..7ad626865d166 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot-list.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-snapshot-list/rbd-snapshot-list.component.html @@ -73,7 +73,7 @@ *ngIf="permission.create" [ngClass]="{'disabled': !selection.hasSingleSelection || selection.first().executing}"> + routerLink="/block/rbd/clone/{{ poolName | encodeUri }}/{{ rbdName | encodeUri }}/{{ selection.first()?.name | encodeUri }}"> Clone @@ -82,7 +82,7 @@ *ngIf="permission.create" [ngClass]="{'disabled': !selection.hasSingleSelection || selection.first().executing}"> + routerLink="/block/rbd/copy/{{ poolName | encodeUri }}/{{ rbdName | encodeUri }}/{{ selection.first()?.name | encodeUri }}"> Copy diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-list/cephfs-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-list/cephfs-list.component.html index 726deba82d287..b3fecb3eca39b 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-list/cephfs-list.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-list/cephfs-list.component.html @@ -1,10 +1,3 @@ - - - - - - {{ service.type }}.{{ service.id }} {{ service.type }}.{{ service.id }} {{ !isLast ? ", " : "" }} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.ts index f574055bded63..630b4f35d1db8 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/hosts.component.ts @@ -17,6 +17,7 @@ export class HostsComponent implements OnInit { columns: Array = []; hosts: Array = []; isLoadingHosts = false; + cdParams = { fromLink: '/hosts' }; @ViewChild('servicesTpl') public servicesTpl: TemplateRef; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/monitor/monitor.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/monitor/monitor.component.html index d59de84c14512..fd4ce6787ffff 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/monitor/monitor.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/monitor/monitor.component.html @@ -1,13 +1,3 @@ - -
    diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.html index 733bcf26fb251..1381ad56033dc 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.html @@ -1,9 +1,3 @@ - - - -
    {{ serviceType }}.{{ serviceId }} - - - - {{ message }} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.html index f0e4d163260d0..4bd688756b313 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.html @@ -1,19 +1,3 @@ - - Loading bucket data... diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-list/rgw-bucket-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-list/rgw-bucket-list.component.html index 0f6afe3eff225..b0ab16540cea7 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-list/rgw-bucket-list.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-list/rgw-bucket-list.component.html @@ -1,12 +1,3 @@ - - - - - - - Loading user data... diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.html index 3cb0bfd83582a..898799f3007b1 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.html @@ -1,12 +1,3 @@ - - - -
    - - - + + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/breadcrumbs/breadcrumbs.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/breadcrumbs/breadcrumbs.component.scss new file mode 100644 index 0000000000000..a8dbf5ef593b3 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/breadcrumbs/breadcrumbs.component.scss @@ -0,0 +1,18 @@ +@import '../../../../defaults'; + +.breadcrumb { + padding: 8px 0; + background-color: transparent; + border-radius: 0; +} + +.breadcrumb > li + li:before { + padding: 0 5px 0 7px; + color: $color-breadcrumb; + font-family: 'ForkAwesome'; + content: '\f101'; +} + +.breadcrumb > li > span { + color: $color-breadcrumb; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/breadcrumbs/breadcrumbs.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/breadcrumbs/breadcrumbs.component.spec.ts new file mode 100644 index 0000000000000..9b7dac3f1b3af --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/breadcrumbs/breadcrumbs.component.spec.ts @@ -0,0 +1,136 @@ +import { CommonModule } from '@angular/common'; +import { Component } from '@angular/core'; +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { Router, Routes } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { configureTestBed } from '../../../../testing/unit-test-helper'; +import { PerformanceCounterBreadcrumbsResolver } from '../../../app-routing.module'; +import { BreadcrumbsComponent } from './breadcrumbs.component'; + +describe('BreadcrumbsComponent', () => { + let component: BreadcrumbsComponent; + let fixture: ComponentFixture; + let router: Router; + + @Component({ selector: 'cd-fake', template: '' }) + class FakeComponent {} + + const routes: Routes = [ + { + path: 'hosts', + component: FakeComponent, + data: { breadcrumbs: 'Cluster/Hosts' } + }, + { + path: 'perf_counters', + component: FakeComponent, + data: { + breadcrumbs: PerformanceCounterBreadcrumbsResolver + } + }, + { + path: 'block', + data: { breadcrumbs: true, text: 'Block', path: null }, + children: [ + { + path: 'rbd', + data: { breadcrumbs: 'Images' }, + children: [ + { path: '', component: FakeComponent }, + { path: 'add', component: FakeComponent, data: { breadcrumbs: 'Add' } } + ] + } + ] + } + ]; + + configureTestBed({ + declarations: [BreadcrumbsComponent, FakeComponent], + imports: [CommonModule, RouterTestingModule.withRoutes(routes)], + providers: [PerformanceCounterBreadcrumbsResolver] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(BreadcrumbsComponent); + router = TestBed.get(Router); + component = fixture.componentInstance; + fixture.detectChanges(); + expect(component.crumbs).toEqual([]); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + expect(component.subscription).toBeDefined(); + }); + + it( + 'should run postProcess and split the breadcrumbs when navigating to hosts', + fakeAsync(() => { + router.navigateByUrl('/hosts'); + tick(); + expect(component.crumbs).toEqual([ + { path: null, text: 'Cluster' }, + { path: '/hosts', text: 'Hosts' } + ]); + }) + ); + + it( + 'should display empty breadcrumb when navigating to perf_counters from unknown path', + fakeAsync(() => { + router.navigateByUrl('/perf_counters'); + tick(); + expect(component.crumbs).toEqual([ + { path: null, text: 'Cluster' }, + { path: null, text: '' }, + { path: '', text: 'Performance Counters' } + ]); + }) + ); + + it( + 'should display Monitor breadcrumb when navigating to perf_counters from Monitors', + fakeAsync(() => { + router.navigate(['/perf_counters'], { queryParams: { fromLink: '/monitor' } }); + tick(); + expect(component.crumbs).toEqual([ + { path: null, text: 'Cluster' }, + { path: '/monitor', text: 'Monitors' }, + { path: '', text: 'Performance Counters' } + ]); + }) + ); + + it( + 'should display Hosts breadcrumb when navigating to perf_counters from Hosts', + fakeAsync(() => { + router.navigate(['/perf_counters'], { queryParams: { fromLink: '/hosts' } }); + tick(); + expect(component.crumbs).toEqual([ + { path: null, text: 'Cluster' }, + { path: '/hosts', text: 'Hosts' }, + { path: '', text: 'Performance Counters' } + ]); + }) + ); + + it( + 'should show all 3 breadcrumbs when navigating to RBD Add', + fakeAsync(() => { + router.navigateByUrl('/block/rbd/add'); + tick(); + expect(component.crumbs).toEqual([ + { path: null, text: 'Block' }, + { path: '/block/rbd', text: 'Images' }, + { path: '/block/rbd/add', text: 'Add' } + ]); + }) + ); + + it('should unsubscribe on ngOnDestroy', () => { + expect(component.subscription.closed).toBeFalsy(); + component.ngOnDestroy(); + expect(component.subscription.closed).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/breadcrumbs/breadcrumbs.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/breadcrumbs/breadcrumbs.component.ts new file mode 100644 index 0000000000000..f7b9d8af1ccb3 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/breadcrumbs/breadcrumbs.component.ts @@ -0,0 +1,126 @@ +/* +The MIT License + +Copyright (c) 2017 (null) McNull https://github.com/McNull + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + */ + +import { Component, Injector, OnDestroy } from '@angular/core'; +import { ActivatedRouteSnapshot, NavigationEnd, Router } from '@angular/router'; + +import { from, Observable, of, Subscription } from 'rxjs'; +import { concat, distinct, filter, first, flatMap, toArray } from 'rxjs/operators'; + +import { BreadcrumbsResolver, IBreadcrumb } from '../../../shared/models/breadcrumbs'; + +@Component({ + selector: 'cd-breadcrumbs', + templateUrl: './breadcrumbs.component.html', + styleUrls: ['./breadcrumbs.component.scss'] +}) +export class BreadcrumbsComponent implements OnDestroy { + crumbs: IBreadcrumb[] = []; + subscription: Subscription; + private defaultResolver = new BreadcrumbsResolver(); + + constructor(private router: Router, private injector: Injector) { + this.subscription = this.router.events + .pipe(filter((x) => x instanceof NavigationEnd)) + .subscribe(() => { + const currentRoot = router.routerState.snapshot.root; + + this._resolveCrumbs(currentRoot) + .pipe( + flatMap((x) => x), + distinct((x) => x.text), + toArray(), + flatMap((x) => { + const y = this.postProcess(x); + return this.wrapIntoObservable(y).pipe(first()); + }) + ) + .subscribe((x) => { + this.crumbs = x; + }); + }); + } + + ngOnDestroy(): void { + this.subscription.unsubscribe(); + } + + private _resolveCrumbs(route: ActivatedRouteSnapshot): Observable { + let crumbs$: Observable; + + const data = route.routeConfig && route.routeConfig.data; + + if (data && data.breadcrumbs) { + let resolver: BreadcrumbsResolver; + + if (data.breadcrumbs.prototype instanceof BreadcrumbsResolver) { + resolver = this.injector.get(data.breadcrumbs); + } else { + resolver = this.defaultResolver; + } + + const result = resolver.resolve(route); + crumbs$ = this.wrapIntoObservable(result).pipe(first()); + } else { + crumbs$ = of([]); + } + + if (route.firstChild) { + crumbs$ = crumbs$.pipe(concat(this._resolveCrumbs(route.firstChild))); + } + + return crumbs$; + } + + postProcess(breadcrumbs: IBreadcrumb[]) { + const result = []; + breadcrumbs.forEach((element) => { + const split = element.text.split('/'); + if (split.length > 1) { + element.text = split[split.length - 1]; + for (let i = 0; i < split.length - 1; i++) { + result.push({ text: split[i], path: null }); + } + } + result.push(element); + }); + return result; + } + + isPromise(value: any): boolean { + return value && typeof value.then === 'function'; + } + + wrapIntoObservable(value: T | Promise | Observable): Observable { + if (value instanceof Observable) { + return value; + } + + if (this.isPromise(value)) { + return from(Promise.resolve(value)); + } + + return of(value as T); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation.module.ts index 178eeade9b7ac..f35e78fd7337b 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation.module.ts @@ -9,6 +9,7 @@ import { SharedModule } from '../../shared/shared.module'; import { AuthModule } from '../auth/auth.module'; import { AboutComponent } from './about/about.component'; import { AdministrationComponent } from './administration/administration.component'; +import { BreadcrumbsComponent } from './breadcrumbs/breadcrumbs.component'; import { DashboardHelpComponent } from './dashboard-help/dashboard-help.component'; import { NavigationComponent } from './navigation/navigation.component'; import { NotificationsComponent } from './notifications/notifications.component'; @@ -29,12 +30,13 @@ import { TaskManagerComponent } from './task-manager/task-manager.component'; ], declarations: [ AboutComponent, + BreadcrumbsComponent, NavigationComponent, NotificationsComponent, TaskManagerComponent, DashboardHelpComponent, AdministrationComponent ], - exports: [NavigationComponent] + exports: [NavigationComponent, BreadcrumbsComponent] }) export class NavigationModule {} 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 d212e21deea94..beae9ebf90622 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 @@ -120,7 +120,7 @@ *ngIf="permissions.rbdMirroring.read"> Mirroring + routerLink="/block/mirroring"> Mirroring {{ summaryData?.rbd_mirroring?.warnings }} { + public resolve( + route: ActivatedRouteSnapshot + ): Observable | Promise | IBreadcrumb[] { + const data = route.routeConfig.data; + const path = data.path === null ? null : this.getFullPath(route); + + const text = + typeof data.breadcrumbs === 'string' + ? data.breadcrumbs + : data.breadcrumbs.text || data.text || path; + + const crumbs: IBreadcrumb[] = [{ text: text, path: path }]; + + return of(crumbs); + } + + public getFullPath(route: ActivatedRouteSnapshot): string { + const relativePath = (segments: UrlSegment[]) => + segments.reduce((a, v) => (a += '/' + v.path), ''); + const fullPath = (routes: ActivatedRouteSnapshot[]) => + routes.reduce((a, v) => (a += relativePath(v.url)), ''); + + return fullPath(route.pathFromRoot); + } +} + +export interface IBreadcrumb { + text: string; + path: string; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/auth-guard.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/auth-guard.service.ts index 398910a014d08..f376e6a8cab9e 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/auth-guard.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/auth-guard.service.ts @@ -1,5 +1,11 @@ import { Injectable } from '@angular/core'; -import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router'; +import { + ActivatedRouteSnapshot, + CanActivate, + CanActivateChild, + Router, + RouterStateSnapshot +} from '@angular/router'; import { AuthStorageService } from './auth-storage.service'; import { ServicesModule } from './services.module'; @@ -7,7 +13,7 @@ import { ServicesModule } from './services.module'; @Injectable({ providedIn: ServicesModule }) -export class AuthGuardService implements CanActivate { +export class AuthGuardService implements CanActivate, CanActivateChild { constructor(private router: Router, private authStorageService: AuthStorageService) {} canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { @@ -17,4 +23,8 @@ export class AuthGuardService implements CanActivate { this.router.navigate(['/login']); return false; } + + canActivateChild(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean { + return this.canActivate(route, state); + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/styles.scss b/src/pybind/mgr/dashboard/frontend/src/styles.scss index 0d05a3162b2a4..a4b5cd474cb7c 100644 --- a/src/pybind/mgr/dashboard/frontend/src/styles.scss +++ b/src/pybind/mgr/dashboard/frontend/src/styles.scss @@ -80,21 +80,7 @@ option { .text-monospace { font-family: monospace; } -/* Breadcrumb */ -.breadcrumb { - padding: 8px 0; - background-color: transparent; - border-radius: 0; -} -.breadcrumb > li + li:before { - padding: 0 5px 0 7px; - color: $color-breadcrumb; - font-family: 'ForkAwesome'; - content: '\f101'; -} -.breadcrumb > li > span { - color: $color-breadcrumb; -} + /* Buttons */ .btn-primary { color: $color-button-text;