From 7fdb3c1504a96a991041934c8abd6453d762857d Mon Sep 17 00:00:00 2001 From: Tiago Melo Date: Thu, 8 Aug 2019 15:01:38 +0000 Subject: [PATCH] mgr/dashboard: Unify Tasks and Notifications into a sidebar Fixes: https://tracker.ceph.com/issues/37402 Signed-off-by: Tiago Melo --- .../frontend/e2e/cluster/crush-map.po.ts | 2 +- .../mgr/dashboard/frontend/package-lock.json | 48 ++++++ .../mgr/dashboard/frontend/package.json | 3 + .../frontend/src/app/app.component.html | 32 +++- .../frontend/src/app/app.component.spec.ts | 52 ++++++- .../frontend/src/app/app.component.ts | 27 +++- .../dashboard/frontend/src/app/app.module.ts | 6 +- .../core/auth/login/login.component.spec.ts | 17 ++- .../app/core/auth/login/login.component.ts | 8 +- .../app/core/navigation/navigation.module.ts | 4 - .../navigation/navigation.component.html | 3 - .../notifications.component.html | 60 +------- .../notifications.component.scss | 10 +- .../notifications.component.spec.ts | 73 ++------- .../notifications/notifications.component.ts | 52 ++----- .../task-manager/task-manager.component.html | 86 ----------- .../task-manager/task-manager.component.scss | 1 - .../task-manager.component.spec.ts | 93 ----------- .../task-manager/task-manager.component.ts | 58 ------- .../shared/components/components.module.ts | 5 + .../notifications-sidebar.component.html | 118 ++++++++++++++ .../notifications-sidebar.component.scss | 39 +++++ .../notifications-sidebar.component.spec.ts | 144 ++++++++++++++++++ .../notifications-sidebar.component.ts | 131 ++++++++++++++++ .../src/app/shared/models/cd-notification.ts | 3 + .../src/app/shared/models/finished-task.ts | 1 + .../app/shared/pipes/duration.pipe.spec.ts | 24 +++ .../src/app/shared/pipes/duration.pipe.ts | 47 ++++++ .../src/app/shared/pipes/pipes.module.ts | 7 +- .../services/notification.service.spec.ts | 28 ++-- .../shared/services/notification.service.ts | 31 +++- .../services/task-message.service.spec.ts | 3 +- .../shared/services/task-wrapper.service.ts | 5 +- .../mgr/dashboard/frontend/src/styles.scss | 2 +- .../frontend/src/styles/popover.scss | 46 ------ 35 files changed, 784 insertions(+), 485 deletions(-) delete mode 100644 src/pybind/mgr/dashboard/frontend/src/app/core/navigation/task-manager/task-manager.component.html delete mode 100644 src/pybind/mgr/dashboard/frontend/src/app/core/navigation/task-manager/task-manager.component.scss delete mode 100644 src/pybind/mgr/dashboard/frontend/src/app/core/navigation/task-manager/task-manager.component.spec.ts delete mode 100644 src/pybind/mgr/dashboard/frontend/src/app/core/navigation/task-manager/task-manager.component.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/duration.pipe.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/duration.pipe.ts delete mode 100644 src/pybind/mgr/dashboard/frontend/src/styles/popover.scss diff --git a/src/pybind/mgr/dashboard/frontend/e2e/cluster/crush-map.po.ts b/src/pybind/mgr/dashboard/frontend/e2e/cluster/crush-map.po.ts index ac2f1587a3b..279fd85621e 100644 --- a/src/pybind/mgr/dashboard/frontend/e2e/cluster/crush-map.po.ts +++ b/src/pybind/mgr/dashboard/frontend/e2e/cluster/crush-map.po.ts @@ -5,7 +5,7 @@ export class CrushMapPageHelper extends PageHelper { pages = { index: '/#/crush-map' }; getPageTitle() { - return $('.card-header').getText(); + return $('cd-crushmap .card-header').getText(); } getCrushNode(idx) { diff --git a/src/pybind/mgr/dashboard/frontend/package-lock.json b/src/pybind/mgr/dashboard/frontend/package-lock.json index 96cc272a291..49fe2c857eb 100644 --- a/src/pybind/mgr/dashboard/frontend/package-lock.json +++ b/src/pybind/mgr/dashboard/frontend/package-lock.json @@ -3465,6 +3465,11 @@ "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", "dev": true }, + "async-mutex": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.1.4.tgz", + "integrity": "sha512-zVWTmAnxxHaeB2B1te84oecI8zTDJ/8G49aVBblRX6be0oq6pAybNcUSxwfgVOmOjSCvN4aYZAqwtyNI8e1YGw==" + }, "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -10985,12 +10990,32 @@ "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=", "dev": true }, + "lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=" + }, + "lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA=" + }, "lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", "integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=", "dev": true }, + "lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" + }, + "lodash.set": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/lodash.set/-/lodash.set-4.3.2.tgz", + "integrity": "sha1-2HV7HagH3eJIFrDWqEvqGnYjCyM=" + }, "lodash.sortby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", @@ -11652,6 +11677,11 @@ "tslib": "^1.9.0" } }, + "ng-sidebar": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/ng-sidebar/-/ng-sidebar-9.1.1.tgz", + "integrity": "sha512-G8BAaV/TsfkMHyy4FbaDEjrKdu0b55aEjZM1Nrz1xG62J/jyfhgK+S0ma3nszUWK6hKMqwXXVBghoX8pl9SoVg==" + }, "ng2-charts": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/ng2-charts/-/ng2-charts-2.3.0.tgz", @@ -11672,6 +11702,19 @@ "resolved": "https://registry.npmjs.org/ngx-bootstrap/-/ngx-bootstrap-5.1.2.tgz", "integrity": "sha512-L9flZCGEf+/G0sOZXs3WJ2tp7SW6/7soQbAnpFmlvFURcSKv9p2/aiH/VbG47Ra50e5i6q3ereKEo7IpGEQwVQ==" }, + "ngx-store": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ngx-store/-/ngx-store-2.1.0.tgz", + "integrity": "sha512-NVFP/VUctQuzwGqmaSx6bbQwT1XmGmHe0ACTMyxoWq7gmpAFAt/LkGuei70aX4ukyH1tQNk9zuYzTQlQOIG7rg==", + "requires": { + "lodash.get": "^4.4.2", + "lodash.isequal": "^4.5.0", + "lodash.merge": "^4.6.1", + "lodash.set": "^4.3.2", + "ts-debug": "^1.3.0", + "tslib": "^1.9.3" + } + }, "ngx-toastr": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/ngx-toastr/-/ngx-toastr-11.0.0.tgz", @@ -15710,6 +15753,11 @@ "integrity": "sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM=", "dev": true }, + "ts-debug": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/ts-debug/-/ts-debug-1.3.0.tgz", + "integrity": "sha512-sP9Q4Nfqu5ImWLH955PpxbjR2zgLWS3NIc2tCw/JZtZMFFxUZe3fvkhdA0vSIpjiGFKPwCg6v0drthjwnSQTGA==" + }, "ts-jest": { "version": "24.1.0", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-24.1.0.tgz", diff --git a/src/pybind/mgr/dashboard/frontend/package.json b/src/pybind/mgr/dashboard/frontend/package.json index 593c39498a6..526fd63532a 100644 --- a/src/pybind/mgr/dashboard/frontend/package.json +++ b/src/pybind/mgr/dashboard/frontend/package.json @@ -71,6 +71,7 @@ "@auth0/angular-jwt": "2.1.1", "@ngx-translate/i18n-polyfill": "1.0.0", "@swimlane/ngx-datatable": "15.0.2", + "async-mutex": "0.1.4", "bootstrap": "4.3.1", "chart.js": "2.8.0", "detect-browser": "4.7.0", @@ -79,9 +80,11 @@ "moment": "2.24.0", "ng-block-ui": "2.1.7", "ng-bootstrap-form-validation": "5.0.0", + "ng-sidebar": "9.1.1", "ng2-charts": "2.3.0", "ng2-tree": "2.0.0-rc.11", "ngx-bootstrap": "5.1.2", + "ngx-store": "2.1.0", "ngx-toastr": "11.0.0", "rxjs": "6.5.3", "rxjs-compat": "6.5.3", 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 3eb48a37654..7e08eef685f 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/app.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/app.component.html @@ -1,8 +1,26 @@ - - -
- - + + + + + + + + + +
+ + +
+ + +
+
- +
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/app.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/app.component.spec.ts index 16fb5adb2e4..cdef9faf56c 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/app.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/app.component.spec.ts @@ -1,20 +1,32 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { RouterTestingModule } from '@angular/router/testing'; -import { configureTestBed } from '../testing/unit-test-helper'; +import { SidebarModule } from 'ng-sidebar'; +import { ToastrModule } from 'ngx-toastr'; + +import { configureTestBed, i18nProviders } from '../testing/unit-test-helper'; import { AppComponent } from './app.component'; +import { PipesModule } from './shared/pipes/pipes.module'; import { AuthStorageService } from './shared/services/auth-storage.service'; +import { NotificationService } from './shared/services/notification.service'; describe('AppComponent', () => { let component: AppComponent; let fixture: ComponentFixture; configureTestBed({ - imports: [RouterTestingModule], + imports: [ + RouterTestingModule, + ToastrModule.forRoot(), + PipesModule, + HttpClientTestingModule, + SidebarModule.forRoot() + ], declarations: [AppComponent], schemas: [NO_ERRORS_SCHEMA], - providers: [AuthStorageService] + providers: [AuthStorageService, i18nProviders] }); beforeEach(() => { @@ -26,4 +38,38 @@ describe('AppComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + describe('Sidebar', () => { + let notificationService: NotificationService; + + beforeEach(() => { + notificationService = TestBed.get(NotificationService); + }); + + it('should always close if sidebarSubject value is true', () => { + // Closed before next value + expect(component.sidebarOpened).toBeFalsy(); + notificationService.sidebarSubject.next(true); + expect(component.sidebarOpened).toBeFalsy(); + + // Opened before next value + component.sidebarOpened = true; + expect(component.sidebarOpened).toBeTruthy(); + notificationService.sidebarSubject.next(true); + expect(component.sidebarOpened).toBeFalsy(); + }); + + it('should toggle sidebar visibility if sidebarSubject value is false', () => { + // Closed before next value + expect(component.sidebarOpened).toBeFalsy(); + notificationService.sidebarSubject.next(false); + expect(component.sidebarOpened).toBeTruthy(); + + // Opened before next value + component.sidebarOpened = true; + expect(component.sidebarOpened).toBeTruthy(); + notificationService.sidebarSubject.next(false); + expect(component.sidebarOpened).toBeFalsy(); + }); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/app.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/app.component.ts index 363a213d214..c3d9430e294 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/app.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/app.component.ts @@ -1,9 +1,11 @@ -import { Component } from '@angular/core'; +import { Component, ViewChild } from '@angular/core'; import { Router } from '@angular/router'; +import { Sidebar } from 'ng-sidebar'; import { TooltipConfig } from 'ngx-bootstrap/tooltip'; import { AuthStorageService } from './shared/services/auth-storage.service'; +import { NotificationService } from './shared/services/notification.service'; @Component({ selector: 'cd-root', @@ -20,9 +22,30 @@ import { AuthStorageService } from './shared/services/auth-storage.service'; ] }) export class AppComponent { + @ViewChild(Sidebar, { static: true }) + sidebar: Sidebar; + title = 'cd'; - constructor(private authStorageService: AuthStorageService, private router: Router) {} + sidebarOpened = false; + // There is a bug in ng-sidebar that will show the sidebar closing animation + // when the page is first loaded. This prevents that. + sidebarAnimate = false; + + constructor( + private authStorageService: AuthStorageService, + private router: Router, + public notificationService: NotificationService + ) { + this.notificationService.sidebarSubject.subscribe((forcedClose) => { + if (forcedClose) { + this.sidebar.close(); + } else { + this.sidebarAnimate = true; + this.sidebarOpened = !this.sidebarOpened; + } + }); + } isLoginActive() { return this.router.url === '/login' || !this.authStorageService.isLoggedIn(); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/app.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/app.module.ts index 2f94a0aeee1..d5d91bc49cc 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/app.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/app.module.ts @@ -8,9 +8,11 @@ import { JwtModule } from '@auth0/angular-jwt'; import { I18n } from '@ngx-translate/i18n-polyfill'; import { BlockUIModule } from 'ng-block-ui'; import { NgBootstrapFormValidationModule } from 'ng-bootstrap-form-validation'; +import { SidebarModule } from 'ng-sidebar'; import { AccordionModule } from 'ngx-bootstrap/accordion'; import { BsDropdownModule } from 'ngx-bootstrap/dropdown'; import { TabsModule } from 'ngx-bootstrap/tabs'; +import { WebStorageModule } from 'ngx-store'; import { ToastrModule } from 'ngx-toastr'; import { AppRoutingModule } from './app-routing.module'; @@ -52,7 +54,9 @@ registerLocaleData(LocaleHelper.getLocaleData(), LocaleHelper.getLocale()); tokenGetter: jwtTokenGetter } }), - NgBootstrapFormValidationModule.forRoot() + NgBootstrapFormValidationModule.forRoot(), + SidebarModule.forRoot(), + WebStorageModule ], exports: [SharedModule], providers: [ 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 daa96f34e8b..03a48b86324 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 @@ -2,7 +2,10 @@ import { HttpClientTestingModule } from '@angular/common/http/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { RouterTestingModule } from '@angular/router/testing'; -import { configureTestBed } from '../../../../testing/unit-test-helper'; +import { ToastrModule } from 'ngx-toastr'; + +import { configureTestBed, i18nProviders } from '../../../../testing/unit-test-helper'; +import { NotificationService } from '../../../shared/services/notification.service'; import { AuthModule } from '../auth.module'; import { LoginComponent } from './login.component'; @@ -11,7 +14,8 @@ describe('LoginComponent', () => { let fixture: ComponentFixture; configureTestBed({ - imports: [RouterTestingModule, HttpClientTestingModule, AuthModule] + imports: [RouterTestingModule, HttpClientTestingModule, AuthModule, ToastrModule.forRoot()], + providers: [i18nProviders] }); beforeEach(() => { @@ -29,4 +33,13 @@ describe('LoginComponent', () => { component.ngOnInit(); expect(component['bsModalService'].getModalsCount()).toBe(0); }); + + it('should call toggleSidebar if not logged in', () => { + const notificationService: NotificationService = TestBed.get(NotificationService); + spyOn(notificationService, 'toggleSidebar').and.callThrough(); + + component.ngOnInit(); + + expect(notificationService.toggleSidebar).toHaveBeenCalledWith(true); + }); }); 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 521ab305d2b..b8c5b10a618 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 @@ -6,6 +6,7 @@ import { BsModalService } from 'ngx-bootstrap/modal'; import { AuthService } from '../../../shared/api/auth.service'; import { Credentials } from '../../../shared/models/credentials'; import { AuthStorageService } from '../../../shared/services/auth-storage.service'; +import { NotificationService } from '../../../shared/services/notification.service'; @Component({ selector: 'cd-login', @@ -20,7 +21,8 @@ export class LoginComponent implements OnInit { private authService: AuthService, private authStorageService: AuthStorageService, private bsModalService: BsModalService, - private router: Router + private router: Router, + private notificationService: NotificationService ) {} ngOnInit() { @@ -34,6 +36,10 @@ export class LoginComponent implements OnInit { for (let i = 1; i <= modalsCount; i++) { this.bsModalService.hide(i); } + + // Make sure notification sidebar is closed. + this.notificationService.toggleSidebar(true); + let token = null; if (window.location.hash.indexOf('access_token=') !== -1) { token = window.location.hash.split('access_token=')[1]; 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 72451931938..088d28b9ada 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 @@ -5,7 +5,6 @@ import { RouterModule } from '@angular/router'; import { CollapseModule } from 'ngx-bootstrap/collapse'; import { BsDropdownModule } from 'ngx-bootstrap/dropdown'; import { PopoverModule } from 'ngx-bootstrap/popover'; -import { ProgressbarModule } from 'ngx-bootstrap/progressbar'; import { TooltipModule } from 'ngx-bootstrap/tooltip'; import { AppRoutingModule } from '../../app-routing.module'; @@ -18,14 +17,12 @@ import { DashboardHelpComponent } from './dashboard-help/dashboard-help.componen import { IdentityComponent } from './identity/identity.component'; import { NavigationComponent } from './navigation/navigation.component'; import { NotificationsComponent } from './notifications/notifications.component'; -import { TaskManagerComponent } from './task-manager/task-manager.component'; @NgModule({ entryComponents: [AboutComponent], imports: [ CommonModule, AuthModule, - ProgressbarModule.forRoot(), CollapseModule.forRoot(), BsDropdownModule.forRoot(), PopoverModule.forRoot(), @@ -39,7 +36,6 @@ import { TaskManagerComponent } from './task-manager/task-manager.component'; BreadcrumbsComponent, NavigationComponent, NotificationsComponent, - TaskManagerComponent, DashboardHelpComponent, AdministrationComponent, IdentityComponent 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 1e7ef31b58b..b49b33f0863 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 @@ -40,9 +40,6 @@ - diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.html index 93024e67abe..3bca268d8cd 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.html @@ -1,56 +1,8 @@ - -
- -
-
- - - - - - - - - - - -
- - - - - - {{ notification.title }} -
-
-
-
-
-
- - -
-
There are no notifications.
-
-
- - - - - - - + - Recent Notifications + Tasks and Notifications diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.scss index 5e61d84bcb6..8a2269b1c46 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.scss @@ -1 +1,9 @@ -@import 'popover.scss'; +@import 'defaults'; + +.running i { + color: $color-primary; +} + +.running:hover i { + color: white; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.spec.ts index 6ce6241ff60..dacdb24c042 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.spec.ts @@ -1,28 +1,22 @@ import { HttpClientTestingModule } from '@angular/common/http/testing'; -import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; -import { PopoverModule } from 'ngx-bootstrap/popover'; import { ToastrModule } from 'ngx-toastr'; import { configureTestBed, i18nProviders } from '../../../../testing/unit-test-helper'; -import { PrometheusService } from '../../../shared/api/prometheus.service'; -import { AuthStorageService } from '../../../shared/services/auth-storage.service'; -import { PrometheusAlertService } from '../../../shared/services/prometheus-alert.service'; -import { PrometheusNotificationService } from '../../../shared/services/prometheus-notification.service'; +import { ExecutingTask } from '../../../shared/models/executing-task'; +import { SummaryService } from '../../../shared/services/summary.service'; import { SharedModule } from '../../../shared/shared.module'; import { NotificationsComponent } from './notifications.component'; describe('NotificationsComponent', () => { let component: NotificationsComponent; let fixture: ComponentFixture; + let summaryService: SummaryService; configureTestBed({ - imports: [ - HttpClientTestingModule, - PopoverModule.forRoot(), - SharedModule, - ToastrModule.forRoot() - ], + imports: [HttpClientTestingModule, SharedModule, ToastrModule.forRoot(), RouterTestingModule], declarations: [NotificationsComponent], providers: i18nProviders }); @@ -30,60 +24,21 @@ describe('NotificationsComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(NotificationsComponent); component = fixture.componentInstance; + summaryService = TestBed.get(SummaryService); + + fixture.detectChanges(); }); it('should create', () => { - fixture.detectChanges(); expect(component).toBeTruthy(); }); - describe('prometheus alert handling', () => { - let prometheusAlertService: PrometheusAlertService; - let prometheusNotificationService: PrometheusNotificationService; - let prometheusAccessAllowed: boolean; - - const expectPrometheusServicesToBeCalledTimes = (n: number) => { - expect(prometheusNotificationService.refresh).toHaveBeenCalledTimes(n); - expect(prometheusAlertService.refresh).toHaveBeenCalledTimes(n); - }; - - beforeEach(() => { - prometheusAccessAllowed = true; - spyOn(TestBed.get(AuthStorageService), 'getPermissions').and.callFake(() => ({ - prometheus: { read: prometheusAccessAllowed } - })); - - spyOn(TestBed.get(PrometheusService), 'ifAlertmanagerConfigured').and.callFake((fn) => fn()); - - prometheusAlertService = TestBed.get(PrometheusAlertService); - spyOn(prometheusAlertService, 'refresh').and.stub(); - - prometheusNotificationService = TestBed.get(PrometheusNotificationService); - spyOn(prometheusNotificationService, 'refresh').and.stub(); - }); - - it('should not refresh prometheus services if not allowed', () => { - prometheusAccessAllowed = false; - fixture.detectChanges(); - - expectPrometheusServicesToBeCalledTimes(0); - }); - it('should first refresh prometheus notifications and alerts during init', () => { - fixture.detectChanges(); - - expect(prometheusAlertService.refresh).toHaveBeenCalledTimes(1); - expectPrometheusServicesToBeCalledTimes(1); - }); + it('should subscribe and check if there are running tasks', () => { + expect(component.hasRunningTasks).toBeFalsy(); - it('should refresh prometheus services every 5s', fakeAsync(() => { - fixture.detectChanges(); + const task = new ExecutingTask('task', { name: 'name' }); + summaryService['summaryDataSource'].next({ executing_tasks: [task] }); - expectPrometheusServicesToBeCalledTimes(1); - tick(5000); - expectPrometheusServicesToBeCalledTimes(2); - tick(15000); - expectPrometheusServicesToBeCalledTimes(5); - component.ngOnDestroy(); - })); + expect(component.hasRunningTasks).toBeTruthy(); }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.ts index a51882fb905..ec615cc4c1a 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.ts @@ -1,60 +1,36 @@ -import { Component, NgZone, OnDestroy, OnInit } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; import * as _ from 'lodash'; import { Icons } from '../../../shared/enum/icons.enum'; -import { CdNotification } from '../../../shared/models/cd-notification'; -import { AuthStorageService } from '../../../shared/services/auth-storage.service'; import { NotificationService } from '../../../shared/services/notification.service'; -import { PrometheusAlertService } from '../../../shared/services/prometheus-alert.service'; -import { PrometheusNotificationService } from '../../../shared/services/prometheus-notification.service'; +import { SummaryService } from '../../../shared/services/summary.service'; @Component({ selector: 'cd-notifications', templateUrl: './notifications.component.html', styleUrls: ['./notifications.component.scss'] }) -export class NotificationsComponent implements OnInit, OnDestroy { - notifications: CdNotification[]; - private interval: number; +export class NotificationsComponent implements OnInit { icons = Icons; + hasRunningTasks = false; + constructor( public notificationService: NotificationService, - private prometheusNotificationService: PrometheusNotificationService, - private authStorageService: AuthStorageService, - private prometheusAlertService: PrometheusAlertService, - private ngZone: NgZone - ) { - this.notifications = []; - } - - ngOnDestroy() { - window.clearInterval(this.interval); - } + private summaryService: SummaryService + ) {} ngOnInit() { - if (this.authStorageService.getPermissions().prometheus.read) { - this.triggerPrometheusAlerts(); - this.ngZone.runOutsideAngular(() => { - this.interval = window.setInterval(() => { - this.ngZone.run(() => { - this.triggerPrometheusAlerts(); - }); - }, 5000); - }); - } - this.notificationService.data$.subscribe((notifications: CdNotification[]) => { - this.notifications = _.orderBy(notifications, ['timestamp'], ['desc']); + this.summaryService.subscribe((data: any) => { + if (!data) { + return; + } + this.hasRunningTasks = data.executing_tasks.length > 0; }); } - private triggerPrometheusAlerts() { - this.prometheusAlertService.refresh(); - this.prometheusNotificationService.refresh(); - } - - removeAll() { - this.notificationService.removeAll(); + toggleSidebar() { + this.notificationService.toggleSidebar(); } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/task-manager/task-manager.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/task-manager/task-manager.component.html deleted file mode 100644 index b5cbb107b25..00000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/task-manager/task-manager.component.html +++ /dev/null @@ -1,86 +0,0 @@ - - -
-
EXECUTING
-
-
- - - - - - - - - -
- - - - - {{ executingTask.description }} -
- -
-
- {{ executingTask.begin_time | cdDate }} - {{ executingTask.progress }} %
-
-
- -
-
FINISHED
-
-
- - - - - - - - - - - - -
- - - - - - - - - - - - - - {{ finishedTask.description }} -
- - {{ finishedTask.errorMessage }} - -
- {{ finishedTask.end_time | cdDate }} -
-
-
-
- -
-
There are no background tasks.
-
-
- - - Background Tasks - ({{ executingTasks.length }}) - diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/task-manager/task-manager.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/task-manager/task-manager.component.scss deleted file mode 100644 index 70cfd4a697d..00000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/task-manager/task-manager.component.scss +++ /dev/null @@ -1 +0,0 @@ -@import '../../../../styles/popover.scss'; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/task-manager/task-manager.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/task-manager/task-manager.component.spec.ts deleted file mode 100644 index ef21174d002..00000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/task-manager/task-manager.component.spec.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { HttpClientTestingModule } from '@angular/common/http/testing'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { RouterTestingModule } from '@angular/router/testing'; - -import { PopoverModule } from 'ngx-bootstrap/popover'; -import { ProgressbarModule } from 'ngx-bootstrap/progressbar'; - -import { configureTestBed, i18nProviders } from '../../../../testing/unit-test-helper'; -import { ExecutingTask } from '../../../shared/models/executing-task'; -import { FinishedTask } from '../../../shared/models/finished-task'; -import { SharedModule } from '../../../shared/shared.module'; -import { TaskManagerComponent } from './task-manager.component'; - -describe('TaskManagerComponent', () => { - let component: TaskManagerComponent; - let fixture: ComponentFixture; - const tasks = { - executing: [], - finished: [] - }; - - configureTestBed({ - imports: [ - SharedModule, - PopoverModule.forRoot(), - ProgressbarModule, - HttpClientTestingModule, - RouterTestingModule - ], - declarations: [TaskManagerComponent], - providers: [i18nProviders] - }); - - beforeEach(() => { - fixture = TestBed.createComponent(TaskManagerComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - tasks.executing = [ - new ExecutingTask('rbd/delete', { - pool_name: 'somePool', - image_name: 'someImage' - }) - ]; - tasks.finished = [ - new FinishedTask('rbd/copy', { - dest_pool_name: 'somePool', - dest_image_name: 'someImage' - }), - new FinishedTask('rbd/clone', { - child_pool_name: 'somePool', - child_image_name: 'someImage' - }) - ]; - tasks.finished[1].success = false; - tasks.finished[1].exception = { code: 17 }; - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should get executing message for task', () => { - component._handleTasks(tasks.executing, []); - expect(component.executingTasks.length).toBe(1); - expect(component.executingTasks[0].description).toBe(`Deleting RBD 'somePool/someImage'`); - }); - - it('should get finished message for successful task', () => { - component._handleTasks([], tasks.finished); - expect(component.finishedTasks.length).toBe(2); - expect(component.finishedTasks[0].description).toBe(`Copied RBD 'somePool/someImage'`); - expect(component.finishedTasks[0].errorMessage).toBe(undefined); - }); - - it('should get failed message for finished task', () => { - component._handleTasks([], tasks.finished); - expect(component.finishedTasks.length).toBe(2); - expect(component.finishedTasks[1].description).toBe(`Failed to clone RBD 'somePool/someImage'`); - expect(component.finishedTasks[1].errorMessage).toBe( - `Name is already used by RBD 'somePool/someImage'.` - ); - }); - - it('should get an empty hour glass with only finished tasks', () => { - component._setIcon(0); - expect(component.icon).toBe('fa fa-hourglass-o'); - }); - - it('should get a nearly empty hour glass with executing tasks', () => { - component._setIcon(10); - expect(component.icon).toBe('fa fa-hourglass-start'); - }); -}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/task-manager/task-manager.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/task-manager/task-manager.component.ts deleted file mode 100644 index 585a1222ba0..00000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/task-manager/task-manager.component.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Component, OnInit } from '@angular/core'; - -import * as _ from 'lodash'; -import { Icons } from '../../../shared/enum/icons.enum'; -import { ExecutingTask } from '../../../shared/models/executing-task'; -import { FinishedTask } from '../../../shared/models/finished-task'; -import { SummaryService } from '../../../shared/services/summary.service'; -import { TaskMessageService } from '../../../shared/services/task-message.service'; - -@Component({ - selector: 'cd-task-manager', - templateUrl: './task-manager.component.html', - styleUrls: ['./task-manager.component.scss'] -}) -export class TaskManagerComponent implements OnInit { - executingTasks: ExecutingTask[] = []; - finishedTasks: FinishedTask[] = []; - - icons = Icons; - icon = _.join([this.icons.hourglass], ' '); - - constructor( - private summaryService: SummaryService, - private taskMessageService: TaskMessageService - ) {} - - ngOnInit() { - this.summaryService.subscribe((data: any) => { - if (!data) { - return; - } - this._handleTasks(data.executing_tasks, data.finished_tasks); - this._setIcon(data.executing_tasks.length); - }); - } - - _handleTasks(executingTasks: ExecutingTask[], finishedTasks: FinishedTask[]) { - for (const excutingTask of executingTasks) { - excutingTask.description = this.taskMessageService.getRunningTitle(excutingTask); - } - for (const finishedTask of finishedTasks) { - if (finishedTask.success === false) { - finishedTask.description = this.taskMessageService.getErrorTitle(finishedTask); - finishedTask.errorMessage = this.taskMessageService.getErrorMessage(finishedTask); - } else { - finishedTask.description = this.taskMessageService.getSuccessTitle(finishedTask); - } - } - this.executingTasks = executingTasks; - this.finishedTasks = finishedTasks; - } - - _setIcon(executingTasks: number) { - const iconSuffix = ['o', 'start', 'half', 'end']; // TODO: Use all suffixes - const iconIndex = executingTasks > 0 ? 1 : 0; - this.icon = [Icons.filledHourglass, iconSuffix[iconIndex]].join('-'); - } -} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts index 841b39655e0..7c66297c627 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts @@ -8,6 +8,7 @@ import { AlertModule } from 'ngx-bootstrap/alert'; import { BsDropdownModule } from 'ngx-bootstrap/dropdown'; import { ModalModule } from 'ngx-bootstrap/modal'; import { PopoverModule } from 'ngx-bootstrap/popover'; +import { ProgressbarModule } from 'ngx-bootstrap/progressbar'; import { TooltipModule } from 'ngx-bootstrap/tooltip'; import { DirectivesModule } from '../directives/directives.module'; @@ -22,6 +23,7 @@ import { HelperComponent } from './helper/helper.component'; import { LanguageSelectorComponent } from './language-selector/language-selector.component'; import { LoadingPanelComponent } from './loading-panel/loading-panel.component'; import { ModalComponent } from './modal/modal.component'; +import { NotificationsSidebarComponent } from './notifications-sidebar/notifications-sidebar.component'; import { RefreshSelectorComponent } from './refresh-selector/refresh-selector.component'; import { SelectBadgesComponent } from './select-badges/select-badges.component'; import { SelectComponent } from './select/select.component'; @@ -37,6 +39,7 @@ import { ViewCacheComponent } from './view-cache/view-cache.component'; ReactiveFormsModule, AlertModule.forRoot(), PopoverModule.forRoot(), + ProgressbarModule.forRoot(), TooltipModule.forRoot(), ChartsModule, ReactiveFormsModule, @@ -55,6 +58,7 @@ import { ViewCacheComponent } from './view-cache/view-cache.component'; UsageBarComponent, LoadingPanelComponent, ModalComponent, + NotificationsSidebarComponent, CriticalConfirmationModalComponent, ConfirmationModalComponent, LanguageSelectorComponent, @@ -76,6 +80,7 @@ import { ViewCacheComponent } from './view-cache/view-cache.component'; LoadingPanelComponent, UsageBarComponent, ModalComponent, + NotificationsSidebarComponent, LanguageSelectorComponent, GrafanaComponent, SelectComponent, diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.html new file mode 100644 index 00000000000..6b4f2f3fa9b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.html @@ -0,0 +1,118 @@ + + +
+
+
+
+ + + + +
+
+
+
{{ executingTask.description }}
+ +
+ +
+ +

+ + {{ executingTask.begin_time | cdDate }} + + + + {{ executingTask.progress || 0 }} % + +

+ +
+
+
+
+ +
+
+
+ + + + + +
+ +
+
+
+
+ + + + +
+
+
+
{{ notification.title }}
+

+

+ + + Duration: {{ notification.duration | duration }} + +
+
+ {{ notification.timestamp | duration: true }} + +

+
+
+
+
+ +
+
+
+
+ + +
+
There are no notifications.
+
+
+ +
+
+ Tasks and Notifications + + +
+ +
+ + + +
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.scss new file mode 100644 index 00000000000..eb522413274 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.scss @@ -0,0 +1,39 @@ +@import 'defaults'; + +// sidebar +::ng-deep .ng-sidebar { + &.ng-sidebar--opened { + margin-right: 20px; + } + + width: 350px; + max-width: 90vw; + z-index: 9 !important; + + top: 6vh !important; + height: 92vh; + + .card { + height: 100%; + + .card-body { + overflow: auto; + } + } + + .separator { + padding: 5px 12px; + color: $color-popover-seperator-text; + background-color: $color-popover-seperator-bg; + font-size: 12px; + } +} + +table { + width: 100%; +} + +.row { + margin-left: 0; + margin-right: 0; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.spec.ts new file mode 100644 index 00000000000..7c9e0f3188b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.spec.ts @@ -0,0 +1,144 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { PopoverModule } from 'ngx-bootstrap/popover'; +import { ProgressbarModule } from 'ngx-bootstrap/progressbar'; +import { ToastrModule } from 'ngx-toastr'; + +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { configureTestBed, i18nProviders } from '../../../../testing/unit-test-helper'; +import { PrometheusService } from '../../api/prometheus.service'; +import { SettingsService } from '../../api/settings.service'; +import { NotificationType } from '../../enum/notification-type.enum'; +import { ExecutingTask } from '../../models/executing-task'; +import { PipesModule } from '../../pipes/pipes.module'; +import { AuthStorageService } from '../../services/auth-storage.service'; +import { NotificationService } from '../../services/notification.service'; +import { PrometheusAlertService } from '../../services/prometheus-alert.service'; +import { PrometheusNotificationService } from '../../services/prometheus-notification.service'; +import { SummaryService } from '../../services/summary.service'; +import { NotificationsSidebarComponent } from './notifications-sidebar.component'; + +describe('NotificationsSidebarComponent', () => { + let component: NotificationsSidebarComponent; + let fixture: ComponentFixture; + + configureTestBed({ + imports: [ + HttpClientTestingModule, + PipesModule, + PopoverModule.forRoot(), + ProgressbarModule.forRoot(), + RouterTestingModule, + ToastrModule.forRoot(), + NoopAnimationsModule + ], + declarations: [NotificationsSidebarComponent], + providers: [ + i18nProviders, + PrometheusService, + SettingsService, + SummaryService, + NotificationService + ] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(NotificationsSidebarComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + describe('prometheus alert handling', () => { + let prometheusAlertService: PrometheusAlertService; + let prometheusNotificationService: PrometheusNotificationService; + let prometheusAccessAllowed: boolean; + + const expectPrometheusServicesToBeCalledTimes = (n: number) => { + expect(prometheusNotificationService.refresh).toHaveBeenCalledTimes(n); + expect(prometheusAlertService.refresh).toHaveBeenCalledTimes(n); + }; + + beforeEach(() => { + prometheusAccessAllowed = true; + spyOn(TestBed.get(AuthStorageService), 'getPermissions').and.callFake(() => ({ + prometheus: { read: prometheusAccessAllowed } + })); + + spyOn(TestBed.get(PrometheusService), 'ifAlertmanagerConfigured').and.callFake((fn) => fn()); + + prometheusAlertService = TestBed.get(PrometheusAlertService); + spyOn(prometheusAlertService, 'refresh').and.stub(); + + prometheusNotificationService = TestBed.get(PrometheusNotificationService); + spyOn(prometheusNotificationService, 'refresh').and.stub(); + }); + + it('should not refresh prometheus services if not allowed', () => { + prometheusAccessAllowed = false; + fixture.detectChanges(); + + expectPrometheusServicesToBeCalledTimes(0); + }); + it('should first refresh prometheus notifications and alerts during init', () => { + fixture.detectChanges(); + + expect(prometheusAlertService.refresh).toHaveBeenCalledTimes(1); + expectPrometheusServicesToBeCalledTimes(1); + }); + + it('should refresh prometheus services every 5s', fakeAsync(() => { + fixture.detectChanges(); + + expectPrometheusServicesToBeCalledTimes(1); + tick(5000); + expectPrometheusServicesToBeCalledTimes(2); + tick(15000); + expectPrometheusServicesToBeCalledTimes(5); + component.ngOnDestroy(); + })); + }); + + describe('Running Tasks', () => { + let summaryService: SummaryService; + + beforeEach(() => { + fixture.detectChanges(); + summaryService = TestBed.get(SummaryService); + + spyOn(component, '_handleTasks').and.callThrough(); + }); + + it('should handle executing tasks', () => { + const running_tasks = new ExecutingTask('rbd/delete', { + pool_name: 'somePool', + image_name: 'someImage' + }); + + summaryService['summaryDataSource'].next({ executing_tasks: [running_tasks] }); + + expect(component._handleTasks).toHaveBeenCalled(); + expect(component.executingTasks.length).toBe(1); + expect(component.executingTasks[0].description).toBe(`Deleting RBD 'somePool/someImage'`); + }); + }); + + describe('Notifications', () => { + it('should fetch latest notifications', fakeAsync(() => { + const notificationService: NotificationService = TestBed.get(NotificationService); + fixture.detectChanges(); + + expect(component.notifications.length).toBe(0); + + notificationService.show(NotificationType.success, 'Sample title', 'Sample message'); + tick(6000); + expect(component.notifications.length).toBe(1); + expect(component.notifications[0].title).toBe('Sample title'); + })); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.ts new file mode 100644 index 00000000000..5c28c20ad8c --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.ts @@ -0,0 +1,131 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + NgZone, + OnDestroy, + OnInit +} from '@angular/core'; + +import { Mutex } from 'async-mutex'; +import * as _ from 'lodash'; +import * as moment from 'moment'; +import { LocalStorage } from 'ngx-store'; + +import { ExecutingTask } from '../../../shared/models/executing-task'; +import { SummaryService } from '../../../shared/services/summary.service'; +import { TaskMessageService } from '../../../shared/services/task-message.service'; +import { Icons } from '../../enum/icons.enum'; +import { CdNotification } from '../../models/cd-notification'; +import { FinishedTask } from '../../models/finished-task'; +import { AuthStorageService } from '../../services/auth-storage.service'; +import { NotificationService } from '../../services/notification.service'; +import { PrometheusAlertService } from '../../services/prometheus-alert.service'; +import { PrometheusNotificationService } from '../../services/prometheus-notification.service'; + +@Component({ + selector: 'cd-notifications-sidebar', + templateUrl: './notifications-sidebar.component.html', + styleUrls: ['./notifications-sidebar.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class NotificationsSidebarComponent implements OnInit, OnDestroy { + notifications: CdNotification[]; + private interval: number; + + executingTasks: ExecutingTask[] = []; + + icons = Icons; + + // Tasks + @LocalStorage() last_task = ''; + mutex = new Mutex(); + + constructor( + public notificationService: NotificationService, + private summaryService: SummaryService, + private taskMessageService: TaskMessageService, + private prometheusNotificationService: PrometheusNotificationService, + private authStorageService: AuthStorageService, + private prometheusAlertService: PrometheusAlertService, + private ngZone: NgZone, + private cdRef: ChangeDetectorRef + ) { + this.notifications = []; + } + + ngOnDestroy() { + window.clearInterval(this.interval); + } + + ngOnInit() { + if (this.authStorageService.getPermissions().prometheus.read) { + this.triggerPrometheusAlerts(); + this.ngZone.runOutsideAngular(() => { + this.interval = window.setInterval(() => { + this.ngZone.run(() => { + this.triggerPrometheusAlerts(); + }); + }, 5000); + }); + } + + this.notificationService.data$.subscribe((notifications: CdNotification[]) => { + this.notifications = _.orderBy(notifications, ['timestamp'], ['desc']); + this.cdRef.detectChanges(); + }); + + this.summaryService.subscribe((data: any) => { + if (!data) { + return; + } + this._handleTasks(data.executing_tasks); + + this.mutex.acquire().then((release) => { + _.filter( + data.finished_tasks, + (task: FinishedTask) => !this.last_task || moment(task.end_time).isAfter(this.last_task) + ).forEach((task) => { + const config = this.notificationService.finishedTaskToNotification(task, task.success); + const notification = new CdNotification(config); + notification.timestamp = task.end_time; + notification.duration = task.duration; + + if (!this.last_task || moment(task.end_time).isAfter(this.last_task)) { + this.last_task = task.end_time; + } + + this.notificationService.save(notification); + }); + + this.cdRef.detectChanges(); + + release(); + }); + }); + } + + _handleTasks(executingTasks: ExecutingTask[]) { + for (const excutingTask of executingTasks) { + excutingTask.description = this.taskMessageService.getRunningTitle(excutingTask); + } + this.executingTasks = executingTasks; + } + + private triggerPrometheusAlerts() { + this.prometheusAlertService.refresh(); + this.prometheusNotificationService.refresh(); + } + + removeAll() { + this.notificationService.removeAll(); + } + + closeSidebar() { + this.notificationService.toggleSidebar(true); + } + + trackByFn(index) { + return index; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-notification.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-notification.ts index 7614a3357fc..3c8c0788932 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-notification.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-notification.ts @@ -4,6 +4,7 @@ import { NotificationType } from '../enum/notification-type.enum'; export class CdNotificationConfig { applicationClass: string; + isFinishedTask = false; private classes = { Ceph: 'ceph-icon', @@ -25,6 +26,7 @@ export class CdNotification extends CdNotificationConfig { timestamp: string; textClass: string; iconClass: string; + duration: number; private textClasses = ['text-danger', 'text-info', 'text-success']; private iconClasses = [Icons.warning, Icons.info, Icons.check]; @@ -37,5 +39,6 @@ export class CdNotification extends CdNotificationConfig { this.timestamp = new Date().toJSON(); this.iconClass = this.iconClasses[this.type]; this.textClass = this.textClasses[this.type]; + this.isFinishedTask = config.isFinishedTask; } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/finished-task.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/finished-task.ts index 3749fafab4b..9dc780963ad 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/finished-task.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/finished-task.ts @@ -9,6 +9,7 @@ export class FinishedTask extends Task { progress: number; ret_value: any; success: boolean; + duration: number; errorMessage: string; } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/duration.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/duration.pipe.spec.ts new file mode 100644 index 00000000000..7241812e560 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/duration.pipe.spec.ts @@ -0,0 +1,24 @@ +import * as moment from 'moment'; + +import { DurationPipe } from './duration.pipe'; + +describe('DurationPipe', () => { + const pipe = new DurationPipe(); + + it('create an instance', () => { + expect(pipe).toBeTruthy(); + }); + + it('transforms seconds into a human readable duration', () => { + expect(pipe.transform(0)).toBe('1 second'); + expect(pipe.transform(6)).toBe('6 seconds'); + expect(pipe.transform(60)).toBe('1 minute'); + expect(pipe.transform(600)).toBe('10 minutes'); + expect(pipe.transform(6000)).toBe('1 hour 40 minutes'); + }); + + it('transforms date into a human readable relative duration', () => { + const date = moment().subtract(130, 'seconds'); + expect(pipe.transform(date, true)).toBe('2 minutes ago'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/duration.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/duration.pipe.ts new file mode 100644 index 00000000000..c2b874ac457 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/duration.pipe.ts @@ -0,0 +1,47 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +import * as moment from 'moment'; + +@Pipe({ + name: 'duration', + pure: false +}) +export class DurationPipe implements PipeTransform { + transform(date: any, isRelative = false): string { + if (isRelative) { + return moment(date).fromNow(); + } else { + return this._forHumans(date); + } + } + + /** + * Translates seconds into human readable format of seconds, minutes, hours, days, and years + * source: https://stackoverflow.com/a/34270811 + * + * @param {number} seconds The number of seconds to be processed + * @return {string} The phrase describing the the amount of time + */ + _forHumans(seconds: number): string { + const levels = [ + [`${Math.floor(seconds / 31536000)}`, 'years'], + [`${Math.floor((seconds % 31536000) / 86400)}`, 'days'], + [`${Math.floor((seconds % 86400) / 3600)}`, 'hours'], + [`${Math.floor((seconds % 3600) / 60)}`, 'minutes'], + [`${Math.floor(seconds % 60)}`, 'seconds'] + ]; + let returntext = ''; + + for (let i = 0, max = levels.length; i < max; i++) { + if (levels[i][0] === '0') { + continue; + } + returntext += + ' ' + + levels[i][0] + + ' ' + + (levels[i][0] === '1' ? levels[i][1].substr(0, levels[i][1].length - 1) : levels[i][1]); + } + return returntext.trim() || '1 second'; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pipes.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pipes.module.ts index 0248203a0db..89e68489dc1 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pipes.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pipes.module.ts @@ -8,6 +8,7 @@ import { CephShortVersionPipe } from './ceph-short-version.pipe'; import { DimlessBinaryPerSecondPipe } from './dimless-binary-per-second.pipe'; import { DimlessBinaryPipe } from './dimless-binary.pipe'; import { DimlessPipe } from './dimless.pipe'; +import { DurationPipe } from './duration.pipe'; import { EmptyPipe } from './empty.pipe'; import { EncodeUriPipe } from './encode-uri.pipe'; import { FilterPipe } from './filter.pipe'; @@ -46,7 +47,8 @@ import { UpperFirstPipe } from './upper-first.pipe'; MillisecondsPipe, IopsPipe, UpperFirstPipe, - RbdConfigurationSourcePipe + RbdConfigurationSourcePipe, + DurationPipe ], exports: [ BooleanTextPipe, @@ -69,7 +71,8 @@ import { UpperFirstPipe } from './upper-first.pipe'; MillisecondsPipe, IopsPipe, UpperFirstPipe, - RbdConfigurationSourcePipe + RbdConfigurationSourcePipe, + DurationPipe ], providers: [ BooleanTextPipe, diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.spec.ts index 133493e3657..2c0c22a9c4a 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.spec.ts @@ -1,4 +1,3 @@ -import { DatePipe } from '@angular/common'; import { fakeAsync, TestBed, tick } from '@angular/core/testing'; import * as _ from 'lodash'; @@ -22,8 +21,6 @@ describe('NotificationService', () => { configureTestBed({ providers: [ - CdDatePipe, - DatePipe, NotificationService, TaskMessageService, { provide: ToastrService, useValue: toastFakeService }, @@ -73,6 +70,7 @@ describe('NotificationService', () => { }; beforeEach(() => { + spyOn(service, 'show').and.callThrough(); service.cancel(service['justShownTimeoutId']); }); @@ -103,16 +101,17 @@ describe('NotificationService', () => { expect(service['dataSource'].getValue().length).toBe(10); })); - it('should show a success task notification', fakeAsync(() => { + it('should show a success task notification, but not save it', fakeAsync(() => { const task = _.assign(new FinishedTask(), { success: true }); + service.notifyTask(task, true); - expectSavedNotificationToHave({ - type: NotificationType.success, - title: 'Executed unknown task', - message: undefined - }); + tick(1500); + + expect(service.show).toHaveBeenCalled(); + const notifications = service['dataSource'].getValue(); + expect(notifications.length).toBe(0); })); it('should be able to stop notifyTask from notifying', fakeAsync(() => { @@ -139,11 +138,12 @@ describe('NotificationService', () => { } ); service.notifyTask(task); - expectSavedNotificationToHave({ - type: NotificationType.error, - title: `Failed to create RBD 'somePool/someImage'`, - message: `Name is already used by RBD 'somePool/someImage'.` - }); + + tick(1500); + + expect(service.show).toHaveBeenCalled(); + const notifications = service['dataSource'].getValue(); + expect(notifications.length).toBe(0); })); it('combines different notifications with the same title', fakeAsync(() => { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.ts index 996fa30ec5b..4a4356c8373 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'; import * as _ from 'lodash'; import { IndividualConfig, ToastrService } from 'ngx-toastr'; -import { BehaviorSubject } from 'rxjs'; +import { BehaviorSubject, Subject } from 'rxjs'; import { NotificationType } from '../enum/notification-type.enum'; import { CdNotification, CdNotificationConfig } from '../models/cd-notification'; @@ -16,12 +16,13 @@ import { TaskMessageService } from './task-message.service'; export class NotificationService { private hideToasties = false; - // Observable sources + // Data observable private dataSource = new BehaviorSubject([]); - - // Observable streams data$ = this.dataSource.asObservable(); + // Sidebar observable + sidebarSubject = new Subject(); + private queued: CdNotificationConfig[] = []; private queuedTimeoutId: number; KEY = 'cdNotifications'; @@ -124,7 +125,10 @@ export class NotificationService { private showQueued() { this.getUnifiedTitleQueue().forEach((config) => { const notification = new CdNotification(config); - this.save(notification); + + if (!notification.isFinishedTask) { + this.save(notification); + } this.showToasty(notification); }); } @@ -173,6 +177,15 @@ export class NotificationService { } notifyTask(finishedTask: FinishedTask, success: boolean = true): number { + const notification = this.finishedTaskToNotification(finishedTask, success); + notification.isFinishedTask = true; + return this.show(notification); + } + + finishedTaskToNotification( + finishedTask: FinishedTask, + success: boolean = true + ): CdNotificationConfig { let notification: CdNotificationConfig; if (finishedTask.success && success) { notification = new CdNotificationConfig( @@ -186,7 +199,9 @@ export class NotificationService { this.taskMessageService.getErrorMessage(finishedTask) ); } - return this.show(notification); + notification.isFinishedTask = true; + + return notification; } /** @@ -204,4 +219,8 @@ export class NotificationService { suspendToasties(suspend: boolean) { this.hideToasties = suspend; } + + toggleSidebar(forceClose = false) { + this.sidebarSubject.next(forceClose); + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.spec.ts index 7a13db09844..d8ddab699d3 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.spec.ts @@ -18,6 +18,7 @@ describe('TaskManagerMessageService', () => { beforeEach(() => { service = TestBed.get(TaskMessageService); finishedTask = new FinishedTask(); + finishedTask.duration = 30; }); it('should be created', () => { @@ -48,7 +49,7 @@ describe('TaskManagerMessageService', () => { expect(service.getErrorTitle(finishedTask)).toBe( 'Failed to ' + operation.failure + ' ' + involves ); - expect(service.getSuccessTitle(finishedTask)).toBe(operation.success + ' ' + involves); + expect(service.getSuccessTitle(finishedTask)).toBe(`${operation.success} ${involves}`); }; const testCreate = (involves: string) => { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-wrapper.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-wrapper.service.ts index 66668ec6838..721e1edcdc2 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-wrapper.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-wrapper.service.ts @@ -3,6 +3,7 @@ import { Injectable } from '@angular/core'; import { Observable, Subscriber } from 'rxjs'; import { NotificationType } from '../enum/notification-type.enum'; +import { CdNotificationConfig } from '../models/cd-notification'; import { ExecutingTask } from '../models/executing-task'; import { FinishedTask } from '../models/finished-task'; import { NotificationService } from './notification.service'; @@ -46,10 +47,12 @@ export class TaskWrapperService { } _handleExecutingTasks(task: FinishedTask) { - this.notificationService.show( + const notification = new CdNotificationConfig( NotificationType.info, this.taskMessageService.getRunningTitle(task) ); + notification.isFinishedTask = true; + this.notificationService.show(notification); const executingTask = new ExecutingTask(task.name, task.metadata); this.summaryService.addRunningTask(executingTask); diff --git a/src/pybind/mgr/dashboard/frontend/src/styles.scss b/src/pybind/mgr/dashboard/frontend/src/styles.scss index 79785c53062..8bb8dd7a0c2 100644 --- a/src/pybind/mgr/dashboard/frontend/src/styles.scss +++ b/src/pybind/mgr/dashboard/frontend/src/styles.scss @@ -80,7 +80,7 @@ option { } .full-height { - height: 100%; + height: 100vh; } .vertical-align { display: flex; diff --git a/src/pybind/mgr/dashboard/frontend/src/styles/popover.scss b/src/pybind/mgr/dashboard/frontend/src/styles/popover.scss deleted file mode 100644 index d15ab9d51a9..00000000000 --- a/src/pybind/mgr/dashboard/frontend/src/styles/popover.scss +++ /dev/null @@ -1,46 +0,0 @@ -@import 'defaults'; - -::ng-deep .popover-content { - padding: 0.5em; - height: auto; - max-height: 70vh; - overflow-x: hidden; -} - -::ng-deep .popover { - min-width: 276px !important; -} - -.separator { - padding: 5px 12px; - color: $color-popover-seperator-text; - background-color: $color-popover-seperator-bg; - font-size: 12px; -} - -.message { - padding: 10px 16px; - color: $color-popover-message-text; - font-size: 12px; -} - -table { - width: 252px; - margin: 5px 12px 5px 5px; - font-size: 12px; - color: $color-popover-table-text; -} - -.icon-col { - width: 50px; - font-size: 10px; -} - -.date { - color: $color-popover-date; -} - -hr { - margin-top: 0px; - margin-bottom: 0px; -} -- 2.39.5