From 20661e10274b2cf57bae65472ab3160681597ee5 Mon Sep 17 00:00:00 2001 From: Nizamudeen A Date: Wed, 14 Sep 2022 21:33:56 +0530 Subject: [PATCH] mgr/dashboard: integrate notification pills with notification sidebar Signed-off-by: Nizamudeen A --- .../dashboard/dashboard.component.html | 75 ++++++------ .../dashboard/dashboard.component.spec.ts | 107 +++++++++++++++++- .../dashboard/dashboard.component.ts | 22 +++- .../notifications/notifications.component.ts | 2 +- .../notifications-sidebar.component.html | 14 +-- .../notifications-sidebar.component.spec.ts | 2 +- .../notifications-sidebar.component.ts | 25 +++- .../src/app/shared/enum/health-icon.enum.ts | 6 + .../src/app/shared/enum/icons.enum.ts | 2 + .../shared/models/notification-count.model.ts | 6 + .../app/shared/pipes/health-icon.pipe.spec.ts | 20 ++++ .../src/app/shared/pipes/health-icon.pipe.ts | 16 +++ .../src/app/shared/pipes/pipes.module.ts | 10 +- .../services/notification.service.spec.ts | 2 +- .../shared/services/notification.service.ts | 55 ++++++++- 15 files changed, 303 insertions(+), 61 deletions(-) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/enum/health-icon.enum.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/models/notification-count.model.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/health-icon.pipe.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/health-icon.pipe.ts diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/new-dashboard/dashboard/dashboard.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/new-dashboard/dashboard/dashboard.component.html index b088e92251f0a..ab688a40e4e4f 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/new-dashboard/dashboard/dashboard.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/new-dashboard/dashboard/dashboard.component.html @@ -13,44 +13,49 @@ - -
- - - -

Cluster

-
-
-
-

Notifications

