]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Queue notifications as default 27274/head
authorStephan Müller <smueller@suse.com>
Fri, 22 Mar 2019 16:12:20 +0000 (17:12 +0100)
committerStephan Müller <smueller@suse.com>
Tue, 30 Apr 2019 08:15:36 +0000 (10:15 +0200)
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 <smueller@suse.com>
src/pybind/mgr/dashboard/frontend/src/app/shared/services/api-interceptor.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert-formatter.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert-formatter.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-notification.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/styles.scss

index cc7fb2ba73f6b44f40e4c3393aa56248983fa69f..a8fcb4e81a8fd13e11fa252c090e7badb9fa9ffe 100644 (file)
@@ -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')
       );
index 76d75f9c83d14464ff85f8cd038896fd007ecbbe..e6fd703491d6608fc74a07a6850e6abd2a77d702 100644 (file)
@@ -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: '<ul><li>Error occurred in path a</li><li>Error occurred in path b</li></ul>'
+      });
+    }));
+  });
 
   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);
     }));
   });
 
index 14bd6b51e1b14c5f2526d3f71274235704772a26..e8515e1b95a1d87ce066c96368e4d0d6eee61da6 100644 (file)
@@ -19,12 +19,12 @@ export class NotificationService {
 
   // Observable sources
   private dataSource = new BehaviorSubject<CdNotification[]>([]);
-  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 = '<ul>' + configs.map((c) => `<li>${c.message}</li>`).join('') + '</ul>';
+      }
+      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) {
index a0a5b49403fe8c8d32f8d2685fa145ebcf4ca2d3..2b865a400caf27b6587f98071557b30f0c6c93d0 100644 (file)
@@ -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]);
     });
   });
 
index 8fdc5ddb68fceacb37e7e153218d7083142aa262..0dbb68ae3cc965053c01d4180cc138952e5003c6 100644 (file)
@@ -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(
index 71d12c0f42853ffc7f72df91160239f5e2a95607..294ac37f884bee591468f07248d3c71238e2822d 100644 (file)
@@ -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);
     });
   });
 });
index b1f8b3b8b7b3cbf2e08ad5035a32189357168852..c5ced809559df47a75f73b864044f5c3b808dff7 100644 (file)
@@ -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'
         )
       ]);
-    });
+    }));
   });
 });
index f3c27494fb8d53248cc5189128e511ac3110f243..2e14f32978453ba526a0bc03b04810c06748f9a9 100644 (file)
@@ -384,3 +384,8 @@ h3.page-header {
   color: #333;
   font-size: 1.1em;
 }
+
+.toast-message > ul {
+  padding-left: 1em;
+  margin: 0;
+}