]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Unify Tasks and Notifications into a sidebar 29706/head
authorTiago Melo <tmelo@suse.com>
Thu, 8 Aug 2019 15:01:38 +0000 (15:01 +0000)
committerTiago Melo <tmelo@suse.com>
Fri, 4 Oct 2019 11:14:02 +0000 (11:14 +0000)
Fixes: https://tracker.ceph.com/issues/37402
Signed-off-by: Tiago Melo <tmelo@suse.com>
35 files changed:
src/pybind/mgr/dashboard/frontend/e2e/cluster/crush-map.po.ts
src/pybind/mgr/dashboard/frontend/package-lock.json
src/pybind/mgr/dashboard/frontend/package.json
src/pybind/mgr/dashboard/frontend/src/app/app.component.html
src/pybind/mgr/dashboard/frontend/src/app/app.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/app.component.ts
src/pybind/mgr/dashboard/frontend/src/app/app.module.ts
src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.ts
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation.module.ts
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.html
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.scss
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.ts
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/task-manager/task-manager.component.html [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/task-manager/task-manager.component.scss [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/task-manager/task-manager.component.spec.ts [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/task-manager/task-manager.component.ts [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-notification.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/finished-task.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/duration.pipe.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/duration.pipe.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pipes.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-wrapper.service.ts
src/pybind/mgr/dashboard/frontend/src/styles.scss
src/pybind/mgr/dashboard/frontend/src/styles/popover.scss [deleted file]

index ac2f1587a3b986bb3df5d95db43e8591c6f05cb8..279fd85621e2b0495b385109170bee404422a69f 100644 (file)
@@ -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) {
index 96cc272a291fbfce3f57d84861fc8dc19b3ec5dd..49fe2c857ebaa185b605a2e530123b938a01143c 100644 (file)
       "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",
       "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",
         "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",
       "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",
       "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",
index 593c39498a63b2da4141aba65c5c4294060e4bc1..526fd63532a30cd8d462a979b202c8b02e85c255 100644 (file)
@@ -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",
     "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",
index 3eb48a37654f0b7460ee782eb122c3bfb05046e6..7e08eef685f116a34a7f146e45ab78f0fb22af3c 100644 (file)
@@ -1,8 +1,26 @@
-<block-ui>
-  <cd-navigation *ngIf="!isLoginActive()"></cd-navigation>
-  <div class="container-fluid"
-       [ngClass]="{'full-height':isLoginActive(), 'dashboard':isDashboardPage()} ">
-    <cd-breadcrumbs></cd-breadcrumbs>
-    <router-outlet></router-outlet>
+<!-- Container for sidebar(s) + page content -->
+<ng-sidebar-container>
+
+  <!-- A sidebar -->
+  <ng-sidebar #sidebar
+              [(opened)]="sidebarOpened"
+              [animate]="sidebarAnimate"
+              position="end"
+              mode="over"
+              autoFocus="false"
+              closeOnClickOutside="true">
+    <cd-notifications-sidebar *ngIf="!isLoginActive()"></cd-notifications-sidebar>
+  </ng-sidebar>
+
+  <!-- Page content -->
+  <div ng-sidebar-content>
+    <block-ui>
+      <cd-navigation *ngIf="!isLoginActive()"></cd-navigation>
+      <div class="container-fluid"
+           [ngClass]="{'full-height':isLoginActive(), 'dashboard':isDashboardPage()} ">
+        <cd-breadcrumbs></cd-breadcrumbs>
+        <router-outlet></router-outlet>
+      </div>
+    </block-ui>
   </div>
-</block-ui>
+</ng-sidebar-container>
index 16fb5adb2e457f616acbc2a823ef1da2bcb1808d..cdef9faf56cd19cbc628384a7932f210648660e2 100644 (file)
@@ -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<AppComponent>;
 
   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();
+    });
+  });
 });
index 363a213d214c296e655606f82e2c4c55a6338c63..c3d9430e2947035cf3714ad6edd936014c593415 100644 (file)
@@ -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();
index 2f94a0aeee1fd9d6025c1d62aa0c0bf6ece409d2..d5d91bc49cc7d6fb1c32dba9683d6b68f9523b53 100644 (file)
@@ -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: [
index daa96f34e8bfda92edd00912fa19859a81d770cf..03a48b86324629773f76d0aa4aadcc41803f5035 100644 (file)
@@ -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<LoginComponent>;
 
   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);
+  });
 });