- - - - - 1 - - - - 1 - - - - 1 - - - - 1 - +
+ + Cluster
+
+
+ Notifications + + + + + + +
+
{ let fixture: ComponentFixture; let configurationService: ConfigurationService; let orchestratorService: MgrModuleService; + let getHealthSpy: jasmine.Spy; + let getNotificationCountSpy: jasmine.Spy; + const healthPayload: Record = { + 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 = { + info: 3, + error: 4, + success: 5, + cephNotifications: 10 + } const configValueData: any = { value: [ @@ -52,13 +81,14 @@ describe('CardComponent', () => { }; 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 ] }); @@ -67,6 +97,10 @@ describe('CardComponent', () => { 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', () => { @@ -86,4 +120,67 @@ describe('CardComponent', () => { 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()); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/new-dashboard/dashboard/dashboard.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/new-dashboard/dashboard/dashboard.component.ts index 5c094b00fd540..07fb38180c64b 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/new-dashboard/dashboard/dashboard.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/new-dashboard/dashboard/dashboard.component.ts @@ -7,8 +7,12 @@ import { ClusterService } from '~/app/shared/api/cluster.service'; 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 { @@ -22,7 +26,7 @@ import { SummaryService } from '~/app/shared/services/summary.service'; templateUrl: './dashboard.component.html', styleUrls: ['./dashboard.component.scss'] }) -export class DashboardComponent implements OnInit, OnDestroy { +export class DashboardComponent implements OnInit, OnDestroy{ detailsCardData: DashboardDetails = {}; osdSettings$: Observable; interval = new Subscription(); @@ -31,6 +35,13 @@ export class DashboardComponent implements OnInit, OnDestroy { color: string; capacity$: Observable; healthData: any; + + notificationType: number; + notificationCountTest$: Observable; + type = NotificationType; + + icons = Icons; + constructor( private summaryService: SummaryService, private configService: ConfigurationService, @@ -39,7 +50,8 @@ export class DashboardComponent implements OnInit, OnDestroy { 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(); @@ -51,6 +63,12 @@ export class DashboardComponent implements OnInit, OnDestroy { 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() { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.ts index 89c6c4037941b..ebaf331966f15 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.ts @@ -42,6 +42,6 @@ export class NotificationsComponent implements OnInit, OnDestroy { } toggleSidebar() { - this.notificationService.toggleSidebar(); + this.notificationService.toggleSidebar(false, 'all'); } } 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 index bba23747b01d4..090cdb9b93cb5 100644 --- 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 @@ -1,7 +1,8 @@
-
+
-
+
Tasks and Notifications 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 index 596f3c358bf4d..42398431c2999 100644 --- 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 @@ -166,7 +166,7 @@ describe('NotificationsSidebarComponent', () => { 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(); 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 index 8c5caf7ff6bd6..2b20c23106058 100644 --- 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 @@ -5,7 +5,7 @@ import { HostBinding, NgZone, OnDestroy, - OnInit + OnInit, } from '@angular/core'; import { Mutex } from 'async-mutex'; @@ -33,6 +33,10 @@ import { TaskMessageService } from '~/app/shared/services/task-message.service'; export class NotificationsSidebarComponent implements OnInit, OnDestroy { @HostBinding('class.active') isSidebarOpened = false; + notificationType: number; + + notificationApplication: string = 'Ceph'; + notifications: CdNotification[]; private interval: number; private timeout: number; @@ -93,13 +97,24 @@ export class NotificationsSidebarComponent implements OnInit, OnDestroy { ); 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(); @@ -157,6 +172,12 @@ export class NotificationsSidebarComponent implements OnInit, OnDestroy { this.notificationService.remove(index); } + removeSpecific(type: number, application = 'Prometheus') { + application === 'all' + ? this.removeAll() + : this.notificationService.removeSpecific(type, application) + } + closeSidebar() { this.isSidebarOpened = false; } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/health-icon.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/health-icon.enum.ts new file mode 100644 index 0000000000000..d11937e323660 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/health-icon.enum.ts @@ -0,0 +1,6 @@ +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 diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts index 6b65f04e8cb2f..b7ecde6f1f0c3 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts @@ -34,6 +34,8 @@ export enum Icons { 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 diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/notification-count.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/notification-count.model.ts new file mode 100644 index 0000000000000..b5ef07057cd8d --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/notification-count.model.ts @@ -0,0 +1,6 @@ +export interface NotificationCount { + error: number; + info: number; + success: number; + cephNotifications: number; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/health-icon.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/health-icon.pipe.spec.ts new file mode 100644 index 0000000000000..e4450d9e1c1b9 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/health-icon.pipe.spec.ts @@ -0,0 +1,20 @@ +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'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/health-icon.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/health-icon.pipe.ts new file mode 100644 index 0000000000000..039862618a47b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/health-icon.pipe.ts @@ -0,0 +1,16 @@ +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; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pipes.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pipes.module.ts index 91d611c0a55fc..5abd7ece86378 100755 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pipes.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pipes.module.ts @@ -30,6 +30,7 @@ import { SanitizeHtmlPipe } from './sanitize-html.pipe'; 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], @@ -62,7 +63,8 @@ import { UpperFirstPipe } from './upper-first.pipe'; MapPipe, TruncatePipe, SanitizeHtmlPipe, - SearchHighlightPipe + SearchHighlightPipe, + HealthIconPipe ], exports: [ ArrayPipe, @@ -93,7 +95,8 @@ import { UpperFirstPipe } from './upper-first.pipe'; MapPipe, TruncatePipe, SanitizeHtmlPipe, - SearchHighlightPipe + SearchHighlightPipe, + HealthIconPipe ], providers: [ ArrayPipe, @@ -120,7 +123,8 @@ import { UpperFirstPipe } from './upper-first.pipe'; DurationPipe, MapPipe, TruncatePipe, - SanitizeHtmlPipe + SanitizeHtmlPipe, + HealthIconPipe ] }) export class PipesModule {} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.spec.ts index 028dd90ea3968..8685b6e524d31 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.spec.ts @@ -51,7 +51,7 @@ describe('NotificationService', () => { '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); })); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.ts index c05dbce0f571f..3ebe2efd165e3 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.ts @@ -2,13 +2,15 @@ import { Injectable } from '@angular/core'; 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' @@ -30,7 +32,8 @@ export class NotificationService { 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[] = []; @@ -55,6 +58,27 @@ export class NotificationService { 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 */ @@ -123,6 +147,24 @@ export class NotificationService { }, 10); } + getNotificationCount() : Observable { + 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))) { @@ -231,7 +273,12 @@ export class NotificationService { 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 + }); } } -- 2.39.5