]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Add configurable MOTD or wall notification 42414/head
authorVolker Theile <vtheile@suse.com>
Mon, 5 Jul 2021 09:49:33 +0000 (11:49 +0200)
committerVolker Theile <vtheile@suse.com>
Tue, 20 Jul 2021 13:29:06 +0000 (15:29 +0200)
Fixes: https://tracker.ceph.com/issues/51408
Signed-off-by: Volker Theile <vtheile@suse.com>
(cherry picked from commit f7f163e75cf5fb6cd022a8d13c28f5b923e01aed)

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 32d02fd4a6bec61553e8ada9ee9166a87c89e5d6..0615e3f3cf9c618f87ae27b0dec7cf14a9d38064 100644 (file)
@@ -1314,6 +1314,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 111c5f38e2a17a92f4255c06faf24afab962f3ea..5ca0c662146f6bddf97d5a276cf38c4709d81a3e 100644 (file)
@@ -57,3 +57,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 4d0c64b9032ab50d5796a1de288b1a9085066fca..955b391d9a63e5f26e11f252fa94d5c3fcaed8c7 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 f559abbf2b95be9605caad0778ed08601b6181b4..a990292aa70e0d7f46af8ff4a28c1ed8086d4d6b 100644 (file)
@@ -9,10 +9,6 @@
     background: vv.$secondary;
     border-top: 4px solid vv.$primary;
 