index 521ab305d2bc38493e53490efea6c706d2a6bf93..b8c5b10a61893840f8a65153e386699ce84fe7b2 100644 (file)
@@ -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];
index 72451931938bdbc3cde97d9a1e62d5b91baa688a..088d28b9ada267a3f8de5ad422f74e250aa69e8f 100644 (file)
@@ -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
index 1e7ef31b58b35c24d0025864143f36bf13639c6b..b49b33f08632e2957a4b910c90717213b102ab70 100644 (file)
@@ -40,9 +40,6 @@
   <li class="nav-item ">
     <cd-language-selector class="cd-navbar"></cd-language-selector>
   </li>
-  <li class="nav-item ">
-    <cd-task-manager class="cd-navbar"></cd-task-manager>
-  </li>
   <li class="nav-item ">
     <cd-notifications class="cd-navbar"></cd-notifications>
   </li>
index 93024e67abe4560dcb272bb410e7192546ea478d..3bca268d8cd3e6a0ddf47629f9258fb6acf50188 100644 (file)
@@ -1,56 +1,8 @@
-<ng-template #notificationsTpl>
-  <div *ngIf="notifications.length > 0">
-    <button type="button" class="btn btn-light btn-block" (click)="removeAll()">
-      <i [ngClass]="[icons.trash]" aria-hidden="true"></i>
-      &nbsp;
-      <ng-container i18n>Remove all</ng-container>
-    </button>
-    <hr>
-    <div *ngFor="let notification of notifications">
-      <table>
-        <tr>
-          <td rowspan="3" class="icon-col text-center">
-            <span [ngClass]="[icons.stack, icons.large2x, notification.textClass]">
-              <i [ngClass]="[icons.circle, icons.stack2x]"></i>
-              <i [ngClass]="[icons.stack1x, icons.inverse, notification.iconClass]"></i>
-            </span>
-          </td>
-          <td>
-            <strong>{{ notification.title }}</strong>
-          </td>
-        </tr>
-        <tr>
-          <td [innerHtml]="notification.message">
-          </td>
-        </tr>
-        <tr>
-          <td [innerHtml]="notificationService.renderTimeAndApplicationHtml(notification)"></td>
-        </tr>
-      </table>
-      <hr>
-    </div>
-  </div>
-</ng-template>
-
-<ng-template #emptyTpl>
-  <div *ngIf="notifications.length === 0">
-    <div class="message"
-         i18n>There are no notifications.</div>
-  </div>
-</ng-template>
-
-<ng-template #popTpl>
-  <ng-container *ngTemplateOutlet="notificationsTpl"></ng-container>
-  <ng-container *ngTemplateOutlet="emptyTpl"></ng-container>
-</ng-template>
-
-<a [popover]="popTpl"
-   placement="bottom"
-   container="body"
-   outsideClick="true"
-   i18n-title
-   title="Recent Notifications">
+<a i18n-title
+   title="Tasks and Notifications"
+   [ngClass]="{ 'running': hasRunningTasks }"
+   (click)="toggleSidebar()">
   <i [ngClass]="[icons.bell]"></i>
-  <span i18n
-        class="d-md-none">Recent Notifications</span>
+  <span class="d-md-none"
+        i18n>Tasks and Notifications</span>
 </a>
index 5e61d84bcb66ca632bd9289ec52b684448fd3622..8a2269b1c466d972f28a293099274be54caf2520 100644 (file)
@@ -1 +1,9 @@
-@import 'popover.scss';
+@import 'defaults';
+
+.running i {
+  color: $color-primary;
+}
+
+.running:hover i {
+  color: white;
+}
index 6ce6241ff6036a3fa8665cd6b2636a311f533e2e..dacdb24c0428d161147a73817a3a8b2506ee0162 100644 (file)
@@ -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<NotificationsComponent>;
+  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();
   });
 });
