pages = { index: '/#/crush-map' };
getPageTitle() {
- return $('.card-header').getText();
+ return $('cd-crushmap .card-header').getText();
}
getCrushNode(idx) {
"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",
"@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",
-<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>
+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(() => {
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();
+ });
+ });
});
-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',
]
})
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();
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';
tokenGetter: jwtTokenGetter
}
}),
- NgBootstrapFormValidationModule.forRoot()
+ NgBootstrapFormValidationModule.forRoot(),
+ SidebarModule.forRoot(),
+ WebStorageModule
],
exports: [SharedModule],
providers: [
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';
let fixture: ComponentFixture<LoginComponent>;
configureTestBed({
- imports: [RouterTestingModule, HttpClientTestingModule, AuthModule]
+ imports: [RouterTestingModule, HttpClientTestingModule, AuthModule, ToastrModule.forRoot()],
+ providers: [i18nProviders]
});
beforeEach(() => {
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);
+ });
});
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',
private authService: AuthService,
private authStorageService: AuthStorageService,
private bsModalService: BsModalService,
- private router: Router
+ private router: Router,
+ private notificationService: NotificationService
) {}
ngOnInit() {
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];
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';
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(),
BreadcrumbsComponent,
NavigationComponent,
NotificationsComponent,
- TaskManagerComponent,
DashboardHelpComponent,
AdministrationComponent,
IdentityComponent
<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>
-<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>
-
- <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>
-@import 'popover.scss';
+@import 'defaults';
+
+.running i {
+ color: $color-primary;
+}
+
+.running:hover i {
+ color: white;
+}
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
});
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();
});
});
-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();
}
}
+++ /dev/null
-<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>
+++ /dev/null
-@import '../../../../styles/popover.scss';
+++ /dev/null
-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');
- });
-});
+++ /dev/null
-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('-');
- }
-}
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';
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';
ReactiveFormsModule,
AlertModule.forRoot(),
PopoverModule.forRoot(),
+ ProgressbarModule.forRoot(),
TooltipModule.forRoot(),
ChartsModule,
ReactiveFormsModule,
UsageBarComponent,
LoadingPanelComponent,
ModalComponent,
+ NotificationsSidebarComponent,
CriticalConfirmationModalComponent,
ConfirmationModalComponent,
LanguageSelectorComponent,
LoadingPanelComponent,
UsageBarComponent,
ModalComponent,
+ NotificationsSidebarComponent,
LanguageSelectorComponent,
GrafanaComponent,
SelectComponent,
--- /dev/null
+<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>
+
+ <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>
--- /dev/null
+@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;
+}
--- /dev/null
+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');
+ }));
+ });
+});
--- /dev/null
+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;
+ }
+}
export class CdNotificationConfig {
applicationClass: string;
+ isFinishedTask = false;
private classes = {
Ceph: 'ceph-icon',
timestamp: string;
textClass: string;
iconClass: string;
+ duration: number;
private textClasses = ['text-danger', 'text-info', 'text-success'];
private iconClasses = [Icons.warning, Icons.info, Icons.check];
this.timestamp = new Date().toJSON();
this.iconClass = this.iconClasses[this.type];
this.textClass = this.textClasses[this.type];
+ this.isFinishedTask = config.isFinishedTask;
}
}
progress: number;
ret_value: any;
success: boolean;
+ duration: number;
errorMessage: string;
}
--- /dev/null
+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');
+ });
+});
--- /dev/null
+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';
+ }
+}
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';
MillisecondsPipe,
IopsPipe,
UpperFirstPipe,
- RbdConfigurationSourcePipe
+ RbdConfigurationSourcePipe,
+ DurationPipe
],
exports: [
BooleanTextPipe,
MillisecondsPipe,
IopsPipe,
UpperFirstPipe,
- RbdConfigurationSourcePipe
+ RbdConfigurationSourcePipe,
+ DurationPipe
],
providers: [
BooleanTextPipe,
-import { DatePipe } from '@angular/common';
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
import * as _ from 'lodash';
configureTestBed({
providers: [
- CdDatePipe,
- DatePipe,
NotificationService,
TaskMessageService,
{ provide: ToastrService, useValue: toastFakeService },
};
beforeEach(() => {
+ spyOn(service, 'show').and.callThrough();
service.cancel(service['justShownTimeoutId']);
});
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(() => {
}
);
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(() => {
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';
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';
private showQueued() {
this.getUnifiedTitleQueue().forEach((config) => {
const notification = new CdNotification(config);
- this.save(notification);
+
+ if (!notification.isFinishedTask) {
+ this.save(notification);
+ }
this.showToasty(notification);
});
}
}
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(
this.taskMessageService.getErrorMessage(finishedTask)
);
}
- return this.show(notification);
+ notification.isFinishedTask = true;
+
+ return notification;
}
/**
suspendToasties(suspend: boolean) {
this.hideToasties = suspend;
}
+
+ toggleSidebar(forceClose = false) {
+ this.sidebarSubject.next(forceClose);
+ }
}
beforeEach(() => {
service = TestBed.get(TaskMessageService);
finishedTask = new FinishedTask();
+ finishedTask.duration = 30;
});
it('should be created', () => {
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) => {
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';
}
_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);
}
.full-height {
- height: 100%;
+ height: 100vh;
}
.vertical-align {
display: flex;
+++ /dev/null
-@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;
-}