-    &.isPwdDisplayed {
-      top: vv.$top-notification-height;
-    }
-
     .navbar-brand,
     .navbar-brand:hover {
       color: vv.$gray-200;
index afac730c73a16fa8a6ca3a810fca9aaf3b4ddb08..241910f2b4152294400b4504358e9e08ef538f81 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 c135def6f4c8a169525a3b388df79dc890685daa..512feecef9d659e0777ecf36021a6501d927c800 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 '~/app/shared/enum/icons.enum';
@@ -9,6 +10,7 @@ import {
   FeatureTogglesMap$,
   FeatureTogglesService
 } from '~/app/shared/services/feature-toggles.service';
+import { MotdNotificationService } from '~/app/shared/services/motd-notification.service';
 import { PrometheusAlertService } from '~/app/shared/services/prometheus-alert.service';
 import { SummaryService } from '~/app/shared/services/summary.service';
 import { TelemetryNotificationService } from '~/app/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..e186e84
--- /dev/null
@@ -0,0 +1,34 @@
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+
+import { MotdService } from '~/app/shared/api/motd.service';
+import { configureTestBed } from '~/testing/unit-test-helper';
+
+describe('MotdService', () => {
+  let service: MotdService;
+  let httpTesting: HttpTestingController;
+
+  configureTestBed({
+    imports: [HttpClientTestingModule],
+    providers: [MotdService]
+  });
+
+  beforeEach(() => {
+    service = TestBed.inject(MotdService);
+    httpTesting = TestBed.inject(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 17b4f1863d53d09bf2050e6d8e18a92085430ad6..737951cb8d54ad2fe3048474042809a3367e9fe2 100644 (file)
@@ -1,5 +1,6 @@
 <ngb-alert type="{{ bootstrapClass }}"
-           [dismissible]="false">
+           [dismissible]="dismissible"
+           (close)="onClose()">
   <table>
     <ng-container *ngIf="size === 'normal'; else slim">
       <tr>
index 9d72928557874ea8c766e013b47ec0d8a36b9c1d..51088840e3334f82f895553c935152abeb1bbf74 100644 (file)
@@ -1,4 +1,4 @@
-import { Component, Input, OnInit } from '@angular/core';
+import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
 
 import { Icons } from '~/app/shared/enum/icons.enum';
 
@@ -13,7 +13,7 @@ export class AlertPanelComponent implements OnInit {
   @Input()
   bootstrapClass = '';
   @Input()
-  type: 'warning' | 'error' | 'info' | 'success';
+  type: 'warning' | 'error' | 'info' | 'success' | 'danger';
   @Input()
   typeIcon: Icons | string;
   @Input()
@@ -22,6 +22,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;
 
@@ -47,6 +56,15 @@ export class AlertPanelComponent implements OnInit {
         this.typeIcon = this.typeIcon || Icons.check;
         this.bootstrapClass = this.bootstrapClass || 'success';
         break;
+      case 'danger':
+        this.title = this.title || $localize`Danger`;
+        this.typeIcon = this.typeIcon || Icons.warning;
+        this.bootstrapClass = this.bootstrapClass || 'danger';
+        break;
     }
   }
+
+  onClose(): void {
+    this.dismissed.emit();
+  }
 }
index bccbc645bab1bf0d23e4557f9cec58ad25802593..ef8b423a3a70c55e583e331197e5b0852935b319 100644 (file)
@@ -16,6 +16,7 @@ import { ClickOutsideModule } from 'ng-click-outside';
 import { ChartsModule } from 'ng2-charts';
 import { SimplebarAngularModule } from 'simplebar-angular';
 
+import { MotdComponent } from '~/app/shared/components/motd/motd.component';
 import { DirectivesModule } from '../directives/directives.module';
 import { PipesModule } from '../pipes/pipes.module';
 import { AlertPanelComponent } from './alert-panel/alert-panel.component';
@@ -91,7 +92,8 @@ import { UsageBarComponent } from './usage-bar/usage-bar.component';
     DocComponent,
     Copy2ClipboardButtonComponent,
     DownloadButtonComponent,
-    FormButtonPanelComponent
+    FormButtonPanelComponent,
+    MotdComponent
   ],
   providers: [],
   exports: [
@@ -117,7 +119,8 @@ import { UsageBarComponent } from './usage-bar/usage-bar.component';
     DocComponent,
     Copy2ClipboardButtonComponent,
     DownloadButtonComponent,
-    FormButtonPanelComponent
+    FormButtonPanelComponent,
+    MotdComponent
   ]
 })
 export class ComponentsModule {}
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..826a8a5
--- /dev/null
@@ -0,0 +1,26 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { DashboardModule } from '~/app/ceph/dashboard/dashboard.module';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+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..297ef27
--- /dev/null
@@ -0,0 +1,33 @@
+import { Component, OnDestroy, OnInit } from '@angular/core';
+
+import { Subscription } from 'rxjs';
+
+import { Motd } from '~/app/shared/api/motd.service';
+import { MotdNotificationService } from '~/app/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..719f32e
--- /dev/null
@@ -0,0 +1,26 @@
+import { TestBed } from '@angular/core/testing';
+import { DomSanitizer } from '@angular/platform-browser';
+
+import { SanitizeHtmlPipe } from '~/app/shared/pipes/sanitize-html.pipe';
+import { configureTestBed } from '~/testing/unit-test-helper';
+
+describe('SanitizeHtmlPipe', () => {
+  let pipe: SanitizeHtmlPipe;
+  let domSanitizer: DomSanitizer;
+
+  configureTestBed({
+    providers: [DomSanitizer]
+  });
+
+  beforeEach(() => {
+    domSanitizer = TestBed.inject(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..267e6aa
--- /dev/null
@@ -0,0 +1,117 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+
+import { Motd } from '~/app/shared/api/motd.service';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { MotdNotificationService } from './motd-notification.service';
+
+describe('MotdNotificationService', () => {
+  let service: MotdNotificationService;
+
+  configureTestBed({
+    providers: [MotdNotificationService],
+    imports: [HttpClientTestingModule]
+  });
+
+  beforeEach(() => {
+    service = TestBed.inject(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..11feca2
--- /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 '~/app/shared/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 1e903dcbeafcd47a7eb525f404fd65cd2ba20ddf..83f88ad0c91d61422f8afaf5f0b56e9f987ae7df 100644 (file)
@@ -50,7 +50,7 @@ if cherrypy is not None:
     patch_cherrypy(cherrypy.__version__)
 
 # pylint: disable=wrong-import-position
-from .plugins import PLUGIN_MANAGER, debug, feature_toggles  # noqa # pylint: disable=unused-import
+from .plugins import PLUGIN_MANAGER, debug, feature_toggles, motd  # isort:skip # noqa E501 # 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..0600135
--- /dev/null
@@ -0,0 +1,98 @@
+# -*- 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))
+        def _set(_, severity: MotdSeverity, 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.value,
+                '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..89f0654aa0b8c2f3e4d8f305ba16413bae82ef36 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()
+    args = {name: int(param) for name, param in parts.items() if param}
+    return datetime.timedelta(**args)
index ce54d5a625f6b4e3f2927c0e9c9a71814f017954..5869b6ae27643dee38f76cc22eea64fcab525bee 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 {posargs}
     mypy --config-file=../mypy.ini -p ceph