diff options
author | Volker Theile <vtheile@suse.com> | 2021-07-05 11:49:33 +0200 |
---|---|---|
committer | Volker Theile <vtheile@suse.com> | 2021-07-21 09:12:43 +0200 |
commit | 9cd5409522475ff5f32fe6f25496972879b0a9d5 (patch) | |
tree | 41cc7ac44c2cc357242959ee6ad71f1cbf4f88be | |
parent | c19cdef2a60fb1586ed44e39a52bf1e241bd435c (diff) | |
download | ceph-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
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] |