From 8b29b65855c4d72f13fa7d4184e6acbdff2c7713 Mon Sep 17 00:00:00 2001 From: Volker Theile Date: Mon, 5 Jul 2021 11:49:33 +0200 Subject: [PATCH] mgr/dashboard: Add configurable MOTD or wall notification Fixes: https://tracker.ceph.com/issues/51408 Signed-off-by: Volker Theile (cherry picked from commit f7f163e75cf5fb6cd022a8d13c28f5b923e01aed) --- doc/mgr/dashboard.rst | 1 + doc/mgr/dashboard_plugins/motd.inc.rst | 30 +++++ .../rados/dashboard/tasks/dashboard.yaml | 1 + qa/tasks/mgr/dashboard/test_motd.py | 37 ++++++ .../navigation/navigation.component.html | 1 + .../navigation/navigation.component.scss | 4 - .../navigation/navigation.component.spec.ts | 3 +- .../navigation/navigation.component.ts | 10 +- .../src/app/shared/api/motd.service.spec.ts | 34 +++++ .../src/app/shared/api/motd.service.ts | 25 ++++ .../alert-panel/alert-panel.component.html | 3 +- .../alert-panel/alert-panel.component.ts | 22 +++- .../shared/components/components.module.ts | 7 +- .../components/motd/motd.component.html | 8 ++ .../components/motd/motd.component.scss | 0 .../components/motd/motd.component.spec.ts | 26 ++++ .../shared/components/motd/motd.component.ts | 33 +++++ .../src/app/shared/pipes/pipes.module.ts | 10 +- .../shared/pipes/sanitize-html.pipe.spec.ts | 26 ++++ .../app/shared/pipes/sanitize-html.pipe.ts | 13 ++ .../motd-notification.service.spec.ts | 117 ++++++++++++++++++ .../services/motd-notification.service.ts | 82 ++++++++++++ src/pybind/mgr/dashboard/module.py | 2 +- src/pybind/mgr/dashboard/plugins/motd.py | 98 +++++++++++++++ src/python-common/ceph/utils.py | 39 ++++++ src/python-common/tox.ini | 2 +- 26 files changed, 618 insertions(+), 16 deletions(-) create mode 100644 doc/mgr/dashboard_plugins/motd.inc.rst create mode 100644 qa/tasks/mgr/dashboard/test_motd.py create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/api/motd.service.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/api/motd.service.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/components/motd/motd.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/components/motd/motd.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/components/motd/motd.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/components/motd/motd.component.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/sanitize-html.pipe.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/sanitize-html.pipe.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/services/motd-notification.service.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/services/motd-notification.service.ts create mode 100644 src/pybind/mgr/dashboard/plugins/motd.py diff --git a/doc/mgr/dashboard.rst b/doc/mgr/dashboard.rst index 32d02fd4a6be..0615e3f3cf9c 100644 --- a/doc/mgr/dashboard.rst +++ b/doc/mgr/dashboard.rst @@ -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 index 000000000000..b8464e1f33a0 --- /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 + +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 111c5f38e2a1..5ca0c662146f 100644 --- a/qa/suites/rados/dashboard/tasks/dashboard.yaml +++ b/qa/suites/rados/dashboard/tasks/dashboard.yaml @@ -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 index 000000000000..2edbf36ba6a6 --- /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 4d0c64b9032a..955b391d9a63 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 @@ +
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 f559abbf2b95..a990292aa70e 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: vv.$secondary; border-top: 4px solid vv.$primary; - &.isPwdDisplayed { - top: vv.$top-notification-height; - } - .navbar-brand, .navbar-brand:hover { color: vv.$gray-200; 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 afac730c73a1..241910f2b415 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 c135def6f4c8..512feecef9d6 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 '~/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 index 000000000000..e186e8423ec8 --- /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 { 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 index 000000000000..dd17b2e04efd --- /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 { + return this.http.get(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 17b4f1863d53..737951cb8d54 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,5 +1,6 @@ + [dismissible]="dismissible" + (close)="onClose()"> 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 9d7292855787..51088840e333 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 @@ -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(); + } } 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 bccbc645bab1..ef8b423a3a70 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 @@ -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 index 000000000000..2fbe5d7f87d3 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/motd/motd.component.html @@ -0,0 +1,8 @@ + + + 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 000000000000..e69de29bb2d1 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 000000000000..826a8a5d058d --- /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 { 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; + + 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 000000000000..297ef2764624 --- /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 '~/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(); + } +} 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 3deb535929d3..9ec4e0492df0 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 000000000000..719f32ee5501 --- /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 { 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 index 000000000000..f6a8b0c9e8c6 --- /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 000000000000..267e6aa57c15 --- /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 { 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 index 000000000000..11feca26b053 --- /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 '~/app/shared/api/motd.service'; + +@Injectable({ + providedIn: 'root' +}) +export class MotdNotificationService implements OnDestroy { + public motd$: Observable; + public motdSource = new BehaviorSubject(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 1e903dcbeafc..83f88ad0c91d 100644 --- a/src/pybind/mgr/dashboard/module.py +++ b/src/pybind/mgr/dashboard/module.py @@ -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 index 000000000000..0600135ac231 --- /dev/null +++ b/src/pybind/mgr/dashboard/plugins/motd.py @@ -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] diff --git a/src/python-common/ceph/utils.py b/src/python-common/ceph/utils.py index 2a272fa3321e..89f0654aa0b8 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-?\d+)s|' + r'(?P-?\d+)m|' + r'(?P-?\d+)h|' + r'(?P-?\d+)d|' + r'(?P-?\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) diff --git a/src/python-common/tox.ini b/src/python-common/tox.ini index ce54d5a625f6..5869b6ae2764 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 {posargs} mypy --config-file=../mypy.ini -p ceph -- 2.47.3