index a51882fb905f43d1cb8da4ae7eae7b8c1c4d8814..ec615cc4c1a4eb882655f0b4fa170897ec9c3bae 100644 (file)
@@ -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 (file)
index b5cbb10..0000000
+++ /dev/null
@@ -1,86 +0,0 @@
-<ng-template #popTemplate>
-  <!-- Executing -->
-  <div *ngIf="executingTasks.length > 0">
-    <div class="separator">EXECUTING</div>
-    <hr>
-    <div *ngFor="let executingTask of executingTasks">
-      <table>
-        <tr>
-          <td rowspan="3"  class="icon-col text-center">
-            <span [ngClass]="[icons.stack, icons.large2x]" class="text-info">
-              <i [ngClass]="[icons.stack2x, icons.circle]"></i>
-              <i [ngClass]="[icons.stack1x, icons.spinner, icons.spin, icons.inverse]"></i>
-            </span>
-          </td>
-          <td colspan="3"><strong>{{ executingTask.description }}</strong>
-            <div class="progress">
-              <progressbar class="progress-striped active" max="100" [value]="executingTask.progress" [striped]="true" [animate]="true"></progressbar>
-            </div>
-          </td>
-        </tr>
-        <tr>
-          <td colspan="2">
-            <small class="date">{{ executingTask.begin_time | cdDate }}</small>
-          </td>
-          <td class="text-right italic" nowrap *ngIf="executingTask.progress"><span>{{ executingTask.progress }} %</span></td>
-        </tr>
-      </table>
-    </div> 
-  </div>
-  <!-- Finished -->
-  <div *ngIf="finishedTasks.length > 0">
-    <div class="separator">FINISHED</div>
-    <hr>
-    <div *ngFor="let finishedTask of finishedTasks">
-      <table>
-        <tr>
-          <td rowspan="3"  class="icon-col text-center">
-            <span *ngIf="!finishedTask.errorMessage">
-              <span [ngClass]="[icons.stack, icons.large2x]" class="text-success">
-                <i [ngClass]="[icons.stack2x, icons.circle]"></i>
-                <i [ngClass]="[icons.stack1x, icons.inverse, icons.check]"></i>
-              </span>
-            </span>
-            <span *ngIf="finishedTask.errorMessage">
-              <span [ngClass]="[icons.stack, icons.large2x]" class="text-danger">
-                <i [ngClass]="[icons.stack2x, icons.circle]"></i>
-                <i [ngClass]="[icons.stack1x, icons.inverse, icons.warning]"></i>
-              </span>
-            </span>
-          </td>
-          <td colspan="2">
-            <strong>{{ finishedTask.description }}</strong>
-          </td>
-        </tr>
-        <tr>
-          <td></td>
-          <td>
-            <span *ngIf="finishedTask.errorMessage" class="text-danger">
-              {{ finishedTask.errorMessage }}
-            </span>
-          </td>
-        </tr>
-        <tr>
-          <td colspan="2">
-            <small class="date">{{ finishedTask.end_time | cdDate }}</small>
-          </td>
-        </tr>
-      </table>
-      <hr>
-    </div>
-  </div>
-  <!-- Empty -->
-  <div *ngIf="executingTasks.length === 0 && finishedTasks.length === 0">
-    <div class="message" i18n>There are no background tasks.</div>
-  </div>
-</ng-template>
-<a [popover]="popTemplate"
-   placement="bottom"
-   container="body"
-   outsideClick="true"
-   i18n-title
-   title="Background Tasks">
-  <i [ngClass]="[icon]"></i>
-  <span i18n class="d-md-none">Background Tasks</span>
-  <span *ngIf="executingTasks.length > 0"> ({{ executingTasks.length }})</span>
-</a>
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 (file)
index 70cfd4a..0000000
+++ /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 (file)
index ef21174..0000000
+++ /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<TaskManagerComponent>;
-  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 (file)
index 585a122..0000000
+++ /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('-');
-  }
-}
index 841b39655e02b83e37484c209def7bb0683be340..7c66297c6276da2c87635aa582c1c9bcfd1ddf52 100644 (file)
@@ -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 (file)
index 0000000..6b4f2f3
--- /dev/null
@@ -0,0 +1,118 @@
+<ng-template #tasksTpl>
+  <!-- Executing -->
+  <div *ngFor="let executingTask of executingTasks; trackBy:trackByFn">
+    <div class="card border-0 mb-3">
+      <div class="row no-gutters">
+        <div class="col-md-3 text-center">
+          <span [ngClass]="[icons.stack, icons.large2x]"
+                class="text-info">
+            <i [ngClass]="[icons.stack2x, icons.circle]"></i>
+            <i [ngClass]="[icons.stack1x, icons.spinner, icons.spin, icons.inverse]"></i>
+          </span>
+        </div>
+        <div class="col-md-9">
+          <div class="card-body p-0">
+            <h6 class="card-title bold">{{ executingTask.description }}</h6>
+
+            <div class="progress mb-1">
+              <progressbar class="progress-striped active"
+                           max="100"
+                           [value]="executingTask.progress"
+                           [striped]="true"
+                           [animate]="true"></progressbar>
+            </div>
+
+            <p class="card-text text-muted">
+              <small class="date float-left">
+                {{ executingTask.begin_time | cdDate }}
+              </small>
+
+              <span class="float-right">
+                {{ executingTask.progress || 0 }} %
+              </span>
+            </p>
+
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <hr>
+  </div>
+</ng-template>
+
+<ng-template #notificationsTpl>
+  <ng-container *ngIf="notifications.length > 0">
+    <button type="button"
+            class="btn btn-light btn-block"
+            (click)="removeAll(); $event.stopPropagation()">
+      <i [ngClass]="[icons.trash]"
+         aria-hidden="true"></i>
+      &nbsp;
+      <ng-container i18n>Clear notifications</ng-container>
+    </button>
+
+    <hr>
+
+    <div *ngFor="let notification of notifications">
+      <div class="card border-0 mb-3">
+        <div class="row no-gutters">
+          <div class="col-md-3 text-center">
+            <span [ngClass]="[icons.stack, icons.large2x, notification.textClass]">
+              <i [ngClass]="[icons.circle, icons.stack2x]"></i>
+              <i [ngClass]="[icons.stack1x, icons.inverse, notification.iconClass]"></i>
+            </span>
+          </div>
+          <div class="col-md-9">
+            <div class="card-body p-0">
+              <h6 class="card-title bold">{{ notification.title }}</h6>
+              <p class="card-text"
+                 [innerHtml]="notification.message"></p>
+              <p class="card-text text-muted">
+                <ng-container *ngIf="notification.duration">
+                  <small>
+                    <ng-container i18n>Duration:</ng-container> {{ notification.duration | duration }}
+                  </small>
+                  <br>
+                </ng-container>
+                <small class="date"
+                       [title]="notification.timestamp | cdDate">{{ notification.timestamp | duration: true }}</small>
+                <i class="float-right custom-icon"
+                   [ngClass]="[notification.applicationClass]"
+                   [title]="notification.application"></i>
+              </p>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <hr>
+    </div>
+  </ng-container>
+</ng-template>
+
+<ng-template #emptyTpl>
+  <div *ngIf="notifications.length === 0 && executingTasks.length === 0">
+    <div class="message text-center"
+         i18n>There are no notifications.</div>
+  </div>
+</ng-template>
+
+<div class="card">
+  <div class="card-header">
+    <ng-container i18n>Tasks and Notifications</ng-container>
+
+    <button class="close float-right"
+            tabindex="-1"
+            type="button"
+            (click)="closeSidebar()">
+      <span>×</span>
+    </button>
+  </div>
+
+  <div class="card-body">
+    <ng-container *ngTemplateOutlet="tasksTpl"></ng-container>
+    <ng-container *ngTemplateOutlet="notificationsTpl"></ng-container>
+    <ng-container *ngTemplateOutlet="emptyTpl"></ng-container>
+  </div>
+</div>
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 (file)
index 0000000..eb52241
--- /dev/null
@@ -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 (file)
index 0000000..7c9e0f3
--- /dev/null
@@ -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<NotificationsSidebarComponent>;
+
+  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 (file)
index 0000000..5c28c20
--- /dev/null
@@ -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;
+  }
+}
index 7614a3357fc5ea13b804c6938eb08428956f87ef..3c8c078893252416ec3f160186507d09a8144860 100644 (file)
@@ -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;
   }
 }
