From 30aee044659f3f71d9a4e48c8062e7c5103dbd33 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Stephan=20M=C3=BCller?= Date: Fri, 22 Mar 2019 17:12:20 +0100 Subject: [PATCH] mgr/dashboard: Queue notifications as default MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit Every time "show" is triggers now the notification gets queued before sending it out. The notification service will make sure not to send out duplicated notifications and to combine notifications that have the same title. The combination of notification is useful for error messages. For example if the mgr goes down you will get a view toasties notifying about a 500 error. With the new implementation you get only one with the different API paths it couldn't get. Fixes: https://tracker.ceph.com/issues/39034 Signed-off-by: Stephan Müller --- .../services/api-interceptor.service.spec.ts | 23 +- .../services/notification.service.spec.ts | 199 ++++++++++-------- .../shared/services/notification.service.ts | 59 ++++-- .../prometheus-alert-formatter.spec.ts | 6 +- .../services/prometheus-alert-formatter.ts | 4 +- .../services/prometheus-alert.service.spec.ts | 22 +- .../prometheus-notification.service.spec.ts | 70 ++++-- .../mgr/dashboard/frontend/src/styles.scss | 5 + 8 files changed, 225 insertions(+), 163 deletions(-) diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/api-interceptor.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/api-interceptor.service.spec.ts index cc7fb2ba73f..a8fcb4e81a8 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/api-interceptor.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/api-interceptor.service.spec.ts @@ -170,16 +170,23 @@ describe('ApiInterceptorService', () => { }); describe('interceptor error handling', () => { + const expectSaveToHaveBeenCalled = (called) => { + tick(510); + if (called) { + expect(notificationService.save).toHaveBeenCalled(); + } else { + expect(notificationService.save).not.toHaveBeenCalled(); + } + }; + it('should show default behaviour', fakeAsync(() => { httpError(undefined, { status: 500 }); - tick(10); - expect(notificationService.save).toHaveBeenCalled(); + expectSaveToHaveBeenCalled(true); })); it('should prevent the default behaviour with preventDefault', fakeAsync(() => { httpError(undefined, { status: 500 }, (resp) => resp.preventDefault()); - tick(10); - expect(notificationService.save).not.toHaveBeenCalled(); + expectSaveToHaveBeenCalled(false); })); it('should be able to use preventDefault with 400 errors', fakeAsync(() => { @@ -188,14 +195,12 @@ describe('ApiInterceptorService', () => { { status: 400 }, (resp) => resp.preventDefault() ); - tick(10); - expect(notificationService.save).not.toHaveBeenCalled(); + expectSaveToHaveBeenCalled(false); })); it('should prevent the default behaviour by status code', fakeAsync(() => { httpError(undefined, { status: 500 }, (resp) => resp.ignoreStatusCode(500)); - tick(10); - expect(notificationService.save).not.toHaveBeenCalled(); + expectSaveToHaveBeenCalled(false); })); it('should use different application icon (default Ceph) in error message', fakeAsync(() => { @@ -203,7 +208,7 @@ describe('ApiInterceptorService', () => { httpError(undefined, { status: 500 }, (resp) => { (resp.application = 'Prometheus'), (resp.message = msg); }); - tick(10); + expectSaveToHaveBeenCalled(true); expect(notificationService.save).toHaveBeenCalledWith( createCdNotification(0, '500 - Unknown Error', msg, undefined, 'Prometheus') ); 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 76d75f9c83d..e6fd703491d 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 @@ -62,120 +62,141 @@ describe('NotificationService', () => { expect(service['dataSource'].getValue().length).toBe(0); })); - it('should create a success notification and save it', fakeAsync(() => { - service.show(new CdNotificationConfig(NotificationType.success, 'Simple test')); - tick(100); - expect(service['dataSource'].getValue().length).toBe(1); - expect(service['dataSource'].getValue()[0].type).toBe(NotificationType.success); - })); + describe('Saved notifications', () => { + const expectSavedNotificationToHave = (expected: {}) => { + tick(510); + expect(service['dataSource'].getValue().length).toBe(1); + const notification = service['dataSource'].getValue()[0]; + Object.keys(expected).forEach((key) => { + expect(notification[key]).toBe(expected[key]); + }); + }; - it('should create an error notification and save it', fakeAsync(() => { - service.show(NotificationType.error, 'Simple test'); - tick(100); - expect(service['dataSource'].getValue().length).toBe(1); - expect(service['dataSource'].getValue()[0].type).toBe(NotificationType.error); - })); + beforeEach(() => { + service.cancel(service['justShownTimeoutId']); + }); - it('should create an info notification and save it', fakeAsync(() => { - service.show(new CdNotificationConfig(NotificationType.info, 'Simple test')); - tick(100); - expect(service['dataSource'].getValue().length).toBe(1); - const notification = service['dataSource'].getValue()[0]; - expect(notification.type).toBe(NotificationType.info); - expect(notification.title).toBe('Simple test'); - expect(notification.message).toBe(undefined); - })); + it('should create a success notification and save it', fakeAsync(() => { + service.show(new CdNotificationConfig(NotificationType.success, 'Simple test')); + expectSavedNotificationToHave({ type: NotificationType.success }); + })); - it('should never have more then 10 notifications', fakeAsync(() => { - for (let index = 0; index < 15; index++) { - service.show(NotificationType.info, 'Simple test'); - tick(100); - } - expect(service['dataSource'].getValue().length).toBe(10); - })); + it('should create an error notification and save it', fakeAsync(() => { + service.show(NotificationType.error, 'Simple test'); + expectSavedNotificationToHave({ type: NotificationType.error }); + })); - it('should show a success task notification', fakeAsync(() => { - const task = _.assign(new FinishedTask(), { - success: true - }); - service.notifyTask(task, true); - tick(100); - expect(service['dataSource'].getValue().length).toBe(1); - const notification = service['dataSource'].getValue()[0]; - expect(notification.type).toBe(NotificationType.success); - expect(notification.title).toBe('Executed unknown task'); - expect(notification.message).toBe(undefined); - })); + it('should create an info notification and save it', fakeAsync(() => { + service.show(new CdNotificationConfig(NotificationType.info, 'Simple test')); + expectSavedNotificationToHave({ + type: NotificationType.info, + title: 'Simple test', + message: undefined + }); + })); - it('should be able to stop notifyTask from notifying', fakeAsync(() => { - const task = _.assign(new FinishedTask(), { - success: true - }); - const timeoutId = service.notifyTask(task, true); - service.cancel(timeoutId); - tick(100); - expect(service['dataSource'].getValue().length).toBe(0); - })); + it('should never have more then 10 notifications', fakeAsync(() => { + for (let index = 0; index < 15; index++) { + service.show(NotificationType.info, 'Simple test'); + tick(510); + } + expect(service['dataSource'].getValue().length).toBe(10); + })); + + it('should show a success task notification', fakeAsync(() => { + const task = _.assign(new FinishedTask(), { + success: true + }); + service.notifyTask(task, true); + expectSavedNotificationToHave({ + type: NotificationType.success, + title: 'Executed unknown task', + message: undefined + }); + })); - it('should show a error task notification', fakeAsync(() => { - const task = _.assign( - new FinishedTask('rbd/create', { - pool_name: 'somePool', - image_name: 'someImage' - }), - { - success: false, - exception: { - code: 17 + it('should be able to stop notifyTask from notifying', fakeAsync(() => { + const task = _.assign(new FinishedTask(), { + success: true + }); + const timeoutId = service.notifyTask(task, true); + service.cancel(timeoutId); + tick(100); + expect(service['dataSource'].getValue().length).toBe(0); + })); + + it('should show a error task notification', fakeAsync(() => { + const task = _.assign( + new FinishedTask('rbd/create', { + pool_name: 'somePool', + image_name: 'someImage' + }), + { + success: false, + exception: { + code: 17 + } } - } - ); - service.notifyTask(task); - tick(100); - expect(service['dataSource'].getValue().length).toBe(1); - const notification = service['dataSource'].getValue()[0]; - expect(notification.type).toBe(NotificationType.error); - expect(notification.title).toBe(`Failed to create RBD 'somePool/someImage'`); - expect(notification.message).toBe(`Name is already used by RBD 'somePool/someImage'.`); - })); + ); + service.notifyTask(task); + expectSavedNotificationToHave({ + type: NotificationType.error, + title: `Failed to create RBD 'somePool/someImage'`, + message: `Name is already used by RBD 'somePool/someImage'.` + }); + })); + + it('combines different notifications with the same title', fakeAsync(() => { + service.show(NotificationType.error, '502 - Bad Gateway', 'Error occurred in path a'); + tick(60); + service.show(NotificationType.error, '502 - Bad Gateway', 'Error occurred in path b'); + expectSavedNotificationToHave({ + type: NotificationType.error, + title: '502 - Bad Gateway', + message: '' + }); + })); + }); describe('notification queue', () => { const n1 = new CdNotificationConfig(NotificationType.success, 'Some success'); const n2 = new CdNotificationConfig(NotificationType.info, 'Some info'); + const showArray = (arr) => arr.forEach((n) => service.show(n)); + beforeEach(() => { - spyOn(service, 'show').and.stub(); + spyOn(service, 'save').and.stub(); }); it('filters out duplicated notifications on single call', fakeAsync(() => { - service.queueNotifications([n1, n1, n2, n2]); - tick(500); - expect(service.show).toHaveBeenCalledTimes(2); + showArray([n1, n1, n2, n2]); + tick(510); + expect(service.save).toHaveBeenCalledTimes(2); })); it('filters out duplicated notifications presented in different calls', fakeAsync(() => { - service.queueNotifications([n1, n2]); - service.queueNotifications([n1, n2]); - tick(500); - expect(service.show).toHaveBeenCalledTimes(2); + showArray([n1, n2]); + showArray([n1, n2]); + tick(1000); + expect(service.save).toHaveBeenCalledTimes(2); })); it('will reset the timeout on every call', fakeAsync(() => { - service.queueNotifications([n1, n2]); - tick(400); - service.queueNotifications([n1, n2]); - tick(100); - expect(service.show).toHaveBeenCalledTimes(0); - tick(400); - expect(service.show).toHaveBeenCalledTimes(2); + showArray([n1, n2]); + tick(490); + showArray([n1, n2]); + tick(450); + expect(service.save).toHaveBeenCalledTimes(0); + tick(60); + expect(service.save).toHaveBeenCalledTimes(2); })); it('wont filter out duplicated notifications if timeout was reached before', fakeAsync(() => { - service.queueNotifications([n1, n2]); - tick(500); - service.queueNotifications([n1, n2]); - tick(500); - expect(service.show).toHaveBeenCalledTimes(4); + showArray([n1, n2]); + tick(510); + showArray([n1, n2]); + tick(510); + expect(service.save).toHaveBeenCalledTimes(4); })); }); 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 14bd6b51e1b..e8515e1b95a 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 @@ -19,12 +19,12 @@ export class NotificationService { // Observable sources private dataSource = new BehaviorSubject([]); - private queuedNotifications: CdNotificationConfig[] = []; // Observable streams data$ = this.dataSource.asObservable(); - private queueTimeoutId: number; + private queued: CdNotificationConfig[] = []; + private queuedTimeoutId: number; KEY = 'cdNotifications'; constructor( @@ -68,21 +68,6 @@ export class NotificationService { localStorage.setItem(this.KEY, JSON.stringify(recent)); } - queueNotifications(notifications: CdNotificationConfig[]) { - this.queuedNotifications = this.queuedNotifications.concat(notifications); - this.cancel(this.queueTimeoutId); - this.queueTimeoutId = window.setTimeout(() => { - this.sendQueuedNotifications(); - }, 500); - } - - private sendQueuedNotifications() { - _.uniqWith(this.queuedNotifications, _.isEqual).forEach((notification) => { - this.show(notification); - }); - this.queuedNotifications = []; - } - /** * Method for showing a notification. * @param {NotificationType} type toastr type @@ -123,10 +108,48 @@ export class NotificationService { application ); } + this.queueToShow(config); + }, 10); + } + + private queueToShow(config: CdNotificationConfig) { + this.cancel(this.queuedTimeoutId); + if (!this.queued.find((c) => _.isEqual(c, config))) { + this.queued.push(config); + } + this.queuedTimeoutId = window.setTimeout(() => { + this.showQueued(); + }, 500); + } + + private showQueued() { + this.getUnifiedTitleQueue().forEach((config) => { const notification = new CdNotification(config); this.save(notification); this.showToasty(notification); - }, 10); + }); + } + + private getUnifiedTitleQueue(): CdNotificationConfig[] { + return Object.values(this.queueShiftByTitle()).map((configs) => { + const config = configs[0]; + if (configs.length > 1) { + config.message = '
    ' + configs.map((c) => `
  • ${c.message}
  • `).join('') + '
'; + } + return config; + }); + } + + private queueShiftByTitle(): { [key: string]: CdNotificationConfig[] } { + const byTitle: { [key: string]: CdNotificationConfig[] } = {}; + let config: CdNotificationConfig; + while ((config = this.queued.shift())) { + if (!byTitle[config.title]) { + byTitle[config.title] = []; + } + byTitle[config.title].push(config); + } + return byTitle; } private showToasty(notification: CdNotification) { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert-formatter.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert-formatter.spec.ts index a0a5b49403f..2b865a400ca 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert-formatter.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert-formatter.spec.ts @@ -28,7 +28,7 @@ describe('PrometheusAlertFormatter', () => { prometheus = new PrometheusHelper(); service = TestBed.get(PrometheusAlertFormatter); notificationService = TestBed.get(NotificationService); - spyOn(notificationService, 'queueNotifications').and.stub(); + spyOn(notificationService, 'show').and.stub(); }); it('should create', () => { @@ -38,13 +38,13 @@ describe('PrometheusAlertFormatter', () => { describe('sendNotifications', () => { it('should not call queue notifications with no notification', () => { service.sendNotifications([]); - expect(notificationService.queueNotifications).not.toHaveBeenCalled(); + expect(notificationService.show).not.toHaveBeenCalled(); }); it('should call queue notifications with notifications', () => { const notifications = [new CdNotificationConfig(NotificationType.success, 'test')]; service.sendNotifications(notifications); - expect(notificationService.queueNotifications).toHaveBeenCalledWith(notifications); + expect(notificationService.show).toHaveBeenCalledWith(notifications[0]); }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert-formatter.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert-formatter.ts index 8fdc5ddb68f..0dbb68ae3cc 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert-formatter.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert-formatter.ts @@ -19,9 +19,7 @@ export class PrometheusAlertFormatter { constructor(private notificationService: NotificationService) {} sendNotifications(notifications: CdNotificationConfig[]) { - if (notifications.length > 0) { - this.notificationService.queueNotifications(notifications); - } + notifications.forEach((n) => this.notificationService.show(n)); } convertToCustomAlerts( diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.spec.ts index 71d12c0f428..294ac37f884 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.spec.ts @@ -63,7 +63,6 @@ describe('PrometheusAlertService', () => { spyOn(window, 'setTimeout').and.callFake((fn: Function) => fn()); notificationService = TestBed.get(NotificationService); - spyOn(notificationService, 'queueNotifications').and.callThrough(); spyOn(notificationService, 'show').and.stub(); prometheusService = TestBed.get(PrometheusService); @@ -86,7 +85,7 @@ describe('PrometheusAlertService', () => { it('should notify on alert change', () => { alerts = [prometheus.createAlert('alert0', 'suppressed')]; service.refresh(); - expect(notificationService.queueNotifications).toHaveBeenCalledWith([ + expect(notificationService.show).toHaveBeenCalledWith( new CdNotificationConfig( NotificationType.info, 'alert0 (suppressed)', @@ -94,7 +93,7 @@ describe('PrometheusAlertService', () => { undefined, 'Prometheus' ) - ]); + ); }); it('should notify on a new alert', () => { @@ -133,22 +132,7 @@ describe('PrometheusAlertService', () => { service.refresh(); alerts = [alert1, prometheus.createAlert('alert2')]; service.refresh(); - expect(notificationService.queueNotifications).toHaveBeenCalledWith([ - new CdNotificationConfig( - NotificationType.error, - 'alert2 (active)', - 'alert2 is active ' + prometheus.createLink('http://alert2'), - undefined, - 'Prometheus' - ), - new CdNotificationConfig( - NotificationType.success, - 'alert0 (resolved)', - 'alert0 is active ' + prometheus.createLink('http://alert0'), - undefined, - 'Prometheus' - ) - ]); + expect(notificationService.show).toHaveBeenCalledTimes(2); }); }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-notification.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-notification.service.spec.ts index b1f8b3b8b7b..c5ced809559 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-notification.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-notification.service.spec.ts @@ -1,7 +1,7 @@ import { HttpClientTestingModule } from '@angular/common/http/testing'; -import { TestBed } from '@angular/core/testing'; +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; -import { ToastModule } from 'ng2-toastr'; +import { ToastModule, ToastsManager } from 'ng2-toastr'; import { of } from 'rxjs'; import { @@ -26,9 +26,20 @@ describe('PrometheusNotificationService', () => { let prometheus: PrometheusHelper; let shown: CdNotificationConfig[]; + const toastFakeService = { + error: () => true, + info: () => true, + success: () => true + }; + configureTestBed({ imports: [ToastModule.forRoot(), SharedModule, HttpClientTestingModule], - providers: [PrometheusNotificationService, PrometheusAlertFormatter, i18nProviders] + providers: [ + PrometheusNotificationService, + PrometheusAlertFormatter, + i18nProviders, + { provide: ToastsManager, useValue: toastFakeService } + ] }); beforeEach(() => { @@ -38,9 +49,9 @@ describe('PrometheusNotificationService', () => { service['notifications'] = []; notificationService = TestBed.get(NotificationService); - spyOn(notificationService, 'queueNotifications').and.callThrough(); shown = []; - spyOn(notificationService, 'show').and.callFake((n) => shown.push(n)); + spyOn(notificationService, 'show').and.callThrough(); + spyOn(notificationService, 'save').and.callFake((n) => shown.push(n)); spyOn(window, 'setTimeout').and.callFake((fn: Function) => fn()); @@ -83,18 +94,31 @@ describe('PrometheusNotificationService', () => { }); describe('looks of fired notifications', () => { - beforeEach(() => { + const asyncRefresh = () => { service.refresh(); + tick(20); + }; + + const expectShown = (expected: {}[]) => { + tick(500); + expect(shown.length).toBe(expected.length); + expected.forEach((e, i) => + Object.keys(e).forEach((key) => expect(shown[i][key]).toEqual(expected[i][key])) + ); + }; + + beforeEach(() => { service.refresh(); - shown = []; }); it('notifies on the second call', () => { + service.refresh(); expect(notificationService.show).toHaveBeenCalledTimes(1); }); - it('notify looks on single notification with single alert like', () => { - expect(notificationService.queueNotifications).toHaveBeenCalledWith([ + it('notify looks on single notification with single alert like', fakeAsync(() => { + asyncRefresh(); + expectShown([ new CdNotificationConfig( NotificationType.error, 'alert0 (active)', @@ -103,12 +127,13 @@ describe('PrometheusNotificationService', () => { 'Prometheus' ) ]); - }); + })); - it('raises multiple pop overs for a single notification with multiple alerts', () => { + it('raises multiple pop overs for a single notification with multiple alerts', fakeAsync(() => { + asyncRefresh(); notifications[0].alerts.push(prometheus.createNotificationAlert('alert1', 'resolved')); - service.refresh(); - expect(shown).toEqual([ + asyncRefresh(); + expectShown([ new CdNotificationConfig( NotificationType.error, 'alert0 (active)', @@ -124,14 +149,14 @@ describe('PrometheusNotificationService', () => { 'Prometheus' ) ]); - }); + })); - it('should raise multiple notifications if they do not look like each other', () => { + it('should raise multiple notifications if they do not look like each other', fakeAsync(() => { notifications[0].alerts.push(prometheus.createNotificationAlert('alert1')); notifications.push(prometheus.createNotification()); notifications[1].alerts.push(prometheus.createNotificationAlert('alert2')); - service.refresh(); - expect(shown).toEqual([ + asyncRefresh(); + expectShown([ new CdNotificationConfig( NotificationType.error, 'alert0 (active)', @@ -154,9 +179,10 @@ describe('PrometheusNotificationService', () => { 'Prometheus' ) ]); - }); + })); it('only shows toasties if it got new data', () => { + service.refresh(); expect(notificationService.show).toHaveBeenCalledTimes(1); notifications = []; service.refresh(); @@ -169,7 +195,7 @@ describe('PrometheusNotificationService', () => { expect(notificationService.show).toHaveBeenCalledTimes(3); }); - it('filters out duplicated and non user visible changes in notifications', () => { + it('filters out duplicated and non user visible changes in notifications', fakeAsync(() => { // Return 2 notifications with 3 duplicated alerts and 1 non visible changed alert const secondAlert = prometheus.createNotificationAlert('alert0'); secondAlert.endsAt = new Date().toString(); // Should be ignored as it's not visible @@ -177,9 +203,9 @@ describe('PrometheusNotificationService', () => { notifications.push(prometheus.createNotification()); notifications[1].alerts.push(prometheus.createNotificationAlert('alert0')); notifications[1].notified = 'by somebody else'; - service.refresh(); + asyncRefresh(); - expect(shown).toEqual([ + expectShown([ new CdNotificationConfig( NotificationType.error, 'alert0 (active)', @@ -188,6 +214,6 @@ describe('PrometheusNotificationService', () => { 'Prometheus' ) ]); - }); + })); }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/styles.scss b/src/pybind/mgr/dashboard/frontend/src/styles.scss index f3c27494fb8..2e14f329784 100644 --- a/src/pybind/mgr/dashboard/frontend/src/styles.scss +++ b/src/pybind/mgr/dashboard/frontend/src/styles.scss @@ -384,3 +384,8 @@ h3.page-header { color: #333; font-size: 1.1em; } + +.toast-message > ul { + padding-left: 1em; + margin: 0; +} -- 2.39.5