</dl>
</cd-card>
- <cd-card *ngIf="healthData"
- title="Status"
+ <cd-card title="Status"
i18n-title
class="col-sm-6 px-3">
- <div class="d-flex">
- <i *ngIf="healthData.health?.status == 'HEALTH_OK'"
- class="pl-2 fa fa-check-circle text-success"></i>
- <i *ngIf="healthData.health?.status == 'HEALTH_WARN'"
- class="pl-2 fa fa-exclamation-triangle text-warning"></i>
- <i *ngIf="healthData.health?.status == 'HEALTH_ERR'"
- class="pl-2 fa fa-exclamation-triangle text-danger"></i>
- <p class="pl-2 pr-5">Cluster</p>
- </div>
- <hr />
- <div class="d-flex flex-wrap">
- <p>Notifications</p>
- <!-- Potentially make widget component -->
- <button class="ml-3 btn btn-outline-danger rounded-pill">
- <i class="fa fa-exclamation-circle"></i>
- <a>1</a>
- </button>
- <span class="ml-2 btn btn-outline-warning rounded-pill">
- <i class="fa fa-exclamation-triangle"></i>
- <a>1</a>
- </span>
- <span class="ml-2 btn btn-outline-success rounded-pill">
- <i class="fa fa-check-circle"></i>
- <a>1</a>
- </span>
- <span class="ml-2 btn btn-outline-primary rounded-pill">
- <i class="fa fa-info-circle"></i>
- <a>1</a>
- </span>
- <span class="ml-2 btn btn-outline-info rounded-pill">
- <i class="fa fa-bell"></i>
- <a>1</a>
- </span>
+ <div class="d-flex ml-2">
+ <i *ngIf="healthData?.health?.status"
+ [ngClass]="[healthData.health.status | healthIcon, icons.large]"
+ [ngStyle]="healthData.health.status | healthColor"
+ [title]="healthData.health.status"></i>
+ <span class="ml-2 mt-n1"
+ i18n>Cluster</span>
</div>
+ <section class="border-top mt-5">
+ <div class="d-flex flex-wrap">
+ <span class="pt-2"
+ i18n>Notifications</span>
+ <!-- Potentially make widget component -->
+ <button class="btn btn-outline-danger rounded-pill ml-2"
+ (click)="toggleSidebar('Prometheus', type.error)"
+ id="dangerNotification">
+ <i [ngClass]="[icons.danger]"></i>
+ <span *ngIf="notificationCountTest$ | async as count">{{ count?.error }}</span>
+ </button>
+
+ <button class="btn btn-outline-success rounded-pill ml-2"
+ (click)="toggleSidebar('Prometheus', type.success)"
+ id="successNotification">
+ <i [ngClass]="[icons.success]"></i>
+ <span *ngIf="notificationCountTest$ | async as count">{{ count.success }}</span>
+ </button>
+ <button class="btn btn-outline-primary rounded-pill ml-2"
+ (click)="toggleSidebar('Prometheus', type.info)"
+ id="infoNotification">
+ <i [ngClass]="[icons.infoCircle]"></i>
+ <span *ngIf="notificationCountTest$ | async as count">{{ count?.info }}</span>
+ </button>
+ <button class="btn btn-outline-info rounded-pill ml-2"
+ (click)="toggleSidebar('Ceph')"
+ id="cephNotification">
+ <i [ngClass]="[icons.bell]"></i>
+ <span *ngIf="notificationCountTest$ | async as count">{{ count?.cephNotifications }}</span>
+ </button>
+ </div>
+ </section>
</cd-card>
<cd-card title="Capacity"
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+import _ from 'lodash';
+import { ToastrModule } from 'ngx-toastr';
import { RouterTestingModule } from '@angular/router/testing';
import { BehaviorSubject, of } from 'rxjs';
import { ConfigurationService } from '~/app/shared/api/configuration.service';
+import { HealthService } from '~/app/shared/api/health.service';
import { MgrModuleService } from '~/app/shared/api/mgr-module.service';
import { CssHelper } from '~/app/shared/classes/css-helper';
-import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
+import { PipesModule } from '~/app/shared/pipes/pipes.module';
+import { NotificationService } from '~/app/shared/services/notification.service';
import { SummaryService } from '~/app/shared/services/summary.service';
import { configureTestBed } from '~/testing/unit-test-helper';
import { CardComponent } from '../card/card.component';
let fixture: ComponentFixture<DashboardComponent>;
let configurationService: ConfigurationService;
let orchestratorService: MgrModuleService;
+ let getHealthSpy: jasmine.Spy;
+ let getNotificationCountSpy: jasmine.Spy;
+ const healthPayload: Record<string, any> = {
+ health: { status: 'HEALTH_OK' },
+ mon_status: { monmap: { mons: [] }, quorum: [] },
+ osd_map: { osds: [] },
+ mgr_map: { standbys: [] },
+ hosts: 0,
+ rgw: 0,
+ fs_map: { filesystems: [], standbys: [] },
+ iscsi_daemons: 0,
+ client_perf: {},
+ scrub_status: 'Inactive',
+ pools: [],
+ df: { stats: {} },
+ pg_info: { object_stats: { num_objects: 0 } }
+ };
+
+ const notificationCountPayload: Record<string, number> = {
+ info: 3,
+ error: 4,
+ success: 5,
+ cephNotifications: 10
+ }
const configValueData: any = {
value: [
};
configureTestBed({
- imports: [RouterTestingModule, HttpClientTestingModule],
+ imports: [RouterTestingModule, HttpClientTestingModule,
+ ToastrModule.forRoot(),
+ PipesModule],
declarations: [DashboardComponent, CardComponent, DashboardPieComponent],
schemas: [NO_ERRORS_SCHEMA],
providers: [
- CssHelper,
- DimlessBinaryPipe,
- { provide: SummaryService, useClass: SummaryServiceMock }
+ { provide: SummaryService, useClass: SummaryServiceMock },
+ CssHelper
]
});
component = fixture.componentInstance;
configurationService = TestBed.inject(ConfigurationService);
orchestratorService = TestBed.inject(MgrModuleService);
+ getHealthSpy = spyOn(TestBed.inject(HealthService), 'getMinimalHealth');
+ getHealthSpy.and.returnValue(of(healthPayload));
+ getNotificationCountSpy = spyOn(TestBed.inject(NotificationService), 'getNotificationCount');
+ getNotificationCountSpy.and.returnValue(of(notificationCountPayload));
});
it('should create', () => {
expect(component.detailsCardData.orchestrator).toBe('Cephadm');
expect(component.detailsCardData.cephVersion).toBe('17.0.0-12222-gcd0cd7cb quincy (dev)');
});
+
+ it('should check if the respective icon is shown for each status', () => {
+ const payload = _.cloneDeep(healthPayload);
+
+ // HEALTH_WARN
+ payload.health['status'] = 'HEALTH_WARN';
+ payload.health['checks'] = [
+ { severity: 'HEALTH_WARN', type: 'WRN', summary: { message: 'fake warning' } }
+ ];
+
+ getHealthSpy.and.returnValue(of(payload));
+ fixture.detectChanges();
+ const clusterStatusCard = fixture.debugElement.query(
+ By.css('cd-card[title="Status"] i')
+ );
+ expect(clusterStatusCard.nativeElement.title).toEqual(`${payload.health.status}`);
+
+ // HEALTH_ERR
+ payload.health['status'] = 'HEALTH_ERR';
+ payload.health['checks'] = [
+ { severity: 'HEALTH_ERR', type: 'ERR', summary: { message: 'fake error' } }
+ ];
+
+ getHealthSpy.and.returnValue(of(payload));
+ fixture.detectChanges();
+ expect(clusterStatusCard.nativeElement.title).toEqual(`${payload.health.status}`);
+
+ // HEALTH_OK
+ payload.health['status'] = 'HEALTH_OK';
+ payload.health['checks'] = [
+ { severity: 'HEALTH_OK', type: 'OK', summary: { message: 'fake success' } }
+ ];
+
+ getHealthSpy.and.returnValue(of(payload));
+ fixture.detectChanges();
+ expect(clusterStatusCard.nativeElement.title).toEqual(`${payload.health.status}`);
+ });
+
+ it('should show the actual notification count on each notification pill', () => {
+ const payload = _.cloneDeep(notificationCountPayload);
+ fixture.detectChanges();
+
+ const cephNotification = fixture.debugElement.query(
+ By.css('button[id=cephNotification] span')
+ );
+
+ const successNotification = fixture.debugElement.query(
+ By.css('button[id=successNotification] span')
+ );
+
+ const dangerNotification = fixture.debugElement.query(
+ By.css('button[id=dangerNotification] span')
+ );
+
+ const infoNotification = fixture.debugElement.query(
+ By.css('button[id=infoNotification] span')
+ );
+
+ expect(cephNotification.nativeElement.textContent).toBe(payload.cephNotifications.toString());
+ expect(successNotification.nativeElement.textContent).toBe(payload.success.toString());
+ expect(dangerNotification.nativeElement.textContent).toBe(payload.error.toString());
+ expect(infoNotification.nativeElement.textContent).toBe(payload.info.toString());
+ });
});
import { ConfigurationService } from '~/app/shared/api/configuration.service';
import { HealthService } from '~/app/shared/api/health.service';
import { MgrModuleService } from '~/app/shared/api/mgr-module.service';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
import { OsdService } from '~/app/shared/api/osd.service';
import { DashboardDetails } from '~/app/shared/models/cd-details';
+import { NotificationCount } from '~/app/shared/models/notification-count.model';
+import { NotificationService } from '~/app/shared/services/notification.service';
import { Permissions } from '~/app/shared/models/permissions';
import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
import {
templateUrl: './dashboard.component.html',
styleUrls: ['./dashboard.component.scss']
})
-export class DashboardComponent implements OnInit, OnDestroy {
+export class DashboardComponent implements OnInit, OnDestroy{
detailsCardData: DashboardDetails = {};
osdSettings$: Observable<any>;
interval = new Subscription();
color: string;
capacity$: Observable<any>;
healthData: any;
+
+ notificationType: number;
+ notificationCountTest$: Observable<NotificationCount>;
+ type = NotificationType;
+
+ icons = Icons;
+
constructor(
private summaryService: SummaryService,
private configService: ConfigurationService,
private osdService: OsdService,
private authStorageService: AuthStorageService,
private featureToggles: FeatureTogglesService,
- private healthService: HealthService
+ private healthService: HealthService,
+ public notificationService: NotificationService,
) {
this.permissions = this.authStorageService.getPermissions();
this.enabledFeature$ = this.featureToggles.get();
this.osdSettings$ = this.osdService.getOsdSettings();
this.capacity$ = this.clusterService.getCapacity();
this.getHealth();
+ this.notificationCountTest$ = this.notificationService.getNotificationCount();
+ }
+
+ toggleSidebar(notificationApplication = 'Prometheus', notificationType = -1) {
+ this.notificationService.toggleSidebar(false, notificationApplication, notificationType, (this.notificationType !== notificationType))
+ this.notificationType = notificationType;
}
ngOnDestroy() {
}
toggleSidebar() {
- this.notificationService.toggleSidebar();
+ this.notificationService.toggleSidebar(false, 'all');
}
}
<ng-template #tasksTpl>
<!-- Executing -->
<div *ngFor="let executingTask of executingTasks; trackBy:trackByFn">
- <div class="card tc_task border-0">
+ <div class="card tc_task border-0"
+ *ngIf="notificationApplication !== 'Prometheus'">
<div class="row no-gutters">
<div class="col-md-2 text-center">
<span [ngClass]="[icons.stack, icons.large2x]"
<ng-container *ngIf="notifications.length > 0">
<button type="button"
class="btn btn-light btn-block"
- (click)="removeAll(); $event.stopPropagation()">
+ (click)="removeSpecific(notificationType, notificationApplication); $event.stopPropagation()">
<i [ngClass]="[icons.trash]"
aria-hidden="true"></i>
<div *ngFor="let notification of notifications; let i = index"
[ngClass]="notification.borderClass">
- <div class="card tc_notification border-0">
+ <div class="card tc_notification border-0"
+ *ngIf="(notification.type === notificationType || notificationType === -1) && (notification.application === notificationApplication || notificationApplication === 'all')">
<div class="row no-gutters">
<div class="col-md-2 text-center">
<span [ngClass]="[icons.stack, icons.large2x, notification.textClass]">
</div>
</div>
</div>
+ <hr>
</div>
- <hr>
</div>
</ng-container>
</ng-template>
</div>
</ng-template>
-<div class="card"
- (clickOutside)="closeSidebar()"
- [clickOutsideEnabled]="isSidebarOpened">
+<div class="card">
<div class="card-header">
<ng-container i18n>Tasks and Notifications</ng-container>
it('should always close if sidebarSubject value is true', fakeAsync(() => {
// Closed before next value
expect(component.isSidebarOpened).toBeFalsy();
- notificationService.sidebarSubject.next(true);
+ notificationService.sidebarSubject.next({forceClose: true, keepOpen: false});
tick();
expect(component.isSidebarOpened).toBeFalsy();
HostBinding,
NgZone,
OnDestroy,
- OnInit
+ OnInit,
} from '@angular/core';
import { Mutex } from 'async-mutex';
export class NotificationsSidebarComponent implements OnInit, OnDestroy {
@HostBinding('class.active') isSidebarOpened = false;
+ notificationType: number;
+
+ notificationApplication: string = 'Ceph';
+
notifications: CdNotification[];
private interval: number;
private timeout: number;
);
this.subs.add(
- this.notificationService.sidebarSubject.subscribe((forceClose) => {
+ this.notificationService.sidebarSubject.subscribe(({ forceClose, notificationType, notificationApplication, keepOpen }) => {
if (forceClose) {
this.isSidebarOpened = false;
+ } else if (keepOpen) {
+ this.isSidebarOpened = true;
} else {
this.isSidebarOpened = !this.isSidebarOpened;
+ this.notificationType = -1;
}
+ notificationType !== -1
+ ? this.notificationType = notificationType
+ : this.notificationType = -1;
+
+ notificationApplication
+ ? this.notificationApplication = notificationApplication
+ : this.notificationApplication = 'Ceph';
+
window.clearTimeout(this.timeout);
this.timeout = window.setTimeout(() => {
this.cdRef.detectChanges();
this.notificationService.remove(index);
}
+ removeSpecific(type: number, application = 'Prometheus') {
+ application === 'all'
+ ? this.removeAll()
+ : this.notificationService.removeSpecific(type, application)
+ }
+
closeSidebar() {
this.isSidebarOpened = false;
}
--- /dev/null
+export enum HealthIcon {
+ HEALTH_ERR = 'fa fa-exclamation-circle',
+ HEALTH_WARN = 'fa fa-exclamation-triangle',
+ HEALTH_OK = 'fa fa-check-circle'
+ }
+
\ No newline at end of file
info = 'fa fa-info', // Notification information
infoCircle = 'fa fa-info-circle', // Info on landing page
questionCircle = 'fa fa-question-circle-o',
+ danger = 'fa fa-exclamation-circle',
+ success = 'fa fa-check-circle',
check = 'fa fa-check', // Notification check
show = 'fa fa-eye', // Show
paragraph = 'fa fa-paragraph', // Silence Matcher - Attribute name
--- /dev/null
+export interface NotificationCount {
+ error: number;
+ info: number;
+ success: number;
+ cephNotifications: number;
+}
--- /dev/null
+import { HealthIconPipe } from './health-icon.pipe';
+
+describe('HealthIconPipe', () => {
+ const pipe = new HealthIconPipe();
+ it('create an instance', () => {
+ expect(pipe).toBeTruthy();
+ });
+
+ it('transforms "HEALTH_OK"', () => {
+ expect(pipe.transform('HEALTH_OK')).toEqual('fa fa-check-circle');
+ });
+
+ it('transforms "HEALTH_WARN"', () => {
+ expect(pipe.transform('HEALTH_WARN')).toEqual('fa fa-exclamation-triangle');
+ });
+
+ it('transforms "HEALTH_ERR"', () => {
+ expect(pipe.transform('HEALTH_ERR')).toEqual('fa fa-exclamation-circle');
+ });
+});
--- /dev/null
+import { Pipe, PipeTransform } from '@angular/core';
+import { HealthIcon } from '../enum/health-icon.enum';
+
+@Pipe({
+ name: 'healthIcon'
+})
+export class HealthIconPipe implements PipeTransform {
+
+ constructor() {}
+
+ transform(value: any): any {
+ return Object.keys(HealthIcon).includes(value as HealthIcon)
+ ? HealthIcon[value]
+ : null;
+ }
+}
import { SearchHighlightPipe } from './search-highlight.pipe';
import { TruncatePipe } from './truncate.pipe';
import { UpperFirstPipe } from './upper-first.pipe';
+import { HealthIconPipe } from './health-icon.pipe';
@NgModule({
imports: [CommonModule],
MapPipe,
TruncatePipe,
SanitizeHtmlPipe,
- SearchHighlightPipe
+ SearchHighlightPipe,
+ HealthIconPipe
],
exports: [
ArrayPipe,
MapPipe,
TruncatePipe,
SanitizeHtmlPipe,
- SearchHighlightPipe
+ SearchHighlightPipe,
+ HealthIconPipe
],
providers: [
ArrayPipe,
DurationPipe,
MapPipe,
TruncatePipe,
- SanitizeHtmlPipe
+ SanitizeHtmlPipe,
+ HealthIconPipe
]
})
export class PipesModule {}
'cdNotifications',
'[{"type":2,"message":"foobar","timestamp":"2018-05-24T09:41:32.726Z"}]'
);
- service = new NotificationService(null, null, null);
+ service = new NotificationService(null, null, null, null);
expect(service['dataSource'].getValue().length).toBe(1);
}));
import _ from 'lodash';
import { IndividualConfig, ToastrService } from 'ngx-toastr';
-import { BehaviorSubject, Subject } from 'rxjs';
+import { BehaviorSubject, Observable, of as observableOf, Subject } from 'rxjs';
import { NotificationType } from '../enum/notification-type.enum';
import { CdNotification, CdNotificationConfig } from '../models/cd-notification';
import { FinishedTask } from '../models/finished-task';
+import { NotificationCount } from '../models/notification-count.model';
import { CdDatePipe } from '../pipes/cd-date.pipe';
import { TaskMessageService } from './task-message.service';
+import { TimerService } from './timer.service';
@Injectable({
providedIn: 'root'
constructor(
public toastr: ToastrService,
private taskMessageService: TaskMessageService,
- private cdDatePipe: CdDatePipe
+ private cdDatePipe: CdDatePipe,
+ private timerService: TimerService
) {
const stringNotifications = localStorage.getItem(this.KEY);
let notifications: CdNotification[] = [];
this.dataSource.next([]);
}
+ /**
+ * Removes a specific set of notification given the type and application.
+ */
+ removeSpecific(type: number, app = 'Prometheus') {
+ const recent = this.dataSource.getValue();
+ let indices = [];
+
+ for(let index = 0; index < recent.length; index++) {
+ if (app === 'Prometheus' && recent[index].type === type && recent[index].application === app) {
+ indices.push(index);
+ } else if (app !== 'Prometheus' && recent[index].application === app) {
+ indices.push(index);
+ }
+ }
+ for (const index of indices.reverse()) {
+ recent.splice(index, 1);
+ }
+ this.dataSource.next(recent);
+ localStorage.setItem(this.KEY, JSON.stringify(recent));
+ }
+
/**
* Removes a single saved notifications
*/
}, 10);
}
+ getNotificationCount() : Observable<NotificationCount> {
+ return this.timerService.get(() => observableOf({
+ error: this._getNotificationCount(NotificationType.error),
+ info: this._getNotificationCount(NotificationType.info),
+ success: this._getNotificationCount(NotificationType.success),
+ cephNotifications: this._getNotificationCount(undefined, 'Ceph')
+ }), 1000
+ );
+ }
+
+ private _getNotificationCount(type?: number, application = 'Prometheus') {
+ return this.dataSource.getValue().filter((notification: CdNotification) =>
+ application === 'Prometheus'
+ ? notification.application === application && notification.type === type
+ : notification.application === application
+ ).length;
+ }
+
private queueToShow(config: CdNotificationConfig) {
this.cancel(this.queuedTimeoutId);
if (!this.queued.find((c) => _.isEqual(c, config))) {
this.hideToasties = suspend;
}
- toggleSidebar(forceClose = false) {
- this.sidebarSubject.next(forceClose);
+ toggleSidebar(forceClose = false, notificationApplication = 'Prometheus', notificationType = -1, keepOpen = false) {
+ this.sidebarSubject.next({
+ forceClose: forceClose,
+ notificationApplication: notificationApplication,
+ notificationType: notificationType,
+ keepOpen: keepOpen
+ });
}
}