index 3749fafab4b3b3dd00ad253f8e736c08c0450482..9dc780963ad418b5fa4b43700ddcd994cb528a1b 100644 (file)
@@ -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 (file)
index 0000000..7241812
--- /dev/null
@@ -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 (file)
index 0000000..c2b874a
--- /dev/null
@@ -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';
+  }
+}
index 0248203a0db66d1ae55143f9b40f2b418a464ad8..89e68489dc171b87cb5840531903e2a46557a543 100644 (file)
@@ -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,
index 133493e3657dd418797682812873721c354f84ae..2c0c22a9c4aa3d8916cf26795c80041c5462485c 100644 (file)
@@ -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(() => {
index 996fa30ec5bf6c7b41e889697a73cfbbec9d1d46..4a4356c83730e2e5d0f16b1aaaacb7d3bb126946 100644 (file)
@@ -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<CdNotification[]>([]);
-
-  // 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);
+  }
 }
index 7a13db0984478aafdd9cfc093c5289a45e26005e..d8ddab699d30923b9a97afeee46a011a6d18a489 100644 (file)
@@ -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) => {
index 66668ec6838d208500de35cf19183dc070611c0f..721e1edcdc2692cf6ad0bd7454e80622d0a5dc14 100644 (file)
@@ -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);
index 79785c5306254f2dc9a97493dcfa9c98f3d4a13f..8bb8dd7a0c277e7531f60c928f76f63c5632f217 100644 (file)
@@ -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 (file)
index d15ab9d..0000000
+++ /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;
-}