mirror of
https://github.com/ceph/ceph
synced 2025-04-11 04:02:04 +00:00
Merge pull request #42188 from votdev/issue_51408_motd
mgr/dashboard: Add configurable MOTD or wall notification Reviewed-by: Ernesto Puerta <epuertat@redhat.com> Reviewed-by: Nizamudeen A <nia@redhat.com> Reviewed-by: Paul Cuzner <pcuzner@redhat.com> Reviewed-by: Tatjana Dehler <tdehler@suse.com> Reviewed-by: sebastian-philipp <NOT@FOUND> Reviewed-by: Kefu Chai <kchai@redhat.com> Reviewed-by: Volker Theile <vtheile@suse.com>
This commit is contained in:
commit
64dbe17fdb
@ -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
|
||||
|
30
doc/mgr/dashboard_plugins/motd.inc.rst
Normal file
30
doc/mgr/dashboard_plugins/motd.inc.rst
Normal file
@ -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.
|
@ -60,3 +60,4 @@ tasks:
|
||||
- tasks.mgr.dashboard.test_summary
|
||||
- tasks.mgr.dashboard.test_telemetry
|
||||
- tasks.mgr.dashboard.test_user
|
||||
- tasks.mgr.dashboard.test_motd
|
||||
|
37
qa/tasks/mgr/dashboard/test_motd.py
Normal file
37
qa/tasks/mgr/dashboard/test_motd.py
Normal file
@ -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)
|
@ -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">
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
|
@ -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 {
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
@ -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);
|
||||
}
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
<ngb-alert type="{{ bootstrapClass }}"
|
||||
[dismissible]="false">
|
||||
[dismissible]="dismissible"
|
||||
(close)="onClose()">
|
||||
<table>
|
||||
<ng-container *ngIf="size === 'normal'; else slim">
|
||||
<tr>
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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 {}
|
||||
|
@ -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>
|
@ -0,0 +1,26 @@
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { DashboardModule } from '~/app/ceph/dashboard/dashboard.module';
|
||||
import { SharedModule } from '~/app/shared/shared.module';
|
||||
import { configureTestBed } from '~/testing/unit-test-helper';
|
||||
import { MotdComponent } from './motd.component';
|
||||
|
||||
describe('MotdComponent', () => {
|
||||
let component: MotdComponent;
|
||||
let fixture: ComponentFixture<MotdComponent>;
|
||||
|
||||
configureTestBed({
|
||||
imports: [DashboardModule, HttpClientTestingModule, SharedModule]
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(MotdComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
@ -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();
|
||||
}
|
||||
}
|
@ -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,
|
||||
@ -113,7 +116,8 @@ import { UpperFirstPipe } from './upper-first.pipe';
|
||||
UpperFirstPipe,
|
||||
DurationPipe,
|
||||
MapPipe,
|
||||
TruncatePipe
|
||||
TruncatePipe,
|
||||
SanitizeHtmlPipe
|
||||
]
|
||||
})
|
||||
export class PipesModule {}
|
||||
|
@ -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.
|
||||
});
|
@ -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);
|
||||
}
|
||||
}
|
@ -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();
|
||||
});
|
||||
});
|
@ -0,0 +1,82 @@
|
||||
import { Injectable, OnDestroy } from '@angular/core';
|
||||
|
||||
import * as _ from 'lodash';
|
||||
import { BehaviorSubject, EMPTY, Observable, of, Subscription } from 'rxjs';
|
||||
import { catchError, delay, mergeMap, repeat, tap } from 'rxjs/operators';
|
||||
|
||||
import { Motd, MotdService } from '~/app/shared/api/motd.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class MotdNotificationService implements OnDestroy {
|
||||
public motd$: Observable<Motd | null>;
|
||||
public motdSource = new BehaviorSubject<Motd | null>(null);
|
||||
|
||||
private subscription: Subscription;
|
||||
private localStorageKey = 'dashboard_motd_hidden';
|
||||
|
||||
constructor(private motdService: MotdService) {
|
||||
this.motd$ = this.motdSource.asObservable();
|
||||
// Check every 60 seconds for the latest MOTD configuration.
|
||||
this.subscription = of(true)
|
||||
.pipe(
|
||||
mergeMap(() => this.motdService.get()),
|
||||
catchError((error) => {
|
||||
// Do not show an error notification.
|
||||
if (_.isFunction(error.preventDefault)) {
|
||||
error.preventDefault();
|
||||
}
|
||||
return EMPTY;
|
||||
}),
|
||||
tap((motd: Motd | null) => this.processResponse(motd)),
|
||||
delay(60000),
|
||||
repeat()
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.subscription.unsubscribe();
|
||||
}
|
||||
|
||||
hide() {
|
||||
// Store the severity and MD5 of the current MOTD in local or
|
||||
// session storage to be able to show it again if the severity
|
||||
// or message of the latest MOTD has changed.
|
||||
const motd: Motd = this.motdSource.getValue();
|
||||
if (motd) {
|
||||
const value = `${motd.severity}:${motd.md5}`;
|
||||
switch (motd.severity) {
|
||||
case 'info':
|
||||
localStorage.setItem(this.localStorageKey, value);
|
||||
sessionStorage.removeItem(this.localStorageKey);
|
||||
break;
|
||||
case 'warning':
|
||||
sessionStorage.setItem(this.localStorageKey, value);
|
||||
localStorage.removeItem(this.localStorageKey);
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.motdSource.next(null);
|
||||
}
|
||||
|
||||
processResponse(motd: Motd | null) {
|
||||
const value: string | null =
|
||||
sessionStorage.getItem(this.localStorageKey) || localStorage.getItem(this.localStorageKey);
|
||||
let visible: boolean = _.isNull(value);
|
||||
// Force a hidden MOTD to be shown again if the severity or message
|
||||
// has been changed.
|
||||
if (!visible && motd) {
|
||||
const [severity, md5] = value.split(':');
|
||||
if (severity !== motd.severity || md5 !== motd.md5) {
|
||||
visible = true;
|
||||
sessionStorage.removeItem(this.localStorageKey);
|
||||
localStorage.removeItem(this.localStorageKey);
|
||||
}
|
||||
}
|
||||
if (visible) {
|
||||
this.motdSource.next(motd);
|
||||
}
|
||||
}
|
||||
}
|
@ -48,7 +48,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()
|
||||
|
||||
|
98
src/pybind/mgr/dashboard/plugins/motd.py
Normal file
98
src/pybind/mgr/dashboard/plugins/motd.py
Normal file
@ -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]
|
@ -1,6 +1,8 @@
|
||||
import datetime
|
||||
import re
|
||||
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def datetime_now() -> datetime.datetime:
|
||||
"""
|
||||
@ -66,3 +68,40 @@ def str_to_datetime(string: str) -> datetime.datetime:
|
||||
|
||||
raise ValueError("Time data {} does not match one of the formats {}".format(
|
||||
string, str(fmts)))
|
||||
|
||||
|
||||
def parse_timedelta(delta: str) -> Optional[datetime.timedelta]:
|
||||
"""
|
||||
Returns a timedelta object represents a duration, the difference
|
||||
between two dates or times.
|
||||
|
||||
>>> parse_timedelta('foo')
|
||||
|
||||
>>> parse_timedelta('2d')
|
||||
datetime.timedelta(days=2)
|
||||
|
||||
>>> parse_timedelta("4w")
|
||||
datetime.timedelta(days=28)
|
||||
|
||||
>>> parse_timedelta("5s")
|
||||
datetime.timedelta(seconds=5)
|
||||
|
||||
>>> parse_timedelta("-5s")
|
||||
datetime.timedelta(days=-1, seconds=86395)
|
||||
|
||||
:param delta: The string to process, e.g. '2h', '10d', '30s'.
|
||||
:return: The `datetime.timedelta` object or `None` in case of
|
||||
a parsing error.
|
||||
"""
|
||||
parts = re.match(r'(?P<seconds>-?\d+)s|'
|
||||
r'(?P<minutes>-?\d+)m|'
|
||||
r'(?P<hours>-?\d+)h|'
|
||||
r'(?P<days>-?\d+)d|'
|
||||
r'(?P<weeks>-?\d+)w$',
|
||||
delta,
|
||||
re.IGNORECASE)
|
||||
if not parts:
|
||||
return None
|
||||
parts = parts.groupdict()
|
||||
args = {name: int(param) for name, param in parts.items() if param}
|
||||
return datetime.timedelta(**args)
|
||||
|
@ -7,7 +7,7 @@ deps=
|
||||
-rrequirements.txt
|
||||
-c{toxinidir}/../mypy-constrains.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
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user