summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorVolker Theile <vtheile@suse.com>2021-07-05 11:49:33 +0200
committerVolker Theile <vtheile@suse.com>2021-07-21 09:12:43 +0200
commit9cd5409522475ff5f32fe6f25496972879b0a9d5 (patch)
tree41cc7ac44c2cc357242959ee6ad71f1cbf4f88be
parentc19cdef2a60fb1586ed44e39a52bf1e241bd435c (diff)
downloadceph-9cd5409522475ff5f32fe6f25496972879b0a9d5.tar.gz
mgr/dashboard: Add configurable MOTD or wall notification
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
-rw-r--r--doc/mgr/dashboard.rst1
-rw-r--r--doc/mgr/dashboard_plugins/motd.inc.rst30
-rw-r--r--qa/suites/rados/dashboard/tasks/dashboard.yaml1
-rw-r--r--qa/tasks/mgr/dashboard/test_motd.py37
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html1
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.scss4
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.spec.ts3
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.ts10
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/motd.service.spec.ts34
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/motd.service.ts25
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/alert-panel/alert-panel.component.html4
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/alert-panel/alert-panel.component.ts20
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts7
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/motd/motd.component.html8
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/motd/motd.component.scss0
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/motd/motd.component.spec.ts26
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/components/motd/motd.component.ts33
-rwxr-xr-xsrc/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pipes.module.ts10
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/sanitize-html.pipe.spec.ts26
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/sanitize-html.pipe.ts13
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/motd-notification.service.spec.ts117
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/services/motd-notification.service.ts82
-rw-r--r--src/pybind/mgr/dashboard/module.py2
-rw-r--r--src/pybind/mgr/dashboard/plugins/motd.py102
-rw-r--r--src/python-common/ceph/utils.py39
-rw-r--r--src/python-common/tox.ini2
26 files changed, 622 insertions, 15 deletions
diff --git a/doc/mgr/dashboard.rst b/doc/mgr/dashboard.rst
index 31cbe585c9a..ca577741dfd 100644
--- a/doc/mgr/dashboard.rst
+++ b/doc/mgr/dashboard.rst
@@ -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
index 00000000000..b8464e1f33a
--- /dev/null
+++ b/doc/mgr/dashboard_plugins/motd.inc.rst
@@ -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.
diff --git a/qa/suites/rados/dashboard/tasks/dashboard.yaml b/qa/suites/rados/dashboard/tasks/dashboard.yaml
index 317c5de1720..0c050d5ddef 100644
--- a/qa/suites/rados/dashboard/tasks/dashboard.yaml
+++ b/qa/suites/rados/dashboard/tasks/dashboard.yaml
@@ -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
index 00000000000..2edbf36ba6a
--- /dev/null
+++ b/qa/tasks/mgr/dashboard/test_motd.py
@@ -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)
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html
index 8068eab8bb3..161e21db9bf 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html
@@ -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">
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.scss
index 9c0d8f09d73..38aa60e2f50 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.scss
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.scss
@@ -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;
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.spec.ts
index 25fb4d6f789..75c97faf46d 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.spec.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.spec.ts
@@ -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,
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.ts
index 285cdc37065..00793dc9b24 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.ts
@@ -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
index 00000000000..07cc5442312
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/motd.service.spec.ts
@@ -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
index 00000000000..dd17b2e04ef
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/motd.service.ts
@@ -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);
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/alert-panel/alert-panel.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/alert-panel/alert-panel.component.html
index 2b606ccdec6..aac801fef45 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/alert-panel/alert-panel.component.html
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/alert-panel/alert-panel.component.html
@@ -1,4 +1,6 @@
-<alert type="{{ bootstrapClass }}">
+<alert type="{{ bootstrapClass }}"
+ [dismissible]="dismissible"
+ (onClose)="onClose()">
<table>
<ng-container *ngIf="size === 'normal'; else slim">
<tr>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/alert-panel/alert-panel.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/alert-panel/alert-panel.component.ts
index 5798d24df11..cd02741d24f 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/alert-panel/alert-panel.component.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/alert-panel/alert-panel.component.ts
@@ -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();
+ }
}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts
index 811e411340a..2b20e18c88d 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts
@@ -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
index 00000000000..2fbe5d7f87d
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/motd/motd.component.html
@@ -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
index 00000000000..e69de29bb2d
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/motd/motd.component.scss
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
index 00000000000..79f09e788eb
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/motd/motd.component.spec.ts
@@ -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
index 00000000000..f3171ff42f0
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/motd/motd.component.ts
@@ -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();
+ }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pipes.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pipes.module.ts
index 3deb535929d..9ec4e0492df 100755
--- a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pipes.module.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pipes.module.ts
@@ -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
index 00000000000..11875252b40
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/sanitize-html.pipe.spec.ts
@@ -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
index 00000000000..f6a8b0c9e8c
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/sanitize-html.pipe.ts
@@ -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
index 00000000000..03009f5286f
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/motd-notification.service.spec.ts
@@ -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
index 00000000000..7e75edfc097
--- /dev/null
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/motd-notification.service.ts
@@ -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);
+ }
+ }
+}
diff --git a/src/pybind/mgr/dashboard/module.py b/src/pybind/mgr/dashboard/module.py
index b88fe1284a1..e57aa45fc4c 100644
--- a/src/pybind/mgr/dashboard/module.py
+++ b/src/pybind/mgr/dashboard/module.py
@@ -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
index 00000000000..eda54c6cc8c
--- /dev/null
+++ b/src/pybind/mgr/dashboard/plugins/motd.py
@@ -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]
diff --git a/src/python-common/ceph/utils.py b/src/python-common/ceph/utils.py
index 2a272fa3321..5c220b6149c 100644
--- a/src/python-common/ceph/utils.py
+++ b/src/python-common/ceph/utils.py
@@ -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)
diff --git a/src/python-common/tox.ini b/src/python-common/tox.ini
index da7037b2d81..bee5d798234 100644
--- a/src/python-common/tox.ini
+++ b/src/python-common/tox.ini
@@ -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]