]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Add configurable MOTD or wall notification 42412/head
authorVolker Theile <vtheile@suse.com>
Mon, 5 Jul 2021 09:49:33 +0000 (11:49 +0200)
committerVolker Theile <vtheile@suse.com>
Wed, 21 Jul 2021 07:12:43 +0000 (09:12 +0200)
Fixes: https://tracker.ceph.com/issues/51408
Signed-off-by: Volker Theile <vtheile@suse.com>
(cherry picked from commit f7f163e75cf5fb6cd022a8d13c28f5b923e01aed)

Conflicts:
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.ts
    src/pybind/mgr/dashboard/frontend/src/app/shared/api/motd.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/alert-panel/alert-panel.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/components/alert-panel/alert-panel.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts
    src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/sanitize-html.pipe.spec.ts
    src/pybind/mgr/dashboard/frontend/src/app/shared/services/motd-notification.service.ts
    src/pybind/mgr/dashboard/frontend/src/app/shared/services/motd-notification.service.spec.ts
src/pybind/mgr/dashboard/module.py
    src/pybind/mgr/dashboard/plugins/motd.py
src/python-common/tox.ini
    src/python-common/ceph/utils.py

26 files changed:
doc/mgr/dashboard.rst
doc/mgr/dashboard_plugins/motd.inc.rst [new file with mode: 0644]
qa/suites/rados/dashboard/tasks/dashboard.yaml
qa/tasks/mgr/dashboard/test_motd.py [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.scss
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/motd.service.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/api/motd.service.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/alert-panel/alert-panel.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/components/alert-panel/alert-panel.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/motd/motd.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/motd/motd.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/motd/motd.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/motd/motd.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pipes.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/sanitize-html.pipe.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/sanitize-html.pipe.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/services/motd-notification.service.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/services/motd-notification.service.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/module.py
src/pybind/mgr/dashboard/plugins/motd.py [new file with mode: 0644]
src/python-common/ceph/utils.py
src/python-common/tox.ini

index 31cbe585c9a8a2cd7441d51b06bf75ef2a8db27a..ca577741dfd5a83abcfc37092d07a2957999922b 100644 (file)
@@ -1189,6 +1189,7 @@ and loosely coupled fashion.
 
 .. include:: dashboard_plugins/feature_toggles.inc.rst
 .. include:: dashboard_plugins/debug.inc.rst
+.. include:: dashboard_plugins/motd.inc.rst
 
 
 Troubleshooting the Dashboard
diff --git a/doc/mgr/dashboard_plugins/motd.inc.rst b/doc/mgr/dashboard_plugins/motd.inc.rst
new file mode 100644 (file)
index 0000000..b8464e1
--- /dev/null
@@ -0,0 +1,30 @@
+.. _dashboard-motd:
+
+Message of the day (MOTD)
+^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Displays a configured `message of the day` at the top of the Ceph Dashboard.
+
+The importance of a MOTD can be configured by its severity, which is
+`info`, `warning` or `danger`. The MOTD can expire after a given time,
+this means it will not be displayed in the UI anymore. Use the following
+syntax to specify the expiration time: `Ns|m|h|d|w` for seconds, minutes,
+hours, days and weeks. If the MOTD should expire after 2 hours, use `2h`
+or `5w` for 5 weeks. Use `0` to configure a MOTD that does not expire.
+
+To configure a MOTD, run the following command::
+
+  $ ceph dashboard motd set <severity:info|warning|danger> <expires> <message>
+
+To show the configured MOTD::
+
+  $ ceph dashboard motd get
+
+To clear the configured MOTD run::
+
+  $ ceph dashboard motd clear
+
+A MOTD with a `info` or `warning` severity can be closed by the user. The
+`info` MOTD is not displayed anymore until the local storage cookies are
+cleared or a new MOTD with a different severity is displayed. A MOTD with
+a 'warning' severity will be displayed again in a new session.
index 317c5de1720b1b4bbd2d91bdea77722113144a17..0c050d5ddef79f3fc3c5d8ddb73e97a28647b613 100644 (file)
@@ -56,3 +56,4 @@ tasks:
         - tasks.mgr.dashboard.test_summary
         - tasks.mgr.dashboard.test_telemetry
         - tasks.mgr.dashboard.test_user
+        - tasks.mgr.dashboard.test_motd
diff --git a/qa/tasks/mgr/dashboard/test_motd.py b/qa/tasks/mgr/dashboard/test_motd.py
new file mode 100644 (file)
index 0000000..2edbf36
--- /dev/null
@@ -0,0 +1,37 @@
+# -*- coding: utf-8 -*-
+# pylint: disable=too-many-public-methods
+
+from __future__ import absolute_import
+
+import time
+
+from .helper import DashboardTestCase
+
+
+class MotdTest(DashboardTestCase):
+    @classmethod
+    def tearDownClass(cls):
+        cls._ceph_cmd(['dashboard', 'motd', 'clear'])
+        super(MotdTest, cls).tearDownClass()
+
+    def setUp(self):
+        super(MotdTest, self).setUp()
+        self._ceph_cmd(['dashboard', 'motd', 'clear'])
+
+    def test_none(self):
+        data = self._get('/ui-api/motd')
+        self.assertStatus(200)
+        self.assertIsNone(data)
+
+    def test_set(self):
+        self._ceph_cmd(['dashboard', 'motd', 'set', 'info', '0', 'foo bar baz'])
+        data = self._get('/ui-api/motd')
+        self.assertStatus(200)
+        self.assertIsInstance(data, dict)
+
+    def test_expired(self):
+        self._ceph_cmd(['dashboard', 'motd', 'set', 'info', '2s', 'foo bar baz'])
+        time.sleep(5)
+        data = self._get('/ui-api/motd')
+        self.assertStatus(200)
+        self.assertIsNone(data)
index 8068eab8bb363ce44577dd1bde4ae5bc82516145..161e21db9bf3a71798fbd5e3c9b0021760d0f1ec 100644 (file)
@@ -1,5 +1,6 @@
 <cd-pwd-expiration-notification></cd-pwd-expiration-notification>
 <cd-telemetry-notification></cd-telemetry-notification>
+<cd-motd></cd-motd>
 <cd-notifications-sidebar></cd-notifications-sidebar>
 
 <div class="cd-navbar-top">
index 9c0d8f09d733416476f6242d608f0ecc563ce1de..38aa60e2f505fc119bcdc22331e1f26276b6f30d 100644 (file)
@@ -9,10 +9,6 @@
     background: $color-navbar-bg;
     border-top: 4px solid $color-nav-top-bar;
 
-    &.isPwdDisplayed {
-      top: $top-notification-height;
-    }
-
     .navbar-brand,
     .navbar-brand:hover {
       color: $color-navbar-brand;
index 25fb4d6f7897cf7cadccb66147e51e64158d1655..75c97faf46d54498e1f164fa689f3a84dc70f43b 100644 (file)
@@ -1,3 +1,4 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
 import { ComponentFixture, TestBed } from '@angular/core/testing';
 import { By } from '@angular/platform-browser';
 
@@ -51,7 +52,7 @@ describe('NavigationComponent', () => {
 
   configureTestBed({
     declarations: [NavigationComponent],
-    imports: [MockModule(NavigationModule)],
+    imports: [HttpClientTestingModule, MockModule(NavigationModule)],
     providers: [
       {
         provide: AuthStorageService,
index 285cdc370659624afd755cd81a498d9b7e96789c..00793dc9b247a8d47c096f651d8748d575dde6d4 100644 (file)
@@ -1,5 +1,6 @@
 import { Component, HostBinding, OnDestroy, OnInit } from '@angular/core';
 
+import * as _ from 'lodash';
 import { Subscription } from 'rxjs';
 
 import { Icons } from '../../../shared/enum/icons.enum';
@@ -9,6 +10,7 @@ import {
   FeatureTogglesMap$,
   FeatureTogglesService
 } from '../../../shared/services/feature-toggles.service';
+import { MotdNotificationService } from '../../../shared/services/motd-notification.service';
 import { PrometheusAlertService } from '../../../shared/services/prometheus-alert.service';
 import { SummaryService } from '../../../shared/services/summary.service';
 import { TelemetryNotificationService } from '../../../shared/services/telemetry-notification.service';
@@ -43,7 +45,8 @@ export class NavigationComponent implements OnInit, OnDestroy {
     private summaryService: SummaryService,
     private featureToggles: FeatureTogglesService,
     private telemetryNotificationService: TelemetryNotificationService,
-    public prometheusAlertService: PrometheusAlertService
+    public prometheusAlertService: PrometheusAlertService,
+    private motdNotificationService: MotdNotificationService
   ) {
     this.permissions = this.authStorageService.getPermissions();
     this.enabledFeature$ = this.featureToggles.get();
@@ -70,6 +73,11 @@ export class NavigationComponent implements OnInit, OnDestroy {
         this.showTopNotification('telemetryNotificationEnabled', visible);
       })
     );
+    this.subs.add(
+      this.motdNotificationService.motd$.subscribe((motd: any) => {
+        this.showTopNotification('motdNotificationEnabled', _.isPlainObject(motd));
+      })
+    );
   }
 
   ngOnDestroy(): void {
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/motd.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/motd.service.spec.ts
new file mode 100644 (file)
index 0000000..07cc544
--- /dev/null
@@ -0,0 +1,34 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+
+import { configureTestBed } from '../../../testing/unit-test-helper';
+import { MotdService } from './motd.service';
+
+describe('MotdService', () => {
+  let service: MotdService;
+  let httpTesting: HttpTestingController;
+
+  configureTestBed({
+    imports: [HttpClientTestingModule],
+    providers: [MotdService]
+  });
+
+  beforeEach(() => {
+    service = TestBed.get(MotdService);
+    httpTesting = TestBed.get(HttpTestingController);
+  });
+
+  afterEach(() => {
+    httpTesting.verify();
+  });
+
+  it('should be created', () => {
+    expect(service).toBeTruthy();
+  });
+
+  it('should get MOTD', () => {
+    service.get().subscribe();
+    const req = httpTesting.expectOne('ui-api/motd');
+    expect(req.request.method).toBe('GET');
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/motd.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/motd.service.ts
new file mode 100644 (file)
index 0000000..dd17b2e
--- /dev/null
@@ -0,0 +1,25 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+import { Observable } from 'rxjs';
+
+export interface Motd {
+  message: string;
+  md5: string;
+  severity: 'info' | 'warning' | 'danger';
+  // The expiration date in ISO 8601. Does not expire if empty.
+  expires: string;
+}
+
+@Injectable({
+  providedIn: 'root'
+})
+export class MotdService {
+  private url = 'ui-api/motd';
+
+  constructor(private http: HttpClient) {}
+
+  get(): Observable<Motd | null> {
+    return this.http.get<Motd | null>(this.url);
+  }
+}
index 2b606ccdec673abd1a57569d532f8f1655ed32b6..aac801fef457ac4bd0c40b9875acc57cf9116a07 100644 (file)
@@ -1,4 +1,6 @@
-<alert type="{{ bootstrapClass }}">
+<alert type="{{ bootstrapClass }}"
+       [dismissible]="dismissible"
+       (onClose)="onClose()">
   <table>
     <ng-container *ngIf="size === 'normal'; else slim">
       <tr>
index 5798d24df11a37671bcfa85b3935b696345d0ec8..cd02741d24f182979aef92f07888545c26cb6150 100644 (file)
@@ -15,7 +15,7 @@ export class AlertPanelComponent implements OnInit {
   @Output()
   backAction = new EventEmitter();
   @Input()
-  type: 'warning' | 'error' | 'info' | 'success';
+  type: 'warning' | 'error' | 'info' | 'success' | 'danger';
   @Input()
   typeIcon: Icons | string;
   @Input()
@@ -24,6 +24,15 @@ export class AlertPanelComponent implements OnInit {
   showIcon = true;
   @Input()
   showTitle = true;
+  @Input()
+  dismissible = false;
+
+  /**
+   * The event that is triggered when the close button (x) has been
+   * pressed.
+   */
+  @Output()
+  dismissed = new EventEmitter();
 
   icons = Icons;
 
@@ -51,6 +60,15 @@ export class AlertPanelComponent implements OnInit {
         this.typeIcon = this.typeIcon || Icons.check;
         this.bootstrapClass = this.bootstrapClass || 'success';
         break;
+      case 'danger':
+        this.title = this.title || this.i18n(`Danger`);
+        this.typeIcon = this.typeIcon || Icons.warning;
+        this.bootstrapClass = this.bootstrapClass || 'danger';
+        break;
     }
   }
+
+  onClose(): void {
+    this.dismissed.emit();
+  }
 }
index 811e411340a6b9c1d240b30e9d27aab868bb1ebb..2b20e18c88d10758279d4730eda7d5fc389e3baf 100644 (file)
@@ -14,6 +14,7 @@ import { ProgressbarModule } from 'ngx-bootstrap/progressbar';
 import { TooltipModule } from 'ngx-bootstrap/tooltip';
 import { SimplebarAngularModule } from 'simplebar-angular';
 
+import { MotdComponent } from '../components/motd/motd.component';
 import { DirectivesModule } from '../directives/directives.module';
 import { PipesModule } from '../pipes/pipes.module';
 import { AlertPanelComponent } from './alert-panel/alert-panel.component';
@@ -85,7 +86,8 @@ import { ViewCacheComponent } from './view-cache/view-cache.component';
     TelemetryNotificationComponent,
     OrchestratorDocPanelComponent,
     OrchestratorDocModalComponent,
-    DocComponent
+    DocComponent,
+    MotdComponent
   ],
   providers: [],
   exports: [
@@ -108,7 +110,8 @@ import { ViewCacheComponent } from './view-cache/view-cache.component';
     PwdExpirationNotificationComponent,
     TelemetryNotificationComponent,
     OrchestratorDocPanelComponent,
-    DocComponent
+    DocComponent,
+    MotdComponent
   ],
   entryComponents: [
     ModalComponent,
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/motd/motd.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/motd/motd.component.html
new file mode 100644 (file)
index 0000000..2fbe5d7
--- /dev/null
@@ -0,0 +1,8 @@
+<cd-alert-panel *ngIf="motd"
+                size="slim"
+                [showTitle]="false"
+                [type]="motd.severity"
+                [dismissible]="motd.severity !== 'danger'"
+                (dismissed)="onDismissed()">
+  <span [innerHTML]="motd.message | sanitizeHtml"></span>
+</cd-alert-panel>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/motd/motd.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/motd/motd.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/motd/motd.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/motd/motd.component.spec.ts
new file mode 100644 (file)
index 0000000..79f09e7
--- /dev/null
@@ -0,0 +1,26 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { configureTestBed } from '../../../../testing/unit-test-helper';
+import { DashboardModule } from '../../../ceph/dashboard/dashboard.module';
+import { SharedModule } from '../../../shared/shared.module';
+import { MotdComponent } from './motd.component';
+
+describe('MotdComponent', () => {
+  let component: MotdComponent;
+  let fixture: ComponentFixture<MotdComponent>;
+
+  configureTestBed({
+    imports: [DashboardModule, HttpClientTestingModule, SharedModule]
+  });
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(MotdComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/motd/motd.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/motd/motd.component.ts
new file mode 100644 (file)
index 0000000..f3171ff
--- /dev/null
@@ -0,0 +1,33 @@
+import { Component, OnDestroy, OnInit } from '@angular/core';
+
+import { Subscription } from 'rxjs';
+
+import { Motd } from '../../../shared/api/motd.service';
+import { MotdNotificationService } from '../../../shared/services/motd-notification.service';
+
+@Component({
+  selector: 'cd-motd',
+  templateUrl: './motd.component.html',
+  styleUrls: ['./motd.component.scss']
+})
+export class MotdComponent implements OnInit, OnDestroy {
+  motd: Motd | undefined = undefined;
+
+  private subscription: Subscription;
+
+  constructor(private motdNotificationService: MotdNotificationService) {}
+
+  ngOnInit(): void {
+    this.subscription = this.motdNotificationService.motd$.subscribe((motd: Motd | undefined) => {
+      this.motd = motd;
+    });
+  }
+
+  ngOnDestroy(): void {
+    this.subscription.unsubscribe();
+  }
+
+  onDismissed(): void {
+    this.motdNotificationService.hide();
+  }
+}
index 3deb535929d3c5509bdb15039e2ac22174f60dba..9ec4e0492df0586ecbf4faf9d37bdf0c1c7b64be 100755 (executable)
@@ -26,6 +26,7 @@ import { OrdinalPipe } from './ordinal.pipe';
 import { RbdConfigurationSourcePipe } from './rbd-configuration-source.pipe';
 import { RelativeDatePipe } from './relative-date.pipe';
 import { RoundPipe } from './round.pipe';
+import { SanitizeHtmlPipe } from './sanitize-html.pipe';
 import { TruncatePipe } from './truncate.pipe';
 import { UpperFirstPipe } from './upper-first.pipe';
 
@@ -58,7 +59,8 @@ import { UpperFirstPipe } from './upper-first.pipe';
     RbdConfigurationSourcePipe,
     DurationPipe,
     MapPipe,
-    TruncatePipe
+    TruncatePipe,
+    SanitizeHtmlPipe
   ],
   exports: [
     ArrayPipe,
@@ -87,7 +89,8 @@ import { UpperFirstPipe } from './upper-first.pipe';
     RbdConfigurationSourcePipe,
     DurationPipe,
     MapPipe,
-    TruncatePipe
+    TruncatePipe,
+    SanitizeHtmlPipe
   ],
   providers: [
     ArrayPipe,
@@ -112,7 +115,8 @@ import { UpperFirstPipe } from './upper-first.pipe';
     NotAvailablePipe,
     UpperFirstPipe,
     MapPipe,
-    TruncatePipe
+    TruncatePipe,
+    SanitizeHtmlPipe
   ]
 })
 export class PipesModule {}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/sanitize-html.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/sanitize-html.pipe.spec.ts
new file mode 100644 (file)
index 0000000..1187525
--- /dev/null
@@ -0,0 +1,26 @@
+import { TestBed } from '@angular/core/testing';
+import { DomSanitizer } from '@angular/platform-browser';
+
+import { configureTestBed } from '../../../testing/unit-test-helper';
+import { SanitizeHtmlPipe } from '../pipes/sanitize-html.pipe';
+
+describe('SanitizeHtmlPipe', () => {
+  let pipe: SanitizeHtmlPipe;
+  let domSanitizer: DomSanitizer;
+
+  configureTestBed({
+    providers: [DomSanitizer]
+  });
+
+  beforeEach(() => {
+    domSanitizer = TestBed.get(DomSanitizer);
+    pipe = new SanitizeHtmlPipe(domSanitizer);
+  });
+
+  it('create an instance', () => {
+    expect(pipe).toBeTruthy();
+  });
+
+  // There is no way to inject a working DomSanitizer in unit tests,
+  // so it is not possible to test the `transform` method.
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/sanitize-html.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/sanitize-html.pipe.ts
new file mode 100644 (file)
index 0000000..f6a8b0c
--- /dev/null
@@ -0,0 +1,13 @@
+import { Pipe, PipeTransform, SecurityContext } from '@angular/core';
+import { DomSanitizer, SafeValue } from '@angular/platform-browser';
+
+@Pipe({
+  name: 'sanitizeHtml'
+})
+export class SanitizeHtmlPipe implements PipeTransform {
+  constructor(private domSanitizer: DomSanitizer) {}
+
+  transform(value: SafeValue | string | null): string | null {
+    return this.domSanitizer.sanitize(SecurityContext.HTML, value);
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/motd-notification.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/motd-notification.service.spec.ts
new file mode 100644 (file)
index 0000000..03009f5
--- /dev/null
@@ -0,0 +1,117 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+
+import { configureTestBed } from '../../../testing/unit-test-helper';
+import { Motd } from '../api/motd.service';
+import { MotdNotificationService } from './motd-notification.service';
+
+describe('MotdNotificationService', () => {
+  let service: MotdNotificationService;
+
+  configureTestBed({
+    providers: [MotdNotificationService],
+    imports: [HttpClientTestingModule]
+  });
+
+  beforeEach(() => {
+    service = TestBed.get(MotdNotificationService);
+  });
+
+  it('should be created', () => {
+    expect(service).toBeTruthy();
+  });
+
+  it('should hide [1]', () => {
+    spyOn(service.motdSource, 'next');
+    spyOn(service.motdSource, 'getValue').and.returnValue({
+      severity: 'info',
+      expires: '',
+      message: 'foo',
+      md5: 'acbd18db4cc2f85cedef654fccc4a4d8'
+    });
+    service.hide();
+    expect(localStorage.getItem('dashboard_motd_hidden')).toBe(
+      'info:acbd18db4cc2f85cedef654fccc4a4d8'
+    );
+    expect(sessionStorage.getItem('dashboard_motd_hidden')).toBeNull();
+    expect(service.motdSource.next).toBeCalledWith(null);
+  });
+
+  it('should hide [2]', () => {
+    spyOn(service.motdSource, 'getValue').and.returnValue({
+      severity: 'warning',
+      expires: '',
+      message: 'bar',
+      md5: '37b51d194a7513e45b56f6524f2d51f2'
+    });
+    service.hide();
+    expect(sessionStorage.getItem('dashboard_motd_hidden')).toBe(
+      'warning:37b51d194a7513e45b56f6524f2d51f2'
+    );
+    expect(localStorage.getItem('dashboard_motd_hidden')).toBeNull();
+  });
+
+  it('should process response [1]', () => {
+    const motd: Motd = {
+      severity: 'danger',
+      expires: '',
+      message: 'foo',
+      md5: 'acbd18db4cc2f85cedef654fccc4a4d8'
+    };
+    spyOn(service.motdSource, 'next');
+    service.processResponse(motd);
+    expect(service.motdSource.next).toBeCalledWith(motd);
+  });
+
+  it('should process response [2]', () => {
+    const motd: Motd = {
+      severity: 'warning',
+      expires: '',
+      message: 'foo',
+      md5: 'acbd18db4cc2f85cedef654fccc4a4d8'
+    };
+    localStorage.setItem('dashboard_motd_hidden', 'info');
+    service.processResponse(motd);
+    expect(sessionStorage.getItem('dashboard_motd_hidden')).toBeNull();
+    expect(localStorage.getItem('dashboard_motd_hidden')).toBeNull();
+  });
+
+  it('should process response [3]', () => {
+    const motd: Motd = {
+      severity: 'info',
+      expires: '',
+      message: 'foo',
+      md5: 'acbd18db4cc2f85cedef654fccc4a4d8'
+    };
+    spyOn(service.motdSource, 'next');
+    localStorage.setItem('dashboard_motd_hidden', 'info:acbd18db4cc2f85cedef654fccc4a4d8');
+    service.processResponse(motd);
+    expect(service.motdSource.next).not.toBeCalled();
+  });
+
+  it('should process response [4]', () => {
+    const motd: Motd = {
+      severity: 'info',
+      expires: '',
+      message: 'foo',
+      md5: 'acbd18db4cc2f85cedef654fccc4a4d8'
+    };
+    spyOn(service.motdSource, 'next');
+    localStorage.setItem('dashboard_motd_hidden', 'info:37b51d194a7513e45b56f6524f2d51f2');
+    service.processResponse(motd);
+    expect(service.motdSource.next).toBeCalled();
+  });
+
+  it('should process response [5]', () => {
+    const motd: Motd = {
+      severity: 'info',
+      expires: '',
+      message: 'foo',
+      md5: 'acbd18db4cc2f85cedef654fccc4a4d8'
+    };
+    spyOn(service.motdSource, 'next');
+    localStorage.setItem('dashboard_motd_hidden', 'danger:acbd18db4cc2f85cedef654fccc4a4d8');
+    service.processResponse(motd);
+    expect(service.motdSource.next).toBeCalled();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/motd-notification.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/motd-notification.service.ts
new file mode 100644 (file)
index 0000000..7e75edf
--- /dev/null
@@ -0,0 +1,82 @@
+import { Injectable, OnDestroy } from '@angular/core';
+
+import * as _ from 'lodash';
+import { BehaviorSubject, EMPTY, Observable, of, Subscription } from 'rxjs';
+import { catchError, delay, mergeMap, repeat, tap } from 'rxjs/operators';
+
+import { Motd, MotdService } from '../api/motd.service';
+
+@Injectable({
+  providedIn: 'root'
+})
+export class MotdNotificationService implements OnDestroy {
+  public motd$: Observable<Motd | null>;
+  public motdSource = new BehaviorSubject<Motd | null>(null);
+
+  private subscription: Subscription;
+  private localStorageKey = 'dashboard_motd_hidden';
+
+  constructor(private motdService: MotdService) {
+    this.motd$ = this.motdSource.asObservable();
+    // Check every 60 seconds for the latest MOTD configuration.
+    this.subscription = of(true)
+      .pipe(
+        mergeMap(() => this.motdService.get()),
+        catchError((error) => {
+          // Do not show an error notification.
+          if (_.isFunction(error.preventDefault)) {
+            error.preventDefault();
+          }
+          return EMPTY;
+        }),
+        tap((motd: Motd | null) => this.processResponse(motd)),
+        delay(60000),
+        repeat()
+      )
+      .subscribe();
+  }
+
+  ngOnDestroy(): void {
+    this.subscription.unsubscribe();
+  }
+
+  hide() {
+    // Store the severity and MD5 of the current MOTD in local or
+    // session storage to be able to show it again if the severity
+    // or message of the latest MOTD has changed.
+    const motd: Motd = this.motdSource.getValue();
+    if (motd) {
+      const value = `${motd.severity}:${motd.md5}`;
+      switch (motd.severity) {
+        case 'info':
+          localStorage.setItem(this.localStorageKey, value);
+          sessionStorage.removeItem(this.localStorageKey);
+          break;
+        case 'warning':
+          sessionStorage.setItem(this.localStorageKey, value);
+          localStorage.removeItem(this.localStorageKey);
+          break;
+      }
+    }
+    this.motdSource.next(null);
+  }
+
+  processResponse(motd: Motd | null) {
+    const value: string | null =
+      sessionStorage.getItem(this.localStorageKey) || localStorage.getItem(this.localStorageKey);
+    let visible: boolean = _.isNull(value);
+    // Force a hidden MOTD to be shown again if the severity or message
+    // has been changed.
+    if (!visible && motd) {
+      const [severity, md5] = value.split(':');
+      if (severity !== motd.severity || md5 !== motd.md5) {
+        visible = true;
+        sessionStorage.removeItem(this.localStorageKey);
+        localStorage.removeItem(this.localStorageKey);
+      }
+    }
+    if (visible) {
+      this.motdSource.next(motd);
+    }
+  }
+}
index b88fe1284a1a8d195e4f9381e906f9e2813228d6..e57aa45fc4ce9dc111961abc88793a80504653c3 100644 (file)
@@ -46,7 +46,7 @@ from .settings import options_command_list, options_schema_list, \
                       handle_option_command
 
 from .plugins import PLUGIN_MANAGER
-from .plugins import feature_toggles, debug  # noqa # pylint: disable=unused-import
+from .plugins import feature_toggles, debug, motd  # noqa # pylint: disable=unused-import
 
 
 PLUGIN_MANAGER.hook.init()
diff --git a/src/pybind/mgr/dashboard/plugins/motd.py b/src/pybind/mgr/dashboard/plugins/motd.py
new file mode 100644 (file)
index 0000000..eda54c6
--- /dev/null
@@ -0,0 +1,102 @@
+# -*- coding: utf-8 -*-
+
+import hashlib
+import json
+from enum import Enum
+from typing import Dict, NamedTuple, Optional
+
+from ceph.utils import datetime_now, datetime_to_str, parse_timedelta, str_to_datetime
+from mgr_module import CLICommand
+
+from . import PLUGIN_MANAGER as PM
+from .plugin import SimplePlugin as SP
+
+
+class MotdSeverity(Enum):
+    INFO = 'info'
+    WARNING = 'warning'
+    DANGER = 'danger'
+
+
+class MotdData(NamedTuple):
+    message: str
+    md5: str  # The MD5 of the message.
+    severity: MotdSeverity
+    expires: str  # The expiration date in ISO 8601. Does not expire if empty.
+
+
+@PM.add_plugin  # pylint: disable=too-many-ancestors
+class Motd(SP):
+    NAME = 'motd'
+
+    OPTIONS = [
+        SP.Option(
+            name=NAME,
+            default='',
+            type='str',
+            desc='The message of the day'
+        )
+    ]
+
+    @PM.add_hook
+    def register_commands(self):
+        @CLICommand("dashboard {name} get".format(name=self.NAME))
+        def _get(_):
+            stdout: str
+            value: str = self.get_option(self.NAME)
+            if not value:
+                stdout = 'No message of the day has been set.'
+            else:
+                data = json.loads(value)
+                if not data['expires']:
+                    data['expires'] = "Never"
+                stdout = 'Message="{message}", severity="{severity}", ' \
+                         'expires="{expires}"'.format(**data)
+            return 0, stdout, ''
+
+        @CLICommand("dashboard {name} set".format(name=self.NAME),
+                    "name=severity,type=CephChoices,strings={} ".format(
+                        "|".join(s.value for s in MotdSeverity))
+                    + "name=expires,type=CephString "
+                    + "name=message,type=CephString")
+        def _set(_, severity: str, expires: str, message: str):
+            if expires != '0':
+                delta = parse_timedelta(expires)
+                if not delta:
+                    return 1, '', 'Invalid expires format, use "2h", "10d" or "30s"'
+                expires = datetime_to_str(datetime_now() + delta)
+            else:
+                expires = ''
+            value: str = json.dumps({
+                'message': message,
+                'md5': hashlib.md5(message.encode()).hexdigest(),
+                'severity': severity,
+                'expires': expires
+            })
+            self.set_option(self.NAME, value)
+            return 0, 'Message of the day has been set.', ''
+
+        @CLICommand("dashboard {name} clear".format(name=self.NAME))
+        def _clear(_):
+            self.set_option(self.NAME, '')
+            return 0, 'Message of the day has been cleared.', ''
+
+    @PM.add_hook
+    def get_controllers(self):
+        from ..controllers import RESTController, UiApiController
+
+        @UiApiController('/motd')
+        class MessageOfTheDay(RESTController):
+            def list(_) -> Optional[Dict]:  # pylint: disable=no-self-argument
+                value: str = self.get_option(self.NAME)
+                if not value:
+                    return None
+                data: MotdData = MotdData(**json.loads(value))
+                # Check if the MOTD has been expired.
+                if data.expires:
+                    expires = str_to_datetime(data.expires)
+                    if expires < datetime_now():
+                        return None
+                return data._asdict()
+
+        return [MessageOfTheDay]
index 2a272fa3321ec455cd0dca935e55007f4438eb6a..5c220b6149c9de3c42a34b41cb1deaf251bca4a0 100644 (file)
@@ -1,6 +1,8 @@
 import datetime
 import re
 
+from typing import Optional
+
 
 def datetime_now() -> datetime.datetime:
     """
@@ -66,3 +68,40 @@ def str_to_datetime(string: str) -> datetime.datetime:
 
     raise ValueError("Time data {} does not match one of the formats {}".format(
         string, str(fmts)))
+
+
+def parse_timedelta(delta: str) -> Optional[datetime.timedelta]:
+    """
+    Returns a timedelta object represents a duration, the difference
+    between two dates or times.
+
+    >>> parse_timedelta('foo')
+
+    >>> parse_timedelta('2d')
+    datetime.timedelta(days=2)
+
+    >>> parse_timedelta("4w")
+    datetime.timedelta(days=28)
+
+    >>> parse_timedelta("5s")
+    datetime.timedelta(seconds=5)
+
+    >>> parse_timedelta("-5s")
+    datetime.timedelta(days=-1, seconds=86395)
+
+    :param delta: The string to process, e.g. '2h', '10d', '30s'.
+    :return: The `datetime.timedelta` object or `None` in case of
+        a parsing error.
+    """
+    parts = re.match(r'(?P<seconds>-?\d+)s|'
+                     r'(?P<minutes>-?\d+)m|'
+                     r'(?P<hours>-?\d+)h|'
+                     r'(?P<days>-?\d+)d|'
+                     r'(?P<weeks>-?\d+)w$',
+                     delta,
+                     re.IGNORECASE)
+    if not parts:
+        return None
+    parts = parts.groupdict()  # type: ignore
+    args = {name: int(param) for name, param in parts.items() if param}  # type: ignore
+    return datetime.timedelta(**args)
index da7037b2d816958cc363652ef552ccc665fcfdeb..bee5d798234951c151ea4826383e0b2b8dfd7e8a 100644 (file)
@@ -6,7 +6,7 @@ skip_missing_interpreters = true
 deps=
     -rrequirements.txt
 commands=
-    pytest --doctest-modules ceph/deployment/service_spec.py
+    pytest --doctest-modules ceph/deployment/service_spec.py ceph/utils.py
     pytest --mypy --mypy-ignore-missing-imports {posargs}
 
 [tool:pytest]