diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 18:45:59 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 18:45:59 +0000 |
commit | 19fcec84d8d7d21e796c7624e521b60d28ee21ed (patch) | |
tree | 42d26aa27d1e3f7c0b8bd3fd14e7d7082f5008dc /src/pybind/mgr/dashboard/frontend/src/app/shared | |
parent | Initial commit. (diff) | |
download | ceph-19fcec84d8d7d21e796c7624e521b60d28ee21ed.tar.xz ceph-19fcec84d8d7d21e796c7624e521b60d28ee21ed.zip |
Adding upstream version 16.2.11+ds.upstream/16.2.11+dsupstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'src/pybind/mgr/dashboard/frontend/src/app/shared')
441 files changed, 28193 insertions, 0 deletions
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/api-client.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/api-client.spec.ts new file mode 100644 index 000000000..0d521a889 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/api-client.spec.ts @@ -0,0 +1,11 @@ +import { ApiClient } from '~/app/shared/api/api-client'; + +class MockApiClient extends ApiClient {} + +describe('ApiClient', () => { + const service = new MockApiClient(); + + it('should get the version header value', () => { + expect(service.getVersionHeaderValue(1, 2)).toBe('application/vnd.ceph.api.v1.2+json'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/api-client.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/api-client.ts new file mode 100644 index 000000000..06583eb10 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/api-client.ts @@ -0,0 +1,5 @@ +export abstract class ApiClient { + getVersionHeaderValue(major: number, minor: number) { + return `application/vnd.ceph.api.v${major}.${minor}+json`; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/auth.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/auth.service.spec.ts new file mode 100644 index 000000000..c32f0ea05 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/auth.service.spec.ts @@ -0,0 +1,57 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { Router, Routes } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { AuthStorageService } from '../services/auth-storage.service'; +import { AuthService } from './auth.service'; + +describe('AuthService', () => { + let service: AuthService; + let httpTesting: HttpTestingController; + + const routes: Routes = [{ path: 'login', children: [] }]; + + configureTestBed({ + providers: [AuthService, AuthStorageService], + imports: [HttpClientTestingModule, RouterTestingModule.withRoutes(routes)] + }); + + beforeEach(() => { + service = TestBed.inject(AuthService); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should login and save the user', fakeAsync(() => { + const fakeCredentials = { username: 'foo', password: 'bar' }; + const fakeResponse = { username: 'foo' }; + service.login(fakeCredentials).subscribe(); + const req = httpTesting.expectOne('api/auth'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(fakeCredentials); + req.flush(fakeResponse); + tick(); + expect(localStorage.getItem('dashboard_username')).toBe('foo'); + })); + + it('should logout and remove the user', () => { + const router = TestBed.inject(Router); + spyOn(router, 'navigate').and.stub(); + + service.logout(); + const req = httpTesting.expectOne('api/auth/logout'); + expect(req.request.method).toBe('POST'); + req.flush({ redirect_url: '#/login' }); + expect(localStorage.getItem('dashboard_username')).toBe(null); + expect(router.navigate).toBeCalledTimes(1); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/auth.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/auth.service.ts new file mode 100644 index 000000000..8a2917992 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/auth.service.ts @@ -0,0 +1,53 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; + +import * as _ from 'lodash'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; + +import { Credentials } from '../models/credentials'; +import { LoginResponse } from '../models/login-response'; +import { AuthStorageService } from '../services/auth-storage.service'; + +@Injectable({ + providedIn: 'root' +}) +export class AuthService { + constructor( + private authStorageService: AuthStorageService, + private http: HttpClient, + private router: Router, + private route: ActivatedRoute + ) {} + + check(token: string) { + return this.http.post('api/auth/check', { token: token }); + } + + login(credentials: Credentials): Observable<LoginResponse> { + return this.http.post('api/auth', credentials).pipe( + tap((resp: LoginResponse) => { + this.authStorageService.set( + resp.username, + resp.permissions, + resp.sso, + resp.pwdExpirationDate, + resp.pwdUpdateRequired + ); + }) + ); + } + + logout(callback: Function = null) { + return this.http.post('api/auth/logout', null).subscribe((resp: any) => { + this.authStorageService.remove(); + const url = _.get(this.route.snapshot.queryParams, 'returnUrl', '/login'); + this.router.navigate([url], { skipLocationChange: true }); + if (callback) { + callback(); + } + window.location.replace(resp.redirect_url); + }); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/ceph-service.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/ceph-service.service.ts new file mode 100644 index 000000000..c62dfea7c --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/ceph-service.service.ts @@ -0,0 +1,63 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +import { Observable } from 'rxjs'; + +import { Daemon } from '../models/daemon.interface'; +import { CephServiceSpec } from '../models/service.interface'; + +@Injectable({ + providedIn: 'root' +}) +export class CephServiceService { + private url = 'api/service'; + + constructor(private http: HttpClient) {} + + list(serviceName?: string): Observable<CephServiceSpec[]> { + const options = serviceName + ? { params: new HttpParams().set('service_name', serviceName) } + : {}; + return this.http.get<CephServiceSpec[]>(this.url, options); + } + + getDaemons(serviceName?: string): Observable<Daemon[]> { + return this.http.get<Daemon[]>(`${this.url}/${serviceName}/daemons`); + } + + create(serviceSpec: { [key: string]: any }) { + const serviceName = serviceSpec['service_id'] + ? `${serviceSpec['service_type']}.${serviceSpec['service_id']}` + : serviceSpec['service_type']; + return this.http.post( + this.url, + { + service_name: serviceName, + service_spec: serviceSpec + }, + { observe: 'response' } + ); + } + + update(serviceSpec: { [key: string]: any }) { + const serviceName = serviceSpec['service_id'] + ? `${serviceSpec['service_type']}.${serviceSpec['service_id']}` + : serviceSpec['service_type']; + return this.http.put( + `${this.url}/${serviceName}`, + { + service_name: serviceName, + service_spec: serviceSpec + }, + { observe: 'response' } + ); + } + + delete(serviceName: string) { + return this.http.delete(`${this.url}/${serviceName}`, { observe: 'response' }); + } + + getKnownTypes(): Observable<string[]> { + return this.http.get<string[]>(`${this.url}/known_types`); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.spec.ts new file mode 100644 index 000000000..58395cd67 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.spec.ts @@ -0,0 +1,98 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { CephfsService } from './cephfs.service'; + +describe('CephfsService', () => { + let service: CephfsService; + let httpTesting: HttpTestingController; + + configureTestBed({ + imports: [HttpClientTestingModule], + providers: [CephfsService] + }); + + beforeEach(() => { + service = TestBed.inject(CephfsService); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call list', () => { + service.list().subscribe(); + const req = httpTesting.expectOne('api/cephfs'); + expect(req.request.method).toBe('GET'); + }); + + it('should call getCephfs', () => { + service.getCephfs(1).subscribe(); + const req = httpTesting.expectOne('api/cephfs/1'); + expect(req.request.method).toBe('GET'); + }); + + it('should call getClients', () => { + service.getClients(1).subscribe(); + const req = httpTesting.expectOne('api/cephfs/1/clients'); + expect(req.request.method).toBe('GET'); + }); + + it('should call getTabs', () => { + service.getTabs(2).subscribe(); + const req = httpTesting.expectOne('ui-api/cephfs/2/tabs'); + expect(req.request.method).toBe('GET'); + }); + + it('should call getMdsCounters', () => { + service.getMdsCounters('1').subscribe(); + const req = httpTesting.expectOne('api/cephfs/1/mds_counters'); + expect(req.request.method).toBe('GET'); + }); + + it('should call lsDir', () => { + service.lsDir(1).subscribe(); + const req = httpTesting.expectOne('ui-api/cephfs/1/ls_dir?depth=2'); + expect(req.request.method).toBe('GET'); + service.lsDir(2, '/some/path').subscribe(); + httpTesting.expectOne('ui-api/cephfs/2/ls_dir?depth=2&path=%252Fsome%252Fpath'); + }); + + it('should call mkSnapshot', () => { + service.mkSnapshot(3, '/some/path').subscribe(); + const req = httpTesting.expectOne('api/cephfs/3/snapshot?path=%252Fsome%252Fpath'); + expect(req.request.method).toBe('POST'); + + service.mkSnapshot(4, '/some/other/path', 'snap').subscribe(); + httpTesting.expectOne('api/cephfs/4/snapshot?path=%252Fsome%252Fother%252Fpath&name=snap'); + }); + + it('should call rmSnapshot', () => { + service.rmSnapshot(1, '/some/path', 'snap').subscribe(); + const req = httpTesting.expectOne('api/cephfs/1/snapshot?path=%252Fsome%252Fpath&name=snap'); + expect(req.request.method).toBe('DELETE'); + }); + + it('should call updateQuota', () => { + service.quota(1, '/some/path', { max_bytes: 1024 }).subscribe(); + let req = httpTesting.expectOne('api/cephfs/1/quota?path=%252Fsome%252Fpath'); + expect(req.request.method).toBe('PUT'); + expect(req.request.body).toEqual({ max_bytes: 1024 }); + + service.quota(1, '/some/path', { max_files: 10 }).subscribe(); + req = httpTesting.expectOne('api/cephfs/1/quota?path=%252Fsome%252Fpath'); + expect(req.request.method).toBe('PUT'); + expect(req.request.body).toEqual({ max_files: 10 }); + + service.quota(1, '/some/path', { max_bytes: 1024, max_files: 10 }).subscribe(); + req = httpTesting.expectOne('api/cephfs/1/quota?path=%252Fsome%252Fpath'); + expect(req.request.method).toBe('PUT'); + expect(req.request.body).toEqual({ max_bytes: 1024, max_files: 10 }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.ts new file mode 100644 index 000000000..02f31ca7b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.ts @@ -0,0 +1,76 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +import _ from 'lodash'; +import { Observable } from 'rxjs'; + +import { cdEncode } from '../decorators/cd-encode'; +import { CephfsDir, CephfsQuotas } from '../models/cephfs-directory-models'; + +@cdEncode +@Injectable({ + providedIn: 'root' +}) +export class CephfsService { + baseURL = 'api/cephfs'; + baseUiURL = 'ui-api/cephfs'; + + constructor(private http: HttpClient) {} + + list() { + return this.http.get(`${this.baseURL}`); + } + + lsDir(id: number, path?: string): Observable<CephfsDir[]> { + let apiPath = `${this.baseUiURL}/${id}/ls_dir?depth=2`; + if (path) { + apiPath += `&path=${encodeURIComponent(path)}`; + } + return this.http.get<CephfsDir[]>(apiPath); + } + + getCephfs(id: number) { + return this.http.get(`${this.baseURL}/${id}`); + } + + getTabs(id: number) { + return this.http.get(`ui-api/cephfs/${id}/tabs`); + } + + getClients(id: number) { + return this.http.get(`${this.baseURL}/${id}/clients`); + } + + evictClient(fsId: number, clientId: number) { + return this.http.delete(`${this.baseURL}/${fsId}/client/${clientId}`); + } + + getMdsCounters(id: string) { + return this.http.get(`${this.baseURL}/${id}/mds_counters`); + } + + mkSnapshot(id: number, path: string, name?: string) { + let params = new HttpParams(); + params = params.append('path', path); + if (!_.isUndefined(name)) { + params = params.append('name', name); + } + return this.http.post(`${this.baseURL}/${id}/snapshot`, null, { params }); + } + + rmSnapshot(id: number, path: string, name: string) { + let params = new HttpParams(); + params = params.append('path', path); + params = params.append('name', name); + return this.http.delete(`${this.baseURL}/${id}/snapshot`, { params }); + } + + quota(id: number, path: string, quotas: CephfsQuotas) { + let params = new HttpParams(); + params = params.append('path', path); + return this.http.put(`${this.baseURL}/${id}/quota`, quotas, { + observe: 'response', + params + }); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cluster.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cluster.service.spec.ts new file mode 100644 index 000000000..758f670ee --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cluster.service.spec.ts @@ -0,0 +1,42 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { fakeAsync, TestBed } from '@angular/core/testing'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { ClusterService } from './cluster.service'; + +describe('ClusterService', () => { + let service: ClusterService; + let httpTesting: HttpTestingController; + + configureTestBed({ + imports: [HttpClientTestingModule], + providers: [ClusterService] + }); + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(ClusterService); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call getStatus', () => { + service.getStatus().subscribe(); + const req = httpTesting.expectOne('api/cluster'); + expect(req.request.method).toBe('GET'); + }); + + it('should update cluster status', fakeAsync(() => { + service.updateStatus('fakeStatus').subscribe(); + const req = httpTesting.expectOne('api/cluster'); + expect(req.request.method).toBe('PUT'); + expect(req.request.body).toEqual({ status: 'fakeStatus' }); + })); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cluster.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cluster.service.ts new file mode 100644 index 000000000..6b435d6ff --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cluster.service.ts @@ -0,0 +1,27 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +import { Observable } from 'rxjs'; + +@Injectable({ + providedIn: 'root' +}) +export class ClusterService { + baseURL = 'api/cluster'; + + constructor(private http: HttpClient) {} + + getStatus(): Observable<string> { + return this.http.get<string>(`${this.baseURL}`, { + headers: { Accept: 'application/vnd.ceph.api.v0.1+json' } + }); + } + + updateStatus(status: string) { + return this.http.put( + `${this.baseURL}`, + { status: status }, + { headers: { Accept: 'application/vnd.ceph.api.v0.1+json' } } + ); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/configuration.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/configuration.service.spec.ts new file mode 100644 index 000000000..da05957a4 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/configuration.service.spec.ts @@ -0,0 +1,99 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { ConfigFormCreateRequestModel } from '~/app/ceph/cluster/configuration/configuration-form/configuration-form-create-request.model'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { ConfigurationService } from './configuration.service'; + +describe('ConfigurationService', () => { + let service: ConfigurationService; + let httpTesting: HttpTestingController; + + configureTestBed({ + providers: [ConfigurationService], + imports: [HttpClientTestingModule] + }); + + beforeEach(() => { + service = TestBed.inject(ConfigurationService); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call getConfigData', () => { + service.getConfigData().subscribe(); + const req = httpTesting.expectOne('api/cluster_conf/'); + expect(req.request.method).toBe('GET'); + }); + + it('should call get', () => { + service.get('configOption').subscribe(); + const req = httpTesting.expectOne('api/cluster_conf/configOption'); + expect(req.request.method).toBe('GET'); + }); + + it('should call create', () => { + const configOption = new ConfigFormCreateRequestModel(); + configOption.name = 'Test option'; + configOption.value = [ + { section: 'section1', value: 'value1' }, + { section: 'section2', value: 'value2' } + ]; + service.create(configOption).subscribe(); + const req = httpTesting.expectOne('api/cluster_conf/'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(configOption); + }); + + it('should call bulkCreate', () => { + const configOptions = { + configOption1: { section: 'section', value: 'value' }, + configOption2: { section: 'section', value: 'value' } + }; + service.bulkCreate(configOptions).subscribe(); + const req = httpTesting.expectOne('api/cluster_conf/'); + expect(req.request.method).toBe('PUT'); + expect(req.request.body).toEqual(configOptions); + }); + + it('should call filter', () => { + const configOptions = ['configOption1', 'configOption2', 'configOption3']; + service.filter(configOptions).subscribe(); + const req = httpTesting.expectOne( + 'api/cluster_conf/filter?names=configOption1,configOption2,configOption3' + ); + expect(req.request.method).toBe('GET'); + }); + + it('should call delete', () => { + service.delete('testOption', 'testSection').subscribe(); + const reg = httpTesting.expectOne('api/cluster_conf/testOption?section=testSection'); + expect(reg.request.method).toBe('DELETE'); + }); + + it('should get value', () => { + const config = { + default: 'a', + value: [ + { section: 'global', value: 'b' }, + { section: 'mon', value: 'c' }, + { section: 'mon.1', value: 'd' }, + { section: 'mds', value: 'e' } + ] + }; + expect(service.getValue(config, 'mon.1')).toBe('d'); + expect(service.getValue(config, 'mon')).toBe('c'); + expect(service.getValue(config, 'mds.1')).toBe('e'); + expect(service.getValue(config, 'mds')).toBe('e'); + expect(service.getValue(config, 'osd')).toBe('b'); + config.value = []; + expect(service.getValue(config, 'osd')).toBe('a'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/configuration.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/configuration.service.ts new file mode 100644 index 000000000..5bad098c9 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/configuration.service.ts @@ -0,0 +1,59 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +import { ConfigFormCreateRequestModel } from '~/app/ceph/cluster/configuration/configuration-form/configuration-form-create-request.model'; + +@Injectable({ + providedIn: 'root' +}) +export class ConfigurationService { + constructor(private http: HttpClient) {} + + private findValue(config: any, section: string) { + if (!config.value) { + return undefined; + } + return config.value.find((v: any) => v.section === section); + } + + getValue(config: any, section: string) { + let val = this.findValue(config, section); + if (!val) { + const indexOfDot = section.indexOf('.'); + if (indexOfDot !== -1) { + val = this.findValue(config, section.substring(0, indexOfDot)); + } + } + if (!val) { + val = this.findValue(config, 'global'); + } + if (val) { + return val.value; + } + return config.default; + } + + getConfigData() { + return this.http.get('api/cluster_conf/'); + } + + get(configOption: string) { + return this.http.get(`api/cluster_conf/${configOption}`); + } + + filter(configOptionNames: Array<string>) { + return this.http.get(`api/cluster_conf/filter?names=${configOptionNames.join(',')}`); + } + + create(configOption: ConfigFormCreateRequestModel) { + return this.http.post('api/cluster_conf/', configOption); + } + + delete(configOption: string, section: string) { + return this.http.delete(`api/cluster_conf/${configOption}?section=${section}`); + } + + bulkCreate(configOptions: object) { + return this.http.put('api/cluster_conf/', configOptions); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/crush-rule.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/crush-rule.service.spec.ts new file mode 100644 index 000000000..1142e5368 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/crush-rule.service.spec.ts @@ -0,0 +1,47 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { CrushRuleService } from './crush-rule.service'; + +describe('CrushRuleService', () => { + let service: CrushRuleService; + let httpTesting: HttpTestingController; + const apiPath = 'api/crush_rule'; + + configureTestBed({ + imports: [HttpClientTestingModule], + providers: [CrushRuleService] + }); + + beforeEach(() => { + service = TestBed.inject(CrushRuleService); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call create', () => { + service.create({ root: 'default', name: 'someRule', failure_domain: 'osd' }).subscribe(); + const req = httpTesting.expectOne(apiPath); + expect(req.request.method).toBe('POST'); + }); + + it('should call delete', () => { + service.delete('test').subscribe(); + const req = httpTesting.expectOne(`${apiPath}/test`); + expect(req.request.method).toBe('DELETE'); + }); + + it('should call getInfo', () => { + service.getInfo().subscribe(); + const req = httpTesting.expectOne(`ui-${apiPath}/info`); + expect(req.request.method).toBe('GET'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/crush-rule.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/crush-rule.service.ts new file mode 100644 index 000000000..e4e7bb605 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/crush-rule.service.ts @@ -0,0 +1,32 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +import { CrushRuleConfig } from '../models/crush-rule'; + +@Injectable({ + providedIn: 'root' +}) +export class CrushRuleService { + apiPath = 'api/crush_rule'; + + formTooltips = { + // Copied from /doc/rados/operations/crush-map.rst + root: $localize`The name of the node under which data should be placed.`, + failure_domain: $localize`The type of CRUSH nodes across which we should separate replicas.`, + device_class: $localize`The device class data should be placed on.` + }; + + constructor(private http: HttpClient) {} + + create(rule: CrushRuleConfig) { + return this.http.post(this.apiPath, rule, { observe: 'response' }); + } + + delete(name: string) { + return this.http.delete(`${this.apiPath}/${name}`, { observe: 'response' }); + } + + getInfo() { + return this.http.get(`ui-${this.apiPath}/info`); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/custom-login-banner.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/custom-login-banner.service.spec.ts new file mode 100644 index 000000000..d1db441c7 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/custom-login-banner.service.spec.ts @@ -0,0 +1,35 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { CustomLoginBannerService } from './custom-login-banner.service'; + +describe('CustomLoginBannerService', () => { + let service: CustomLoginBannerService; + let httpTesting: HttpTestingController; + const baseUiURL = 'ui-api/login/custom_banner'; + + configureTestBed({ + providers: [CustomLoginBannerService], + imports: [HttpClientTestingModule] + }); + + beforeEach(() => { + service = TestBed.inject(CustomLoginBannerService); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call getBannerText', () => { + service.getBannerText().subscribe(); + const req = httpTesting.expectOne(baseUiURL); + expect(req.request.method).toBe('GET'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/custom-login-banner.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/custom-login-banner.service.ts new file mode 100644 index 000000000..7c499eb13 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/custom-login-banner.service.ts @@ -0,0 +1,15 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root' +}) +export class CustomLoginBannerService { + baseUiURL = 'ui-api/login/custom_banner'; + + constructor(private http: HttpClient) {} + + getBannerText() { + return this.http.get<string>(this.baseUiURL); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/daemon.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/daemon.service.spec.ts new file mode 100644 index 000000000..787e5db7c --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/daemon.service.spec.ts @@ -0,0 +1,39 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { DaemonService } from './daemon.service'; + +describe('DaemonService', () => { + let service: DaemonService; + let httpTesting: HttpTestingController; + + configureTestBed({ + providers: [DaemonService], + imports: [HttpClientTestingModule] + }); + + beforeEach(() => { + service = TestBed.inject(DaemonService); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call action', () => { + const put_data: any = { + action: 'start', + container_image: null + }; + service.action('osd.1', 'start').subscribe(); + const req = httpTesting.expectOne('api/daemon/osd.1'); + expect(req.request.method).toBe('PUT'); + expect(req.request.body).toEqual(put_data); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/daemon.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/daemon.service.ts new file mode 100644 index 000000000..a66ed7edb --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/daemon.service.ts @@ -0,0 +1,28 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +import { cdEncode } from '~/app/shared/decorators/cd-encode'; + +@cdEncode +@Injectable({ + providedIn: 'root' +}) +export class DaemonService { + private url = 'api/daemon'; + + constructor(private http: HttpClient) {} + + action(daemonName: string, actionType: string) { + return this.http.put( + `${this.url}/${daemonName}`, + { + action: actionType, + container_image: null + }, + { + headers: { Accept: 'application/vnd.ceph.api.v0.1+json' }, + observe: 'response' + } + ); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/erasure-code-profile.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/erasure-code-profile.service.spec.ts new file mode 100644 index 000000000..caf3da0c6 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/erasure-code-profile.service.spec.ts @@ -0,0 +1,55 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { ErasureCodeProfile } from '../models/erasure-code-profile'; +import { ErasureCodeProfileService } from './erasure-code-profile.service'; + +describe('ErasureCodeProfileService', () => { + let service: ErasureCodeProfileService; + let httpTesting: HttpTestingController; + const apiPath = 'api/erasure_code_profile'; + const testProfile: ErasureCodeProfile = { name: 'test', plugin: 'jerasure', k: 2, m: 1 }; + + configureTestBed({ + imports: [HttpClientTestingModule], + providers: [ErasureCodeProfileService] + }); + + beforeEach(() => { + service = TestBed.inject(ErasureCodeProfileService); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call list', () => { + service.list().subscribe(); + const req = httpTesting.expectOne(apiPath); + expect(req.request.method).toBe('GET'); + }); + + it('should call create', () => { + service.create(testProfile).subscribe(); + const req = httpTesting.expectOne(apiPath); + expect(req.request.method).toBe('POST'); + }); + + it('should call delete', () => { + service.delete('test').subscribe(); + const req = httpTesting.expectOne(`${apiPath}/test`); + expect(req.request.method).toBe('DELETE'); + }); + + it('should call getInfo', () => { + service.getInfo().subscribe(); + const req = httpTesting.expectOne(`ui-${apiPath}/info`); + expect(req.request.method).toBe('GET'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/erasure-code-profile.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/erasure-code-profile.service.ts new file mode 100644 index 000000000..d2bd131a4 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/erasure-code-profile.service.ts @@ -0,0 +1,110 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +import { Observable } from 'rxjs'; + +import { ErasureCodeProfile } from '../models/erasure-code-profile'; + +@Injectable({ + providedIn: 'root' +}) +export class ErasureCodeProfileService { + apiPath = 'api/erasure_code_profile'; + + formTooltips = { + // Copied from /doc/rados/operations/erasure-code.*.rst + k: $localize`Each object is split in data-chunks parts, each stored on a different OSD.`, + + m: $localize`Compute coding chunks for each object and store them on different OSDs. + The number of coding chunks is also the number of OSDs that can be down without losing data.`, + + plugins: { + jerasure: { + description: $localize`The jerasure plugin is the most generic and flexible plugin, + it is also the default for Ceph erasure coded pools.`, + technique: $localize`The more flexible technique is reed_sol_van : it is enough to set k + and m. The cauchy_good technique can be faster but you need to chose the packetsize + carefully. All of reed_sol_r6_op, liberation, blaum_roth, liber8tion are RAID6 equivalents + in the sense that they can only be configured with m=2.`, + packetSize: $localize`The encoding will be done on packets of bytes size at a time. + Choosing the right packet size is difficult. + The jerasure documentation contains extensive information on this topic.` + }, + lrc: { + description: $localize`With the jerasure plugin, when an erasure coded object is stored on + multiple OSDs, recovering from the loss of one OSD requires reading from all the others. + For instance if jerasure is configured with k=8 and m=4, losing one OSD requires reading + from the eleven others to repair. + + The lrc erasure code plugin creates local parity chunks to be able to recover using + less OSDs. For instance if lrc is configured with k=8, m=4 and l=4, it will create + an additional parity chunk for every four OSDs. When a single OSD is lost, it can be + recovered with only four OSDs instead of eleven.`, + l: $localize`Group the coding and data chunks into sets of size locality. For instance, + for k=4 and m=2, when locality=3 two groups of three are created. Each set can + be recovered without reading chunks from another set.`, + crushLocality: $localize`The type of the crush bucket in which each set of chunks defined + by l will be stored. For instance, if it is set to rack, each group of l chunks will be + placed in a different rack. It is used to create a CRUSH rule step such as step choose + rack. If it is not set, no such grouping is done.` + }, + isa: { + description: $localize`The isa plugin encapsulates the ISA library. It only runs on Intel processors.`, + technique: $localize`The ISA plugin comes in two Reed Solomon forms. + If reed_sol_van is set, it is Vandermonde, if cauchy is set, it is Cauchy.` + }, + shec: { + description: $localize`The shec plugin encapsulates the multiple SHEC library. + It allows ceph to recover data more efficiently than Reed Solomon codes.`, + c: $localize`The number of parity chunks each of which includes each data chunk in its + calculation range. The number is used as a durability estimator. For instance, if c=2, + 2 OSDs can be down without losing data.` + }, + clay: { + description: $localize`CLAY (short for coupled-layer) codes are erasure codes designed to + bring about significant savings in terms of network bandwidth and disk IO when a failed + node/OSD/rack is being repaired.`, + d: $localize`Number of OSDs requested to send data during recovery of a single chunk. + d needs to be chosen such that k+1 <= d <= k+m-1. The larger the d, the better + the savings.`, + scalar_mds: $localize`scalar_mds specifies the plugin that is used as a building block + in the layered construction. It can be one of jerasure, isa, shec.`, + technique: $localize`technique specifies the technique that will be picked + within the 'scalar_mds' plugin specified. Supported techniques + are 'reed_sol_van', 'reed_sol_r6_op', 'cauchy_orig', + 'cauchy_good', 'liber8tion' for jerasure, 'reed_sol_van', + 'cauchy' for isa and 'single', 'multiple' for shec.` + } + }, + + crushRoot: $localize`The name of the crush bucket used for the first step of the CRUSH rule. + For instance step take default.`, + + crushFailureDomain: $localize`Ensure that no two chunks are in a bucket with the same failure + domain. For instance, if the failure domain is host no two chunks will be stored on the same + host. It is used to create a CRUSH rule step such as step chooseleaf host.`, + + crushDeviceClass: $localize`Restrict placement to devices of a specific class + (e.g., ssd or hdd), using the crush device class names in the CRUSH map.`, + + directory: $localize`Set the directory name from which the erasure code plugin is loaded.` + }; + + constructor(private http: HttpClient) {} + + list(): Observable<ErasureCodeProfile[]> { + return this.http.get<ErasureCodeProfile[]>(this.apiPath); + } + + create(ecp: ErasureCodeProfile) { + return this.http.post(this.apiPath, ecp, { observe: 'response' }); + } + + delete(name: string) { + return this.http.delete(`${this.apiPath}/${name}`, { observe: 'response' }); + } + + getInfo() { + return this.http.get(`ui-${this.apiPath}/info`); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/health.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/health.service.spec.ts new file mode 100644 index 000000000..84eeac0f3 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/health.service.spec.ts @@ -0,0 +1,40 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { HealthService } from './health.service'; + +describe('HealthService', () => { + let service: HealthService; + let httpTesting: HttpTestingController; + + configureTestBed({ + providers: [HealthService], + imports: [HttpClientTestingModule] + }); + + beforeEach(() => { + service = TestBed.inject(HealthService); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call getFullHealth', () => { + service.getFullHealth().subscribe(); + const req = httpTesting.expectOne('api/health/full'); + expect(req.request.method).toBe('GET'); + }); + + it('should call getMinimalHealth', () => { + service.getMinimalHealth().subscribe(); + const req = httpTesting.expectOne('api/health/minimal'); + expect(req.request.method).toBe('GET'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/health.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/health.service.ts new file mode 100644 index 000000000..a8f7c467a --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/health.service.ts @@ -0,0 +1,17 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root' +}) +export class HealthService { + constructor(private http: HttpClient) {} + + getFullHealth() { + return this.http.get('api/health/full'); + } + + getMinimalHealth() { + return this.http.get('api/health/minimal'); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.spec.ts new file mode 100644 index 000000000..e4b6476f2 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.spec.ts @@ -0,0 +1,91 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { HostService } from './host.service'; + +describe('HostService', () => { + let service: HostService; + let httpTesting: HttpTestingController; + + configureTestBed({ + providers: [HostService], + imports: [HttpClientTestingModule] + }); + + beforeEach(() => { + service = TestBed.inject(HostService); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call list', fakeAsync(() => { + let result; + service.list('true').subscribe((resp) => (result = resp)); + const req = httpTesting.expectOne('api/host?facts=true'); + expect(req.request.method).toBe('GET'); + req.flush(['foo', 'bar']); + tick(); + expect(result).toEqual(['foo', 'bar']); + })); + + it('should make a GET request on the devices endpoint when requesting devices', () => { + const hostname = 'hostname'; + service.getDevices(hostname).subscribe(); + const req = httpTesting.expectOne(`api/host/${hostname}/devices`); + expect(req.request.method).toBe('GET'); + }); + + it('should update host', fakeAsync(() => { + service.update('mon0', true, ['foo', 'bar'], true, false).subscribe(); + const req = httpTesting.expectOne('api/host/mon0'); + expect(req.request.method).toBe('PUT'); + expect(req.request.body).toEqual({ + force: false, + labels: ['foo', 'bar'], + maintenance: true, + update_labels: true, + drain: false + }); + })); + + it('should test host drain call', fakeAsync(() => { + service.update('host0', false, null, false, false, true).subscribe(); + const req = httpTesting.expectOne('api/host/host0'); + expect(req.request.method).toBe('PUT'); + expect(req.request.body).toEqual({ + force: false, + labels: null, + maintenance: false, + update_labels: false, + drain: true + }); + })); + + it('should call getInventory', () => { + service.getInventory('host-0').subscribe(); + let req = httpTesting.expectOne('api/host/host-0/inventory'); + expect(req.request.method).toBe('GET'); + + service.getInventory('host-0', true).subscribe(); + req = httpTesting.expectOne('api/host/host-0/inventory?refresh=true'); + expect(req.request.method).toBe('GET'); + }); + + it('should call inventoryList', () => { + service.inventoryList().subscribe(); + let req = httpTesting.expectOne('ui-api/host/inventory'); + expect(req.request.method).toBe('GET'); + + service.inventoryList(true).subscribe(); + req = httpTesting.expectOne('ui-api/host/inventory?refresh=true'); + expect(req.request.method).toBe('GET'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.ts new file mode 100644 index 000000000..d13f41527 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.ts @@ -0,0 +1,154 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +import _ from 'lodash'; +import { Observable, of as observableOf } from 'rxjs'; +import { map, mergeMap, toArray } from 'rxjs/operators'; + +import { InventoryDevice } from '~/app/ceph/cluster/inventory/inventory-devices/inventory-device.model'; +import { InventoryHost } from '~/app/ceph/cluster/inventory/inventory-host.model'; +import { ApiClient } from '~/app/shared/api/api-client'; +import { CdHelperClass } from '~/app/shared/classes/cd-helper.class'; +import { Daemon } from '../models/daemon.interface'; +import { CdDevice } from '../models/devices'; +import { SmartDataResponseV1 } from '../models/smart'; +import { DeviceService } from '../services/device.service'; + +@Injectable({ + providedIn: 'root' +}) +export class HostService extends ApiClient { + baseURL = 'api/host'; + baseUIURL = 'ui-api/host'; + + predefinedLabels = ['mon', 'mgr', 'osd', 'mds', 'rgw', 'nfs', 'iscsi', 'rbd', 'grafana']; + + constructor(private http: HttpClient, private deviceService: DeviceService) { + super(); + } + + list(facts: string): Observable<object[]> { + return this.http.get<object[]>(this.baseURL, { + headers: { Accept: 'application/vnd.ceph.api.v1.1+json' }, + params: { facts: facts } + }); + } + + create(hostname: string, addr: string, labels: string[], status: string) { + return this.http.post( + this.baseURL, + { hostname: hostname, addr: addr, labels: labels, status: status }, + { observe: 'response', headers: { Accept: CdHelperClass.cdVersionHeader('0', '1') } } + ); + } + + delete(hostname: string) { + return this.http.delete(`${this.baseURL}/${hostname}`, { observe: 'response' }); + } + + getDevices(hostname: string): Observable<CdDevice[]> { + return this.http + .get<CdDevice[]>(`${this.baseURL}/${hostname}/devices`) + .pipe(map((devices) => devices.map((device) => this.deviceService.prepareDevice(device)))); + } + + getSmartData(hostname: string) { + return this.http.get<SmartDataResponseV1>(`${this.baseURL}/${hostname}/smart`); + } + + getDaemons(hostname: string): Observable<Daemon[]> { + return this.http.get<Daemon[]>(`${this.baseURL}/${hostname}/daemons`); + } + + getLabels(): Observable<string[]> { + return this.http.get<string[]>(`${this.baseUIURL}/labels`); + } + + update( + hostname: string, + updateLabels = false, + labels: string[] = [], + maintenance = false, + force = false, + drain = false + ) { + return this.http.put( + `${this.baseURL}/${hostname}`, + { + update_labels: updateLabels, + labels: labels, + maintenance: maintenance, + force: force, + drain: drain + }, + { headers: { Accept: this.getVersionHeaderValue(0, 1) } } + ); + } + + identifyDevice(hostname: string, device: string, duration: number) { + return this.http.post(`${this.baseURL}/${hostname}/identify_device`, { + device, + duration + }); + } + + private getInventoryParams(refresh?: boolean): HttpParams { + let params = new HttpParams(); + if (refresh) { + params = params.append('refresh', _.toString(refresh)); + } + return params; + } + + /** + * Get inventory of a host. + * + * @param hostname the host query. + * @param refresh true to ask the Orchestrator to refresh inventory. + */ + getInventory(hostname: string, refresh?: boolean): Observable<InventoryHost> { + const params = this.getInventoryParams(refresh); + return this.http.get<InventoryHost>(`${this.baseURL}/${hostname}/inventory`, { + params: params + }); + } + + /** + * Get inventories of all hosts. + * + * @param refresh true to ask the Orchestrator to refresh inventory. + */ + inventoryList(refresh?: boolean): Observable<InventoryHost[]> { + const params = this.getInventoryParams(refresh); + return this.http.get<InventoryHost[]>(`${this.baseUIURL}/inventory`, { params: params }); + } + + /** + * Get device list via host inventories. + * + * @param hostname the host to query. undefined for all hosts. + * @param refresh true to ask the Orchestrator to refresh inventory. + */ + inventoryDeviceList(hostname?: string, refresh?: boolean): Observable<InventoryDevice[]> { + let observable; + if (hostname) { + observable = this.getInventory(hostname, refresh).pipe(toArray()); + } else { + observable = this.inventoryList(refresh); + } + return observable.pipe( + mergeMap((hosts: InventoryHost[]) => { + const devices = _.flatMap(hosts, (host) => { + return host.devices.map((device) => { + device.hostname = host.name; + device.uid = device.device_id + ? `${device.device_id}-${device.hostname}-${device.path}` + : `${device.hostname}-${device.path}`; + return device; + }); + }); + return observableOf(devices); + }) + ); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/iscsi.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/iscsi.service.spec.ts new file mode 100644 index 000000000..fcb1804a6 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/iscsi.service.spec.ts @@ -0,0 +1,97 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { IscsiService } from './iscsi.service'; + +describe('IscsiService', () => { + let service: IscsiService; + let httpTesting: HttpTestingController; + + configureTestBed({ + providers: [IscsiService], + imports: [HttpClientTestingModule] + }); + + beforeEach(() => { + service = TestBed.inject(IscsiService); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call listTargets', () => { + service.listTargets().subscribe(); + const req = httpTesting.expectOne('api/iscsi/target'); + expect(req.request.method).toBe('GET'); + }); + + it('should call getTarget', () => { + service.getTarget('iqn.foo').subscribe(); + const req = httpTesting.expectOne('api/iscsi/target/iqn.foo'); + expect(req.request.method).toBe('GET'); + }); + + it('should call status', () => { + service.status().subscribe(); + const req = httpTesting.expectOne('ui-api/iscsi/status'); + expect(req.request.method).toBe('GET'); + }); + + it('should call settings', () => { + service.settings().subscribe(); + const req = httpTesting.expectOne('ui-api/iscsi/settings'); + expect(req.request.method).toBe('GET'); + }); + + it('should call portals', () => { + service.portals().subscribe(); + const req = httpTesting.expectOne('ui-api/iscsi/portals'); + expect(req.request.method).toBe('GET'); + }); + + it('should call createTarget', () => { + service.createTarget({ target_iqn: 'foo' }).subscribe(); + const req = httpTesting.expectOne('api/iscsi/target'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ target_iqn: 'foo' }); + }); + + it('should call updateTarget', () => { + service.updateTarget('iqn.foo', { target_iqn: 'foo' }).subscribe(); + const req = httpTesting.expectOne('api/iscsi/target/iqn.foo'); + expect(req.request.method).toBe('PUT'); + expect(req.request.body).toEqual({ target_iqn: 'foo' }); + }); + + it('should call deleteTarget', () => { + service.deleteTarget('target_iqn').subscribe(); + const req = httpTesting.expectOne('api/iscsi/target/target_iqn'); + expect(req.request.method).toBe('DELETE'); + }); + + it('should call getDiscovery', () => { + service.getDiscovery().subscribe(); + const req = httpTesting.expectOne('api/iscsi/discoveryauth'); + expect(req.request.method).toBe('GET'); + }); + + it('should call updateDiscovery', () => { + service + .updateDiscovery({ + user: 'foo', + password: 'bar', + mutual_user: 'mutual_foo', + mutual_password: 'mutual_bar' + }) + .subscribe(); + const req = httpTesting.expectOne('api/iscsi/discoveryauth'); + expect(req.request.method).toBe('PUT'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/iscsi.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/iscsi.service.ts new file mode 100644 index 000000000..9ef0310c7 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/iscsi.service.ts @@ -0,0 +1,60 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +import { cdEncode } from '../decorators/cd-encode'; + +@cdEncode +@Injectable({ + providedIn: 'root' +}) +export class IscsiService { + constructor(private http: HttpClient) {} + + listTargets() { + return this.http.get(`api/iscsi/target`); + } + + getTarget(target_iqn: string) { + return this.http.get(`api/iscsi/target/${target_iqn}`); + } + + updateTarget(target_iqn: string, target: any) { + return this.http.put(`api/iscsi/target/${target_iqn}`, target, { observe: 'response' }); + } + + status() { + return this.http.get(`ui-api/iscsi/status`); + } + + settings() { + return this.http.get(`ui-api/iscsi/settings`); + } + + version() { + return this.http.get(`ui-api/iscsi/version`); + } + + portals() { + return this.http.get(`ui-api/iscsi/portals`); + } + + createTarget(target: any) { + return this.http.post(`api/iscsi/target`, target, { observe: 'response' }); + } + + deleteTarget(target_iqn: string) { + return this.http.delete(`api/iscsi/target/${target_iqn}`, { observe: 'response' }); + } + + getDiscovery() { + return this.http.get(`api/iscsi/discoveryauth`); + } + + updateDiscovery(auth: any) { + return this.http.put(`api/iscsi/discoveryauth`, auth); + } + + overview() { + return this.http.get(`ui-api/iscsi/overview`); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/logging.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/logging.service.spec.ts new file mode 100644 index 000000000..6458827f0 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/logging.service.spec.ts @@ -0,0 +1,39 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { LoggingService } from './logging.service'; + +describe('LoggingService', () => { + let service: LoggingService; + let httpTesting: HttpTestingController; + + configureTestBed({ + providers: [LoggingService], + imports: [HttpClientTestingModule] + }); + + beforeEach(() => { + service = TestBed.inject(LoggingService); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call jsError', () => { + service.jsError('foo', 'bar', 'baz').subscribe(); + const req = httpTesting.expectOne('ui-api/logging/js-error'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ + url: 'foo', + message: 'bar', + stack: 'baz' + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/logging.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/logging.service.ts new file mode 100644 index 000000000..85846946b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/logging.service.ts @@ -0,0 +1,18 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root' +}) +export class LoggingService { + constructor(private http: HttpClient) {} + + jsError(url: string, message: string, stack: any) { + const request = { + url: url, + message: message, + stack: stack + }; + return this.http.post('ui-api/logging/js-error', request); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/logs.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/logs.service.spec.ts new file mode 100644 index 000000000..82c12dad8 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/logs.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 { LogsService } from './logs.service'; + +describe('LogsService', () => { + let service: LogsService; + let httpTesting: HttpTestingController; + + configureTestBed({ + providers: [LogsService], + imports: [HttpClientTestingModule] + }); + + beforeEach(() => { + service = TestBed.inject(LogsService); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call getLogs', () => { + service.getLogs().subscribe(); + const req = httpTesting.expectOne('api/logs/all'); + expect(req.request.method).toBe('GET'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/logs.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/logs.service.ts new file mode 100644 index 000000000..252769dbd --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/logs.service.ts @@ -0,0 +1,17 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root' +}) +export class LogsService { + constructor(private http: HttpClient) {} + + getLogs() { + return this.http.get('api/logs/all'); + } + + validateDashboardUrl(uid: string) { + return this.http.get(`api/grafana/validation/${uid}`); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.spec.ts new file mode 100644 index 000000000..77e6fb221 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.spec.ts @@ -0,0 +1,66 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { MgrModuleService } from './mgr-module.service'; + +describe('MgrModuleService', () => { + let service: MgrModuleService; + let httpTesting: HttpTestingController; + + configureTestBed({ + imports: [HttpClientTestingModule], + providers: [MgrModuleService] + }); + + beforeEach(() => { + service = TestBed.inject(MgrModuleService); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call list', () => { + service.list().subscribe(); + const req = httpTesting.expectOne('api/mgr/module'); + expect(req.request.method).toBe('GET'); + }); + + it('should call getConfig', () => { + service.getConfig('foo').subscribe(); + const req = httpTesting.expectOne('api/mgr/module/foo'); + expect(req.request.method).toBe('GET'); + }); + + it('should call updateConfig', () => { + const config = { foo: 'bar' }; + service.updateConfig('xyz', config).subscribe(); + const req = httpTesting.expectOne('api/mgr/module/xyz'); + expect(req.request.method).toBe('PUT'); + expect(req.request.body.config).toEqual(config); + }); + + it('should call enable', () => { + service.enable('foo').subscribe(); + const req = httpTesting.expectOne('api/mgr/module/foo/enable'); + expect(req.request.method).toBe('POST'); + }); + + it('should call disable', () => { + service.disable('bar').subscribe(); + const req = httpTesting.expectOne('api/mgr/module/bar/disable'); + expect(req.request.method).toBe('POST'); + }); + + it('should call getOptions', () => { + service.getOptions('foo').subscribe(); + const req = httpTesting.expectOne('api/mgr/module/foo/options'); + expect(req.request.method).toBe('GET'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.ts new file mode 100644 index 000000000..3942a1a44 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.ts @@ -0,0 +1,65 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +import { Observable } from 'rxjs'; + +@Injectable({ + providedIn: 'root' +}) +export class MgrModuleService { + private url = 'api/mgr/module'; + + constructor(private http: HttpClient) {} + + /** + * Get the list of Ceph Mgr modules and their state (enabled/disabled). + * @return {Observable<Object[]>} + */ + list(): Observable<Object[]> { + return this.http.get<Object[]>(`${this.url}`); + } + + /** + * Get the Ceph Mgr module configuration. + * @param {string} module The name of the mgr module. + * @return {Observable<Object>} + */ + getConfig(module: string): Observable<Object> { + return this.http.get(`${this.url}/${module}`); + } + + /** + * Update the Ceph Mgr module configuration. + * @param {string} module The name of the mgr module. + * @param {object} config The configuration. + * @return {Observable<Object>} + */ + updateConfig(module: string, config: object): Observable<Object> { + return this.http.put(`${this.url}/${module}`, { config: config }); + } + + /** + * Enable the Ceph Mgr module. + * @param {string} module The name of the mgr module. + */ + enable(module: string) { + return this.http.post(`${this.url}/${module}/enable`, null); + } + + /** + * Disable the Ceph Mgr module. + * @param {string} module The name of the mgr module. + */ + disable(module: string) { + return this.http.post(`${this.url}/${module}/disable`, null); + } + + /** + * Get the Ceph Mgr module options. + * @param {string} module The name of the mgr module. + * @return {Observable<Object>} + */ + getOptions(module: string): Observable<Object> { + return this.http.get(`${this.url}/${module}/options`); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/monitor.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/monitor.service.spec.ts new file mode 100644 index 000000000..29396866d --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/monitor.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 { MonitorService } from './monitor.service'; + +describe('MonitorService', () => { + let service: MonitorService; + let httpTesting: HttpTestingController; + + configureTestBed({ + providers: [MonitorService], + imports: [HttpClientTestingModule] + }); + + beforeEach(() => { + service = TestBed.inject(MonitorService); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call getMonitor', () => { + service.getMonitor().subscribe(); + const req = httpTesting.expectOne('api/monitor'); + expect(req.request.method).toBe('GET'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/monitor.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/monitor.service.ts new file mode 100644 index 000000000..42ca9a7af --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/monitor.service.ts @@ -0,0 +1,13 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root' +}) +export class MonitorService { + constructor(private http: HttpClient) {} + + getMonitor() { + return this.http.get('api/monitor'); + } +} 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 000000000..e186e8423 --- /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 000000000..dd17b2e04 --- /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/api/nfs.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nfs.service.spec.ts new file mode 100644 index 000000000..139fa490b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nfs.service.spec.ts @@ -0,0 +1,74 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { NfsService } from './nfs.service'; + +describe('NfsService', () => { + let service: NfsService; + let httpTesting: HttpTestingController; + + configureTestBed({ + providers: [NfsService], + imports: [HttpClientTestingModule] + }); + + beforeEach(() => { + service = TestBed.inject(NfsService); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call list', () => { + service.list().subscribe(); + const req = httpTesting.expectOne('api/nfs-ganesha/export'); + expect(req.request.method).toBe('GET'); + }); + + it('should call get', () => { + service.get('cluster_id', 'export_id').subscribe(); + const req = httpTesting.expectOne('api/nfs-ganesha/export/cluster_id/export_id'); + expect(req.request.method).toBe('GET'); + }); + + it('should call create', () => { + service.create('foo').subscribe(); + const req = httpTesting.expectOne('api/nfs-ganesha/export'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual('foo'); + }); + + it('should call update', () => { + service.update('cluster_id', 1, 'foo').subscribe(); + const req = httpTesting.expectOne('api/nfs-ganesha/export/cluster_id/1'); + expect(req.request.body).toEqual('foo'); + expect(req.request.method).toBe('PUT'); + }); + + it('should call delete', () => { + service.delete('hostName', 'exportId').subscribe(); + const req = httpTesting.expectOne('api/nfs-ganesha/export/hostName/exportId'); + expect(req.request.method).toBe('DELETE'); + }); + + it('should call lsDir', () => { + service.lsDir('a', 'foo_dir').subscribe(); + const req = httpTesting.expectOne('ui-api/nfs-ganesha/lsdir/a?root_dir=foo_dir'); + expect(req.request.method).toBe('GET'); + }); + + it('should not call lsDir if volume is not provided', fakeAsync(() => { + service.lsDir('', 'foo_dir').subscribe({ + error: (error: string) => expect(error).toEqual('Please specify a filesystem volume.') + }); + tick(); + httpTesting.expectNone('ui-api/nfs-ganesha/lsdir/?root_dir=foo_dir'); + })); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nfs.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nfs.service.ts new file mode 100644 index 000000000..9b4e4a0a2 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nfs.service.ts @@ -0,0 +1,108 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +import { Observable, throwError } from 'rxjs'; + +import { NfsFSAbstractionLayer } from '~/app/ceph/nfs/models/nfs.fsal'; +import { ApiClient } from '~/app/shared/api/api-client'; + +export interface Directory { + paths: string[]; +} + +@Injectable({ + providedIn: 'root' +}) +export class NfsService extends ApiClient { + apiPath = 'api/nfs-ganesha'; + uiApiPath = 'ui-api/nfs-ganesha'; + + nfsAccessType = [ + { + value: 'RW', + help: $localize`Allows all operations` + }, + { + value: 'RO', + help: $localize`Allows only operations that do not modify the server` + }, + { + value: 'NONE', + help: $localize`Allows no access at all` + } + ]; + + nfsFsal: NfsFSAbstractionLayer[] = [ + { + value: 'CEPH', + descr: $localize`CephFS`, + disabled: false + }, + { + value: 'RGW', + descr: $localize`Object Gateway`, + disabled: false + } + ]; + + nfsSquash = { + no_root_squash: ['no_root_squash', 'noidsquash', 'none'], + root_id_squash: ['root_id_squash', 'rootidsquash', 'rootid'], + root_squash: ['root_squash', 'rootsquash', 'root'], + all_squash: ['all_squash', 'allsquash', 'all', 'allanonymous', 'all_anonymous'] + }; + + constructor(private http: HttpClient) { + super(); + } + + list() { + return this.http.get(`${this.apiPath}/export`); + } + + get(clusterId: string, exportId: string) { + return this.http.get(`${this.apiPath}/export/${clusterId}/${exportId}`); + } + + create(nfs: any) { + return this.http.post(`${this.apiPath}/export`, nfs, { + headers: { Accept: this.getVersionHeaderValue(2, 0) }, + observe: 'response' + }); + } + + update(clusterId: string, id: number, nfs: any) { + return this.http.put(`${this.apiPath}/export/${clusterId}/${id}`, nfs, { + headers: { Accept: this.getVersionHeaderValue(2, 0) }, + observe: 'response' + }); + } + + delete(clusterId: string, exportId: string) { + return this.http.delete(`${this.apiPath}/export/${clusterId}/${exportId}`, { + headers: { Accept: this.getVersionHeaderValue(2, 0) }, + observe: 'response' + }); + } + + listClusters() { + return this.http.get(`${this.apiPath}/cluster`, { + headers: { Accept: this.getVersionHeaderValue(0, 1) } + }); + } + + lsDir(fs_name: string, root_dir: string): Observable<Directory> { + if (!fs_name) { + return throwError($localize`Please specify a filesystem volume.`); + } + return this.http.get<Directory>(`${this.uiApiPath}/lsdir/${fs_name}?root_dir=${root_dir}`); + } + + fsals() { + return this.http.get(`${this.uiApiPath}/fsals`); + } + + filesystems() { + return this.http.get(`${this.uiApiPath}/cephfs/filesystems`); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.spec.ts new file mode 100644 index 000000000..c49cb8b0d --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.spec.ts @@ -0,0 +1,35 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { OrchestratorService } from './orchestrator.service'; + +describe('OrchestratorService', () => { + let service: OrchestratorService; + let httpTesting: HttpTestingController; + const uiApiPath = 'ui-api/orchestrator'; + + configureTestBed({ + providers: [OrchestratorService], + imports: [HttpClientTestingModule] + }); + + beforeEach(() => { + service = TestBed.inject(OrchestratorService); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call status', () => { + service.status().subscribe(); + const req = httpTesting.expectOne(`${uiApiPath}/status`); + expect(req.request.method).toBe('GET'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.ts new file mode 100644 index 000000000..a6e33e834 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.ts @@ -0,0 +1,46 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +import _ from 'lodash'; +import { Observable } from 'rxjs'; + +import { OrchestratorFeature } from '../models/orchestrator.enum'; +import { OrchestratorStatus } from '../models/orchestrator.interface'; + +@Injectable({ + providedIn: 'root' +}) +export class OrchestratorService { + private url = 'ui-api/orchestrator'; + + disableMessages = { + noOrchestrator: $localize`The feature is disabled because Orchestrator is not available.`, + missingFeature: $localize`The Orchestrator backend doesn't support this feature.` + }; + + constructor(private http: HttpClient) {} + + status(): Observable<OrchestratorStatus> { + return this.http.get<OrchestratorStatus>(`${this.url}/status`); + } + + hasFeature(status: OrchestratorStatus, features: OrchestratorFeature[]): boolean { + return _.every(features, (feature) => _.get(status.features, `${feature}.available`)); + } + + getTableActionDisableDesc( + status: OrchestratorStatus, + features: OrchestratorFeature[] + ): boolean | string { + if (!status) { + return false; + } + if (!status.available) { + return this.disableMessages.noOrchestrator; + } + if (!this.hasFeature(status, features)) { + return this.disableMessages.missingFeature; + } + return false; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/osd.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/osd.service.spec.ts new file mode 100644 index 000000000..d1f999779 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/osd.service.spec.ts @@ -0,0 +1,183 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { OsdService } from './osd.service'; + +describe('OsdService', () => { + let service: OsdService; + let httpTesting: HttpTestingController; + + configureTestBed({ + providers: [OsdService], + imports: [HttpClientTestingModule] + }); + + beforeEach(() => { + service = TestBed.inject(OsdService); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call create', () => { + const trackingId = 'all_hdd, host1_ssd'; + const post_data = { + method: 'drive_groups', + data: [ + { + service_name: 'osd', + service_id: 'all_hdd', + host_pattern: '*', + data_devices: { + rotational: true + } + }, + { + service_name: 'osd', + service_id: 'host1_ssd', + host_pattern: 'host1', + data_devices: { + rotational: false + } + } + ], + tracking_id: trackingId + }; + service.create(post_data.data, trackingId).subscribe(); + const req = httpTesting.expectOne('api/osd'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(post_data); + }); + + it('should call delete', () => { + const id = 1; + service.delete(id, true, true).subscribe(); + const req = httpTesting.expectOne(`api/osd/${id}?preserve_id=true&force=true`); + expect(req.request.method).toBe('DELETE'); + }); + + it('should call getList', () => { + service.getList().subscribe(); + const req = httpTesting.expectOne('api/osd'); + expect(req.request.method).toBe('GET'); + }); + + it('should call getDetails', () => { + service.getDetails(1).subscribe(); + const req = httpTesting.expectOne('api/osd/1'); + expect(req.request.method).toBe('GET'); + }); + + it('should call scrub, with deep=true', () => { + service.scrub('foo', true).subscribe(); + const req = httpTesting.expectOne('api/osd/foo/scrub?deep=true'); + expect(req.request.method).toBe('POST'); + }); + + it('should call scrub, with deep=false', () => { + service.scrub('foo', false).subscribe(); + const req = httpTesting.expectOne('api/osd/foo/scrub?deep=false'); + expect(req.request.method).toBe('POST'); + }); + + it('should call getFlags', () => { + service.getFlags().subscribe(); + const req = httpTesting.expectOne('api/osd/flags'); + expect(req.request.method).toBe('GET'); + }); + + it('should call updateFlags', () => { + service.updateFlags(['foo']).subscribe(); + const req = httpTesting.expectOne('api/osd/flags'); + expect(req.request.method).toBe('PUT'); + expect(req.request.body).toEqual({ flags: ['foo'] }); + }); + + it('should call updateIndividualFlags to update individual flags', () => { + const flags = { noin: true, noout: true }; + const ids = [0, 1]; + service.updateIndividualFlags(flags, ids).subscribe(); + const req = httpTesting.expectOne('api/osd/flags/individual'); + expect(req.request.method).toBe('PUT'); + expect(req.request.body).toEqual({ flags: flags, ids: ids }); + }); + + it('should mark the OSD out', () => { + service.markOut(1).subscribe(); + const req = httpTesting.expectOne('api/osd/1/mark'); + expect(req.request.method).toBe('PUT'); + expect(req.request.body).toEqual({ action: 'out' }); + }); + + it('should mark the OSD in', () => { + service.markIn(1).subscribe(); + const req = httpTesting.expectOne('api/osd/1/mark'); + expect(req.request.method).toBe('PUT'); + expect(req.request.body).toEqual({ action: 'in' }); + }); + + it('should mark the OSD down', () => { + service.markDown(1).subscribe(); + const req = httpTesting.expectOne('api/osd/1/mark'); + expect(req.request.method).toBe('PUT'); + expect(req.request.body).toEqual({ action: 'down' }); + }); + + it('should reweight an OSD', () => { + service.reweight(1, 0.5).subscribe(); + const req = httpTesting.expectOne('api/osd/1/reweight'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ weight: 0.5 }); + }); + + it('should update OSD', () => { + service.update(1, 'hdd').subscribe(); + const req = httpTesting.expectOne('api/osd/1'); + expect(req.request.method).toBe('PUT'); + expect(req.request.body).toEqual({ device_class: 'hdd' }); + }); + + it('should mark an OSD lost', () => { + service.markLost(1).subscribe(); + const req = httpTesting.expectOne('api/osd/1/mark'); + expect(req.request.method).toBe('PUT'); + expect(req.request.body).toEqual({ action: 'lost' }); + }); + + it('should purge an OSD', () => { + service.purge(1).subscribe(); + const req = httpTesting.expectOne('api/osd/1/purge'); + expect(req.request.method).toBe('POST'); + }); + + it('should destroy an OSD', () => { + service.destroy(1).subscribe(); + const req = httpTesting.expectOne('api/osd/1/destroy'); + expect(req.request.method).toBe('POST'); + }); + + it('should return if it is safe to destroy an OSD', () => { + service.safeToDestroy('[0,1]').subscribe(); + const req = httpTesting.expectOne('api/osd/safe_to_destroy?ids=[0,1]'); + expect(req.request.method).toBe('GET'); + }); + + it('should call the devices endpoint to retrieve smart data', () => { + service.getDevices(1).subscribe(); + const req = httpTesting.expectOne('api/osd/1/devices'); + expect(req.request.method).toBe('GET'); + }); + + it('should call getDeploymentOptions', () => { + service.getDeploymentOptions().subscribe(); + const req = httpTesting.expectOne('ui-api/osd/deployment_options'); + expect(req.request.method).toBe('GET'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/osd.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/osd.service.ts new file mode 100644 index 000000000..34461bf63 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/osd.service.ts @@ -0,0 +1,190 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +import _ from 'lodash'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { CdDevice } from '../models/devices'; +import { InventoryDeviceType } from '../models/inventory-device-type.model'; +import { DeploymentOptions } from '../models/osd-deployment-options'; +import { OsdSettings } from '../models/osd-settings'; +import { SmartDataResponseV1 } from '../models/smart'; +import { DeviceService } from '../services/device.service'; + +@Injectable({ + providedIn: 'root' +}) +export class OsdService { + private path = 'api/osd'; + private uiPath = 'ui-api/osd'; + + osdDevices: InventoryDeviceType[] = []; + + osdRecvSpeedModalPriorities = { + KNOWN_PRIORITIES: [ + { + name: null, + text: $localize`-- Select the priority --`, + values: { + osd_max_backfills: null, + osd_recovery_max_active: null, + osd_recovery_max_single_start: null, + osd_recovery_sleep: null + } + }, + { + name: 'low', + text: $localize`Low`, + values: { + osd_max_backfills: 1, + osd_recovery_max_active: 1, + osd_recovery_max_single_start: 1, + osd_recovery_sleep: 0.5 + } + }, + { + name: 'default', + text: $localize`Default`, + values: { + osd_max_backfills: 1, + osd_recovery_max_active: 3, + osd_recovery_max_single_start: 1, + osd_recovery_sleep: 0 + } + }, + { + name: 'high', + text: $localize`High`, + values: { + osd_max_backfills: 4, + osd_recovery_max_active: 4, + osd_recovery_max_single_start: 4, + osd_recovery_sleep: 0 + } + } + ] + }; + + constructor(private http: HttpClient, private deviceService: DeviceService) {} + + create(driveGroups: Object[], trackingId: string, method = 'drive_groups') { + const request = { + method: method, + data: driveGroups, + tracking_id: trackingId + }; + return this.http.post(this.path, request, { observe: 'response' }); + } + + getList() { + return this.http.get(`${this.path}`); + } + + getOsdSettings(): Observable<OsdSettings> { + return this.http.get<OsdSettings>(`${this.path}/settings`, { + headers: { Accept: 'application/vnd.ceph.api.v0.1+json' } + }); + } + + getDetails(id: number) { + interface OsdData { + osd_map: { [key: string]: any }; + osd_metadata: { [key: string]: any }; + smart: { [device_identifier: string]: any }; + } + return this.http.get<OsdData>(`${this.path}/${id}`); + } + + /** + * @param id OSD ID + */ + getSmartData(id: number) { + return this.http.get<SmartDataResponseV1>(`${this.path}/${id}/smart`); + } + + scrub(id: string, deep: boolean) { + return this.http.post(`${this.path}/${id}/scrub?deep=${deep}`, null); + } + + getDeploymentOptions() { + return this.http.get<DeploymentOptions>(`${this.uiPath}/deployment_options`); + } + + getFlags() { + return this.http.get(`${this.path}/flags`); + } + + updateFlags(flags: string[]) { + return this.http.put(`${this.path}/flags`, { flags: flags }); + } + + updateIndividualFlags(flags: { [flag: string]: boolean }, ids: number[]) { + return this.http.put(`${this.path}/flags/individual`, { flags: flags, ids: ids }); + } + + markOut(id: number) { + return this.http.put(`${this.path}/${id}/mark`, { action: 'out' }); + } + + markIn(id: number) { + return this.http.put(`${this.path}/${id}/mark`, { action: 'in' }); + } + + markDown(id: number) { + return this.http.put(`${this.path}/${id}/mark`, { action: 'down' }); + } + + reweight(id: number, weight: number) { + return this.http.post(`${this.path}/${id}/reweight`, { weight: weight }); + } + + update(id: number, deviceClass: string) { + return this.http.put(`${this.path}/${id}`, { device_class: deviceClass }); + } + + markLost(id: number) { + return this.http.put(`${this.path}/${id}/mark`, { action: 'lost' }); + } + + purge(id: number) { + return this.http.post(`${this.path}/${id}/purge`, null); + } + + destroy(id: number) { + return this.http.post(`${this.path}/${id}/destroy`, null); + } + + delete(id: number, preserveId?: boolean, force?: boolean) { + const params = { + preserve_id: preserveId ? 'true' : 'false', + force: force ? 'true' : 'false' + }; + return this.http.delete(`${this.path}/${id}`, { observe: 'response', params: params }); + } + + safeToDestroy(ids: string) { + interface SafeToDestroyResponse { + active: number[]; + missing_stats: number[]; + stored_pgs: number[]; + is_safe_to_destroy: boolean; + message?: string; + } + return this.http.get<SafeToDestroyResponse>(`${this.path}/safe_to_destroy?ids=${ids}`); + } + + safeToDelete(ids: string) { + interface SafeToDeleteResponse { + is_safe_to_delete: boolean; + message?: string; + } + return this.http.get<SafeToDeleteResponse>(`${this.path}/safe_to_delete?svc_ids=${ids}`); + } + + getDevices(osdId: number) { + return this.http + .get<CdDevice[]>(`${this.path}/${osdId}/devices`) + .pipe(map((devices) => devices.map((device) => this.deviceService.prepareDevice(device)))); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/performance-counter.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/performance-counter.service.spec.ts new file mode 100644 index 000000000..12b13787b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/performance-counter.service.spec.ts @@ -0,0 +1,45 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { PerformanceCounterService } from './performance-counter.service'; + +describe('PerformanceCounterService', () => { + let service: PerformanceCounterService; + let httpTesting: HttpTestingController; + + configureTestBed({ + providers: [PerformanceCounterService], + imports: [HttpClientTestingModule] + }); + + beforeEach(() => { + service = TestBed.inject(PerformanceCounterService); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call list', () => { + service.list().subscribe(); + const req = httpTesting.expectOne('api/perf_counters'); + expect(req.request.method).toBe('GET'); + }); + + it('should call get', () => { + let result; + service.get('foo', '1').subscribe((resp) => { + result = resp; + }); + const req = httpTesting.expectOne('api/perf_counters/foo/1'); + expect(req.request.method).toBe('GET'); + req.flush({ counters: [{ foo: 'bar' }] }); + expect(result).toEqual([{ foo: 'bar' }]); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/performance-counter.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/performance-counter.service.ts new file mode 100644 index 000000000..36be6f383 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/performance-counter.service.ts @@ -0,0 +1,29 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +import { of as observableOf } from 'rxjs'; +import { mergeMap } from 'rxjs/operators'; + +import { cdEncode } from '../decorators/cd-encode'; + +@cdEncode +@Injectable({ + providedIn: 'root' +}) +export class PerformanceCounterService { + private url = 'api/perf_counters'; + + constructor(private http: HttpClient) {} + + list() { + return this.http.get(this.url); + } + + get(service_type: string, service_id: string) { + return this.http.get(`${this.url}/${service_type}/${service_id}`).pipe( + mergeMap((resp: any) => { + return observableOf(resp['counters']); + }) + ); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/pool.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/pool.service.spec.ts new file mode 100644 index 000000000..292da3c21 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/pool.service.spec.ts @@ -0,0 +1,123 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { RbdConfigurationSourceField } from '../models/configuration'; +import { RbdConfigurationService } from '../services/rbd-configuration.service'; +import { PoolService } from './pool.service'; + +describe('PoolService', () => { + let service: PoolService; + let httpTesting: HttpTestingController; + const apiPath = 'api/pool'; + + configureTestBed({ + providers: [PoolService, RbdConfigurationService], + imports: [HttpClientTestingModule] + }); + + beforeEach(() => { + service = TestBed.inject(PoolService); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call getList', () => { + service.getList().subscribe(); + const req = httpTesting.expectOne(`${apiPath}?stats=true`); + expect(req.request.method).toBe('GET'); + }); + + it('should call getInfo', () => { + service.getInfo().subscribe(); + const req = httpTesting.expectOne(`ui-${apiPath}/info`); + expect(req.request.method).toBe('GET'); + }); + + it('should call create', () => { + const pool = { pool: 'somePool' }; + service.create(pool).subscribe(); + const req = httpTesting.expectOne(apiPath); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(pool); + }); + + it('should call update', () => { + service.update({ pool: 'somePool', application_metadata: [] }).subscribe(); + const req = httpTesting.expectOne(`${apiPath}/somePool`); + expect(req.request.method).toBe('PUT'); + expect(req.request.body).toEqual({ application_metadata: [] }); + }); + + it('should call delete', () => { + service.delete('somePool').subscribe(); + const req = httpTesting.expectOne(`${apiPath}/somePool`); + expect(req.request.method).toBe('DELETE'); + }); + + it('should call list without parameter', fakeAsync(() => { + let result; + service.list().then((resp) => (result = resp)); + const req = httpTesting.expectOne(`${apiPath}?attrs=`); + expect(req.request.method).toBe('GET'); + req.flush(['foo', 'bar']); + tick(); + expect(result).toEqual(['foo', 'bar']); + })); + + it('should call list with a list', fakeAsync(() => { + let result; + service.list(['foo']).then((resp) => (result = resp)); + const req = httpTesting.expectOne(`${apiPath}?attrs=foo`); + expect(req.request.method).toBe('GET'); + req.flush(['foo', 'bar']); + tick(); + expect(result).toEqual(['foo', 'bar']); + })); + + it('should test injection of data from getConfiguration()', fakeAsync(() => { + const pool = 'foo'; + let value; + service.getConfiguration(pool).subscribe((next) => (value = next)); + const req = httpTesting.expectOne(`${apiPath}/${pool}/configuration`); + expect(req.request.method).toBe('GET'); + req.flush([ + { + name: 'rbd_qos_bps_limit', + value: '60', + source: RbdConfigurationSourceField.global + }, + { + name: 'rbd_qos_iops_limit', + value: '0', + source: RbdConfigurationSourceField.global + } + ]); + tick(); + expect(value).toEqual([ + { + description: 'The desired limit of IO bytes per second.', + displayName: 'BPS Limit', + name: 'rbd_qos_bps_limit', + source: RbdConfigurationSourceField.global, + type: 0, + value: '60' + }, + { + description: 'The desired limit of IO operations per second.', + displayName: 'IOPS Limit', + name: 'rbd_qos_iops_limit', + source: RbdConfigurationSourceField.global, + type: 1, + value: '0' + } + ]); + })); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/pool.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/pool.service.ts new file mode 100644 index 000000000..78d5819ec --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/pool.service.ts @@ -0,0 +1,74 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { cdEncode } from '../decorators/cd-encode'; +import { RbdConfigurationEntry } from '../models/configuration'; +import { RbdConfigurationService } from '../services/rbd-configuration.service'; + +@cdEncode +@Injectable({ + providedIn: 'root' +}) +export class PoolService { + apiPath = 'api/pool'; + + constructor(private http: HttpClient, private rbdConfigurationService: RbdConfigurationService) {} + + create(pool: any) { + return this.http.post(this.apiPath, pool, { observe: 'response' }); + } + + update(pool: any) { + let name: string; + if (pool.hasOwnProperty('srcpool')) { + name = pool.srcpool; + delete pool.srcpool; + } else { + name = pool.pool; + delete pool.pool; + } + return this.http.put(`${this.apiPath}/${encodeURIComponent(name)}`, pool, { + observe: 'response' + }); + } + + delete(name: string) { + return this.http.delete(`${this.apiPath}/${name}`, { observe: 'response' }); + } + + get(poolName: string) { + return this.http.get(`${this.apiPath}/${poolName}`); + } + + getList() { + return this.http.get(`${this.apiPath}?stats=true`); + } + + getConfiguration(poolName: string): Observable<RbdConfigurationEntry[]> { + return this.http.get<RbdConfigurationEntry[]>(`${this.apiPath}/${poolName}/configuration`).pipe( + // Add static data maintained in RbdConfigurationService + map((values) => + values.map((entry) => + Object.assign(entry, this.rbdConfigurationService.getOptionByName(entry.name)) + ) + ) + ); + } + + getInfo() { + return this.http.get(`ui-${this.apiPath}/info`); + } + + list(attrs: string[] = []) { + const attrsStr = attrs.join(','); + return this.http + .get(`${this.apiPath}?attrs=${attrsStr}`) + .toPromise() + .then((resp: any) => { + return resp; + }); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.spec.ts new file mode 100644 index 000000000..c42f6e7ac --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.spec.ts @@ -0,0 +1,247 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { AlertmanagerNotification } from '../models/prometheus-alerts'; +import { PrometheusService } from './prometheus.service'; +import { SettingsService } from './settings.service'; + +describe('PrometheusService', () => { + let service: PrometheusService; + let httpTesting: HttpTestingController; + + configureTestBed({ + providers: [PrometheusService, SettingsService], + imports: [HttpClientTestingModule] + }); + + beforeEach(() => { + service = TestBed.inject(PrometheusService); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should get alerts', () => { + service.getAlerts().subscribe(); + const req = httpTesting.expectOne('api/prometheus'); + expect(req.request.method).toBe('GET'); + }); + + it('should get silences', () => { + service.getSilences().subscribe(); + const req = httpTesting.expectOne('api/prometheus/silences'); + expect(req.request.method).toBe('GET'); + }); + + it('should set a silence', () => { + const silence = { + id: 'someId', + matchers: [ + { + name: 'getZero', + value: 0, + isRegex: false + } + ], + startsAt: '2019-01-25T14:32:46.646300974Z', + endsAt: '2019-01-25T18:32:46.646300974Z', + createdBy: 'someCreator', + comment: 'for testing purpose' + }; + service.setSilence(silence).subscribe(); + const req = httpTesting.expectOne('api/prometheus/silence'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(silence); + }); + + it('should expire a silence', () => { + service.expireSilence('someId').subscribe(); + const req = httpTesting.expectOne('api/prometheus/silence/someId'); + expect(req.request.method).toBe('DELETE'); + }); + + it('should call getNotificationSince without a notification', () => { + service.getNotifications().subscribe(); + const req = httpTesting.expectOne('api/prometheus/notifications?from=last'); + expect(req.request.method).toBe('GET'); + }); + + it('should call getNotificationSince with notification', () => { + service.getNotifications({ id: '42' } as AlertmanagerNotification).subscribe(); + const req = httpTesting.expectOne('api/prometheus/notifications?from=42'); + expect(req.request.method).toBe('GET'); + }); + + describe('test getRules()', () => { + let data: {}; // Subset of PrometheusRuleGroup to keep the tests concise. + + beforeEach(() => { + data = { + groups: [ + { + name: 'test', + rules: [ + { + name: 'load_0', + type: 'alerting' + }, + { + name: 'load_1', + type: 'alerting' + }, + { + name: 'load_2', + type: 'alerting' + } + ] + }, + { + name: 'recording_rule', + rules: [ + { + name: 'node_memory_MemUsed_percent', + type: 'recording' + } + ] + } + ] + }; + }); + + it('should get rules without applying filters', () => { + service.getRules().subscribe((rules) => { + expect(rules).toEqual(data); + }); + + const req = httpTesting.expectOne('api/prometheus/rules'); + expect(req.request.method).toBe('GET'); + req.flush(data); + }); + + it('should get rewrite rules only', () => { + service.getRules('rewrites').subscribe((rules) => { + expect(rules).toEqual({ + groups: [ + { name: 'test', rules: [] }, + { name: 'recording_rule', rules: [] } + ] + }); + }); + + const req = httpTesting.expectOne('api/prometheus/rules'); + expect(req.request.method).toBe('GET'); + req.flush(data); + }); + + it('should get alerting rules only', () => { + service.getRules('alerting').subscribe((rules) => { + expect(rules).toEqual({ + groups: [ + { + name: 'test', + rules: [ + { name: 'load_0', type: 'alerting' }, + { name: 'load_1', type: 'alerting' }, + { name: 'load_2', type: 'alerting' } + ] + }, + { name: 'recording_rule', rules: [] } + ] + }); + }); + + const req = httpTesting.expectOne('api/prometheus/rules'); + expect(req.request.method).toBe('GET'); + req.flush(data); + }); + }); + + describe('ifAlertmanagerConfigured', () => { + let x: any; + let host: string; + + const receiveConfig = () => { + const req = httpTesting.expectOne('api/settings/alertmanager-api-host'); + expect(req.request.method).toBe('GET'); + req.flush({ value: host }); + }; + + beforeEach(() => { + x = false; + TestBed.inject(SettingsService)['settings'] = {}; + service.ifAlertmanagerConfigured( + (v) => (x = v), + () => (x = []) + ); + host = 'http://localhost:9093'; + }); + + it('changes x in a valid case', () => { + expect(x).toBe(false); + receiveConfig(); + expect(x).toBe(host); + }); + + it('does changes x an empty array in a invalid case', () => { + host = ''; + receiveConfig(); + expect(x).toEqual([]); + }); + + it('disables the set setting', () => { + receiveConfig(); + service.disableAlertmanagerConfig(); + x = false; + service.ifAlertmanagerConfigured((v) => (x = v)); + expect(x).toBe(false); + }); + }); + + describe('ifPrometheusConfigured', () => { + let x: any; + let host: string; + + const receiveConfig = () => { + const req = httpTesting.expectOne('api/settings/prometheus-api-host'); + expect(req.request.method).toBe('GET'); + req.flush({ value: host }); + }; + + beforeEach(() => { + x = false; + TestBed.inject(SettingsService)['settings'] = {}; + service.ifPrometheusConfigured( + (v) => (x = v), + () => (x = []) + ); + host = 'http://localhost:9090'; + }); + + it('changes x in a valid case', () => { + expect(x).toBe(false); + receiveConfig(); + expect(x).toBe(host); + }); + + it('does changes x an empty array in a invalid case', () => { + host = ''; + receiveConfig(); + expect(x).toEqual([]); + }); + + it('disables the set setting', () => { + receiveConfig(); + service.disablePrometheusConfig(); + x = false; + service.ifPrometheusConfigured((v) => (x = v)); + expect(x).toBe(false); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.ts new file mode 100644 index 000000000..581917219 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.ts @@ -0,0 +1,82 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { AlertmanagerSilence } from '../models/alertmanager-silence'; +import { + AlertmanagerAlert, + AlertmanagerNotification, + PrometheusRuleGroup +} from '../models/prometheus-alerts'; +import { SettingsService } from './settings.service'; + +@Injectable({ + providedIn: 'root' +}) +export class PrometheusService { + private baseURL = 'api/prometheus'; + private settingsKey = { + alertmanager: 'api/settings/alertmanager-api-host', + prometheus: 'api/settings/prometheus-api-host' + }; + + constructor(private http: HttpClient, private settingsService: SettingsService) {} + + ifAlertmanagerConfigured(fn: (value?: string) => void, elseFn?: () => void): void { + this.settingsService.ifSettingConfigured(this.settingsKey.alertmanager, fn, elseFn); + } + + disableAlertmanagerConfig(): void { + this.settingsService.disableSetting(this.settingsKey.alertmanager); + } + + ifPrometheusConfigured(fn: (value?: string) => void, elseFn?: () => void): void { + this.settingsService.ifSettingConfigured(this.settingsKey.prometheus, fn, elseFn); + } + + disablePrometheusConfig(): void { + this.settingsService.disableSetting(this.settingsKey.prometheus); + } + + getAlerts(params = {}): Observable<AlertmanagerAlert[]> { + return this.http.get<AlertmanagerAlert[]>(this.baseURL, { params }); + } + + getSilences(params = {}): Observable<AlertmanagerSilence[]> { + return this.http.get<AlertmanagerSilence[]>(`${this.baseURL}/silences`, { params }); + } + + getRules( + type: 'all' | 'alerting' | 'rewrites' = 'all' + ): Observable<{ groups: PrometheusRuleGroup[] }> { + return this.http.get<{ groups: PrometheusRuleGroup[] }>(`${this.baseURL}/rules`).pipe( + map((rules) => { + if (['alerting', 'rewrites'].includes(type)) { + rules.groups.map((group) => { + group.rules = group.rules.filter((rule) => rule.type === type); + }); + } + return rules; + }) + ); + } + + setSilence(silence: AlertmanagerSilence) { + return this.http.post<object>(`${this.baseURL}/silence`, silence, { observe: 'response' }); + } + + expireSilence(silenceId: string) { + return this.http.delete(`${this.baseURL}/silence/${silenceId}`, { observe: 'response' }); + } + + getNotifications( + notification?: AlertmanagerNotification + ): Observable<AlertmanagerNotification[]> { + const url = `${this.baseURL}/notifications?from=${ + notification && notification.id ? notification.id : 'last' + }`; + return this.http.get<AlertmanagerNotification[]>(url); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd-mirroring.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd-mirroring.service.spec.ts new file mode 100644 index 000000000..3f883d91f --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd-mirroring.service.spec.ts @@ -0,0 +1,164 @@ +import { HttpRequest } from '@angular/common/http'; +import { + HttpClientTestingModule, + HttpTestingController, + TestRequest +} from '@angular/common/http/testing'; +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { RbdMirroringService } from './rbd-mirroring.service'; + +describe('RbdMirroringService', () => { + let service: RbdMirroringService; + let httpTesting: HttpTestingController; + let getMirroringSummaryCalls: () => TestRequest[]; + let flushCalls: (call: TestRequest) => void; + + const summary: Record<string, any> = { + status: 0, + content_data: { + daemons: [], + pools: [], + image_error: [], + image_syncing: [], + image_ready: [] + }, + executing_tasks: [{}] + }; + + configureTestBed({ + providers: [RbdMirroringService], + imports: [HttpClientTestingModule] + }); + + beforeEach(() => { + service = TestBed.inject(RbdMirroringService); + httpTesting = TestBed.inject(HttpTestingController); + getMirroringSummaryCalls = () => { + return httpTesting.match((request: HttpRequest<any>) => { + return request.url.match(/api\/block\/mirroring\/summary/) && request.method === 'GET'; + }); + }; + flushCalls = (call: TestRequest) => { + if (!call.cancelled) { + call.flush(summary); + } + }; + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should periodically poll summary', fakeAsync(() => { + const subs = service.startPolling(); + tick(); + const calledWith: any[] = []; + service.subscribeSummary((data) => { + calledWith.push(data); + }); + tick(service.REFRESH_INTERVAL * 2); + const calls = getMirroringSummaryCalls(); + + expect(calls.length).toEqual(3); + calls.forEach((call: TestRequest) => flushCalls(call)); + expect(calledWith).toEqual([summary]); + + subs.unsubscribe(); + })); + + it('should get pool config', () => { + service.getPool('poolName').subscribe(); + + const req = httpTesting.expectOne('api/block/mirroring/pool/poolName'); + expect(req.request.method).toBe('GET'); + }); + + it('should update pool config', () => { + const request = { + mirror_mode: 'pool' + }; + service.updatePool('poolName', request).subscribe(); + + const req = httpTesting.expectOne('api/block/mirroring/pool/poolName'); + expect(req.request.method).toBe('PUT'); + expect(req.request.body).toEqual(request); + }); + + it('should get site name', () => { + service.getSiteName().subscribe(); + + const req = httpTesting.expectOne('api/block/mirroring/site_name'); + expect(req.request.method).toBe('GET'); + }); + + it('should set site name', () => { + service.setSiteName('site-a').subscribe(); + + const req = httpTesting.expectOne('api/block/mirroring/site_name'); + expect(req.request.method).toBe('PUT'); + expect(req.request.body).toEqual({ site_name: 'site-a' }); + }); + + it('should create bootstrap token', () => { + service.createBootstrapToken('poolName').subscribe(); + + const req = httpTesting.expectOne('api/block/mirroring/pool/poolName/bootstrap/token'); + expect(req.request.method).toBe('POST'); + }); + + it('should import bootstrap token', () => { + service.importBootstrapToken('poolName', 'rx', 'token-1234').subscribe(); + + const req = httpTesting.expectOne('api/block/mirroring/pool/poolName/bootstrap/peer'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ + direction: 'rx', + token: 'token-1234' + }); + }); + + it('should get peer config', () => { + service.getPeer('poolName', 'peerUUID').subscribe(); + + const req = httpTesting.expectOne('api/block/mirroring/pool/poolName/peer/peerUUID'); + expect(req.request.method).toBe('GET'); + }); + + it('should add peer config', () => { + const request = { + cluster_name: 'remote', + client_id: 'admin', + mon_host: 'localhost', + key: '1234' + }; + service.addPeer('poolName', request).subscribe(); + + const req = httpTesting.expectOne('api/block/mirroring/pool/poolName/peer'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(request); + }); + + it('should update peer config', () => { + const request = { + cluster_name: 'remote' + }; + service.updatePeer('poolName', 'peerUUID', request).subscribe(); + + const req = httpTesting.expectOne('api/block/mirroring/pool/poolName/peer/peerUUID'); + expect(req.request.method).toBe('PUT'); + expect(req.request.body).toEqual(request); + }); + + it('should delete peer config', () => { + service.deletePeer('poolName', 'peerUUID').subscribe(); + + const req = httpTesting.expectOne('api/block/mirroring/pool/poolName/peer/peerUUID'); + expect(req.request.method).toBe('DELETE'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd-mirroring.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd-mirroring.service.ts new file mode 100644 index 000000000..4958382e2 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd-mirroring.service.ts @@ -0,0 +1,114 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +import { BehaviorSubject, Observable, Subscription } from 'rxjs'; +import { filter } from 'rxjs/operators'; + +import { cdEncode, cdEncodeNot } from '../decorators/cd-encode'; +import { MirroringSummary } from '../models/mirroring-summary'; +import { TimerService } from '../services/timer.service'; + +@cdEncode +@Injectable({ + providedIn: 'root' +}) +export class RbdMirroringService { + readonly REFRESH_INTERVAL = 30000; + // Observable sources + private summaryDataSource = new BehaviorSubject<MirroringSummary>(null); + // Observable streams + summaryData$ = this.summaryDataSource.asObservable(); + + constructor(private http: HttpClient, private timerService: TimerService) {} + + startPolling(): Subscription { + return this.timerService + .get(() => this.retrieveSummaryObservable(), this.REFRESH_INTERVAL) + .subscribe(this.retrieveSummaryObserver()); + } + + refresh(): Subscription { + return this.retrieveSummaryObservable().subscribe(this.retrieveSummaryObserver()); + } + + private retrieveSummaryObservable(): Observable<MirroringSummary> { + return this.http.get('api/block/mirroring/summary'); + } + + private retrieveSummaryObserver(): (data: MirroringSummary) => void { + return (data: any) => { + this.summaryDataSource.next(data); + }; + } + + /** + * Subscribes to the summaryData, + * which is updated periodically or when a new task is created. + */ + subscribeSummary( + next: (summary: MirroringSummary) => void, + error?: (error: any) => void + ): Subscription { + return this.summaryData$.pipe(filter((value) => !!value)).subscribe(next, error); + } + + getPool(poolName: string) { + return this.http.get(`api/block/mirroring/pool/${poolName}`); + } + + updatePool(poolName: string, request: any) { + return this.http.put(`api/block/mirroring/pool/${poolName}`, request, { observe: 'response' }); + } + + getSiteName() { + return this.http.get(`api/block/mirroring/site_name`); + } + + setSiteName(@cdEncodeNot siteName: string) { + return this.http.put( + `api/block/mirroring/site_name`, + { site_name: siteName }, + { observe: 'response' } + ); + } + + createBootstrapToken(poolName: string) { + return this.http.post(`api/block/mirroring/pool/${poolName}/bootstrap/token`, {}); + } + + importBootstrapToken( + poolName: string, + @cdEncodeNot direction: string, + @cdEncodeNot token: string + ) { + const request = { + direction: direction, + token: token + }; + return this.http.post(`api/block/mirroring/pool/${poolName}/bootstrap/peer`, request, { + observe: 'response' + }); + } + + getPeer(poolName: string, peerUUID: string) { + return this.http.get(`api/block/mirroring/pool/${poolName}/peer/${peerUUID}`); + } + + addPeer(poolName: string, request: any) { + return this.http.post(`api/block/mirroring/pool/${poolName}/peer`, request, { + observe: 'response' + }); + } + + updatePeer(poolName: string, peerUUID: string, request: any) { + return this.http.put(`api/block/mirroring/pool/${poolName}/peer/${peerUUID}`, request, { + observe: 'response' + }); + } + + deletePeer(poolName: string, peerUUID: string) { + return this.http.delete(`api/block/mirroring/pool/${poolName}/peer/${peerUUID}`, { + observe: 'response' + }); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd.model.ts new file mode 100644 index 000000000..d14b2bc40 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd.model.ts @@ -0,0 +1,30 @@ +import { RbdConfigurationEntry } from '../models/configuration'; + +export interface RbdPool { + pool_name: string; + status: number; + value: RbdImage[]; + headers: any; +} + +export interface RbdImage { + disk_usage: number; + stripe_unit: number; + name: string; + parent: any; + pool_name: string; + num_objs: number; + block_name_prefix: string; + snapshots: any[]; + obj_size: number; + data_pool: string; + total_disk_usage: number; + features: number; + configuration: RbdConfigurationEntry[]; + timestamp: string; + id: string; + features_name: string[]; + stripe_count: number; + order: number; + size: number; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd.service.spec.ts new file mode 100644 index 000000000..84abf6d34 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd.service.spec.ts @@ -0,0 +1,181 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { ImageSpec } from '../models/image-spec'; +import { RbdConfigurationService } from '../services/rbd-configuration.service'; +import { RbdService } from './rbd.service'; + +describe('RbdService', () => { + let service: RbdService; + let httpTesting: HttpTestingController; + + configureTestBed({ + providers: [RbdService, RbdConfigurationService], + imports: [HttpClientTestingModule] + }); + + beforeEach(() => { + service = TestBed.inject(RbdService); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call create', () => { + service.create('foo').subscribe(); + const req = httpTesting.expectOne('api/block/image'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual('foo'); + }); + + it('should call delete', () => { + service.delete(new ImageSpec('poolName', null, 'rbdName')).subscribe(); + const req = httpTesting.expectOne('api/block/image/poolName%2FrbdName'); + expect(req.request.method).toBe('DELETE'); + }); + + it('should call update', () => { + service.update(new ImageSpec('poolName', null, 'rbdName'), 'foo').subscribe(); + const req = httpTesting.expectOne('api/block/image/poolName%2FrbdName'); + expect(req.request.body).toEqual('foo'); + expect(req.request.method).toBe('PUT'); + }); + + it('should call get', () => { + service.get(new ImageSpec('poolName', null, 'rbdName')).subscribe(); + const req = httpTesting.expectOne('api/block/image/poolName%2FrbdName'); + expect(req.request.method).toBe('GET'); + }); + + it('should call list', () => { + /* tslint:disable:no-empty */ + const context = new CdTableFetchDataContext(() => {}); + service.list(context.toParams()).subscribe(); + const req = httpTesting.expectOne('api/block/image?offset=0&limit=10&search=&sort=+name'); + expect(req.request.method).toBe('GET'); + }); + + it('should call copy', () => { + service.copy(new ImageSpec('poolName', null, 'rbdName'), 'foo').subscribe(); + const req = httpTesting.expectOne('api/block/image/poolName%2FrbdName/copy'); + expect(req.request.body).toEqual('foo'); + expect(req.request.method).toBe('POST'); + }); + + it('should call flatten', () => { + service.flatten(new ImageSpec('poolName', null, 'rbdName')).subscribe(); + const req = httpTesting.expectOne('api/block/image/poolName%2FrbdName/flatten'); + expect(req.request.body).toEqual(null); + expect(req.request.method).toBe('POST'); + }); + + it('should call defaultFeatures', () => { + service.defaultFeatures().subscribe(); + const req = httpTesting.expectOne('api/block/image/default_features'); + expect(req.request.method).toBe('GET'); + }); + + it('should call cloneFormatVersion', () => { + service.cloneFormatVersion().subscribe(); + const req = httpTesting.expectOne('api/block/image/clone_format_version'); + expect(req.request.method).toBe('GET'); + }); + + it('should call createSnapshot', () => { + service.createSnapshot(new ImageSpec('poolName', null, 'rbdName'), 'snapshotName').subscribe(); + const req = httpTesting.expectOne('api/block/image/poolName%2FrbdName/snap'); + expect(req.request.body).toEqual({ + snapshot_name: 'snapshotName' + }); + expect(req.request.method).toBe('POST'); + }); + + it('should call renameSnapshot', () => { + service + .renameSnapshot(new ImageSpec('poolName', null, 'rbdName'), 'snapshotName', 'foo') + .subscribe(); + const req = httpTesting.expectOne('api/block/image/poolName%2FrbdName/snap/snapshotName'); + expect(req.request.body).toEqual({ + new_snap_name: 'foo' + }); + expect(req.request.method).toBe('PUT'); + }); + + it('should call protectSnapshot', () => { + service + .protectSnapshot(new ImageSpec('poolName', null, 'rbdName'), 'snapshotName', true) + .subscribe(); + const req = httpTesting.expectOne('api/block/image/poolName%2FrbdName/snap/snapshotName'); + expect(req.request.body).toEqual({ + is_protected: true + }); + expect(req.request.method).toBe('PUT'); + }); + + it('should call rollbackSnapshot', () => { + service + .rollbackSnapshot(new ImageSpec('poolName', null, 'rbdName'), 'snapshotName') + .subscribe(); + const req = httpTesting.expectOne( + 'api/block/image/poolName%2FrbdName/snap/snapshotName/rollback' + ); + expect(req.request.body).toEqual(null); + expect(req.request.method).toBe('POST'); + }); + + it('should call cloneSnapshot', () => { + service + .cloneSnapshot(new ImageSpec('poolName', null, 'rbdName'), 'snapshotName', null) + .subscribe(); + const req = httpTesting.expectOne('api/block/image/poolName%2FrbdName/snap/snapshotName/clone'); + expect(req.request.body).toEqual(null); + expect(req.request.method).toBe('POST'); + }); + + it('should call deleteSnapshot', () => { + service.deleteSnapshot(new ImageSpec('poolName', null, 'rbdName'), 'snapshotName').subscribe(); + const req = httpTesting.expectOne('api/block/image/poolName%2FrbdName/snap/snapshotName'); + expect(req.request.method).toBe('DELETE'); + }); + + it('should call moveTrash', () => { + service.moveTrash(new ImageSpec('poolName', null, 'rbdName'), 1).subscribe(); + const req = httpTesting.expectOne('api/block/image/poolName%2FrbdName/move_trash'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ delay: 1 }); + }); + + describe('should compose image spec', () => { + it('with namespace', () => { + expect(new ImageSpec('mypool', 'myns', 'myimage').toString()).toBe('mypool/myns/myimage'); + }); + + it('without namespace', () => { + expect(new ImageSpec('mypool', null, 'myimage').toString()).toBe('mypool/myimage'); + }); + }); + + describe('should parse image spec', () => { + it('with namespace', () => { + const imageSpec = ImageSpec.fromString('mypool/myns/myimage'); + expect(imageSpec.poolName).toBe('mypool'); + expect(imageSpec.namespace).toBe('myns'); + expect(imageSpec.imageName).toBe('myimage'); + }); + + it('without namespace', () => { + const imageSpec = ImageSpec.fromString('mypool/myimage'); + expect(imageSpec.poolName).toBe('mypool'); + expect(imageSpec.namespace).toBeNull(); + expect(imageSpec.imageName).toBe('myimage'); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd.service.ts new file mode 100644 index 000000000..555f0db0f --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd.service.ts @@ -0,0 +1,198 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +import _ from 'lodash'; +import { map } from 'rxjs/operators'; + +import { ApiClient } from '~/app/shared/api/api-client'; +import { cdEncode, cdEncodeNot } from '../decorators/cd-encode'; +import { ImageSpec } from '../models/image-spec'; +import { RbdConfigurationService } from '../services/rbd-configuration.service'; +import { RbdPool } from './rbd.model'; + +@cdEncode +@Injectable({ + providedIn: 'root' +}) +export class RbdService extends ApiClient { + constructor(private http: HttpClient, private rbdConfigurationService: RbdConfigurationService) { + super(); + } + + isRBDPool(pool: any) { + return _.indexOf(pool.application_metadata, 'rbd') !== -1 && !pool.pool_name.includes('/'); + } + + create(rbd: any) { + return this.http.post('api/block/image', rbd, { observe: 'response' }); + } + + delete(imageSpec: ImageSpec) { + return this.http.delete(`api/block/image/${imageSpec.toStringEncoded()}`, { + observe: 'response' + }); + } + + update(imageSpec: ImageSpec, rbd: any) { + return this.http.put(`api/block/image/${imageSpec.toStringEncoded()}`, rbd, { + observe: 'response' + }); + } + + get(imageSpec: ImageSpec) { + return this.http.get(`api/block/image/${imageSpec.toStringEncoded()}`); + } + + list(params: any) { + return this.http + .get<RbdPool[]>('api/block/image', { + params: params, + headers: { Accept: this.getVersionHeaderValue(2, 0) }, + observe: 'response' + }) + .pipe( + map((response: any) => { + return response['body'].map((pool: any) => { + pool.value.map((image: any) => { + if (!image.configuration) { + return image; + } + image.configuration.map((option: any) => + Object.assign(option, this.rbdConfigurationService.getOptionByName(option.name)) + ); + return image; + }); + pool['headers'] = response.headers; + return pool; + }); + }) + ); + } + + copy(imageSpec: ImageSpec, rbd: any) { + return this.http.post(`api/block/image/${imageSpec.toStringEncoded()}/copy`, rbd, { + observe: 'response' + }); + } + + flatten(imageSpec: ImageSpec) { + return this.http.post(`api/block/image/${imageSpec.toStringEncoded()}/flatten`, null, { + observe: 'response' + }); + } + + defaultFeatures() { + return this.http.get('api/block/image/default_features'); + } + + cloneFormatVersion() { + return this.http.get<number>('api/block/image/clone_format_version'); + } + + createSnapshot(imageSpec: ImageSpec, @cdEncodeNot snapshotName: string) { + const request = { + snapshot_name: snapshotName + }; + return this.http.post(`api/block/image/${imageSpec.toStringEncoded()}/snap`, request, { + observe: 'response' + }); + } + + renameSnapshot(imageSpec: ImageSpec, snapshotName: string, @cdEncodeNot newSnapshotName: string) { + const request = { + new_snap_name: newSnapshotName + }; + return this.http.put( + `api/block/image/${imageSpec.toStringEncoded()}/snap/${snapshotName}`, + request, + { + observe: 'response' + } + ); + } + + protectSnapshot(imageSpec: ImageSpec, snapshotName: string, @cdEncodeNot isProtected: boolean) { + const request = { + is_protected: isProtected + }; + return this.http.put( + `api/block/image/${imageSpec.toStringEncoded()}/snap/${snapshotName}`, + request, + { + observe: 'response' + } + ); + } + + rollbackSnapshot(imageSpec: ImageSpec, snapshotName: string) { + return this.http.post( + `api/block/image/${imageSpec.toStringEncoded()}/snap/${snapshotName}/rollback`, + null, + { observe: 'response' } + ); + } + + cloneSnapshot(imageSpec: ImageSpec, snapshotName: string, request: any) { + return this.http.post( + `api/block/image/${imageSpec.toStringEncoded()}/snap/${snapshotName}/clone`, + request, + { observe: 'response' } + ); + } + + deleteSnapshot(imageSpec: ImageSpec, snapshotName: string) { + return this.http.delete(`api/block/image/${imageSpec.toStringEncoded()}/snap/${snapshotName}`, { + observe: 'response' + }); + } + + listTrash() { + return this.http.get(`api/block/image/trash/`); + } + + createNamespace(pool: string, namespace: string) { + const request = { + namespace: namespace + }; + return this.http.post(`api/block/pool/${pool}/namespace`, request, { observe: 'response' }); + } + + listNamespaces(pool: string) { + return this.http.get(`api/block/pool/${pool}/namespace/`); + } + + deleteNamespace(pool: string, namespace: string) { + return this.http.delete(`api/block/pool/${pool}/namespace/${namespace}`, { + observe: 'response' + }); + } + + moveTrash(imageSpec: ImageSpec, delay: number) { + return this.http.post( + `api/block/image/${imageSpec.toStringEncoded()}/move_trash`, + { delay: delay }, + { observe: 'response' } + ); + } + + purgeTrash(poolName: string) { + return this.http.post(`api/block/image/trash/purge/?pool_name=${poolName}`, null, { + observe: 'response' + }); + } + + restoreTrash(imageSpec: ImageSpec, @cdEncodeNot newImageName: string) { + return this.http.post( + `api/block/image/trash/${imageSpec.toStringEncoded()}/restore`, + { new_image_name: newImageName }, + { observe: 'response' } + ); + } + + removeTrash(imageSpec: ImageSpec, force = false) { + return this.http.delete( + `api/block/image/trash/${imageSpec.toStringEncoded()}/?force=${force}`, + { observe: 'response' } + ); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-bucket.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-bucket.service.spec.ts new file mode 100644 index 000000000..b22b67e34 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-bucket.service.spec.ts @@ -0,0 +1,102 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { configureTestBed, RgwHelper } from '~/testing/unit-test-helper'; +import { RgwBucketService } from './rgw-bucket.service'; + +describe('RgwBucketService', () => { + let service: RgwBucketService; + let httpTesting: HttpTestingController; + + configureTestBed({ + providers: [RgwBucketService], + imports: [HttpClientTestingModule] + }); + + beforeEach(() => { + service = TestBed.inject(RgwBucketService); + httpTesting = TestBed.inject(HttpTestingController); + RgwHelper.selectDaemon(); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call list', () => { + service.list().subscribe(); + const req = httpTesting.expectOne(`api/rgw/bucket?${RgwHelper.DAEMON_QUERY_PARAM}&stats=false`); + expect(req.request.method).toBe('GET'); + }); + + it('should call list with stats and user id', () => { + service.list(true, 'test-name').subscribe(); + const req = httpTesting.expectOne( + `api/rgw/bucket?${RgwHelper.DAEMON_QUERY_PARAM}&stats=true&uid=test-name` + ); + expect(req.request.method).toBe('GET'); + }); + + it('should call get', () => { + service.get('foo').subscribe(); + const req = httpTesting.expectOne(`api/rgw/bucket/foo?${RgwHelper.DAEMON_QUERY_PARAM}`); + expect(req.request.method).toBe('GET'); + }); + + it('should call create', () => { + service + .create('foo', 'bar', 'default', 'default-placement', false, 'COMPLIANCE', '5') + .subscribe(); + const req = httpTesting.expectOne( + `api/rgw/bucket?bucket=foo&uid=bar&zonegroup=default&placement_target=default-placement&lock_enabled=false&lock_mode=COMPLIANCE&lock_retention_period_days=5&${RgwHelper.DAEMON_QUERY_PARAM}` + ); + expect(req.request.method).toBe('POST'); + }); + + it('should call update', () => { + service + .update('foo', 'bar', 'baz', 'Enabled', 'Enabled', '1', '223344', 'GOVERNANCE', '10') + .subscribe(); + const req = httpTesting.expectOne( + `api/rgw/bucket/foo?${RgwHelper.DAEMON_QUERY_PARAM}&bucket_id=bar&uid=baz&versioning_state=Enabled&mfa_delete=Enabled&mfa_token_serial=1&mfa_token_pin=223344&lock_mode=GOVERNANCE&lock_retention_period_days=10` + ); + expect(req.request.method).toBe('PUT'); + }); + + it('should call delete, with purgeObjects = true', () => { + service.delete('foo').subscribe(); + const req = httpTesting.expectOne( + `api/rgw/bucket/foo?${RgwHelper.DAEMON_QUERY_PARAM}&purge_objects=true` + ); + expect(req.request.method).toBe('DELETE'); + }); + + it('should call delete, with purgeObjects = false', () => { + service.delete('foo', false).subscribe(); + const req = httpTesting.expectOne( + `api/rgw/bucket/foo?${RgwHelper.DAEMON_QUERY_PARAM}&purge_objects=false` + ); + expect(req.request.method).toBe('DELETE'); + }); + + it('should call exists', () => { + let result; + service.exists('foo').subscribe((resp) => { + result = resp; + }); + const req = httpTesting.expectOne(`api/rgw/bucket/foo?${RgwHelper.DAEMON_QUERY_PARAM}`); + expect(req.request.method).toBe('GET'); + req.flush(['foo', 'bar']); + expect(result).toBe(true); + }); + + it('should convert lock retention period to days', () => { + expect(service.getLockDays({ lock_retention_period_years: 1000 })).toBe(365242); + expect(service.getLockDays({ lock_retention_period_days: 5 })).toBe(5); + expect(service.getLockDays({})).toBe(0); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-bucket.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-bucket.service.ts new file mode 100644 index 000000000..fc88bfa71 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-bucket.service.ts @@ -0,0 +1,128 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +import _ from 'lodash'; +import { of as observableOf } from 'rxjs'; +import { catchError, mapTo } from 'rxjs/operators'; + +import { ApiClient } from '~/app/shared/api/api-client'; +import { RgwDaemonService } from '~/app/shared/api/rgw-daemon.service'; +import { cdEncode } from '~/app/shared/decorators/cd-encode'; + +@cdEncode +@Injectable({ + providedIn: 'root' +}) +export class RgwBucketService extends ApiClient { + private url = 'api/rgw/bucket'; + + constructor(private http: HttpClient, private rgwDaemonService: RgwDaemonService) { + super(); + } + + /** + * Get the list of buckets. + * @return Observable<Object[]> + */ + list(stats: boolean = false, uid: string = '') { + return this.rgwDaemonService.request((params: HttpParams) => { + params = params.append('stats', stats.toString()); + if (uid) { + params = params.append('uid', uid); + } + return this.http.get(this.url, { + headers: { Accept: this.getVersionHeaderValue(1, 1) }, + params: params + }); + }); + } + + get(bucket: string) { + return this.rgwDaemonService.request((params: HttpParams) => { + return this.http.get(`${this.url}/${bucket}`, { params: params }); + }); + } + + create( + bucket: string, + uid: string, + zonegroup: string, + placementTarget: string, + lockEnabled: boolean, + lock_mode: 'GOVERNANCE' | 'COMPLIANCE', + lock_retention_period_days: string + ) { + return this.rgwDaemonService.request((params: HttpParams) => { + return this.http.post(this.url, null, { + params: new HttpParams({ + fromObject: { + bucket, + uid, + zonegroup, + placement_target: placementTarget, + lock_enabled: String(lockEnabled), + lock_mode, + lock_retention_period_days, + daemon_name: params.get('daemon_name') + } + }) + }); + }); + } + + update( + bucket: string, + bucketId: string, + uid: string, + versioningState: string, + mfaDelete: string, + mfaTokenSerial: string, + mfaTokenPin: string, + lockMode: 'GOVERNANCE' | 'COMPLIANCE', + lockRetentionPeriodDays: string + ) { + return this.rgwDaemonService.request((params: HttpParams) => { + params = params.append('bucket_id', bucketId); + params = params.append('uid', uid); + params = params.append('versioning_state', versioningState); + params = params.append('mfa_delete', mfaDelete); + params = params.append('mfa_token_serial', mfaTokenSerial); + params = params.append('mfa_token_pin', mfaTokenPin); + params = params.append('lock_mode', lockMode); + params = params.append('lock_retention_period_days', lockRetentionPeriodDays); + return this.http.put(`${this.url}/${bucket}`, null, { params: params }); + }); + } + + delete(bucket: string, purgeObjects = true) { + return this.rgwDaemonService.request((params: HttpParams) => { + params = params.append('purge_objects', purgeObjects ? 'true' : 'false'); + return this.http.delete(`${this.url}/${bucket}`, { params: params }); + }); + } + + /** + * Check if the specified bucket exists. + * @param {string} bucket The bucket name to check. + * @return Observable<boolean> + */ + exists(bucket: string) { + return this.get(bucket).pipe( + mapTo(true), + catchError((error: Event) => { + if (_.isFunction(error.preventDefault)) { + error.preventDefault(); + } + return observableOf(false); + }) + ); + } + + getLockDays(bucketData: object): number { + if (bucketData['lock_retention_period_years'] > 0) { + return Math.floor(bucketData['lock_retention_period_years'] * 365.242); + } + + return bucketData['lock_retention_period_days'] || 0; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-daemon.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-daemon.service.spec.ts new file mode 100644 index 000000000..d669ddefc --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-daemon.service.spec.ts @@ -0,0 +1,90 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; + +import { of } from 'rxjs'; + +import { RgwDaemon } from '~/app/ceph/rgw/models/rgw-daemon'; +import { configureTestBed, RgwHelper } from '~/testing/unit-test-helper'; +import { RgwDaemonService } from './rgw-daemon.service'; + +describe('RgwDaemonService', () => { + let service: RgwDaemonService; + let httpTesting: HttpTestingController; + let selectDaemonSpy: jasmine.Spy; + + const daemonList: Array<RgwDaemon> = RgwHelper.getDaemonList(); + const retrieveDaemonList = (reqDaemonList: RgwDaemon[], daemon: RgwDaemon) => { + service + .request((params) => of(params)) + .subscribe((params) => expect(params.get('daemon_name')).toBe(daemon.id)); + const listReq = httpTesting.expectOne('api/rgw/daemon'); + listReq.flush(reqDaemonList); + tick(); + expect(service['selectedDaemon'].getValue()).toEqual(daemon); + }; + + configureTestBed({ + providers: [RgwDaemonService], + imports: [HttpClientTestingModule] + }); + + beforeEach(() => { + service = TestBed.inject(RgwDaemonService); + selectDaemonSpy = spyOn(service, 'selectDaemon').and.callThrough(); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should get daemon list', () => { + service.list().subscribe(); + const req = httpTesting.expectOne('api/rgw/daemon'); + req.flush(daemonList); + expect(req.request.method).toBe('GET'); + expect(service['daemons'].getValue()).toEqual(daemonList); + }); + + it('should call "get daemon"', () => { + service.get('foo').subscribe(); + const req = httpTesting.expectOne('api/rgw/daemon/foo'); + expect(req.request.method).toBe('GET'); + }); + + it('should call request and not select any daemon from empty daemon list', fakeAsync(() => { + expect(() => retrieveDaemonList([], null)).toThrowError('No RGW daemons found!'); + expect(selectDaemonSpy).toHaveBeenCalledTimes(0); + })); + + it('should call request and select default daemon from daemon list', fakeAsync(() => { + retrieveDaemonList(daemonList, daemonList[1]); + expect(selectDaemonSpy).toHaveBeenCalledTimes(1); + expect(selectDaemonSpy).toHaveBeenCalledWith(daemonList[1]); + })); + + it('should call request and select first daemon from daemon list that has no default', fakeAsync(() => { + const noDefaultDaemonList = daemonList.map((daemon) => { + daemon.default = false; + return daemon; + }); + retrieveDaemonList(noDefaultDaemonList, noDefaultDaemonList[0]); + expect(selectDaemonSpy).toHaveBeenCalledTimes(1); + expect(selectDaemonSpy).toHaveBeenCalledWith(noDefaultDaemonList[0]); + })); + + it('should update default daemon if not exist in daemon list', fakeAsync(() => { + const tmpDaemonList = [...daemonList]; + service.selectDaemon(tmpDaemonList[1]); // Select 'default' daemon. + tmpDaemonList.splice(1, 1); // Remove 'default' daemon. + tmpDaemonList[0].default = true; // Set new 'default' daemon. + service.list().subscribe(); + const testReq = httpTesting.expectOne('api/rgw/daemon'); + testReq.flush(tmpDaemonList); + expect(service['selectedDaemon'].getValue()).toEqual(tmpDaemonList[0]); + })); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-daemon.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-daemon.service.ts new file mode 100644 index 000000000..5c513c7f1 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-daemon.service.ts @@ -0,0 +1,82 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +import _ from 'lodash'; +import { BehaviorSubject, Observable, of, throwError } from 'rxjs'; +import { mergeMap, take, tap } from 'rxjs/operators'; + +import { RgwDaemon } from '~/app/ceph/rgw/models/rgw-daemon'; +import { cdEncode } from '~/app/shared/decorators/cd-encode'; + +@cdEncode +@Injectable({ + providedIn: 'root' +}) +export class RgwDaemonService { + private url = 'api/rgw/daemon'; + private daemons = new BehaviorSubject<RgwDaemon[]>([]); + daemons$ = this.daemons.asObservable(); + private selectedDaemon = new BehaviorSubject<RgwDaemon>(null); + selectedDaemon$ = this.selectedDaemon.asObservable(); + + constructor(private http: HttpClient) {} + + list(): Observable<RgwDaemon[]> { + return this.http.get<RgwDaemon[]>(this.url).pipe( + tap((daemons: RgwDaemon[]) => { + this.daemons.next(daemons); + const selectedDaemon = this.selectedDaemon.getValue(); + // Set or re-select the default daemon if the current one is not + // in the list anymore. + if (_.isEmpty(selectedDaemon) || undefined === _.find(daemons, { id: selectedDaemon.id })) { + this.selectDefaultDaemon(daemons); + } + }) + ); + } + + get(id: string) { + return this.http.get(`${this.url}/${id}`); + } + + selectDaemon(daemon: RgwDaemon) { + this.selectedDaemon.next(daemon); + } + + private selectDefaultDaemon(daemons: RgwDaemon[]): RgwDaemon { + if (daemons.length === 0) { + return null; + } + + for (const daemon of daemons) { + if (daemon.default) { + this.selectDaemon(daemon); + return daemon; + } + } + + this.selectDaemon(daemons[0]); + return daemons[0]; + } + + request(next: (params: HttpParams) => Observable<any>) { + return this.selectedDaemon.pipe( + mergeMap((daemon: RgwDaemon) => + // If there is no selected daemon, retrieve daemon list so default daemon will be selected. + _.isEmpty(daemon) + ? this.list().pipe( + mergeMap((daemons) => + _.isEmpty(daemons) ? throwError('No RGW daemons found!') : this.selectedDaemon$ + ) + ) + : of(daemon) + ), + take(1), + mergeMap((daemon: RgwDaemon) => { + let params = new HttpParams(); + params = params.append('daemon_name', daemon.id); + return next(params); + }) + ); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-site.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-site.service.spec.ts new file mode 100644 index 000000000..fa769d88b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-site.service.spec.ts @@ -0,0 +1,43 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { configureTestBed, RgwHelper } from '~/testing/unit-test-helper'; +import { RgwSiteService } from './rgw-site.service'; + +describe('RgwSiteService', () => { + let service: RgwSiteService; + let httpTesting: HttpTestingController; + + configureTestBed({ + providers: [RgwSiteService], + imports: [HttpClientTestingModule] + }); + + beforeEach(() => { + service = TestBed.inject(RgwSiteService); + httpTesting = TestBed.inject(HttpTestingController); + RgwHelper.selectDaemon(); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should contain site endpoint in GET request', () => { + service.get().subscribe(); + const req = httpTesting.expectOne(`${service['url']}?${RgwHelper.DAEMON_QUERY_PARAM}`); + expect(req.request.method).toBe('GET'); + }); + + it('should add query param in GET request', () => { + const query = 'placement-targets'; + service.get(query).subscribe(); + httpTesting.expectOne( + `${service['url']}?${RgwHelper.DAEMON_QUERY_PARAM}&query=placement-targets` + ); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-site.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-site.service.ts new file mode 100644 index 000000000..49589c83f --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-site.service.ts @@ -0,0 +1,38 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +import { Observable } from 'rxjs'; +import { map, mergeMap } from 'rxjs/operators'; + +import { RgwDaemon } from '~/app/ceph/rgw/models/rgw-daemon'; +import { RgwDaemonService } from '~/app/shared/api/rgw-daemon.service'; +import { cdEncode } from '~/app/shared/decorators/cd-encode'; + +@cdEncode +@Injectable({ + providedIn: 'root' +}) +export class RgwSiteService { + private url = 'api/rgw/site'; + + constructor(private http: HttpClient, private rgwDaemonService: RgwDaemonService) {} + + get(query?: string) { + return this.rgwDaemonService.request((params: HttpParams) => { + if (query) { + params = params.append('query', query); + } + return this.http.get(this.url, { params: params }); + }); + } + + isDefaultRealm(): Observable<boolean> { + return this.get('default-realm').pipe( + mergeMap((defaultRealm: string) => + this.rgwDaemonService.selectedDaemon$.pipe( + map((selectedDaemon: RgwDaemon) => selectedDaemon.realm_name === defaultRealm) + ) + ) + ); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-user.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-user.service.spec.ts new file mode 100644 index 000000000..7884f2385 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-user.service.spec.ts @@ -0,0 +1,170 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { of as observableOf, throwError } from 'rxjs'; + +import { configureTestBed, RgwHelper } from '~/testing/unit-test-helper'; +import { RgwUserService } from './rgw-user.service'; + +describe('RgwUserService', () => { + let service: RgwUserService; + let httpTesting: HttpTestingController; + + configureTestBed({ + imports: [HttpClientTestingModule], + providers: [RgwUserService] + }); + + beforeEach(() => { + service = TestBed.inject(RgwUserService); + httpTesting = TestBed.inject(HttpTestingController); + RgwHelper.selectDaemon(); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call list with empty result', () => { + let result; + service.list().subscribe((resp) => { + result = resp; + }); + const req = httpTesting.expectOne(`api/rgw/user?${RgwHelper.DAEMON_QUERY_PARAM}`); + expect(req.request.method).toBe('GET'); + req.flush([]); + expect(result).toEqual([]); + }); + + it('should call list with result', () => { + let result; + service.list().subscribe((resp) => { + result = resp; + }); + let req = httpTesting.expectOne(`api/rgw/user?${RgwHelper.DAEMON_QUERY_PARAM}`); + expect(req.request.method).toBe('GET'); + req.flush(['foo', 'bar']); + + req = httpTesting.expectOne(`api/rgw/user/foo?${RgwHelper.DAEMON_QUERY_PARAM}`); + expect(req.request.method).toBe('GET'); + req.flush({ name: 'foo' }); + + req = httpTesting.expectOne(`api/rgw/user/bar?${RgwHelper.DAEMON_QUERY_PARAM}`); + expect(req.request.method).toBe('GET'); + req.flush({ name: 'bar' }); + + expect(result).toEqual([{ name: 'foo' }, { name: 'bar' }]); + }); + + it('should call enumerate', () => { + service.enumerate().subscribe(); + const req = httpTesting.expectOne(`api/rgw/user?${RgwHelper.DAEMON_QUERY_PARAM}`); + expect(req.request.method).toBe('GET'); + }); + + it('should call get', () => { + service.get('foo').subscribe(); + const req = httpTesting.expectOne(`api/rgw/user/foo?${RgwHelper.DAEMON_QUERY_PARAM}`); + expect(req.request.method).toBe('GET'); + }); + + it('should call getQuota', () => { + service.getQuota('foo').subscribe(); + const req = httpTesting.expectOne(`api/rgw/user/foo/quota?${RgwHelper.DAEMON_QUERY_PARAM}`); + expect(req.request.method).toBe('GET'); + }); + + it('should call update', () => { + service.update('foo', { xxx: 'yyy' }).subscribe(); + const req = httpTesting.expectOne(`api/rgw/user/foo?${RgwHelper.DAEMON_QUERY_PARAM}&xxx=yyy`); + expect(req.request.method).toBe('PUT'); + }); + + it('should call updateQuota', () => { + service.updateQuota('foo', { xxx: 'yyy' }).subscribe(); + const req = httpTesting.expectOne( + `api/rgw/user/foo/quota?${RgwHelper.DAEMON_QUERY_PARAM}&xxx=yyy` + ); + expect(req.request.method).toBe('PUT'); + }); + + it('should call create', () => { + service.create({ foo: 'bar' }).subscribe(); + const req = httpTesting.expectOne(`api/rgw/user?${RgwHelper.DAEMON_QUERY_PARAM}&foo=bar`); + expect(req.request.method).toBe('POST'); + }); + + it('should call delete', () => { + service.delete('foo').subscribe(); + const req = httpTesting.expectOne(`api/rgw/user/foo?${RgwHelper.DAEMON_QUERY_PARAM}`); + expect(req.request.method).toBe('DELETE'); + }); + + it('should call createSubuser', () => { + service.createSubuser('foo', { xxx: 'yyy' }).subscribe(); + const req = httpTesting.expectOne( + `api/rgw/user/foo/subuser?${RgwHelper.DAEMON_QUERY_PARAM}&xxx=yyy` + ); + expect(req.request.method).toBe('POST'); + }); + + it('should call deleteSubuser', () => { + service.deleteSubuser('foo', 'bar').subscribe(); + const req = httpTesting.expectOne( + `api/rgw/user/foo/subuser/bar?${RgwHelper.DAEMON_QUERY_PARAM}` + ); + expect(req.request.method).toBe('DELETE'); + }); + + it('should call addCapability', () => { + service.addCapability('foo', 'bar', 'baz').subscribe(); + const req = httpTesting.expectOne( + `api/rgw/user/foo/capability?${RgwHelper.DAEMON_QUERY_PARAM}&type=bar&perm=baz` + ); + expect(req.request.method).toBe('POST'); + }); + + it('should call deleteCapability', () => { + service.deleteCapability('foo', 'bar', 'baz').subscribe(); + const req = httpTesting.expectOne( + `api/rgw/user/foo/capability?${RgwHelper.DAEMON_QUERY_PARAM}&type=bar&perm=baz` + ); + expect(req.request.method).toBe('DELETE'); + }); + + it('should call addS3Key', () => { + service.addS3Key('foo', { xxx: 'yyy' }).subscribe(); + const req = httpTesting.expectOne( + `api/rgw/user/foo/key?${RgwHelper.DAEMON_QUERY_PARAM}&key_type=s3&xxx=yyy` + ); + expect(req.request.method).toBe('POST'); + }); + + it('should call deleteS3Key', () => { + service.deleteS3Key('foo', 'bar').subscribe(); + const req = httpTesting.expectOne( + `api/rgw/user/foo/key?${RgwHelper.DAEMON_QUERY_PARAM}&key_type=s3&access_key=bar` + ); + expect(req.request.method).toBe('DELETE'); + }); + + it('should call exists with an existent uid', (done) => { + spyOn(service, 'get').and.returnValue(observableOf({})); + service.exists('foo').subscribe((res) => { + expect(res).toBe(true); + done(); + }); + }); + + it('should call exists with a non existent uid', (done) => { + spyOn(service, 'get').and.returnValue(throwError('bar')); + service.exists('baz').subscribe((res) => { + expect(res).toBe(false); + done(); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-user.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-user.service.ts new file mode 100644 index 000000000..66167bcab --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-user.service.ts @@ -0,0 +1,179 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +import _ from 'lodash'; +import { forkJoin as observableForkJoin, Observable, of as observableOf } from 'rxjs'; +import { catchError, mapTo, mergeMap } from 'rxjs/operators'; + +import { RgwDaemonService } from '~/app/shared/api/rgw-daemon.service'; +import { cdEncode } from '~/app/shared/decorators/cd-encode'; + +@cdEncode +@Injectable({ + providedIn: 'root' +}) +export class RgwUserService { + private url = 'api/rgw/user'; + + constructor(private http: HttpClient, private rgwDaemonService: RgwDaemonService) {} + + /** + * Get the list of users. + * @return {Observable<Object[]>} + */ + list() { + return this.enumerate().pipe( + mergeMap((uids: string[]) => { + if (uids.length > 0) { + return observableForkJoin( + uids.map((uid: string) => { + return this.get(uid); + }) + ); + } + return observableOf([]); + }) + ); + } + + /** + * Get the list of usernames. + * @return {Observable<string[]>} + */ + enumerate() { + return this.rgwDaemonService.request((params: HttpParams) => { + return this.http.get(this.url, { params: params }); + }); + } + + enumerateEmail() { + return this.rgwDaemonService.request((params: HttpParams) => { + return this.http.get(`${this.url}/get_emails`, { params: params }); + }); + } + + get(uid: string) { + return this.rgwDaemonService.request((params: HttpParams) => { + return this.http.get(`${this.url}/${uid}`, { params: params }); + }); + } + + getQuota(uid: string) { + return this.rgwDaemonService.request((params: HttpParams) => { + return this.http.get(`${this.url}/${uid}/quota`, { params: params }); + }); + } + + create(args: Record<string, any>) { + return this.rgwDaemonService.request((params: HttpParams) => { + _.keys(args).forEach((key) => { + params = params.append(key, args[key]); + }); + return this.http.post(this.url, null, { params: params }); + }); + } + + update(uid: string, args: Record<string, any>) { + return this.rgwDaemonService.request((params: HttpParams) => { + _.keys(args).forEach((key) => { + params = params.append(key, args[key]); + }); + return this.http.put(`${this.url}/${uid}`, null, { params: params }); + }); + } + + updateQuota(uid: string, args: Record<string, string>) { + return this.rgwDaemonService.request((params: HttpParams) => { + _.keys(args).forEach((key) => { + params = params.append(key, args[key]); + }); + return this.http.put(`${this.url}/${uid}/quota`, null, { params: params }); + }); + } + + delete(uid: string) { + return this.rgwDaemonService.request((params: HttpParams) => { + return this.http.delete(`${this.url}/${uid}`, { params: params }); + }); + } + + createSubuser(uid: string, args: Record<string, string>) { + return this.rgwDaemonService.request((params: HttpParams) => { + _.keys(args).forEach((key) => { + params = params.append(key, args[key]); + }); + return this.http.post(`${this.url}/${uid}/subuser`, null, { params: params }); + }); + } + + deleteSubuser(uid: string, subuser: string) { + return this.rgwDaemonService.request((params: HttpParams) => { + return this.http.delete(`${this.url}/${uid}/subuser/${subuser}`, { params: params }); + }); + } + + addCapability(uid: string, type: string, perm: string) { + return this.rgwDaemonService.request((params: HttpParams) => { + params = params.append('type', type); + params = params.append('perm', perm); + return this.http.post(`${this.url}/${uid}/capability`, null, { params: params }); + }); + } + + deleteCapability(uid: string, type: string, perm: string) { + return this.rgwDaemonService.request((params: HttpParams) => { + params = params.append('type', type); + params = params.append('perm', perm); + return this.http.delete(`${this.url}/${uid}/capability`, { params: params }); + }); + } + + addS3Key(uid: string, args: Record<string, string>) { + return this.rgwDaemonService.request((params: HttpParams) => { + params = params.append('key_type', 's3'); + _.keys(args).forEach((key) => { + params = params.append(key, args[key]); + }); + return this.http.post(`${this.url}/${uid}/key`, null, { params: params }); + }); + } + + deleteS3Key(uid: string, accessKey: string) { + return this.rgwDaemonService.request((params: HttpParams) => { + params = params.append('key_type', 's3'); + params = params.append('access_key', accessKey); + return this.http.delete(`${this.url}/${uid}/key`, { params: params }); + }); + } + + /** + * Check if the specified user ID exists. + * @param {string} uid The user ID to check. + * @return {Observable<boolean>} + */ + exists(uid: string): Observable<boolean> { + return this.get(uid).pipe( + mapTo(true), + catchError((error: Event) => { + if (_.isFunction(error.preventDefault)) { + error.preventDefault(); + } + return observableOf(false); + }) + ); + } + + // Using @cdEncodeNot would be the preferred way here, but this + // causes an error: https://tracker.ceph.com/issues/37505 + // Use decodeURIComponent as workaround. + // emailExists(@cdEncodeNot email: string): Observable<boolean> { + emailExists(email: string): Observable<boolean> { + email = decodeURIComponent(email); + return this.enumerateEmail().pipe( + mergeMap((resp: any[]) => { + const index = _.indexOf(resp, email); + return observableOf(-1 !== index); + }) + ); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/role.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/role.service.spec.ts new file mode 100644 index 000000000..c5af5877c --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/role.service.spec.ts @@ -0,0 +1,75 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { RoleService } from './role.service'; + +describe('RoleService', () => { + let service: RoleService; + let httpTesting: HttpTestingController; + + configureTestBed({ + providers: [RoleService], + imports: [HttpClientTestingModule] + }); + + beforeEach(() => { + service = TestBed.inject(RoleService); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call list', () => { + service.list().subscribe(); + const req = httpTesting.expectOne('api/role'); + expect(req.request.method).toBe('GET'); + }); + + it('should call delete', () => { + service.delete('role1').subscribe(); + const req = httpTesting.expectOne('api/role/role1'); + expect(req.request.method).toBe('DELETE'); + }); + + it('should call get', () => { + service.get('role1').subscribe(); + const req = httpTesting.expectOne('api/role/role1'); + expect(req.request.method).toBe('GET'); + }); + + it('should call clone', () => { + service.clone('foo', 'bar').subscribe(); + const req = httpTesting.expectOne('api/role/foo/clone'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ new_name: 'bar' }); + }); + + it('should check if role name exists', () => { + let exists: boolean; + service.exists('role1').subscribe((res: boolean) => { + exists = res; + }); + const req = httpTesting.expectOne('api/role'); + expect(req.request.method).toBe('GET'); + req.flush([{ name: 'role0' }, { name: 'role1' }]); + expect(exists).toBeTruthy(); + }); + + it('should check if role name does not exist', () => { + let exists: boolean; + service.exists('role2').subscribe((res: boolean) => { + exists = res; + }); + const req = httpTesting.expectOne('api/role'); + expect(req.request.method).toBe('GET'); + req.flush([{ name: 'role0' }, { name: 'role1' }]); + expect(exists).toBeFalsy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/role.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/role.service.ts new file mode 100644 index 000000000..e76846b41 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/role.service.ts @@ -0,0 +1,49 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +import { Observable, of as observableOf } from 'rxjs'; +import { mergeMap } from 'rxjs/operators'; + +import { RoleFormModel } from '~/app/core/auth/role-form/role-form.model'; + +@Injectable({ + providedIn: 'root' +}) +export class RoleService { + constructor(private http: HttpClient) {} + + list() { + return this.http.get('api/role'); + } + + delete(name: string) { + return this.http.delete(`api/role/${name}`); + } + + get(name: string) { + return this.http.get(`api/role/${name}`); + } + + create(role: RoleFormModel) { + return this.http.post(`api/role`, role); + } + + clone(name: string, newName: string) { + return this.http.post(`api/role/${name}/clone`, { new_name: newName }); + } + + update(role: RoleFormModel) { + return this.http.put(`api/role/${role.name}`, role); + } + + exists(name: string): Observable<boolean> { + return this.list().pipe( + mergeMap((roles: Array<RoleFormModel>) => { + const exists = roles.some((currentRole: RoleFormModel) => { + return currentRole.name === name; + }); + return observableOf(exists); + }) + ); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/scope.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/scope.service.spec.ts new file mode 100644 index 000000000..811e1924f --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/scope.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 { ScopeService } from './scope.service'; + +describe('ScopeService', () => { + let service: ScopeService; + let httpTesting: HttpTestingController; + + configureTestBed({ + providers: [ScopeService], + imports: [HttpClientTestingModule] + }); + + beforeEach(() => { + service = TestBed.inject(ScopeService); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call list', () => { + service.list().subscribe(); + const req = httpTesting.expectOne('ui-api/scope'); + expect(req.request.method).toBe('GET'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/scope.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/scope.service.ts new file mode 100644 index 000000000..11e5da80a --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/scope.service.ts @@ -0,0 +1,13 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root' +}) +export class ScopeService { + constructor(private http: HttpClient) {} + + list() { + return this.http.get('ui-api/scope'); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/settings.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/settings.service.spec.ts new file mode 100644 index 000000000..06bd19823 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/settings.service.spec.ts @@ -0,0 +1,154 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { SettingsService } from './settings.service'; + +describe('SettingsService', () => { + let service: SettingsService; + let httpTesting: HttpTestingController; + + const exampleUrl = 'api/settings/something'; + const exampleValue = 'http://localhost:3000'; + + configureTestBed({ + providers: [SettingsService], + imports: [HttpClientTestingModule] + }); + + beforeEach(() => { + service = TestBed.inject(SettingsService); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call validateGrafanaDashboardUrl', () => { + service.validateGrafanaDashboardUrl('s').subscribe(); + const req = httpTesting.expectOne('api/grafana/validation/s'); + expect(req.request.method).toBe('GET'); + }); + + describe('getSettingsValue', () => { + const testMethod = (data: object, expected: string) => { + expect(service['getSettingsValue'](data)).toBe(expected); + }; + + it('should explain the logic of the method', () => { + expect('' || undefined).toBe(undefined); + expect(undefined || '').toBe(''); + expect('test' || undefined || '').toBe('test'); + }); + + it('should test the method for empty string values', () => { + testMethod({}, ''); + testMethod({ wrongAttribute: 'test' }, ''); + testMethod({ value: '' }, ''); + testMethod({ instance: '' }, ''); + }); + + it('should test the method for non empty string values', () => { + testMethod({ value: 'test' }, 'test'); + testMethod({ instance: 'test' }, 'test'); + }); + }); + + describe('isSettingConfigured', () => { + let increment: number; + + const testConfig = (url: string, value: string) => { + service.ifSettingConfigured( + url, + (setValue) => { + expect(setValue).toBe(value); + increment++; + }, + () => { + increment--; + } + ); + }; + + const expectSettingsApiCall = (url: string, value: object, isSet: string) => { + testConfig(url, isSet); + const req = httpTesting.expectOne(url); + expect(req.request.method).toBe('GET'); + req.flush(value); + tick(); + expect(increment).toBe(isSet !== '' ? 1 : -1); + expect(service['settings'][url]).toBe(isSet); + }; + + beforeEach(() => { + increment = 0; + }); + + it(`should return true if 'value' does not contain an empty string`, fakeAsync(() => { + expectSettingsApiCall(exampleUrl, { value: exampleValue }, exampleValue); + })); + + it(`should return false if 'value' does contain an empty string`, fakeAsync(() => { + expectSettingsApiCall(exampleUrl, { value: '' }, ''); + })); + + it(`should return true if 'instance' does not contain an empty string`, fakeAsync(() => { + expectSettingsApiCall(exampleUrl, { value: exampleValue }, exampleValue); + })); + + it(`should return false if 'instance' does contain an empty string`, fakeAsync(() => { + expectSettingsApiCall(exampleUrl, { instance: '' }, ''); + })); + + it(`should return false if the api object is empty`, fakeAsync(() => { + expectSettingsApiCall(exampleUrl, {}, ''); + })); + + it(`should call the API once even if it is called multiple times`, fakeAsync(() => { + expectSettingsApiCall(exampleUrl, { value: exampleValue }, exampleValue); + testConfig(exampleUrl, exampleValue); + httpTesting.expectNone(exampleUrl); + expect(increment).toBe(2); + })); + }); + + it('should disable a set setting', () => { + service['settings'] = { [exampleUrl]: exampleValue }; + service.disableSetting(exampleUrl); + expect(service['settings']).toEqual({ [exampleUrl]: '' }); + }); + + it('should return the specified settings (1)', () => { + let result; + service.getValues('foo,bar').subscribe((resp) => { + result = resp; + }); + const req = httpTesting.expectOne('api/settings?names=foo,bar'); + expect(req.request.method).toBe('GET'); + req.flush([ + { name: 'foo', default: '', type: 'str', value: 'test' }, + { name: 'bar', default: 0, type: 'int', value: 2 } + ]); + expect(result).toEqual({ + foo: 'test', + bar: 2 + }); + }); + + it('should return the specified settings (2)', () => { + service.getValues(['abc', 'xyz']).subscribe(); + const req = httpTesting.expectOne('api/settings?names=abc,xyz'); + expect(req.request.method).toBe('GET'); + }); + + it('should return standard settings', () => { + service.getStandardSettings().subscribe(); + const req = httpTesting.expectOne('ui-api/standard_settings'); + expect(req.request.method).toBe('GET'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/settings.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/settings.service.ts new file mode 100644 index 000000000..1e53fa064 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/settings.service.ts @@ -0,0 +1,77 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +import _ from 'lodash'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +class SettingResponse { + name: string; + default: any; + type: string; + value: any; +} + +@Injectable({ + providedIn: 'root' +}) +export class SettingsService { + constructor(private http: HttpClient) {} + + private settings: { [url: string]: string } = {}; + + getValues(names: string | string[]): Observable<{ [key: string]: any }> { + if (_.isArray(names)) { + names = names.join(','); + } + return this.http.get(`api/settings?names=${names}`).pipe( + map((resp: SettingResponse[]) => { + const result = {}; + _.forEach(resp, (option: SettingResponse) => { + _.set(result, option.name, option.value); + }); + return result; + }) + ); + } + + ifSettingConfigured(url: string, fn: (value?: string) => void, elseFn?: () => void): void { + const setting = this.settings[url]; + if (setting === undefined) { + this.http.get(url).subscribe( + (data: any) => { + this.settings[url] = this.getSettingsValue(data); + this.ifSettingConfigured(url, fn, elseFn); + }, + (resp) => { + if (resp.status !== 401) { + this.settings[url] = ''; + } + } + ); + } else if (setting !== '') { + fn(setting); + } else { + if (elseFn) { + elseFn(); + } + } + } + + // Easiest way to stop reloading external content that can't be reached + disableSetting(url: string) { + this.settings[url] = ''; + } + + private getSettingsValue(data: any): string { + return data.value || data.instance || ''; + } + + validateGrafanaDashboardUrl(uid: string) { + return this.http.get(`api/grafana/validation/${uid}`); + } + + getStandardSettings(): Observable<{ [key: string]: any }> { + return this.http.get('ui-api/standard_settings'); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/telemetry.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/telemetry.service.spec.ts new file mode 100644 index 000000000..a90fcff7a --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/telemetry.service.spec.ts @@ -0,0 +1,58 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { TelemetryService } from './telemetry.service'; + +describe('TelemetryService', () => { + let service: TelemetryService; + let httpTesting: HttpTestingController; + + configureTestBed({ + imports: [HttpClientTestingModule], + providers: [TelemetryService] + }); + + beforeEach(() => { + service = TestBed.inject(TelemetryService); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call getReport', () => { + service.getReport().subscribe(); + const req = httpTesting.expectOne('api/telemetry/report'); + expect(req.request.method).toBe('GET'); + }); + + it('should call enable to enable module', () => { + service.enable(true).subscribe(); + const req = httpTesting.expectOne('api/telemetry'); + expect(req.request.method).toBe('PUT'); + expect(req.request.body.enable).toBe(true); + expect(req.request.body.license_name).toBe('sharing-1-0'); + }); + + it('should call enable to disable module', () => { + service.enable(false).subscribe(); + const req = httpTesting.expectOne('api/telemetry'); + expect(req.request.method).toBe('PUT'); + expect(req.request.body.enable).toBe(false); + expect(req.request.body.license_name).toBeUndefined(); + }); + + it('should call enable to enable module by default', () => { + service.enable().subscribe(); + const req = httpTesting.expectOne('api/telemetry'); + expect(req.request.method).toBe('PUT'); + expect(req.request.body.enable).toBe(true); + expect(req.request.body.license_name).toBe('sharing-1-0'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/telemetry.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/telemetry.service.ts new file mode 100644 index 000000000..8a175f66d --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/telemetry.service.ts @@ -0,0 +1,23 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root' +}) +export class TelemetryService { + private url = 'api/telemetry'; + + constructor(private http: HttpClient) {} + + getReport() { + return this.http.get(`${this.url}/report`); + } + + enable(enable: boolean = true) { + const body = { enable: enable }; + if (enable) { + body['license_name'] = 'sharing-1-0'; + } + return this.http.put(`${this.url}`, body); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/user.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/user.service.spec.ts new file mode 100644 index 000000000..ba038a725 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/user.service.spec.ts @@ -0,0 +1,104 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { UserFormModel } from '~/app/core/auth/user-form/user-form.model'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { UserService } from './user.service'; + +describe('UserService', () => { + let service: UserService; + let httpTesting: HttpTestingController; + + configureTestBed({ + providers: [UserService], + imports: [HttpClientTestingModule] + }); + + beforeEach(() => { + service = TestBed.inject(UserService); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call create', () => { + const user = new UserFormModel(); + user.username = 'user0'; + user.password = 'pass0'; + user.name = 'User 0'; + user.email = 'user0@email.com'; + user.roles = ['administrator']; + service.create(user).subscribe(); + const req = httpTesting.expectOne('api/user'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(user); + }); + + it('should call delete', () => { + service.delete('user0').subscribe(); + const req = httpTesting.expectOne('api/user/user0'); + expect(req.request.method).toBe('DELETE'); + }); + + it('should call update', () => { + const user = new UserFormModel(); + user.username = 'user0'; + user.password = 'pass0'; + user.name = 'User 0'; + user.email = 'user0@email.com'; + user.roles = ['administrator']; + service.update(user).subscribe(); + const req = httpTesting.expectOne('api/user/user0'); + expect(req.request.body).toEqual(user); + expect(req.request.method).toBe('PUT'); + }); + + it('should call get', () => { + service.get('user0').subscribe(); + const req = httpTesting.expectOne('api/user/user0'); + expect(req.request.method).toBe('GET'); + }); + + it('should call list', () => { + service.list().subscribe(); + const req = httpTesting.expectOne('api/user'); + expect(req.request.method).toBe('GET'); + }); + + it('should call changePassword', () => { + service.changePassword('user0', 'foo', 'bar').subscribe(); + const req = httpTesting.expectOne('api/user/user0/change_password'); + expect(req.request.body).toEqual({ + old_password: 'foo', + new_password: 'bar' + }); + expect(req.request.method).toBe('POST'); + }); + + it('should call validatePassword', () => { + service.validatePassword('foo').subscribe(); + const req = httpTesting.expectOne('api/user/validate_password'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ password: 'foo', old_password: null, username: null }); + }); + + it('should call validatePassword (incl. name)', () => { + service.validatePassword('foo_bar', 'bar').subscribe(); + const req = httpTesting.expectOne('api/user/validate_password'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ password: 'foo_bar', username: 'bar', old_password: null }); + }); + + it('should call validatePassword (incl. old password)', () => { + service.validatePassword('foo', null, 'foo').subscribe(); + const req = httpTesting.expectOne('api/user/validate_password'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ password: 'foo', old_password: 'foo', username: null }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/user.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/user.service.ts new file mode 100644 index 000000000..95c80dd46 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/user.service.ts @@ -0,0 +1,62 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +import { Observable, of as observableOf } from 'rxjs'; +import { catchError, mapTo } from 'rxjs/operators'; + +import { UserFormModel } from '~/app/core/auth/user-form/user-form.model'; + +@Injectable({ + providedIn: 'root' +}) +export class UserService { + constructor(private http: HttpClient) {} + + list() { + return this.http.get('api/user'); + } + + delete(username: string) { + return this.http.delete(`api/user/${username}`); + } + + get(username: string) { + return this.http.get(`api/user/${username}`); + } + + create(user: UserFormModel) { + return this.http.post(`api/user`, user); + } + + update(user: UserFormModel) { + return this.http.put(`api/user/${user.username}`, user); + } + + changePassword(username: string, oldPassword: string, newPassword: string) { + // Note, the specified user MUST be logged in to be able to change + // the password. The backend ensures that the password of another + // user can not be changed, otherwise an error will be thrown. + return this.http.post(`api/user/${username}/change_password`, { + old_password: oldPassword, + new_password: newPassword + }); + } + + validateUserName(user_name: string): Observable<boolean> { + return this.get(user_name).pipe( + mapTo(true), + catchError((error) => { + error.preventDefault(); + return observableOf(false); + }) + ); + } + + validatePassword(password: string, username: string = null, oldPassword: string = null) { + return this.http.post('api/user/validate_password', { + password: password, + username: username, + old_password: oldPassword + }); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/cd-helper.class.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/cd-helper.class.spec.ts new file mode 100644 index 000000000..a5a28650d --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/cd-helper.class.spec.ts @@ -0,0 +1,66 @@ +import { CdHelperClass } from './cd-helper.class'; + +class MockClass { + n = 42; + o = { + x: 'something', + y: [1, 2, 3], + z: true + }; + b: boolean; +} + +describe('CdHelperClass', () => { + describe('updateChanged', () => { + let old: MockClass; + let used: MockClass; + let structure = { + n: 42, + o: { + x: 'something', + y: [1, 2, 3], + z: true + } + } as any; + + beforeEach(() => { + old = new MockClass(); + used = new MockClass(); + structure = { + n: 42, + o: { + x: 'something', + y: [1, 2, 3], + z: true + } + }; + }); + + it('should not update anything', () => { + CdHelperClass.updateChanged(used, structure); + expect(used).toEqual(old); + }); + + it('should only change n', () => { + CdHelperClass.updateChanged(used, { n: 17 }); + expect(used.n).not.toEqual(old.n); + expect(used.n).toBe(17); + }); + + it('should update o on change of o.y', () => { + CdHelperClass.updateChanged(used, structure); + structure.o.y.push(4); + expect(used.o.y).toEqual(old.o.y); + CdHelperClass.updateChanged(used, structure); + expect(used.o.y).toEqual([1, 2, 3, 4]); + }); + + it('should change b, o and n', () => { + structure.o.x.toUpperCase(); + structure.n++; + structure.b = true; + CdHelperClass.updateChanged(used, structure); + expect(used).toEqual(structure); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/cd-helper.class.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/cd-helper.class.ts new file mode 100644 index 000000000..250573125 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/cd-helper.class.ts @@ -0,0 +1,28 @@ +import _ from 'lodash'; + +export class CdHelperClass { + /** + * Simple way to only update variables if they have really changed and not just the reference + * + * @param componentThis - In order to update the variables if necessary + * @param change - The variable name (attribute of the object) is followed by the current value + * it would update even if it equals + */ + static updateChanged(componentThis: any, change: { [publicVarName: string]: any }) { + let hasChanges = false; + + Object.keys(change).forEach((publicVarName) => { + const data = change[publicVarName]; + if (!_.isEqual(data, componentThis[publicVarName])) { + componentThis[publicVarName] = data; + hasChanges = true; + } + }); + + return hasChanges; + } + + static cdVersionHeader(major_ver: string, minor_ver: string) { + return `application/vnd.ceph.api.v${major_ver}.${minor_ver}+json`; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/crush.node.selection.class.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/crush.node.selection.class.spec.ts new file mode 100644 index 000000000..e09364015 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/crush.node.selection.class.spec.ts @@ -0,0 +1,220 @@ +import { FormControl } from '@angular/forms'; + +import _ from 'lodash'; + +import { configureTestBed, Mocks } from '~/testing/unit-test-helper'; +import { CrushNode } from '../models/crush-node'; +import { CrushNodeSelectionClass } from './crush.node.selection.class'; + +describe('CrushNodeSelectionService', () => { + const nodes = Mocks.getCrushMap(); + + let service: CrushNodeSelectionClass; + let controls: { + root: FormControl; + failure: FormControl; + device: FormControl; + }; + + // Object contains functions to get something + const get = { + nodeByName: (name: string): CrushNode => nodes.find((node) => node.name === name), + nodesByNames: (names: string[]): CrushNode[] => names.map(get.nodeByName) + }; + + // Expects that are used frequently + const assert = { + formFieldValues: (root: CrushNode, failureDomain: string, device: string) => { + expect(controls.root.value).toEqual(root); + expect(controls.failure.value).toBe(failureDomain); + expect(controls.device.value).toBe(device); + }, + valuesOnRootChange: ( + rootName: string, + expectedFailureDomain: string, + expectedDevice: string + ) => { + const node = get.nodeByName(rootName); + controls.root.setValue(node); + assert.formFieldValues(node, expectedFailureDomain, expectedDevice); + }, + failureDomainNodes: ( + failureDomains: { [failureDomain: string]: CrushNode[] }, + expected: { [failureDomains: string]: string[] | CrushNode[] } + ) => { + expect(Object.keys(failureDomains)).toEqual(Object.keys(expected)); + Object.keys(failureDomains).forEach((key) => { + if (_.isString(expected[key][0])) { + expect(failureDomains[key]).toEqual(get.nodesByNames(expected[key] as string[])); + } else { + expect(failureDomains[key]).toEqual(expected[key]); + } + }); + } + }; + + configureTestBed({ + providers: [CrushNodeSelectionClass] + }); + + beforeEach(() => { + controls = { + root: new FormControl(null), + failure: new FormControl(''), + device: new FormControl('') + }; + // Normally this should be extended by the class using it + service = new CrushNodeSelectionClass(); + // Therefore to get it working correctly use "this" instead of "service" + service.initCrushNodeSelection(nodes, controls.root, controls.failure, controls.device); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + expect(nodes.length).toBe(12); + }); + + describe('lists', () => { + afterEach(() => { + // The available buckets should not change + expect(service.buckets).toEqual( + get.nodesByNames(['default', 'hdd-rack', 'mix-host', 'ssd-host', 'ssd-rack']) + ); + }); + + it('has the following lists after init', () => { + assert.failureDomainNodes(service.failureDomains, { + host: ['ssd-host', 'mix-host'], + osd: ['osd.1', 'osd.0', 'osd.2'], + rack: ['hdd-rack', 'ssd-rack'], + 'osd-rack': ['osd2.0', 'osd2.1', 'osd3.0', 'osd3.1'] + }); + expect(service.devices).toEqual(['hdd', 'ssd']); + }); + + it('has the following lists after selection of ssd-host', () => { + controls.root.setValue(get.nodeByName('ssd-host')); + assert.failureDomainNodes(service.failureDomains, { + // Not host as it only exist once + osd: ['osd.1', 'osd.0', 'osd.2'] + }); + expect(service.devices).toEqual(['ssd']); + }); + + it('has the following lists after selection of mix-host', () => { + controls.root.setValue(get.nodeByName('mix-host')); + expect(service.devices).toEqual(['hdd', 'ssd']); + assert.failureDomainNodes(service.failureDomains, { + rack: ['hdd-rack', 'ssd-rack'], + 'osd-rack': ['osd2.0', 'osd2.1', 'osd3.0', 'osd3.1'] + }); + }); + }); + + describe('selection', () => { + it('selects the first root after init automatically', () => { + assert.formFieldValues(get.nodeByName('default'), 'osd-rack', ''); + }); + + it('should select all values automatically by selecting "ssd-host" as root', () => { + assert.valuesOnRootChange('ssd-host', 'osd', 'ssd'); + }); + + it('selects automatically the most common failure domain', () => { + // Select mix-host as mix-host has multiple failure domains (osd-rack and rack) + assert.valuesOnRootChange('mix-host', 'osd-rack', ''); + }); + + it('should override automatic selections', () => { + assert.formFieldValues(get.nodeByName('default'), 'osd-rack', ''); + assert.valuesOnRootChange('ssd-host', 'osd', 'ssd'); + assert.valuesOnRootChange('mix-host', 'osd-rack', ''); + }); + + it('should not override manual selections if possible', () => { + controls.failure.setValue('rack'); + controls.failure.markAsDirty(); + controls.device.setValue('ssd'); + controls.device.markAsDirty(); + assert.valuesOnRootChange('mix-host', 'rack', 'ssd'); + }); + + it('should preselect device by domain selection', () => { + controls.failure.setValue('osd'); + assert.formFieldValues(get.nodeByName('default'), 'osd', 'ssd'); + }); + }); + + describe('get available OSDs count', () => { + it('should have 4 available OSDs with the default selection', () => { + expect(service.deviceCount).toBe(4); + }); + + it('should reduce available OSDs to 2 if a device type is set', () => { + controls.device.setValue('ssd'); + controls.device.markAsDirty(); + expect(service.deviceCount).toBe(2); + }); + + it('should show 3 OSDs when selecting "ssd-host"', () => { + assert.valuesOnRootChange('ssd-host', 'osd', 'ssd'); + expect(service.deviceCount).toBe(3); + }); + }); + + describe('search tree', () => { + it('returns the following list after searching for mix-host', () => { + const subNodes = CrushNodeSelectionClass.search(nodes, 'mix-host'); + expect(subNodes).toEqual( + get.nodesByNames([ + 'mix-host', + 'hdd-rack', + 'osd2.0', + 'osd2.1', + 'ssd-rack', + 'osd3.0', + 'osd3.1' + ]) + ); + }); + + it('returns the following list after searching for mix-host with SSDs', () => { + const subNodes = CrushNodeSelectionClass.search(nodes, 'mix-host~ssd'); + expect(subNodes.map((n) => n.name)).toEqual(['mix-host', 'ssd-rack', 'osd3.0', 'osd3.1']); + }); + + it('returns an empty array if node can not be found', () => { + expect(CrushNodeSelectionClass.search(nodes, 'not-there')).toEqual([]); + }); + + it('returns the following list after searching for mix-host failure domains', () => { + const subNodes = CrushNodeSelectionClass.search(nodes, 'mix-host'); + assert.failureDomainNodes(CrushNodeSelectionClass.getFailureDomains(subNodes), { + host: ['mix-host'], + rack: ['hdd-rack', 'ssd-rack'], + 'osd-rack': ['osd2.0', 'osd2.1', 'osd3.0', 'osd3.1'] + }); + }); + + it('returns the following list after searching for mix-host failure domains for a specific type', () => { + const subNodes = CrushNodeSelectionClass.search(nodes, 'mix-host~hdd'); + const hddHost = _.cloneDeep(get.nodesByNames(['mix-host'])[0]); + hddHost.children = [-4]; + assert.failureDomainNodes(CrushNodeSelectionClass.getFailureDomains(subNodes), { + host: [hddHost], + rack: ['hdd-rack'], + 'osd-rack': ['osd2.0', 'osd2.1'] + }); + const ssdHost = _.cloneDeep(get.nodesByNames(['mix-host'])[0]); + ssdHost.children = [-5]; + assert.failureDomainNodes( + CrushNodeSelectionClass.searchFailureDomains(nodes, 'mix-host~ssd'), + { + host: [ssdHost], + rack: ['ssd-rack'], + 'osd-rack': ['osd3.0', 'osd3.1'] + } + ); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/crush.node.selection.class.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/crush.node.selection.class.ts new file mode 100644 index 000000000..34cebbcc8 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/crush.node.selection.class.ts @@ -0,0 +1,221 @@ +import { AbstractControl } from '@angular/forms'; + +import _ from 'lodash'; + +import { CrushNode } from '../models/crush-node'; + +export class CrushNodeSelectionClass { + private nodes: CrushNode[] = []; + private idTree: { [id: number]: CrushNode } = {}; + private allDevices: string[] = []; + private controls: { + root: AbstractControl; + failure: AbstractControl; + device: AbstractControl; + }; + + buckets: CrushNode[] = []; + failureDomains: { [type: string]: CrushNode[] } = {}; + failureDomainKeys: string[] = []; + devices: string[] = []; + deviceCount = 0; + + static searchFailureDomains( + nodes: CrushNode[], + s: string + ): { [failureDomain: string]: CrushNode[] } { + return this.getFailureDomains(this.search(nodes, s)); + } + + /** + * Filters crush map for a node and it's tree. + * The node name as provided in crush rules attribute item_name is supported. + * This means that '$name~$deviceType' can be used and will result in a crush map + * that only include buckets with the specified device in use as their leaf. + */ + static search(nodes: CrushNode[], s: string): CrushNode[] { + const [search, deviceType] = s.split('~'); // Used inside item_name in crush rules + const node = nodes.find((n) => ['name', 'id', 'type'].some((attr) => n[attr] === search)); + if (!node) { + return []; + } + nodes = this.getSubNodes(node, this.createIdTreeFromNodes(nodes)); + if (deviceType) { + nodes = this.filterNodesByDeviceType(nodes, deviceType); + } + return nodes; + } + + static createIdTreeFromNodes(nodes: CrushNode[]): { [id: number]: CrushNode } { + const idTree = {}; + nodes.forEach((node) => { + idTree[node.id] = node; + }); + return idTree; + } + + static getSubNodes(node: CrushNode, idTree: { [id: number]: CrushNode }): CrushNode[] { + let subNodes = [node]; // Includes parent node + if (!node.children) { + return subNodes; + } + node.children.forEach((id) => { + const childNode = idTree[id]; + subNodes = subNodes.concat(this.getSubNodes(childNode, idTree)); + }); + return subNodes; + } + + static filterNodesByDeviceType(nodes: CrushNode[], deviceType: string): any { + let doNotInclude = nodes + .filter((n) => n.device_class && n.device_class !== deviceType) + .map((n) => n.id); + let foundNewNode: boolean; + let childrenToRemove = doNotInclude; + + // Filters out all unwanted nodes + do { + foundNewNode = false; + nodes = nodes.filter((n) => !doNotInclude.includes(n.id)); // Unwanted nodes + // Find nodes where all children were filtered + const toRemoveNext: number[] = []; + nodes.forEach((n) => { + if (n.children && n.children.every((id) => doNotInclude.includes(id))) { + toRemoveNext.push(n.id); + foundNewNode = true; + } + }); + if (foundNewNode) { + doNotInclude = toRemoveNext; // Reduces array length + childrenToRemove = childrenToRemove.concat(toRemoveNext); + } + } while (foundNewNode); + + // Removes filtered out children in all left nodes with children + nodes = _.cloneDeep(nodes); // Clone objects to not change original objects + nodes = nodes.map((n) => { + if (!n.children) { + return n; + } + n.children = n.children.filter((id) => !childrenToRemove.includes(id)); + return n; + }); + + return nodes; + } + + static getFailureDomains(nodes: CrushNode[]): { [failureDomain: string]: CrushNode[] } { + const domains = {}; + nodes.forEach((node) => { + const type = node.type; + if (!domains[type]) { + domains[type] = []; + } + domains[type].push(node); + }); + return domains; + } + + initCrushNodeSelection( + nodes: CrushNode[], + rootControl: AbstractControl, + failureControl: AbstractControl, + deviceControl: AbstractControl + ) { + this.nodes = nodes; + this.idTree = CrushNodeSelectionClass.createIdTreeFromNodes(nodes); + nodes.forEach((node) => { + this.idTree[node.id] = node; + }); + this.buckets = _.sortBy( + nodes.filter((n) => n.children), + 'name' + ); + this.controls = { + root: rootControl, + failure: failureControl, + device: deviceControl + }; + this.preSelectRoot(); + this.controls.root.valueChanges.subscribe(() => this.onRootChange()); + this.controls.failure.valueChanges.subscribe(() => this.onFailureDomainChange()); + this.controls.device.valueChanges.subscribe(() => this.onDeviceChange()); + } + + private preSelectRoot() { + const rootNode = this.nodes.find((node) => node.type === 'root'); + this.silentSet(this.controls.root, rootNode); + this.onRootChange(); + } + + private silentSet(control: AbstractControl, value: any) { + control.setValue(value, { emitEvent: false }); + } + + private onRootChange() { + const nodes = CrushNodeSelectionClass.getSubNodes(this.controls.root.value, this.idTree); + const domains = CrushNodeSelectionClass.getFailureDomains(nodes); + Object.keys(domains).forEach((type) => { + if (domains[type].length <= 1) { + delete domains[type]; + } + }); + this.failureDomains = domains; + this.failureDomainKeys = Object.keys(domains).sort(); + this.updateFailureDomain(); + } + + private updateFailureDomain() { + let failureDomain = this.getIncludedCustomValue( + this.controls.failure, + Object.keys(this.failureDomains) + ); + if (failureDomain === '') { + failureDomain = this.setMostCommonDomain(this.controls.failure); + } + this.updateDevices(failureDomain); + } + + private getIncludedCustomValue(control: AbstractControl, includedIn: string[]) { + return control.dirty && includedIn.includes(control.value) ? control.value : ''; + } + + private setMostCommonDomain(failureControl: AbstractControl): string { + let winner = { n: 0, type: '' }; + Object.keys(this.failureDomains).forEach((type) => { + const n = this.failureDomains[type].length; + if (winner.n < n) { + winner = { n, type }; + } + }); + this.silentSet(failureControl, winner.type); + return winner.type; + } + + private onFailureDomainChange() { + this.updateDevices(); + } + + private updateDevices(failureDomain: string = this.controls.failure.value) { + const subNodes = _.flatten( + this.failureDomains[failureDomain].map((node) => + CrushNodeSelectionClass.getSubNodes(node, this.idTree) + ) + ); + this.allDevices = subNodes.filter((n) => n.device_class).map((n) => n.device_class); + this.devices = _.uniq(this.allDevices).sort(); + const device = + this.devices.length === 1 + ? this.devices[0] + : this.getIncludedCustomValue(this.controls.device, this.devices); + this.silentSet(this.controls.device, device); + this.onDeviceChange(device); + } + + private onDeviceChange(deviceType: string = this.controls.device.value) { + this.deviceCount = + deviceType === '' + ? this.allDevices.length + : this.allDevices.filter((type) => type === deviceType).length; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/css-helper.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/css-helper.ts new file mode 100644 index 000000000..e5caef761 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/css-helper.ts @@ -0,0 +1,5 @@ +export class CssHelper { + propertyValue(propertyName: string): string { + return getComputedStyle(document.body).getPropertyValue(`--${propertyName}`); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/list-with-details.class.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/list-with-details.class.ts new file mode 100644 index 000000000..2eaeeb35e --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/list-with-details.class.ts @@ -0,0 +1,29 @@ +import { NgZone } from '@angular/core'; + +import { TableStatus } from './table-status'; + +export class ListWithDetails { + expandedRow: any; + staleTimeout: number; + tableStatus: TableStatus; + + constructor(protected ngZone?: NgZone) {} + + setExpandedRow(expandedRow: any) { + this.expandedRow = expandedRow; + } + + setTableRefreshTimeout() { + clearTimeout(this.staleTimeout); + this.ngZone.runOutsideAngular(() => { + this.staleTimeout = window.setTimeout(() => { + this.ngZone.run(() => { + this.tableStatus = new TableStatus( + 'warning', + $localize`The user list data might be stale. If needed, you can manually reload it.` + ); + }); + }, 10000); + }); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/table-status-view-cache.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/table-status-view-cache.spec.ts new file mode 100644 index 000000000..cff2ec33a --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/table-status-view-cache.spec.ts @@ -0,0 +1,40 @@ +import { ViewCacheStatus } from '../enum/view-cache-status.enum'; +import { TableStatusViewCache } from './table-status-view-cache'; + +describe('TableStatusViewCache', () => { + it('should create an instance', () => { + const ts = new TableStatusViewCache(); + expect(ts).toBeTruthy(); + expect(ts).toEqual({ msg: '', type: 'light' }); + }); + + it('should create a ValueStale instance', () => { + let ts = new TableStatusViewCache(ViewCacheStatus.ValueStale); + expect(ts).toEqual({ type: 'warning', msg: 'Displaying previously cached data.' }); + + ts = new TableStatusViewCache(ViewCacheStatus.ValueStale, 'foo bar'); + expect(ts).toEqual({ type: 'warning', msg: 'Displaying previously cached data for foo bar.' }); + }); + + it('should create a ValueNone instance', () => { + let ts = new TableStatusViewCache(ViewCacheStatus.ValueNone); + expect(ts).toEqual({ type: 'info', msg: 'Retrieving data. Please wait...' }); + + ts = new TableStatusViewCache(ViewCacheStatus.ValueNone, 'foo bar'); + expect(ts).toEqual({ type: 'info', msg: 'Retrieving data for foo bar. Please wait...' }); + }); + + it('should create a ValueException instance', () => { + let ts = new TableStatusViewCache(ViewCacheStatus.ValueException); + expect(ts).toEqual({ + type: 'danger', + msg: 'Could not load data. Please check the cluster health.' + }); + + ts = new TableStatusViewCache(ViewCacheStatus.ValueException, 'foo bar'); + expect(ts).toEqual({ + type: 'danger', + msg: 'Could not load data for foo bar. Please check the cluster health.' + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/table-status-view-cache.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/table-status-view-cache.ts new file mode 100644 index 000000000..91c53a0aa --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/table-status-view-cache.ts @@ -0,0 +1,37 @@ +import { ViewCacheStatus } from '../enum/view-cache-status.enum'; +import { TableStatus } from './table-status'; + +export class TableStatusViewCache extends TableStatus { + constructor(status: ViewCacheStatus = ViewCacheStatus.ValueOk, statusFor: string = '') { + super(); + + switch (status) { + case ViewCacheStatus.ValueOk: + this.type = 'light'; + this.msg = ''; + break; + case ViewCacheStatus.ValueNone: + this.type = 'info'; + this.msg = + (statusFor ? $localize`Retrieving data for ${statusFor}.` : $localize`Retrieving data.`) + + ' ' + + $localize`Please wait...`; + break; + case ViewCacheStatus.ValueStale: + this.type = 'warning'; + this.msg = statusFor + ? $localize`Displaying previously cached data for ${statusFor}.` + : $localize`Displaying previously cached data.`; + break; + case ViewCacheStatus.ValueException: + this.type = 'danger'; + this.msg = + (statusFor + ? $localize`Could not load data for ${statusFor}.` + : $localize`Could not load data.`) + + ' ' + + $localize`Please check the cluster health.`; + break; + } + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/table-status.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/table-status.spec.ts new file mode 100644 index 000000000..7fa7ba1a4 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/table-status.spec.ts @@ -0,0 +1,15 @@ +import { TableStatus } from './table-status'; + +describe('TableStatus', () => { + it('should create an instance', () => { + const ts = new TableStatus(); + expect(ts).toBeTruthy(); + expect(ts).toEqual({ msg: '', type: 'light' }); + }); + + it('should create with parameters', () => { + const ts = new TableStatus('danger', 'foo'); + expect(ts).toBeTruthy(); + expect(ts).toEqual({ msg: 'foo', type: 'danger' }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/table-status.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/table-status.ts new file mode 100644 index 000000000..fa9be80fe --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/classes/table-status.ts @@ -0,0 +1,3 @@ +export class TableStatus { + constructor(public type: 'info' | 'warning' | 'danger' | 'light' = 'light', public msg = '') {} +} 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 new file mode 100644 index 000000000..be8096427 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/alert-panel/alert-panel.component.html @@ -0,0 +1,42 @@ +<ngb-alert type="{{ bootstrapClass }}" + [dismissible]="dismissible" + (closed)="onClose()"> + <table> + <ng-container *ngIf="size === 'normal'; else slim"> + <tr> + <td *ngIf="showIcon" + rowspan="2" + class="alert-panel-icon"> + <i [ngClass]="[icons.large3x]" + class="alert-{{ bootstrapClass }} {{ typeIcon }}" + aria-hidden="true"></i> + </td> + <td *ngIf="showTitle" + class="alert-panel-title">{{ title }}</td> + </tr> + <tr> + <td class="alert-panel-text"> + <ng-container *ngTemplateOutlet="content"></ng-container> + </td> + </tr> + </ng-container> + <ng-template #slim> + <tr> + <td *ngIf="showIcon" + class="alert-panel-icon"> + <i class="alert-{{ bootstrapClass }} {{ typeIcon }}" + aria-hidden="true"></i> + </td> + <td *ngIf="showTitle" + class="alert-panel-title">{{ title }}</td> + <td class="alert-panel-text"> + <ng-container *ngTemplateOutlet="content"></ng-container> + </td> + </tr> + </ng-template> + </table> +</ngb-alert> + +<ng-template #content> + <ng-content></ng-content> +</ng-template> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/alert-panel/alert-panel.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/alert-panel/alert-panel.component.scss new file mode 100644 index 000000000..6b89d6d3e --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/alert-panel/alert-panel.component.scss @@ -0,0 +1,12 @@ +.alert-panel-icon { + padding-right: 0.5em; + vertical-align: top; +} + +.alert-panel-title { + font-weight: bold; +} + +.alert { + margin-bottom: 0; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/alert-panel/alert-panel.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/alert-panel/alert-panel.component.spec.ts new file mode 100644 index 000000000..4b1f3f7cc --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/alert-panel/alert-panel.component.spec.ts @@ -0,0 +1,26 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { NgbAlertModule } from '@ng-bootstrap/ng-bootstrap'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { AlertPanelComponent } from './alert-panel.component'; + +describe('AlertPanelComponent', () => { + let component: AlertPanelComponent; + let fixture: ComponentFixture<AlertPanelComponent>; + + configureTestBed({ + declarations: [AlertPanelComponent], + imports: [NgbAlertModule] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(AlertPanelComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); 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 new file mode 100644 index 000000000..51088840e --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/alert-panel/alert-panel.component.ts @@ -0,0 +1,70 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; + +import { Icons } from '~/app/shared/enum/icons.enum'; + +@Component({ + selector: 'cd-alert-panel', + templateUrl: './alert-panel.component.html', + styleUrls: ['./alert-panel.component.scss'] +}) +export class AlertPanelComponent implements OnInit { + @Input() + title = ''; + @Input() + bootstrapClass = ''; + @Input() + type: 'warning' | 'error' | 'info' | 'success' | 'danger'; + @Input() + typeIcon: Icons | string; + @Input() + size: 'slim' | 'normal' = 'normal'; + @Input() + 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; + + ngOnInit() { + switch (this.type) { + case 'warning': + this.title = this.title || $localize`Warning`; + this.typeIcon = this.typeIcon || Icons.warning; + this.bootstrapClass = this.bootstrapClass || 'warning'; + break; + case 'error': + this.title = this.title || $localize`Error`; + this.typeIcon = this.typeIcon || Icons.destroyCircle; + this.bootstrapClass = this.bootstrapClass || 'danger'; + break; + case 'info': + this.title = this.title || $localize`Information`; + this.typeIcon = this.typeIcon || Icons.infoCircle; + this.bootstrapClass = this.bootstrapClass || 'info'; + break; + case 'success': + this.title = this.title || $localize`Success`; + 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/back-button/back-button.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/back-button/back-button.component.html new file mode 100644 index 000000000..a9090aaf2 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/back-button/back-button.component.html @@ -0,0 +1,5 @@ +<button class="btn btn-light tc_backButton" + (click)="back()" + type="button"> + {{ name }} +</button> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/back-button/back-button.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/back-button/back-button.component.scss new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/back-button/back-button.component.scss diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/back-button/back-button.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/back-button/back-button.component.spec.ts new file mode 100644 index 000000000..d3120a283 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/back-button/back-button.component.spec.ts @@ -0,0 +1,25 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { BackButtonComponent } from './back-button.component'; + +describe('BackButtonComponent', () => { + let component: BackButtonComponent; + let fixture: ComponentFixture<BackButtonComponent>; + + configureTestBed({ + imports: [RouterTestingModule], + declarations: [BackButtonComponent] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(BackButtonComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/back-button/back-button.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/back-button/back-button.component.ts new file mode 100644 index 000000000..a578f0394 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/back-button/back-button.component.ts @@ -0,0 +1,24 @@ +import { Location } from '@angular/common'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; + +import { ActionLabelsI18n } from '~/app/shared/constants/app.constants'; + +@Component({ + selector: 'cd-back-button', + templateUrl: './back-button.component.html', + styleUrls: ['./back-button.component.scss'] +}) +export class BackButtonComponent { + @Output() backAction = new EventEmitter(); + @Input() name: string = this.actionLabels.CANCEL; + + constructor(private location: Location, private actionLabels: ActionLabelsI18n) {} + + back() { + if (this.backAction.observers.length === 0) { + this.location.back(); + } else { + this.backAction.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 new file mode 100644 index 000000000..a281bf859 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts @@ -0,0 +1,132 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { RouterModule } from '@angular/router'; + +import { + NgbAlertModule, + NgbDatepickerModule, + NgbDropdownModule, + NgbPopoverModule, + NgbProgressbarModule, + NgbTimepickerModule, + NgbTooltipModule +} from '@ng-bootstrap/ng-bootstrap'; +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'; +import { BackButtonComponent } from './back-button/back-button.component'; +import { ConfigOptionComponent } from './config-option/config-option.component'; +import { ConfirmationModalComponent } from './confirmation-modal/confirmation-modal.component'; +import { Copy2ClipboardButtonComponent } from './copy2clipboard-button/copy2clipboard-button.component'; +import { CriticalConfirmationModalComponent } from './critical-confirmation-modal/critical-confirmation-modal.component'; +import { CustomLoginBannerComponent } from './custom-login-banner/custom-login-banner.component'; +import { DateTimePickerComponent } from './date-time-picker/date-time-picker.component'; +import { DocComponent } from './doc/doc.component'; +import { DownloadButtonComponent } from './download-button/download-button.component'; +import { FormButtonPanelComponent } from './form-button-panel/form-button-panel.component'; +import { FormModalComponent } from './form-modal/form-modal.component'; +import { GrafanaComponent } from './grafana/grafana.component'; +import { HelperComponent } from './helper/helper.component'; +import { LanguageSelectorComponent } from './language-selector/language-selector.component'; +import { LoadingPanelComponent } from './loading-panel/loading-panel.component'; +import { ModalComponent } from './modal/modal.component'; +import { NotificationsSidebarComponent } from './notifications-sidebar/notifications-sidebar.component'; +import { OrchestratorDocPanelComponent } from './orchestrator-doc-panel/orchestrator-doc-panel.component'; +import { PwdExpirationNotificationComponent } from './pwd-expiration-notification/pwd-expiration-notification.component'; +import { RefreshSelectorComponent } from './refresh-selector/refresh-selector.component'; +import { SelectBadgesComponent } from './select-badges/select-badges.component'; +import { SelectComponent } from './select/select.component'; +import { SparklineComponent } from './sparkline/sparkline.component'; +import { SubmitButtonComponent } from './submit-button/submit-button.component'; +import { TelemetryNotificationComponent } from './telemetry-notification/telemetry-notification.component'; +import { UsageBarComponent } from './usage-bar/usage-bar.component'; +import { WizardComponent } from './wizard/wizard.component'; + +@NgModule({ + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + NgbAlertModule, + NgbPopoverModule, + NgbProgressbarModule, + NgbTooltipModule, + ChartsModule, + ReactiveFormsModule, + PipesModule, + DirectivesModule, + NgbDropdownModule, + ClickOutsideModule, + SimplebarAngularModule, + RouterModule, + NgbDatepickerModule, + NgbTimepickerModule + ], + declarations: [ + SparklineComponent, + HelperComponent, + SelectBadgesComponent, + SubmitButtonComponent, + UsageBarComponent, + LoadingPanelComponent, + ModalComponent, + NotificationsSidebarComponent, + CriticalConfirmationModalComponent, + ConfirmationModalComponent, + LanguageSelectorComponent, + GrafanaComponent, + SelectComponent, + BackButtonComponent, + RefreshSelectorComponent, + ConfigOptionComponent, + AlertPanelComponent, + FormModalComponent, + PwdExpirationNotificationComponent, + TelemetryNotificationComponent, + OrchestratorDocPanelComponent, + DateTimePickerComponent, + DocComponent, + Copy2ClipboardButtonComponent, + DownloadButtonComponent, + FormButtonPanelComponent, + MotdComponent, + WizardComponent, + CustomLoginBannerComponent + ], + providers: [], + exports: [ + SparklineComponent, + HelperComponent, + SelectBadgesComponent, + SubmitButtonComponent, + BackButtonComponent, + LoadingPanelComponent, + UsageBarComponent, + ModalComponent, + NotificationsSidebarComponent, + LanguageSelectorComponent, + GrafanaComponent, + SelectComponent, + RefreshSelectorComponent, + ConfigOptionComponent, + AlertPanelComponent, + PwdExpirationNotificationComponent, + TelemetryNotificationComponent, + OrchestratorDocPanelComponent, + DateTimePickerComponent, + DocComponent, + Copy2ClipboardButtonComponent, + DownloadButtonComponent, + FormButtonPanelComponent, + MotdComponent, + WizardComponent, + CustomLoginBannerComponent + ] +}) +export class ComponentsModule {} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.component.html new file mode 100644 index 000000000..0b0f87957 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.component.html @@ -0,0 +1,77 @@ +<div [formGroup]="optionsFormGroup"> + <div *ngFor="let option of options; let last = last"> + <div class="form-group row pt-2" + *ngIf="option.type === 'bool'"> + <label class="cd-col-form-label" + [for]="option.name"> + <b>{{ option.text }}</b> + <br> + <span class="text-muted"> + {{ option.desc }} + <cd-helper *ngIf="option.long_desc"> + {{ option.long_desc }}</cd-helper> + </span> + </label> + + <div class="cd-col-form-input"> + <div class="custom-control custom-checkbox"> + <input class="custom-control-input" + type="checkbox" + [id]="option.name" + [formControlName]="option.name"> + <label class="custom-control-label" + [for]="option.name"></label> + </div> + </div> + </div> + + <div class="form-group row pt-2" + *ngIf="option.type !== 'bool'"> + <label class="cd-col-form-label" + [for]="option.name">{{ option.text }} + <br> + <span class="text-muted"> + {{ option.desc }} + <cd-helper *ngIf="option.long_desc"> + {{ option.long_desc }}</cd-helper> + </span> + </label> + <div class="cd-col-form-input"> + <div class="input-group"> + <input class="form-control" + [type]="option.additionalTypeInfo.inputType" + [id]="option.name" + [placeholder]="option.additionalTypeInfo.humanReadable" + [formControlName]="option.name" + [step]="getStep(option.type, optionsForm.getValue(option.name))"> + <div class="input-group-append" + *ngIf="optionsFormShowReset"> + <button class="btn btn-light" + type="button" + data-toggle="button" + title="Remove the custom configuration value. The default configuration will be inherited and used instead." + (click)="resetValue(option.name)" + i18n-title> + <i [ngClass]="[icons.erase]" + aria-hidden="true"></i> + </button> + </div> + </div> + <span class="invalid-feedback" + *ngIf="optionsForm.showError(option.name, optionsFormDir, 'pattern')"> + {{ option.additionalTypeInfo.patternHelpText }}</span> + <span class="invalid-feedback" + *ngIf="optionsForm.showError(option.name, optionsFormDir, 'invalidUuid')"> + {{ option.additionalTypeInfo.patternHelpText }}</span> + <span class="invalid-feedback" + *ngIf="optionsForm.showError(option.name, optionsFormDir, 'max')" + i18n>The entered value is too high! It must not be greater than {{ option.maxValue }}.</span> + <span class="invalid-feedback" + *ngIf="optionsForm.showError(option.name, optionsFormDir, 'min')" + i18n>The entered value is too low! It must not be lower than {{ option.minValue }}.</span> + </div> + </div> + <hr *ngIf="!last" + class="my-2"> + </div> +</div> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.component.scss new file mode 100644 index 000000000..e35c2e37b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.component.scss @@ -0,0 +1,10 @@ +.custom-checkbox { + label, + input { + cursor: pointer; + } +} + +.col-form-label { + text-align: left; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.component.spec.ts new file mode 100644 index 000000000..200a27615 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.component.spec.ts @@ -0,0 +1,295 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; + +import { NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'; +import _ from 'lodash'; +import { of as observableOf } from 'rxjs'; + +import { ConfigurationService } from '~/app/shared/api/configuration.service'; +import { CdFormGroup } from '~/app/shared/forms/cd-form-group'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { HelperComponent } from '../helper/helper.component'; +import { ConfigOptionComponent } from './config-option.component'; + +describe('ConfigOptionComponent', () => { + let component: ConfigOptionComponent; + let fixture: ComponentFixture<ConfigOptionComponent>; + let configurationService: ConfigurationService; + let oNames: Array<string>; + + configureTestBed({ + declarations: [ConfigOptionComponent, HelperComponent], + imports: [NgbPopoverModule, ReactiveFormsModule, HttpClientTestingModule], + providers: [ConfigurationService] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ConfigOptionComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + configurationService = TestBed.inject(ConfigurationService); + + const configOptions: Record<string, any> = [ + { + name: 'osd_scrub_auto_repair_num_errors', + type: 'uint', + level: 'advanced', + desc: 'Maximum number of detected errors to automatically repair', + long_desc: '', + default: 5, + daemon_default: '', + tags: [], + services: [], + see_also: ['osd_scrub_auto_repair'], + min: '', + max: '', + can_update_at_runtime: true, + flags: [] + }, + { + name: 'osd_debug_deep_scrub_sleep', + type: 'float', + level: 'dev', + desc: + 'Inject an expensive sleep during deep scrub IO to make it easier to induce preemption', + long_desc: '', + default: 0, + daemon_default: '', + tags: [], + services: [], + see_also: [], + min: '', + max: '', + can_update_at_runtime: true, + flags: [] + }, + { + name: 'osd_heartbeat_interval', + type: 'int', + level: 'advanced', + desc: 'Interval (in seconds) between peer pings', + long_desc: '', + default: 6, + daemon_default: '', + tags: [], + services: [], + see_also: [], + min: 1, + max: 86400, + can_update_at_runtime: true, + flags: [], + value: [ + { + section: 'osd', + value: 6 + } + ] + }, + { + name: 'bluestore_compression_algorithm', + type: 'str', + level: 'advanced', + desc: 'Default compression algorithm to use when writing object data', + long_desc: + 'This controls the default compressor to use (if any) if the ' + + 'per-pool property is not set. Note that zstd is *not* recommended for ' + + 'bluestore due to high CPU overhead when compressing small amounts of data.', + default: 'snappy', + daemon_default: '', + tags: [], + services: [], + see_also: [], + enum_values: ['', 'snappy', 'zlib', 'zstd', 'lz4'], + min: '', + max: '', + can_update_at_runtime: true, + flags: ['runtime'] + }, + { + name: 'rbd_discard_on_zeroed_write_same', + type: 'bool', + level: 'advanced', + desc: 'discard data on zeroed write same instead of writing zero', + long_desc: '', + default: true, + daemon_default: '', + tags: [], + services: ['rbd'], + see_also: [], + min: '', + max: '', + can_update_at_runtime: true, + flags: [] + }, + { + name: 'rbd_journal_max_payload_bytes', + type: 'size', + level: 'advanced', + desc: 'maximum journal payload size before splitting', + long_desc: '', + daemon_default: '', + tags: [], + services: ['rbd'], + see_also: [], + min: '', + max: '', + can_update_at_runtime: true, + flags: [], + default: '16384' + }, + { + name: 'cluster_addr', + type: 'addr', + level: 'basic', + desc: 'cluster-facing address to bind to', + long_desc: '', + daemon_default: '', + tags: ['network'], + services: ['osd'], + see_also: [], + min: '', + max: '', + can_update_at_runtime: false, + flags: [], + default: '-' + }, + { + name: 'fsid', + type: 'uuid', + level: 'basic', + desc: 'cluster fsid (uuid)', + long_desc: '', + daemon_default: '', + tags: ['service'], + services: ['common'], + see_also: [], + min: '', + max: '', + can_update_at_runtime: false, + flags: ['no_mon_update'], + default: '00000000-0000-0000-0000-000000000000' + }, + { + name: 'mgr_tick_period', + type: 'secs', + level: 'advanced', + desc: 'Period in seconds of beacon messages to monitor', + long_desc: '', + daemon_default: '', + tags: [], + services: ['mgr'], + see_also: [], + min: '', + max: '', + can_update_at_runtime: true, + flags: [], + default: '2' + } + ]; + + spyOn(configurationService, 'filter').and.returnValue(observableOf(configOptions)); + oNames = _.map(configOptions, 'name'); + component.optionNames = oNames; + component.optionsForm = new CdFormGroup({}); + component.optionsFormGroupName = 'testFormGroupName'; + component.ngOnInit(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('optionNameToText', () => { + it('should format config option names correctly', () => { + const configOptionNames = { + osd_scrub_auto_repair_num_errors: 'Scrub Auto Repair Num Errors', + osd_debug_deep_scrub_sleep: 'Debug Deep Scrub Sleep', + osd_heartbeat_interval: 'Heartbeat Interval', + bluestore_compression_algorithm: 'Bluestore Compression Algorithm', + rbd_discard_on_zeroed_write_same: 'Rbd Discard On Zeroed Write Same', + rbd_journal_max_payload_bytes: 'Rbd Journal Max Payload Bytes', + cluster_addr: 'Cluster Addr', + fsid: 'Fsid', + mgr_tick_period: 'Tick Period' + }; + + component.options.forEach((option) => { + expect(option.text).toEqual(configOptionNames[option.name]); + }); + }); + }); + + describe('createForm', () => { + it('should set the optionsFormGroupName correctly', () => { + expect(component.optionsFormGroupName).toEqual('testFormGroupName'); + }); + + it('should create a FormControl for every config option', () => { + component.options.forEach((option) => { + expect(Object.keys(component.optionsFormGroup.controls)).toContain(option.name); + }); + }); + }); + + describe('loadStorageData', () => { + it('should create a list of config options by names', () => { + expect(component.options.length).toEqual(9); + + component.options.forEach((option) => { + expect(oNames).toContain(option.name); + }); + }); + + it('should add all needed attributes to every config option', () => { + component.options.forEach((option) => { + const optionKeys = Object.keys(option); + expect(optionKeys).toContain('text'); + expect(optionKeys).toContain('additionalTypeInfo'); + expect(optionKeys).toContain('value'); + + if (option.type !== 'bool' && option.type !== 'str') { + expect(optionKeys).toContain('patternHelpText'); + } + + if (option.name === 'osd_heartbeat_interval') { + expect(optionKeys).toContain('maxValue'); + expect(optionKeys).toContain('minValue'); + } + }); + }); + + it('should set minValue and maxValue correctly', () => { + component.options.forEach((option) => { + if (option.name === 'osd_heartbeat_interval') { + expect(option.minValue).toEqual(1); + expect(option.maxValue).toEqual(86400); + } + }); + }); + + it('should set the value attribute correctly', () => { + component.options.forEach((option) => { + if (option.name === 'osd_heartbeat_interval') { + const value = option.value; + expect(value).toBeDefined(); + expect(value).toEqual({ section: 'osd', value: 6 }); + } else { + expect(option.value).toBeUndefined(); + } + }); + }); + + it('should set the FormControl value correctly', () => { + component.options.forEach((option) => { + const value = component.optionsFormGroup.getValue(option.name); + if (option.name === 'osd_heartbeat_interval') { + expect(value).toBeDefined(); + expect(value).toEqual(6); + } else { + expect(value).toBeNull(); + } + }); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.component.ts new file mode 100644 index 000000000..2ac8e569a --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.component.ts @@ -0,0 +1,120 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { FormControl, NgForm } from '@angular/forms'; + +import _ from 'lodash'; + +import { ConfigurationService } from '~/app/shared/api/configuration.service'; +import { Icons } from '~/app/shared/enum/icons.enum'; +import { CdFormGroup } from '~/app/shared/forms/cd-form-group'; +import { ConfigOptionTypes } from './config-option.types'; + +@Component({ + selector: 'cd-config-option', + templateUrl: './config-option.component.html', + styleUrls: ['./config-option.component.scss'] +}) +export class ConfigOptionComponent implements OnInit { + @Input() + optionNames: Array<string> = []; + @Input() + optionsForm: CdFormGroup = new CdFormGroup({}); + @Input() + optionsFormDir: NgForm = new NgForm([], []); + @Input() + optionsFormGroupName = ''; + @Input() + optionsFormShowReset = true; + + icons = Icons; + options: Array<any> = []; + optionsFormGroup: CdFormGroup = new CdFormGroup({}); + + constructor(private configService: ConfigurationService) {} + + private static optionNameToText(optionName: string): string { + const sections = ['mon', 'mgr', 'osd', 'mds', 'client']; + return optionName + .split('_') + .filter((c, index) => index !== 0 || !sections.includes(c)) + .map((c) => c.charAt(0).toUpperCase() + c.substring(1)) + .join(' '); + } + + ngOnInit() { + this.createForm(); + this.loadStoredData(); + } + + private createForm() { + this.optionsForm.addControl(this.optionsFormGroupName, this.optionsFormGroup); + this.optionNames.forEach((optionName) => { + this.optionsFormGroup.addControl(optionName, new FormControl(null)); + }); + } + + getStep(type: string, value: any): number | undefined { + return ConfigOptionTypes.getTypeStep(type, value); + } + + private loadStoredData() { + this.configService.filter(this.optionNames).subscribe((data: any) => { + this.options = data.map((configOption: any) => { + const formControl = this.optionsForm.get(configOption.name); + const typeValidators = ConfigOptionTypes.getTypeValidators(configOption); + configOption.additionalTypeInfo = ConfigOptionTypes.getType(configOption.type); + + // Set general information and value + configOption.text = ConfigOptionComponent.optionNameToText(configOption.name); + configOption.value = _.find(configOption.value, (p) => { + return p.section === 'osd'; // TODO: Can handle any other section + }); + if (configOption.value) { + if (configOption.additionalTypeInfo.name === 'bool') { + formControl.setValue(configOption.value.value === 'true'); + } else { + formControl.setValue(configOption.value.value); + } + } + + // Set type information and validators + if (typeValidators) { + configOption.patternHelpText = typeValidators.patternHelpText; + if ('max' in typeValidators && typeValidators.max !== '') { + configOption.maxValue = typeValidators.max; + } + if ('min' in typeValidators && typeValidators.min !== '') { + configOption.minValue = typeValidators.min; + } + formControl.setValidators(typeValidators.validators); + } + + return configOption; + }); + }); + } + + saveValues() { + const options = {}; + this.optionNames.forEach((optionName) => { + const optionValue = this.optionsForm.getValue(optionName); + if (optionValue !== null && optionValue !== '') { + options[optionName] = { + section: 'osd', // TODO: Can handle any other section + value: optionValue + }; + } + }); + + return this.configService.bulkCreate({ options: options }); + } + + resetValue(optionName: string) { + this.configService.delete(optionName, 'osd').subscribe( + // TODO: Can handle any other section + () => { + const formControl = this.optionsForm.get(optionName); + formControl.reset(); + } + ); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.model.ts new file mode 100644 index 000000000..d3ebc5f37 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.model.ts @@ -0,0 +1,12 @@ +export class ConfigFormModel { + name: string; + desc: string; + long_desc: string; + type: string; + value: Array<any>; + default: any; + daemon_default: any; + min: any; + max: any; + services: Array<string>; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.types.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.types.spec.ts new file mode 100644 index 000000000..8c34111b9 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.types.spec.ts @@ -0,0 +1,272 @@ +import { ConfigFormModel } from './config-option.model'; +import { ConfigOptionTypes } from './config-option.types'; + +describe('ConfigOptionTypes', () => { + describe('getType', () => { + it('should return uint type', () => { + const ret = ConfigOptionTypes.getType('uint'); + expect(ret).toBeTruthy(); + expect(ret.name).toBe('uint'); + expect(ret.inputType).toBe('number'); + expect(ret.humanReadable).toBe('Unsigned integer value'); + expect(ret.defaultMin).toBe(0); + expect(ret.patternHelpText).toBe('The entered value needs to be an unsigned number.'); + expect(ret.isNumberType).toBe(true); + expect(ret.allowsNegative).toBe(false); + }); + + it('should return int type', () => { + const ret = ConfigOptionTypes.getType('int'); + expect(ret).toBeTruthy(); + expect(ret.name).toBe('int'); + expect(ret.inputType).toBe('number'); + expect(ret.humanReadable).toBe('Integer value'); + expect(ret.defaultMin).toBeUndefined(); + expect(ret.patternHelpText).toBe('The entered value needs to be a number.'); + expect(ret.isNumberType).toBe(true); + expect(ret.allowsNegative).toBe(true); + }); + + it('should return size type', () => { + const ret = ConfigOptionTypes.getType('size'); + expect(ret).toBeTruthy(); + expect(ret.name).toBe('size'); + expect(ret.inputType).toBe('number'); + expect(ret.humanReadable).toBe('Unsigned integer value (>=16bit)'); + expect(ret.defaultMin).toBe(0); + expect(ret.patternHelpText).toBe('The entered value needs to be a unsigned number.'); + expect(ret.isNumberType).toBe(true); + expect(ret.allowsNegative).toBe(false); + }); + + it('should return secs type', () => { + const ret = ConfigOptionTypes.getType('secs'); + expect(ret).toBeTruthy(); + expect(ret.name).toBe('secs'); + expect(ret.inputType).toBe('number'); + expect(ret.humanReadable).toBe('Number of seconds'); + expect(ret.defaultMin).toBe(1); + expect(ret.patternHelpText).toBe('The entered value needs to be a number >= 1.'); + expect(ret.isNumberType).toBe(true); + expect(ret.allowsNegative).toBe(false); + }); + + it('should return float type', () => { + const ret = ConfigOptionTypes.getType('float'); + expect(ret).toBeTruthy(); + expect(ret.name).toBe('float'); + expect(ret.inputType).toBe('number'); + expect(ret.humanReadable).toBe('Double value'); + expect(ret.defaultMin).toBeUndefined(); + expect(ret.patternHelpText).toBe('The entered value needs to be a number or decimal.'); + expect(ret.isNumberType).toBe(true); + expect(ret.allowsNegative).toBe(true); + }); + + it('should return str type', () => { + const ret = ConfigOptionTypes.getType('str'); + expect(ret).toBeTruthy(); + expect(ret.name).toBe('str'); + expect(ret.inputType).toBe('text'); + expect(ret.humanReadable).toBe('Text'); + expect(ret.defaultMin).toBeUndefined(); + expect(ret.patternHelpText).toBeUndefined(); + expect(ret.isNumberType).toBe(false); + expect(ret.allowsNegative).toBeUndefined(); + }); + + it('should return addr type', () => { + const ret = ConfigOptionTypes.getType('addr'); + expect(ret).toBeTruthy(); + expect(ret.name).toBe('addr'); + expect(ret.inputType).toBe('text'); + expect(ret.humanReadable).toBe('IPv4 or IPv6 address'); + expect(ret.defaultMin).toBeUndefined(); + expect(ret.patternHelpText).toBe('The entered value needs to be a valid IP address.'); + expect(ret.isNumberType).toBe(false); + expect(ret.allowsNegative).toBeUndefined(); + }); + + it('should return uuid type', () => { + const ret = ConfigOptionTypes.getType('uuid'); + expect(ret).toBeTruthy(); + expect(ret.name).toBe('uuid'); + expect(ret.inputType).toBe('text'); + expect(ret.humanReadable).toBe('UUID'); + expect(ret.defaultMin).toBeUndefined(); + expect(ret.patternHelpText).toBe( + 'The entered value is not a valid UUID, e.g.: 67dcac9f-2c03-4d6c-b7bd-1210b3a259a8' + ); + expect(ret.isNumberType).toBe(false); + expect(ret.allowsNegative).toBeUndefined(); + }); + + it('should return bool type', () => { + const ret = ConfigOptionTypes.getType('bool'); + expect(ret).toBeTruthy(); + expect(ret.name).toBe('bool'); + expect(ret.inputType).toBe('checkbox'); + expect(ret.humanReadable).toBe('Boolean value'); + expect(ret.defaultMin).toBeUndefined(); + expect(ret.patternHelpText).toBeUndefined(); + expect(ret.isNumberType).toBe(false); + expect(ret.allowsNegative).toBeUndefined(); + }); + + it('should throw an error for unknown type', () => { + expect(() => ConfigOptionTypes.getType('unknown')).toThrowError( + 'Found unknown type "unknown" for config option.' + ); + }); + }); + + describe('getTypeValidators', () => { + it('should return two validators for type uint, secs and size', () => { + const types = ['uint', 'size', 'secs']; + + types.forEach((valType) => { + const configOption = new ConfigFormModel(); + configOption.type = valType; + + const ret = ConfigOptionTypes.getTypeValidators(configOption); + expect(ret).toBeTruthy(); + expect(ret.validators.length).toBe(2); + }); + }); + + it('should return a validator for types float, int, addr and uuid', () => { + const types = ['float', 'int', 'addr', 'uuid']; + + types.forEach((valType) => { + const configOption = new ConfigFormModel(); + configOption.type = valType; + + const ret = ConfigOptionTypes.getTypeValidators(configOption); + expect(ret).toBeTruthy(); + expect(ret.validators.length).toBe(1); + }); + }); + + it('should return undefined for type bool and str', () => { + const types = ['str', 'bool']; + + types.forEach((valType) => { + const configOption = new ConfigFormModel(); + configOption.type = valType; + + const ret = ConfigOptionTypes.getTypeValidators(configOption); + expect(ret).toBeUndefined(); + }); + }); + + it('should return a pattern and a min validator', () => { + const configOption = new ConfigFormModel(); + configOption.type = 'int'; + configOption.min = 2; + + const ret = ConfigOptionTypes.getTypeValidators(configOption); + expect(ret).toBeTruthy(); + expect(ret.validators.length).toBe(2); + expect(ret.min).toBe(2); + expect(ret.max).toBeUndefined(); + }); + + it('should return a pattern and a max validator', () => { + const configOption = new ConfigFormModel(); + configOption.type = 'int'; + configOption.max = 5; + + const ret = ConfigOptionTypes.getTypeValidators(configOption); + expect(ret).toBeTruthy(); + expect(ret.validators.length).toBe(2); + expect(ret.min).toBeUndefined(); + expect(ret.max).toBe(5); + }); + + it('should return multiple validators', () => { + const configOption = new ConfigFormModel(); + configOption.type = 'float'; + configOption.max = 5.2; + configOption.min = 1.5; + + const ret = ConfigOptionTypes.getTypeValidators(configOption); + expect(ret).toBeTruthy(); + expect(ret.validators.length).toBe(3); + expect(ret.min).toBe(1.5); + expect(ret.max).toBe(5.2); + }); + + it( + 'should return a pattern help text for type uint, int, size, secs, ' + 'float, addr and uuid', + () => { + const types = ['uint', 'int', 'size', 'secs', 'float', 'addr', 'uuid']; + + types.forEach((valType) => { + const configOption = new ConfigFormModel(); + configOption.type = valType; + + const ret = ConfigOptionTypes.getTypeValidators(configOption); + expect(ret).toBeTruthy(); + expect(ret.patternHelpText).toBeDefined(); + }); + } + ); + }); + + describe('getTypeStep', () => { + it('should return the correct step for type uint and value 0', () => { + const ret = ConfigOptionTypes.getTypeStep('uint', 0); + expect(ret).toBe(1); + }); + + it('should return the correct step for type int and value 1', () => { + const ret = ConfigOptionTypes.getTypeStep('int', 1); + expect(ret).toBe(1); + }); + + it('should return the correct step for type int and value null', () => { + const ret = ConfigOptionTypes.getTypeStep('int', null); + expect(ret).toBe(1); + }); + + it('should return the correct step for type size and value 2', () => { + const ret = ConfigOptionTypes.getTypeStep('size', 2); + expect(ret).toBe(1); + }); + + it('should return the correct step for type secs and value 3', () => { + const ret = ConfigOptionTypes.getTypeStep('secs', 3); + expect(ret).toBe(1); + }); + + it('should return the correct step for type float and value 1', () => { + const ret = ConfigOptionTypes.getTypeStep('float', 1); + expect(ret).toBe(0.1); + }); + + it('should return the correct step for type float and value 0.1', () => { + const ret = ConfigOptionTypes.getTypeStep('float', 0.1); + expect(ret).toBe(0.1); + }); + + it('should return the correct step for type float and value 0.02', () => { + const ret = ConfigOptionTypes.getTypeStep('float', 0.02); + expect(ret).toBe(0.01); + }); + + it('should return the correct step for type float and value 0.003', () => { + const ret = ConfigOptionTypes.getTypeStep('float', 0.003); + expect(ret).toBe(0.001); + }); + + it('should return the correct step for type float and value null', () => { + const ret = ConfigOptionTypes.getTypeStep('float', null); + expect(ret).toBe(0.1); + }); + + it('should return undefined for unknown type', () => { + const ret = ConfigOptionTypes.getTypeStep('unknown', 1); + expect(ret).toBeUndefined(); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.types.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.types.ts new file mode 100644 index 000000000..33336652c --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.types.ts @@ -0,0 +1,147 @@ +import { Validators } from '@angular/forms'; + +import _ from 'lodash'; + +import { CdValidators } from '~/app/shared/forms/cd-validators'; +import { ConfigFormModel } from './config-option.model'; + +export class ConfigOptionTypes { + // TODO: I18N + private static knownTypes: Array<any> = [ + { + name: 'uint', + inputType: 'number', + humanReadable: 'Unsigned integer value', + defaultMin: 0, + patternHelpText: 'The entered value needs to be an unsigned number.', + isNumberType: true, + allowsNegative: false + }, + { + name: 'int', + inputType: 'number', + humanReadable: 'Integer value', + patternHelpText: 'The entered value needs to be a number.', + isNumberType: true, + allowsNegative: true + }, + { + name: 'size', + inputType: 'number', + humanReadable: 'Unsigned integer value (>=16bit)', + defaultMin: 0, + patternHelpText: 'The entered value needs to be a unsigned number.', + isNumberType: true, + allowsNegative: false + }, + { + name: 'secs', + inputType: 'number', + humanReadable: 'Number of seconds', + defaultMin: 1, + patternHelpText: 'The entered value needs to be a number >= 1.', + isNumberType: true, + allowsNegative: false + }, + { + name: 'float', + inputType: 'number', + humanReadable: 'Double value', + patternHelpText: 'The entered value needs to be a number or decimal.', + isNumberType: true, + allowsNegative: true + }, + { name: 'str', inputType: 'text', humanReadable: 'Text', isNumberType: false }, + { + name: 'addr', + inputType: 'text', + humanReadable: 'IPv4 or IPv6 address', + patternHelpText: 'The entered value needs to be a valid IP address.', + isNumberType: false + }, + { + name: 'uuid', + inputType: 'text', + humanReadable: 'UUID', + patternHelpText: + 'The entered value is not a valid UUID, e.g.: 67dcac9f-2c03-4d6c-b7bd-1210b3a259a8', + isNumberType: false + }, + { name: 'bool', inputType: 'checkbox', humanReadable: 'Boolean value', isNumberType: false } + ]; + + public static getType(type: string): any { + const currentType = _.find(this.knownTypes, (t) => { + return t.name === type; + }); + + if (currentType !== undefined) { + return currentType; + } + + throw new Error('Found unknown type "' + type + '" for config option.'); + } + + public static getTypeValidators(configOption: ConfigFormModel): any { + const typeParams = ConfigOptionTypes.getType(configOption.type); + + if (typeParams.name === 'bool' || typeParams.name === 'str') { + return; + } + + const typeValidators: Record<string, any> = { + validators: [], + patternHelpText: typeParams.patternHelpText + }; + + if (typeParams.isNumberType) { + if (configOption.max && configOption.max !== '') { + typeValidators['max'] = configOption.max; + typeValidators.validators.push(Validators.max(configOption.max)); + } + + if (configOption.min && configOption.min !== '') { + typeValidators['min'] = configOption.min; + typeValidators.validators.push(Validators.min(configOption.min)); + } else if ('defaultMin' in typeParams) { + typeValidators['min'] = typeParams.defaultMin; + typeValidators.validators.push(Validators.min(typeParams.defaultMin)); + } + + if (configOption.type === 'float') { + typeValidators.validators.push(CdValidators.decimalNumber()); + } else { + typeValidators.validators.push(CdValidators.number(typeParams.allowsNegative)); + } + } else if (configOption.type === 'addr') { + typeValidators.validators = [CdValidators.ip()]; + } else if (configOption.type === 'uuid') { + typeValidators.validators = [CdValidators.uuid()]; + } + + return typeValidators; + } + + public static getTypeStep(type: string, value: number): number | undefined { + const numberTypes = ['uint', 'int', 'size', 'secs']; + + if (numberTypes.includes(type)) { + return 1; + } + + if (type === 'float') { + if (value !== null) { + const stringVal = value.toString(); + if (stringVal.indexOf('.') !== -1) { + // Value type float and contains decimal characters + const decimal = value.toString().split('.'); + return Math.pow(10, -decimal[1].length); + } + } + + return 0.1; + } + + return undefined; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.html new file mode 100644 index 000000000..294d43f77 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.html @@ -0,0 +1,27 @@ +<cd-modal (hide)="cancel()"> + <ng-container class="modal-title"> + <span class="text-warning" + *ngIf="warning"> + <i class="fa fa-exclamation-triangle fa-1x"></i> + </span>{{ titleText }}</ng-container> + <ng-container class="modal-content"> + <form name="confirmationForm" + #formDir="ngForm" + [formGroup]="confirmationForm" + novalidate> + <div class="modal-body"> + <ng-container *ngTemplateOutlet="bodyTpl; context: bodyContext"></ng-container> + <p *ngIf="description"> + {{description}} + </p> + </div> + <div class="modal-footer"> + <cd-form-button-panel (submitActionEvent)="onSubmit(confirmationForm.value)" + (backActionEvent)="boundCancel()" + [form]="confirmationForm" + [submitText]="buttonText" + [showSubmit]="showSubmit"></cd-form-button-panel> + </div> + </form> + </ng-container> +</cd-modal> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.scss new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.scss diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.spec.ts new file mode 100644 index 000000000..a76c5d378 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.spec.ts @@ -0,0 +1,185 @@ +import { Component, NgModule, NO_ERRORS_SCHEMA, TemplateRef, ViewChild } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { NgbActiveModal, NgbModalModule, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; + +import { ModalService } from '~/app/shared/services/modal.service'; +import { configureTestBed, FixtureHelper } from '~/testing/unit-test-helper'; +import { BackButtonComponent } from '../back-button/back-button.component'; +import { FormButtonPanelComponent } from '../form-button-panel/form-button-panel.component'; +import { ModalComponent } from '../modal/modal.component'; +import { SubmitButtonComponent } from '../submit-button/submit-button.component'; +import { ConfirmationModalComponent } from './confirmation-modal.component'; + +@NgModule({}) +export class MockModule {} + +@Component({ + template: `<ng-template #fillTpl>Template based description.</ng-template>` +}) +class MockComponent { + @ViewChild('fillTpl', { static: true }) + fillTpl: TemplateRef<any>; + modalRef: NgbModalRef; + returnValue: any; + + // Normally private, but public is needed by tests + constructor(public modalService: ModalService) {} + + private openModal(extendBaseState = {}) { + this.modalRef = this.modalService.show( + ConfirmationModalComponent, + Object.assign( + { + titleText: 'Title is a must have', + buttonText: 'Action label', + bodyTpl: this.fillTpl, + description: 'String based description.', + onSubmit: () => { + this.returnValue = 'The submit action has to hide manually.'; + } + }, + extendBaseState + ) + ); + } + + basicModal() { + this.openModal(); + } + + customCancelModal() { + this.openModal({ + onCancel: () => (this.returnValue = 'If you have todo something besides hiding the modal.') + }); + } +} + +describe('ConfirmationModalComponent', () => { + let component: ConfirmationModalComponent; + let fixture: ComponentFixture<ConfirmationModalComponent>; + let mockComponent: MockComponent; + let mockFixture: ComponentFixture<MockComponent>; + let fh: FixtureHelper; + + const expectReturnValue = (v: string) => expect(mockComponent.returnValue).toBe(v); + + configureTestBed({ + declarations: [ + ConfirmationModalComponent, + BackButtonComponent, + MockComponent, + ModalComponent, + SubmitButtonComponent, + FormButtonPanelComponent + ], + schemas: [NO_ERRORS_SCHEMA], + imports: [ReactiveFormsModule, MockModule, RouterTestingModule, NgbModalModule], + providers: [NgbActiveModal, SubmitButtonComponent, FormButtonPanelComponent] + }); + + beforeEach(() => { + fh = new FixtureHelper(); + mockFixture = TestBed.createComponent(MockComponent); + mockComponent = mockFixture.componentInstance; + mockFixture.detectChanges(); + + spyOn(TestBed.inject(ModalService), 'show').and.callFake((_modalComp, config) => { + fixture = TestBed.createComponent(ConfirmationModalComponent); + component = fixture.componentInstance; + component = Object.assign(component, config); + component.activeModal = { close: () => true } as any; + spyOn(component.activeModal, 'close').and.callThrough(); + fh.updateFixture(fixture); + }); + }); + + it('should create', () => { + mockComponent.basicModal(); + expect(component).toBeTruthy(); + }); + + describe('Throws errors', () => { + const expectError = (config: object, expected: string) => { + mockComponent.basicModal(); + component = Object.assign(component, config); + expect(() => component.ngOnInit()).toThrowError(expected); + }; + + it('has no submit action defined', () => { + expectError( + { + onSubmit: undefined + }, + 'No submit action defined' + ); + }); + + it('has no title defined', () => { + expectError( + { + titleText: undefined + }, + 'No title defined' + ); + }); + + it('has no action name defined', () => { + expectError( + { + buttonText: undefined + }, + 'No action name defined' + ); + }); + + it('has no description defined', () => { + expectError( + { + bodyTpl: undefined, + description: undefined + }, + 'No description defined' + ); + }); + }); + + describe('basics', () => { + beforeEach(() => { + mockComponent.basicModal(); + spyOn(component, 'onSubmit').and.callThrough(); + }); + + it('should show the correct title', () => { + expect(fh.getText('.modal-title')).toBe('Title is a must have'); + }); + + it('should show the correct action name', () => { + expect(fh.getText('.tc_submitButton')).toBe('Action label'); + }); + + it('should use the correct submit action', () => { + // In order to ignore the `ElementRef` usage of `SubmitButtonComponent` + spyOn(fh.getElementByCss('.tc_submitButton').componentInstance, 'focusButton'); + fh.clickElement('.tc_submitButton'); + expect(component.onSubmit).toHaveBeenCalledTimes(1); + expect(component.activeModal.close).toHaveBeenCalledTimes(0); + expectReturnValue('The submit action has to hide manually.'); + }); + + it('should use the default cancel action', () => { + fh.clickElement('.tc_backButton'); + expect(component.onSubmit).toHaveBeenCalledTimes(0); + expect(component.activeModal.close).toHaveBeenCalledTimes(1); + expectReturnValue(undefined); + }); + + it('should show the description', () => { + expect(fh.getText('.modal-body')).toBe( + 'Template based description. String based description.' + ); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.ts new file mode 100644 index 000000000..fe5624981 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/confirmation-modal/confirmation-modal.component.ts @@ -0,0 +1,65 @@ +import { Component, OnDestroy, OnInit, TemplateRef } from '@angular/core'; +import { FormGroup } from '@angular/forms'; + +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; + +@Component({ + selector: 'cd-confirmation-modal', + templateUrl: './confirmation-modal.component.html', + styleUrls: ['./confirmation-modal.component.scss'] +}) +export class ConfirmationModalComponent implements OnInit, OnDestroy { + // Needed + buttonText: string; + titleText: string; + onSubmit: Function; + + // One of them is needed + bodyTpl?: TemplateRef<any>; + description?: TemplateRef<any>; + + // Optional + warning = false; + bodyData?: object; + onCancel?: Function; + bodyContext?: object; + showSubmit = true; + + // Component only + boundCancel = this.cancel.bind(this); + confirmationForm: FormGroup; + private canceled = false; + + constructor(public activeModal: NgbActiveModal) { + this.confirmationForm = new FormGroup({}); + } + + ngOnInit() { + this.bodyContext = this.bodyContext || {}; + this.bodyContext['$implicit'] = this.bodyData; + if (!this.onSubmit) { + throw new Error('No submit action defined'); + } else if (!this.buttonText) { + throw new Error('No action name defined'); + } else if (!this.titleText) { + throw new Error('No title defined'); + } else if (!this.bodyTpl && !this.description) { + throw new Error('No description defined'); + } + } + + ngOnDestroy() { + if (this.onCancel && this.canceled) { + this.onCancel(); + } + } + + cancel() { + this.canceled = true; + this.activeModal.close(); + } + + stopLoadingSpinner() { + this.confirmationForm.setErrors({ cdSubmitButton: true }); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/copy2clipboard-button/copy2clipboard-button.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/copy2clipboard-button/copy2clipboard-button.component.html new file mode 100644 index 000000000..25a3f3cfe --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/copy2clipboard-button/copy2clipboard-button.component.html @@ -0,0 +1,7 @@ +<button (click)="onClick()" + type="button" + class="btn btn-light" + i18n-title + title="Copy to Clipboard"> + <i [ngClass]="[icons.clipboard]"></i> +</button> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/copy2clipboard-button/copy2clipboard-button.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/copy2clipboard-button/copy2clipboard-button.component.scss new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/copy2clipboard-button/copy2clipboard-button.component.scss diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/copy2clipboard-button/copy2clipboard-button.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/copy2clipboard-button/copy2clipboard-button.component.spec.ts new file mode 100644 index 000000000..2842793c6 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/copy2clipboard-button/copy2clipboard-button.component.spec.ts @@ -0,0 +1,65 @@ +import { TestBed } from '@angular/core/testing'; + +import * as BrowserDetect from 'detect-browser'; +import { ToastrService } from 'ngx-toastr'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { Copy2ClipboardButtonComponent } from './copy2clipboard-button.component'; + +describe('Copy2ClipboardButtonComponent', () => { + let component: Copy2ClipboardButtonComponent; + + configureTestBed({ + providers: [ + { + provide: ToastrService, + useValue: { + error: () => true, + success: () => true + } + } + ] + }); + + it('should create an instance', () => { + component = new Copy2ClipboardButtonComponent(null); + expect(component).toBeTruthy(); + }); + + describe('test onClick behaviours', () => { + let toastrService: ToastrService; + let queryFn: jasmine.Spy; + let writeTextFn: jasmine.Spy; + + beforeEach(() => { + toastrService = TestBed.inject(ToastrService); + component = new Copy2ClipboardButtonComponent(toastrService); + spyOn<any>(component, 'getText').and.returnValue('foo'); + Object.assign(navigator, { + permissions: { query: jest.fn() }, + clipboard: { + writeText: jest.fn() + } + }); + queryFn = spyOn(navigator.permissions, 'query'); + }); + + it('should not call permissions API', () => { + spyOn(BrowserDetect, 'detect').and.returnValue({ name: 'firefox' }); + writeTextFn = spyOn(navigator.clipboard, 'writeText').and.returnValue( + new Promise<void>((resolve, _) => { + resolve(); + }) + ); + component.onClick(); + expect(queryFn).not.toHaveBeenCalled(); + expect(writeTextFn).toHaveBeenCalledWith('foo'); + }); + + it('should call permissions API', () => { + spyOn(BrowserDetect, 'detect').and.returnValue({ name: 'chrome' }); + component.onClick(); + expect(queryFn).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/copy2clipboard-button/copy2clipboard-button.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/copy2clipboard-button/copy2clipboard-button.component.ts new file mode 100644 index 000000000..2cc656bfc --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/copy2clipboard-button/copy2clipboard-button.component.ts @@ -0,0 +1,55 @@ +import { Component, HostListener, Input } from '@angular/core'; + +import { detect } from 'detect-browser'; +import { ToastrService } from 'ngx-toastr'; + +import { Icons } from '~/app/shared/enum/icons.enum'; + +@Component({ + selector: 'cd-copy-2-clipboard-button', + templateUrl: './copy2clipboard-button.component.html', + styleUrls: ['./copy2clipboard-button.component.scss'] +}) +export class Copy2ClipboardButtonComponent { + @Input() + private source: string; + + @Input() + byId = true; + + icons = Icons; + + constructor(private toastr: ToastrService) {} + + private getText(): string { + const element = document.getElementById(this.source) as HTMLInputElement; + return element.value; + } + + @HostListener('click') + onClick() { + try { + const browser = detect(); + const text = this.byId ? this.getText() : this.source; + const toastrFn = () => { + this.toastr.success('Copied text to the clipboard successfully.'); + }; + if (['firefox', 'ie', 'ios', 'safari'].includes(browser.name)) { + // Various browsers do not support the `Permissions API`. + // https://developer.mozilla.org/en-US/docs/Web/API/Permissions_API#Browser_compatibility + navigator.clipboard.writeText(text).then(() => toastrFn()); + } else { + // Checking if we have the clipboard-write permission + navigator.permissions + .query({ name: 'clipboard-write' as PermissionName }) + .then((result: any) => { + if (result.state === 'granted' || result.state === 'prompt') { + navigator.clipboard.writeText(text).then(() => toastrFn()); + } + }); + } + } catch (_) { + this.toastr.error('Failed to copy text to the clipboard.'); + } + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component.html new file mode 100644 index 000000000..29b669b14 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component.html @@ -0,0 +1,55 @@ +<cd-modal #modal + [modalRef]="activeModal"> + <ng-container class="modal-title"> + <ng-container *ngTemplateOutlet="deletionHeading"></ng-container> + </ng-container> + + <ng-container class="modal-content"> + <form name="deletionForm" + #formDir="ngForm" + [formGroup]="deletionForm" + novalidate> + <div class="modal-body"> + <ng-container *ngTemplateOutlet="bodyTemplate; context: bodyContext"></ng-container> + <div class="question"> + <span *ngIf="itemNames; else noNames"> + <p *ngIf="itemNames.length === 1; else manyNames" + i18n>Are you sure that you want to {{ actionDescription | lowercase }} <strong>{{ itemNames[0] }}</strong>?</p> + <ng-template #manyNames> + <p i18n>Are you sure that you want to {{ actionDescription | lowercase }} the selected items?</p> + <ul> + <li *ngFor="let itemName of itemNames"><strong>{{ itemName }}</strong></li> + </ul> + </ng-template > + </span> + <ng-template #noNames> + <p i18n>Are you sure that you want to {{ actionDescription | lowercase }} the selected {{ itemDescription }}?</p> + </ng-template> + <ng-container *ngTemplateOutlet="childFormGroupTemplate; context:{form:deletionForm}"></ng-container> + <div class="form-group"> + <div class="custom-control custom-checkbox"> + <input type="checkbox" + class="custom-control-input" + name="confirmation" + id="confirmation" + formControlName="confirmation" + autofocus> + <label class="custom-control-label" + for="confirmation" + i18n>Yes, I am sure.</label> + </div> + </div> + </div> + </div> + <div class="modal-footer"> + <cd-form-button-panel (submitActionEvent)="callSubmitAction()" + [form]="deletionForm" + [submitText]="(actionDescription | titlecase) + ' ' + itemDescription"></cd-form-button-panel> + </div> + </form> + </ng-container> +</cd-modal> + +<ng-template #deletionHeading> + {{ actionDescription | titlecase }} {{ itemDescription }} +</ng-template> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component.scss new file mode 100644 index 000000000..979cb13fe --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component.scss @@ -0,0 +1,11 @@ +.modal-body .question { + margin-top: 1em; +} + +.modal-body label { + font-weight: bold; +} + +.modal-body .question .form-check { + padding-top: 7px; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component.spec.ts new file mode 100644 index 000000000..e501d9f32 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component.spec.ts @@ -0,0 +1,235 @@ +import { Component, NgModule, NO_ERRORS_SCHEMA, TemplateRef, ViewChild } from '@angular/core'; +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { NgForm, ReactiveFormsModule } from '@angular/forms'; + +import { NgbActiveModal, NgbModalModule, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; +import { Observable, Subscriber, timer as observableTimer } from 'rxjs'; + +import { DirectivesModule } from '~/app/shared/directives/directives.module'; +import { ModalService } from '~/app/shared/services/modal.service'; +import { configureTestBed, modalServiceShow } from '~/testing/unit-test-helper'; +import { AlertPanelComponent } from '../alert-panel/alert-panel.component'; +import { LoadingPanelComponent } from '../loading-panel/loading-panel.component'; +import { CriticalConfirmationModalComponent } from './critical-confirmation-modal.component'; + +@NgModule({}) +export class MockModule {} + +@Component({ + template: ` + <button type="button" class="btn btn-danger" (click)="openCtrlDriven()"> + <i class="fa fa-times"></i>Deletion Ctrl-Test + <ng-template #ctrlDescription> + The spinner is handled by the controller if you have use the modal as ViewChild in order to + use it's functions to stop the spinner or close the dialog. + </ng-template> + </button> + + <button type="button" class="btn btn-danger" (click)="openModalDriven()"> + <i class="fa fa-times"></i>Deletion Modal-Test + <ng-template #modalDescription> + The spinner is handled by the modal if your given deletion function returns a Observable. + </ng-template> + </button> + ` +}) +class MockComponent { + @ViewChild('ctrlDescription', { static: true }) + ctrlDescription: TemplateRef<any>; + @ViewChild('modalDescription', { static: true }) + modalDescription: TemplateRef<any>; + someData = [1, 2, 3, 4, 5]; + finished: number[]; + ctrlRef: NgbModalRef; + modalRef: NgbModalRef; + + // Normally private - public was needed for the tests + constructor(public modalService: ModalService) {} + + openCtrlDriven() { + this.ctrlRef = this.modalService.show(CriticalConfirmationModalComponent, { + submitAction: this.fakeDeleteController.bind(this), + bodyTemplate: this.ctrlDescription + }); + } + + openModalDriven() { + this.modalRef = this.modalService.show(CriticalConfirmationModalComponent, { + submitActionObservable: this.fakeDelete(), + bodyTemplate: this.modalDescription + }); + } + + finish() { + this.finished = [6, 7, 8, 9]; + } + + fakeDelete() { + return (): Observable<any> => { + return new Observable((observer: Subscriber<any>) => { + observableTimer(100).subscribe(() => { + observer.next(this.finish()); + observer.complete(); + }); + }); + }; + } + + fakeDeleteController() { + observableTimer(100).subscribe(() => { + this.finish(); + this.ctrlRef.close(); + }); + } +} + +describe('CriticalConfirmationModalComponent', () => { + let mockComponent: MockComponent; + let component: CriticalConfirmationModalComponent; + let mockFixture: ComponentFixture<MockComponent>; + + configureTestBed( + { + declarations: [ + MockComponent, + CriticalConfirmationModalComponent, + LoadingPanelComponent, + AlertPanelComponent + ], + schemas: [NO_ERRORS_SCHEMA], + imports: [ReactiveFormsModule, MockModule, DirectivesModule, NgbModalModule], + providers: [NgbActiveModal] + }, + [CriticalConfirmationModalComponent] + ); + + beforeEach(() => { + mockFixture = TestBed.createComponent(MockComponent); + mockComponent = mockFixture.componentInstance; + spyOn(mockComponent.modalService, 'show').and.callFake((_modalComp, config) => { + const data = modalServiceShow(CriticalConfirmationModalComponent, config); + component = data.componentInstance; + return data; + }); + mockComponent.openCtrlDriven(); + mockFixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should throw an error if no action is defined', () => { + component = Object.assign(component, { + submitAction: null, + submitActionObservable: null + }); + expect(() => component.ngOnInit()).toThrowError('No submit action defined'); + }); + + it('should test if the ctrl driven mock is set correctly through mock component', () => { + expect(component.bodyTemplate).toBeTruthy(); + expect(component.submitAction).toBeTruthy(); + expect(component.submitActionObservable).not.toBeTruthy(); + }); + + it('should test if the modal driven mock is set correctly through mock component', () => { + mockComponent.openModalDriven(); + expect(component.bodyTemplate).toBeTruthy(); + expect(component.submitActionObservable).toBeTruthy(); + expect(component.submitAction).not.toBeTruthy(); + }); + + describe('component functions', () => { + const changeValue = (value: boolean) => { + const ctrl = component.deletionForm.get('confirmation'); + ctrl.setValue(value); + ctrl.markAsDirty(); + ctrl.updateValueAndValidity(); + mockFixture.detectChanges(); + }; + + it('should test hideModal', () => { + expect(component.activeModal).toBeTruthy(); + expect(component.hideModal).toBeTruthy(); + spyOn(component.activeModal, 'close').and.callThrough(); + expect(component.activeModal.close).not.toHaveBeenCalled(); + component.hideModal(); + expect(component.activeModal.close).toHaveBeenCalled(); + }); + + describe('validate confirmation', () => { + const testValidation = (submitted: boolean, error: string, expected: boolean) => { + expect( + component.deletionForm.showError('confirmation', <NgForm>{ submitted: submitted }, error) + ).toBe(expected); + }; + + beforeEach(() => { + component.deletionForm.reset(); + }); + + it('should test empty values', () => { + component.deletionForm.reset(); + testValidation(false, undefined, false); + testValidation(true, 'required', true); + component.deletionForm.reset(); + changeValue(true); + changeValue(false); + testValidation(true, 'required', true); + }); + }); + + describe('deletion call', () => { + beforeEach(() => { + spyOn(component, 'stopLoadingSpinner').and.callThrough(); + spyOn(component, 'hideModal').and.callThrough(); + }); + + describe('Controller driven', () => { + beforeEach(() => { + spyOn(component, 'submitAction').and.callThrough(); + spyOn(mockComponent.ctrlRef, 'close').and.callThrough(); + }); + + it('should test fake deletion that closes modal', fakeAsync(() => { + // Before deletionCall + expect(component.submitAction).not.toHaveBeenCalled(); + // During deletionCall + component.callSubmitAction(); + expect(component.stopLoadingSpinner).not.toHaveBeenCalled(); + expect(component.hideModal).not.toHaveBeenCalled(); + expect(mockComponent.ctrlRef.close).not.toHaveBeenCalled(); + expect(component.submitAction).toHaveBeenCalled(); + expect(mockComponent.finished).toBe(undefined); + // After deletionCall + tick(2000); + expect(component.hideModal).not.toHaveBeenCalled(); + expect(mockComponent.ctrlRef.close).toHaveBeenCalled(); + expect(mockComponent.finished).toEqual([6, 7, 8, 9]); + })); + }); + + describe('Modal driven', () => { + beforeEach(() => { + mockComponent.openModalDriven(); + spyOn(component, 'stopLoadingSpinner').and.callThrough(); + spyOn(component, 'hideModal').and.callThrough(); + spyOn(mockComponent, 'fakeDelete').and.callThrough(); + }); + + it('should delete and close modal', fakeAsync(() => { + // During deletionCall + component.callSubmitAction(); + expect(mockComponent.finished).toBe(undefined); + expect(component.hideModal).not.toHaveBeenCalled(); + // After deletionCall + tick(2000); + expect(mockComponent.finished).toEqual([6, 7, 8, 9]); + expect(component.stopLoadingSpinner).not.toHaveBeenCalled(); + expect(component.hideModal).toHaveBeenCalled(); + })); + }); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component.ts new file mode 100644 index 000000000..4c634f8ca --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component.ts @@ -0,0 +1,63 @@ +import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core'; +import { FormControl, Validators } from '@angular/forms'; + +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { Observable } from 'rxjs'; + +import { CdFormGroup } from '~/app/shared/forms/cd-form-group'; +import { SubmitButtonComponent } from '../submit-button/submit-button.component'; + +@Component({ + selector: 'cd-deletion-modal', + templateUrl: './critical-confirmation-modal.component.html', + styleUrls: ['./critical-confirmation-modal.component.scss'] +}) +export class CriticalConfirmationModalComponent implements OnInit { + @ViewChild(SubmitButtonComponent, { static: true }) + submitButton: SubmitButtonComponent; + bodyTemplate: TemplateRef<any>; + bodyContext: object; + submitActionObservable: () => Observable<any>; + submitAction: Function; + deletionForm: CdFormGroup; + itemDescription: 'entry'; + itemNames: string[]; + actionDescription = 'delete'; + + childFormGroup: CdFormGroup; + childFormGroupTemplate: TemplateRef<any>; + + constructor(public activeModal: NgbActiveModal) {} + + ngOnInit() { + const controls = { + confirmation: new FormControl(false, [Validators.requiredTrue]) + }; + if (this.childFormGroup) { + controls['child'] = this.childFormGroup; + } + this.deletionForm = new CdFormGroup(controls); + if (!(this.submitAction || this.submitActionObservable)) { + throw new Error('No submit action defined'); + } + } + + callSubmitAction() { + if (this.submitActionObservable) { + this.submitActionObservable().subscribe({ + error: this.stopLoadingSpinner.bind(this), + complete: this.hideModal.bind(this) + }); + } else { + this.submitAction(); + } + } + + hideModal() { + this.activeModal.close(); + } + + stopLoadingSpinner() { + this.deletionForm.setErrors({ cdSubmitButton: true }); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/custom-login-banner/custom-login-banner.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/custom-login-banner/custom-login-banner.component.html new file mode 100644 index 000000000..7bb087c3f --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/custom-login-banner/custom-login-banner.component.html @@ -0,0 +1,2 @@ +<p class="login-text" + *ngIf="bannerText$ | async as bannerText">{{ bannerText }}</p> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/custom-login-banner/custom-login-banner.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/custom-login-banner/custom-login-banner.component.scss new file mode 100644 index 000000000..4721f6531 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/custom-login-banner/custom-login-banner.component.scss @@ -0,0 +1,5 @@ +.login-text { + font-weight: bold; + margin: 0; + padding: 12px 20% 12px 12px; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/custom-login-banner/custom-login-banner.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/custom-login-banner/custom-login-banner.component.spec.ts new file mode 100644 index 000000000..6005cbd0b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/custom-login-banner/custom-login-banner.component.spec.ts @@ -0,0 +1,25 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { CustomLoginBannerComponent } from './custom-login-banner.component'; + +describe('CustomLoginBannerComponent', () => { + let component: CustomLoginBannerComponent; + let fixture: ComponentFixture<CustomLoginBannerComponent>; + + configureTestBed({ + declarations: [CustomLoginBannerComponent], + imports: [HttpClientTestingModule] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(CustomLoginBannerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/custom-login-banner/custom-login-banner.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/custom-login-banner/custom-login-banner.component.ts new file mode 100644 index 000000000..ad0d54688 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/custom-login-banner/custom-login-banner.component.ts @@ -0,0 +1,20 @@ +import { Component, OnInit } from '@angular/core'; + +import _ from 'lodash'; +import { Observable } from 'rxjs'; + +import { CustomLoginBannerService } from '~/app/shared/api/custom-login-banner.service'; + +@Component({ + selector: 'cd-custom-login-banner', + templateUrl: './custom-login-banner.component.html', + styleUrls: ['./custom-login-banner.component.scss'] +}) +export class CustomLoginBannerComponent implements OnInit { + bannerText$: Observable<string>; + constructor(private customLoginBannerService: CustomLoginBannerService) {} + + ngOnInit(): void { + this.bannerText$ = this.customLoginBannerService.getBannerText(); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/date-time-picker/date-time-picker.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/date-time-picker/date-time-picker.component.html new file mode 100644 index 000000000..7f8388f47 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/date-time-picker/date-time-picker.component.html @@ -0,0 +1,13 @@ +<div class="d-flex justify-content-center"> + <ngb-datepicker #dp + [(ngModel)]="date" + [minDate]="minDate" + (ngModelChange)="onModelChange()"></ngb-datepicker> +</div> + +<div class="d-flex justify-content-center" + *ngIf="hasTime"> + <ngb-timepicker [seconds]="hasSeconds" + [(ngModel)]="time" + (ngModelChange)="onModelChange()"></ngb-timepicker> +</div> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/date-time-picker/date-time-picker.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/date-time-picker/date-time-picker.component.scss new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/date-time-picker/date-time-picker.component.scss diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/date-time-picker/date-time-picker.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/date-time-picker/date-time-picker.component.spec.ts new file mode 100644 index 000000000..00d09e3b4 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/date-time-picker/date-time-picker.component.spec.ts @@ -0,0 +1,58 @@ +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { FormControl, FormsModule } from '@angular/forms'; + +import { NgbDatepickerModule, NgbTimepickerModule } from '@ng-bootstrap/ng-bootstrap'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { DateTimePickerComponent } from './date-time-picker.component'; + +describe('DateTimePickerComponent', () => { + let component: DateTimePickerComponent; + let fixture: ComponentFixture<DateTimePickerComponent>; + + configureTestBed({ + declarations: [DateTimePickerComponent], + imports: [NgbDatepickerModule, NgbTimepickerModule, FormsModule] + }); + + beforeEach(() => { + spyOn(Date, 'now').and.returnValue(new Date('2022-02-22T00:00:00.00')); + fixture = TestBed.createComponent(DateTimePickerComponent); + component = fixture.componentInstance; + }); + + it('should create with correct datetime', fakeAsync(() => { + component.control = new FormControl('2022-02-26 00:00:00'); + fixture.detectChanges(); + tick(); + expect(component).toBeTruthy(); + expect(component.control.value).toBe('2022-02-26 00:00:00'); + })); + + it('should update control value if datetime is not valid', fakeAsync(() => { + component.control = new FormControl('not valid'); + fixture.detectChanges(); + tick(); + expect(component.control.value).toBe('2022-02-22 00:00:00'); + })); + + it('should init with only date enabled', () => { + component.control = new FormControl(); + component.hasTime = false; + fixture.detectChanges(); + expect(component.format).toBe('YYYY-MM-DD'); + }); + + it('should init with time enabled', () => { + component.control = new FormControl(); + component.hasSeconds = false; + fixture.detectChanges(); + expect(component.format).toBe('YYYY-MM-DD HH:mm'); + }); + + it('should init with seconds enabled', () => { + component.control = new FormControl(); + fixture.detectChanges(); + expect(component.format).toBe('YYYY-MM-DD HH:mm:ss'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/date-time-picker/date-time-picker.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/date-time-picker/date-time-picker.component.ts new file mode 100644 index 000000000..390edbfd8 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/date-time-picker/date-time-picker.component.ts @@ -0,0 +1,67 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { FormControl } from '@angular/forms'; + +import { NgbCalendar, NgbDateStruct, NgbTimeStruct } from '@ng-bootstrap/ng-bootstrap'; +import moment from 'moment'; +import { Subscription } from 'rxjs'; + +@Component({ + selector: 'cd-date-time-picker', + templateUrl: './date-time-picker.component.html', + styleUrls: ['./date-time-picker.component.scss'] +}) +export class DateTimePickerComponent implements OnInit { + @Input() + control: FormControl; + + @Input() + hasSeconds = true; + + @Input() + hasTime = true; + + format: string; + minDate: NgbDateStruct; + date: NgbDateStruct; + time: NgbTimeStruct; + + sub: Subscription; + + constructor(private calendar: NgbCalendar) {} + + ngOnInit() { + this.minDate = this.calendar.getToday(); + if (!this.hasTime) { + this.format = 'YYYY-MM-DD'; + } else if (this.hasSeconds) { + this.format = 'YYYY-MM-DD HH:mm:ss'; + } else { + this.format = 'YYYY-MM-DD HH:mm'; + } + + let mom = moment(this.control?.value, this.format); + + if (!mom.isValid() || mom.isBefore(moment())) { + mom = moment(); + } + + this.date = { year: mom.year(), month: mom.month() + 1, day: mom.date() }; + this.time = { hour: mom.hour(), minute: mom.minute(), second: mom.second() }; + + this.onModelChange(); + } + + onModelChange() { + if (this.date) { + const datetime = Object.assign({}, this.date, this.time); + datetime.month--; + setTimeout(() => { + this.control.setValue(moment(datetime).format(this.format)); + }); + } else { + setTimeout(() => { + this.control.setValue(''); + }); + } + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/doc/doc.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/doc/doc.component.html new file mode 100644 index 000000000..b90fedc0c --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/doc/doc.component.html @@ -0,0 +1,2 @@ +<a href="{{ docUrl }}" + target="_blank">{{ docText }}</a> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/doc/doc.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/doc/doc.component.scss new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/doc/doc.component.scss diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/doc/doc.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/doc/doc.component.spec.ts new file mode 100644 index 000000000..3fb31024e --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/doc/doc.component.spec.ts @@ -0,0 +1,27 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CephReleaseNamePipe } from '~/app/shared/pipes/ceph-release-name.pipe'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { DocComponent } from './doc.component'; + +describe('DocComponent', () => { + let component: DocComponent; + let fixture: ComponentFixture<DocComponent>; + + configureTestBed({ + declarations: [DocComponent], + imports: [HttpClientTestingModule], + providers: [CephReleaseNamePipe] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(DocComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/doc/doc.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/doc/doc.component.ts new file mode 100644 index 000000000..6dffc360b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/doc/doc.component.ts @@ -0,0 +1,28 @@ +import { Component, Input, OnInit } from '@angular/core'; + +import { DocService } from '~/app/shared/services/doc.service'; + +@Component({ + selector: 'cd-doc', + templateUrl: './doc.component.html', + styleUrls: ['./doc.component.scss'] +}) +export class DocComponent implements OnInit { + @Input() section: string; + @Input() docText = $localize`documentation`; + @Input() noSubscribe: boolean; + + docUrl: string; + + constructor(private docService: DocService) {} + + ngOnInit() { + if (this.noSubscribe) { + this.docUrl = this.docService.urlGenerator(this.section); + } else { + this.docService.subscribeOnce(this.section, (url: string) => { + this.docUrl = url; + }); + } + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/download-button/download-button.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/download-button/download-button.component.html new file mode 100644 index 000000000..a7e476501 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/download-button/download-button.component.html @@ -0,0 +1,23 @@ +<div ngbDropdown + placement="bottom-right"> + <button type="button" + [title]="title" + class="btn btn-light dropdown-toggle-split" + ngbDropdownToggle> + <i [ngClass]="[icons.download]"></i> + </button> + <div ngbDropdownMenu> + <button ngbDropdownItem + (click)="download('json')" + *ngIf="objectItem"> + <i [ngClass]="[icons.json]"></i> + <span>JSON</span> + </button> + <button ngbDropdownItem + (click)="download()" + *ngIf="textItem"> + <i [ngClass]="[icons.text]"></i> + <span>Text</span> + </button> + </div> +</div> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/download-button/download-button.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/download-button/download-button.component.scss new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/download-button/download-button.component.scss diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/download-button/download-button.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/download-button/download-button.component.spec.ts new file mode 100644 index 000000000..7dbfc2b1c --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/download-button/download-button.component.spec.ts @@ -0,0 +1,39 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TextToDownloadService } from '~/app/shared/services/text-to-download.service'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { DownloadButtonComponent } from './download-button.component'; + +describe('DownloadButtonComponent', () => { + let component: DownloadButtonComponent; + let fixture: ComponentFixture<DownloadButtonComponent>; + + configureTestBed({ + declarations: [DownloadButtonComponent], + providers: [TextToDownloadService] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(DownloadButtonComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should call download function', () => { + component.objectItem = { + testA: 'testA', + testB: 'testB' + }; + const downloadSpy = spyOn(TestBed.inject(TextToDownloadService), 'download'); + component.fileName = `${'reportText.json'}_${new Date().toLocaleDateString()}`; + component.download('json'); + expect(downloadSpy).toHaveBeenCalledWith( + JSON.stringify(component.objectItem, null, 2), + `${component.fileName}.json` + ); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/download-button/download-button.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/download-button/download-button.component.ts new file mode 100644 index 000000000..48fde7921 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/download-button/download-button.component.ts @@ -0,0 +1,31 @@ +import { Component, Input } from '@angular/core'; + +import { Icons } from '~/app/shared/enum/icons.enum'; +import { TextToDownloadService } from '~/app/shared/services/text-to-download.service'; + +@Component({ + selector: 'cd-download-button', + templateUrl: './download-button.component.html', + styleUrls: ['./download-button.component.scss'] +}) +export class DownloadButtonComponent { + @Input() objectItem: object; + @Input() textItem: string; + @Input() fileName: any; + @Input() title = $localize`Download`; + + icons = Icons; + constructor(private textToDownloadService: TextToDownloadService) {} + + download(format?: string) { + this.fileName = `${this.fileName}_${new Date().toLocaleDateString()}`; + if (format === 'json') { + this.textToDownloadService.download( + JSON.stringify(this.objectItem, null, 2), + `${this.fileName}.json` + ); + } else { + this.textToDownloadService.download(this.textItem, `${this.fileName}.txt`); + } + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-button-panel/form-button-panel.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-button-panel/form-button-panel.component.html new file mode 100644 index 000000000..476ed9609 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-button-panel/form-button-panel.component.html @@ -0,0 +1,11 @@ +<div [class]="wrappingClass"> + <cd-back-button class="m-2" + (backAction)="backAction()" + [name]="cancelText"></cd-back-button> + <cd-submit-button *ngIf="showSubmit" + (submitAction)="submitAction()" + [disabled]="disabled" + [form]="form" + [ariaLabel]="submitText" + data-cy="submitBtn">{{ submitText }}</cd-submit-button> +</div> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-button-panel/form-button-panel.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-button-panel/form-button-panel.component.scss new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-button-panel/form-button-panel.component.scss diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-button-panel/form-button-panel.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-button-panel/form-button-panel.component.spec.ts new file mode 100644 index 000000000..b8350485b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-button-panel/form-button-panel.component.spec.ts @@ -0,0 +1,25 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { FormButtonPanelComponent } from './form-button-panel.component'; + +describe('FormButtonPanelComponent', () => { + let component: FormButtonPanelComponent; + let fixture: ComponentFixture<FormButtonPanelComponent>; + + configureTestBed({ + declarations: [FormButtonPanelComponent], + schemas: [NO_ERRORS_SCHEMA] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(FormButtonPanelComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-button-panel/form-button-panel.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-button-panel/form-button-panel.component.ts new file mode 100644 index 000000000..0d48f63c0 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-button-panel/form-button-panel.component.ts @@ -0,0 +1,59 @@ +import { Location } from '@angular/common'; +import { Component, EventEmitter, Input, Output, ViewChild } from '@angular/core'; +import { FormGroup, NgForm } from '@angular/forms'; + +import { ActionLabelsI18n } from '~/app/shared/constants/app.constants'; +import { ModalService } from '~/app/shared/services/modal.service'; +import { SubmitButtonComponent } from '../submit-button/submit-button.component'; + +@Component({ + selector: 'cd-form-button-panel', + templateUrl: './form-button-panel.component.html', + styleUrls: ['./form-button-panel.component.scss'] +}) +export class FormButtonPanelComponent { + @ViewChild(SubmitButtonComponent) + submitButton: SubmitButtonComponent; + + @Output() + submitActionEvent = new EventEmitter(); + @Output() + backActionEvent = new EventEmitter(); + + @Input() + form: FormGroup | NgForm; + @Input() + showSubmit = true; + @Input() + wrappingClass = ''; + @Input() + btnClass = ''; + @Input() + submitText: string = this.actionLabels.CREATE; + @Input() + cancelText: string = this.actionLabels.CANCEL; + @Input() + disabled = false; + + constructor( + private location: Location, + private actionLabels: ActionLabelsI18n, + private modalService: ModalService + ) {} + + submitAction() { + this.submitActionEvent.emit(); + } + + backAction() { + if (this.backActionEvent.observers.length === 0) { + if (this.modalService.hasOpenModals()) { + this.modalService.dismissAll(); + } else { + this.location.back(); + } + } else { + this.backActionEvent.emit(); + } + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.html new file mode 100755 index 000000000..47fca49c7 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.html @@ -0,0 +1,69 @@ +<cd-modal [modalRef]="activeModal"> + <ng-container *ngIf="titleText" + class="modal-title"> + {{ titleText }} + </ng-container> + <ng-container class="modal-content"> + <form [formGroup]="formGroup" + #formDir="ngForm" + novalidate> + <div class="modal-body"> + <p *ngIf="message">{{ message }}</p> + <ng-container *ngFor="let field of fields"> + <div class="form-group row cd-{{field.name}}-form-group"> + <label *ngIf="field.label" + class="cd-col-form-label" + [ngClass]="{'required': field?.required === true}" + [for]="field.name"> + {{ field.label }} + </label> + <div [ngClass]="{'cd-col-form-input': field.label, 'col-sm-12': !field.label}"> + <input *ngIf="['text', 'number'].includes(field.type)" + [type]="field.type" + class="form-control" + [id]="field.name" + [name]="field.name" + [formControlName]="field.name"> + <input *ngIf="field.type === 'binary'" + type="text" + class="form-control" + [id]="field.name" + [name]="field.name" + [formControlName]="field.name" + cdDimlessBinary> + <select *ngIf="field.type === 'select'" + class="form-control" + [id]="field.name" + [formControlName]="field.name"> + <option *ngIf="field?.typeConfig?.placeholder" + [ngValue]="null"> + {{ field?.typeConfig?.placeholder }} + </option> + <option *ngFor="let option of field?.typeConfig?.options" + [value]="option.value"> + {{ option.text }} + </option> + </select> + <cd-select-badges *ngIf="field.type === 'select-badges'" + [id]="field.name" + [data]="field.value" + [customBadges]="field?.typeConfig?.customBadges" + [options]="field?.typeConfig?.options" + [messages]="field?.typeConfig?.messages"> + </cd-select-badges> + <span *ngIf="formGroup.showError(field.name, formDir)" + class="invalid-feedback"> + {{ getError(field) }} + </span> + </div> + </div> + </ng-container> + </div> + <div class="modal-footer"> + <cd-form-button-panel (submitActionEvent)="onSubmitForm(formGroup.value)" + [form]="formGroup" + [submitText]="submitButtonText"></cd-form-button-panel> + </div> + </form> + </ng-container> +</cd-modal> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.scss new file mode 100755 index 000000000..e69de29bb --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.scss diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.spec.ts new file mode 100755 index 000000000..219c2e79f --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.spec.ts @@ -0,0 +1,149 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule, Validators } from '@angular/forms'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; + +import { CdValidators } from '~/app/shared/forms/cd-validators'; +import { SharedModule } from '~/app/shared/shared.module'; +import { configureTestBed, FixtureHelper, FormHelper } from '~/testing/unit-test-helper'; +import { FormModalComponent } from './form-modal.component'; + +describe('InputModalComponent', () => { + let component: FormModalComponent; + let fixture: ComponentFixture<FormModalComponent>; + let fh: FixtureHelper; + let formHelper: FormHelper; + let submitted: object; + + const initialState = { + titleText: 'Some title', + message: 'Some description', + fields: [ + { + type: 'text', + name: 'requiredField', + value: 'some-value', + required: true + }, + { + type: 'number', + name: 'optionalField', + label: 'Optional', + errors: { min: 'Value has to be above zero!' }, + validators: [Validators.min(0), Validators.max(10)] + }, + { + type: 'binary', + name: 'dimlessBinary', + label: 'Size', + value: 2048, + validators: [CdValidators.binaryMin(1024), CdValidators.binaryMax(3072)] + } + ], + submitButtonText: 'Submit button name', + onSubmit: (values: object) => (submitted = values) + }; + + configureTestBed({ + imports: [RouterTestingModule, ReactiveFormsModule, SharedModule], + providers: [NgbActiveModal] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(FormModalComponent); + component = fixture.componentInstance; + Object.assign(component, initialState); + fixture.detectChanges(); + fh = new FixtureHelper(fixture); + formHelper = new FormHelper(component.formGroup); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('has the defined title', () => { + fh.expectTextToBe('.modal-title', 'Some title'); + }); + + it('has the defined description', () => { + fh.expectTextToBe('.modal-body > p', 'Some description'); + }); + + it('should display both inputs', () => { + fh.expectElementVisible('#requiredField', true); + fh.expectElementVisible('#optionalField', true); + }); + + it('has one defined label field', () => { + fh.expectTextToBe('.cd-col-form-label', 'Optional'); + }); + + it('has a predefined values for requiredField', () => { + fh.expectFormFieldToBe('#requiredField', 'some-value'); + }); + + it('gives back all form values on submit', () => { + component.onSubmitForm(component.formGroup.value); + expect(submitted).toEqual({ + dimlessBinary: 2048, + requiredField: 'some-value', + optionalField: null + }); + }); + + it('tests required field validation', () => { + formHelper.expectErrorChange('requiredField', '', 'required'); + }); + + it('tests required field message', () => { + formHelper.setValue('requiredField', '', true); + fh.expectTextToBe('.cd-requiredField-form-group .invalid-feedback', 'This field is required.'); + }); + + it('tests custom validator on number field', () => { + formHelper.expectErrorChange('optionalField', -1, 'min'); + formHelper.expectErrorChange('optionalField', 11, 'max'); + }); + + it('tests custom validator error message', () => { + formHelper.setValue('optionalField', -1, true); + fh.expectTextToBe( + '.cd-optionalField-form-group .invalid-feedback', + 'Value has to be above zero!' + ); + }); + + it('tests default error message', () => { + formHelper.setValue('optionalField', 11, true); + fh.expectTextToBe('.cd-optionalField-form-group .invalid-feedback', 'An error occurred.'); + }); + + it('tests binary error messages', () => { + formHelper.setValue('dimlessBinary', '4 K', true); + fh.expectTextToBe( + '.cd-dimlessBinary-form-group .invalid-feedback', + 'Size has to be at most 3 KiB or less' + ); + formHelper.setValue('dimlessBinary', '0.5 K', true); + fh.expectTextToBe( + '.cd-dimlessBinary-form-group .invalid-feedback', + 'Size has to be at least 1 KiB or more' + ); + }); + + it('shows result of dimlessBinary pipe', () => { + fh.expectFormFieldToBe('#dimlessBinary', '2 KiB'); + }); + + it('changes dimlessBinary value and the result will still be a number', () => { + formHelper.setValue('dimlessBinary', '3 K', true); + component.onSubmitForm(component.formGroup.value); + expect(submitted).toEqual({ + dimlessBinary: 3072, + requiredField: 'some-value', + optionalField: null + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.ts new file mode 100755 index 000000000..46dd942e9 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/form-modal/form-modal.component.ts @@ -0,0 +1,110 @@ +import { Component, OnInit } from '@angular/core'; +import { FormControl, ValidatorFn, Validators } from '@angular/forms'; + +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import _ from 'lodash'; + +import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder'; +import { CdFormGroup } from '~/app/shared/forms/cd-form-group'; +import { CdFormModalFieldConfig } from '~/app/shared/models/cd-form-modal-field-config'; +import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe'; +import { FormatterService } from '~/app/shared/services/formatter.service'; + +@Component({ + selector: 'cd-form-modal', + templateUrl: './form-modal.component.html', + styleUrls: ['./form-modal.component.scss'] +}) +export class FormModalComponent implements OnInit { + // Input + titleText: string; + message: string; + fields: CdFormModalFieldConfig[]; + submitButtonText: string; + onSubmit: Function; + + // Internal + formGroup: CdFormGroup; + + constructor( + public activeModal: NgbActiveModal, + private formBuilder: CdFormBuilder, + private formatter: FormatterService, + private dimlessBinaryPipe: DimlessBinaryPipe + ) {} + + ngOnInit() { + this.createForm(); + } + + createForm() { + const controlsConfig: Record<string, FormControl> = {}; + this.fields.forEach((field) => { + controlsConfig[field.name] = this.createFormControl(field); + }); + this.formGroup = this.formBuilder.group(controlsConfig); + } + + private createFormControl(field: CdFormModalFieldConfig): FormControl { + let validators: ValidatorFn[] = []; + if (_.isBoolean(field.required) && field.required) { + validators.push(Validators.required); + } + if (field.validators) { + validators = validators.concat(field.validators); + } + return new FormControl( + _.defaultTo( + field.type === 'binary' ? this.dimlessBinaryPipe.transform(field.value) : field.value, + null + ), + { validators } + ); + } + + getError(field: CdFormModalFieldConfig): string { + const formErrors = this.formGroup.get(field.name).errors; + const errors = Object.keys(formErrors).map((key) => { + return this.getErrorMessage(key, formErrors[key], field.errors); + }); + return errors.join('<br>'); + } + + private getErrorMessage( + error: string, + errorContext: any, + fieldErrors: { [error: string]: string } + ): string { + if (fieldErrors) { + const customError = fieldErrors[error]; + if (customError) { + return customError; + } + } + if (['binaryMin', 'binaryMax'].includes(error)) { + // binaryMin and binaryMax return a function that take I18n to + // provide a translated error message. + return errorContext(); + } + if (error === 'required') { + return $localize`This field is required.`; + } + return $localize`An error occurred.`; + } + + onSubmitForm(values: any) { + const binaries = this.fields + .filter((field) => field.type === 'binary') + .map((field) => field.name); + binaries.forEach((key) => { + const value = values[key]; + if (value) { + values[key] = this.formatter.toBytes(value); + } + }); + this.activeModal.close(); + if (_.isFunction(this.onSubmit)) { + this.onSubmit(values); + } + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/grafana/grafana.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/grafana/grafana.component.html new file mode 100644 index 000000000..8ad98b27f --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/grafana/grafana.component.html @@ -0,0 +1,78 @@ +<!-- Embed dashboard --> +<cd-loading-panel *ngIf="loading && grafanaExist" + i18n>Loading panel data...</cd-loading-panel> + +<cd-alert-panel type="info" + *ngIf="!grafanaExist" + i18n>Please consult the <cd-doc section="grafana"></cd-doc> on + how to configure and enable the monitoring functionality.</cd-alert-panel> + +<cd-alert-panel type="info" + *ngIf="!dashboardExist" + i18n>Grafana Dashboard doesn't exist. Please refer to + <cd-doc section="grafana"></cd-doc> on how to add dashboards to Grafana.</cd-alert-panel> + +<ng-container *ngIf="grafanaExist && dashboardExist"> + <div class="row"> + <div class="col"> + <div class="form-inline timepicker"> + <label for="timepicker" + class="ml-1 my-1" + i18n>Grafana Time Picker</label> + + <select id="timepicker" + name="timepicker" + class="custom-select my-1 mx-3" + [(ngModel)]="time" + (ngModelChange)="onTimepickerChange($event)"> + <option *ngFor="let key of grafanaTimes" + [ngValue]="key.value">{{ key.name }} + </option> + </select> + + <button class="btn btn-light my-1" + i18n-title + title="Reset Settings" + (click)="reset()"> + <i [ngClass]="[icons.undo]"></i> + </button> + <button class="btn btn-light my-1 ml-3" + i18n-title + title="Show hidden information" + (click)="showMessage = !showMessage"> + <i [ngClass]="[icons.infoCircle, icons.large]"></i> + </button> + </div> + </div> + </div> + + <div class="row"> + <div class="col my-3" + *ngIf="showMessage"> + <cd-alert-panel type="info" + class="mb-3" + *ngIf="showMessage" + dismissible="true" + (dismissed)="showMessage = false" + i18n>If no embedded Grafana Dashboard appeared below, please follow <a [href]="grafanaSrc" + target="_blank" + noopener + noreferrer>this link </a> to check if Grafana is reachable and there are no HTTPS certificate issues. You may need to reload this page after accepting any Browser certificate exceptions</cd-alert-panel> + </div> + </div> + + <div class="row"> + <div class="col"> + <div class="grafana-container"> + <iframe #iframe + id="iframe" + [src]="grafanaSrc" + class="grafana" + [ngClass]="panelStyle" + frameborder="0" + scrolling="no"> + </iframe> + </div> + </div> + </div> +</ng-container> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/grafana/grafana.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/grafana/grafana.component.scss new file mode 100644 index 000000000..7b43a460f --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/grafana/grafana.component.scss @@ -0,0 +1,33 @@ +.grafana { + height: 600px; + width: 100%; + z-index: 0; +} + +.grafana_one { + height: 400px; +} + +.grafana_two { + height: 750px; +} + +.grafana_three { + height: 900px; +} + +.grafana_four { + height: 1160px; +} + +.timepicker { + label { + font-weight: 700; + } +} + +.dropdown-menu { + left: auto; + right: 20px; + top: 20px; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/grafana/grafana.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/grafana/grafana.component.spec.ts new file mode 100644 index 000000000..63733fd75 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/grafana/grafana.component.spec.ts @@ -0,0 +1,81 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { NgbAlertModule } from '@ng-bootstrap/ng-bootstrap'; +import { of } from 'rxjs'; + +import { SettingsService } from '~/app/shared/api/settings.service'; +import { CephReleaseNamePipe } from '~/app/shared/pipes/ceph-release-name.pipe'; +import { SummaryService } from '~/app/shared/services/summary.service'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { AlertPanelComponent } from '../alert-panel/alert-panel.component'; +import { DocComponent } from '../doc/doc.component'; +import { LoadingPanelComponent } from '../loading-panel/loading-panel.component'; +import { GrafanaComponent } from './grafana.component'; + +describe('GrafanaComponent', () => { + let component: GrafanaComponent; + let fixture: ComponentFixture<GrafanaComponent>; + const expected_url = + 'http:localhost:3000/d/foo/somePath&refresh=2s&var-datasource=Dashboard1&kiosk&from=now-1h&to=now'; + + configureTestBed({ + declarations: [GrafanaComponent, AlertPanelComponent, LoadingPanelComponent, DocComponent], + imports: [NgbAlertModule, HttpClientTestingModule, RouterTestingModule, FormsModule], + providers: [CephReleaseNamePipe, SettingsService, SummaryService] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(GrafanaComponent); + component = fixture.componentInstance; + component.grafanaPath = 'somePath'; + component.uid = 'foo'; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should have found out that grafana does not exist', () => { + fixture.detectChanges(); + expect(component.grafanaExist).toBe(false); + expect(component.baseUrl).toBe(undefined); + expect(component.loading).toBe(true); + expect(component.url).toBe(undefined); + expect(component.grafanaSrc).toEqual(undefined); + }); + + describe('with grafana initialized', () => { + beforeEach(() => { + TestBed.inject(SettingsService)['settings'] = { 'api/grafana/url': 'http:localhost:3000' }; + fixture.detectChanges(); + }); + + it('should have found out that grafana exists and dashboard exists', () => { + expect(component.time).toBe('from=now-1h&to=now'); + expect(component.grafanaExist).toBe(true); + expect(component.baseUrl).toBe('http:localhost:3000/d/'); + expect(component.loading).toBe(false); + expect(component.url).toBe(expected_url); + expect(component.grafanaSrc).toEqual({ + changingThisBreaksApplicationSecurity: expected_url + }); + }); + + it('should reset the values', () => { + component.reset(); + expect(component.time).toBe('from=now-1h&to=now'); + expect(component.url).toBe(expected_url); + expect(component.grafanaSrc).toEqual({ + changingThisBreaksApplicationSecurity: expected_url + }); + }); + + it('should have Dashboard', () => { + TestBed.inject(SettingsService).validateGrafanaDashboardUrl = () => of({ uid: 200 }); + expect(component.dashboardExist).toBe(true); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/grafana/grafana.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/grafana/grafana.component.ts new file mode 100644 index 000000000..2815160ab --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/grafana/grafana.component.ts @@ -0,0 +1,201 @@ +import { Component, Input, OnChanges, OnInit } from '@angular/core'; +import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; + +import { SettingsService } from '~/app/shared/api/settings.service'; +import { Icons } from '~/app/shared/enum/icons.enum'; + +@Component({ + selector: 'cd-grafana', + templateUrl: './grafana.component.html', + styleUrls: ['./grafana.component.scss'] +}) +export class GrafanaComponent implements OnInit, OnChanges { + grafanaSrc: SafeUrl; + url: string; + protocol: string; + host: string; + port: number; + baseUrl: any; + panelStyle: any; + grafanaExist = false; + mode = '&kiosk'; + datasource = 'Dashboard1'; + loading = true; + styles: Record<string, string> = {}; + dashboardExist = true; + showMessage = false; + time: string; + grafanaTimes: any; + icons = Icons; + readonly DEFAULT_TIME: string = 'from=now-1h&to=now'; + + @Input() + grafanaPath: string; + @Input() + grafanaStyle: string; + @Input() + uid: string; + + constructor(private sanitizer: DomSanitizer, private settingsService: SettingsService) { + this.grafanaTimes = [ + { + name: $localize`Last 5 minutes`, + value: 'from=now-5m&to=now' + }, + { + name: $localize`Last 15 minutes`, + value: 'from=now-15m&to=now' + }, + { + name: $localize`Last 30 minutes`, + value: 'from=now-30m&to=now' + }, + { + name: $localize`Last 1 hour (Default)`, + value: 'from=now-1h&to=now' + }, + { + name: $localize`Last 3 hours`, + value: 'from=now-3h&to=now' + }, + { + name: $localize`Last 6 hours`, + value: 'from=now-6h&to=now' + }, + { + name: $localize`Last 12 hours`, + value: 'from=now-12h&to=now' + }, + { + name: $localize`Last 24 hours`, + value: 'from=now-24h&to=now' + }, + { + name: $localize`Yesterday`, + value: 'from=now-1d%2Fd&to=now-1d%2Fd' + }, + { + name: $localize`Today so far`, + value: 'from=now%2Fd&to=now' + }, + { + name: $localize`Day before yesterday`, + value: 'from=now-2d%2Fd&to=now-2d%2Fd' + }, + { + name: $localize`Last 2 days`, + value: 'from=now-2d&to=now' + }, + { + name: $localize`This day last week`, + value: 'from=now-7d%2Fd&to=now-7d%2Fd' + }, + { + name: $localize`Previous week`, + value: 'from=now-1w%2Fw&to=now-1w%2Fw' + }, + { + name: $localize`This week so far`, + value: 'from=now%2Fw&to=now' + }, + { + name: $localize`Last 7 days`, + value: 'from=now-7d&to=now' + }, + { + name: $localize`Previous month`, + value: 'from=now-1M%2FM&to=now-1M%2FM' + }, + { + name: $localize`This month so far`, + value: 'from=now%2FM&to=now' + }, + { + name: $localize`Last 30 days`, + value: 'from=now-30d&to=now' + }, + { + name: $localize`Last 90 days`, + value: 'from=now-90d&to=now' + }, + { + name: $localize`Last 6 months`, + value: 'from=now-6M&to=now' + }, + { + name: $localize`Last 1 year`, + value: 'from=now-1y&to=now' + }, + { + name: $localize`Previous year`, + value: 'from=now-1y%2Fy&to=now-1y%2Fy' + }, + { + name: $localize`This year so far`, + value: 'from=now%2Fy&to=now' + }, + { + name: $localize`Last 2 years`, + value: 'from=now-2y&to=now' + }, + { + name: $localize`Last 5 years`, + value: 'from=now-5y&to=now' + } + ]; + } + + ngOnInit() { + this.time = this.DEFAULT_TIME; + this.styles = { + one: 'grafana_one', + two: 'grafana_two', + three: 'grafana_three', + four: 'grafana_four' + }; + + this.settingsService.ifSettingConfigured('api/grafana/url', (url) => { + this.grafanaExist = true; + this.loading = false; + this.baseUrl = url + '/d/'; + this.getFrame(); + }); + this.panelStyle = this.styles[this.grafanaStyle]; + } + + getFrame() { + this.settingsService + .validateGrafanaDashboardUrl(this.uid) + .subscribe((data: any) => (this.dashboardExist = data === 200)); + this.url = + this.baseUrl + + this.uid + + '/' + + this.grafanaPath + + '&refresh=2s' + + `&var-datasource=${this.datasource}` + + this.mode + + '&' + + this.time; + this.grafanaSrc = this.sanitizer.bypassSecurityTrustResourceUrl(this.url); + } + + onTimepickerChange() { + if (this.grafanaExist) { + this.getFrame(); + } + } + + reset() { + this.time = this.DEFAULT_TIME; + if (this.grafanaExist) { + this.getFrame(); + } + } + + ngOnChanges() { + if (this.grafanaExist) { + this.getFrame(); + } + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/helper/helper.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/helper/helper.component.html new file mode 100644 index 000000000..f7bc12b5b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/helper/helper.component.html @@ -0,0 +1,11 @@ +<ng-template #popoverTpl> + <div [class]="class" + [innerHtml]="html"> + </div> + <ng-content></ng-content> +</ng-template> +<i [ngClass]="[icons.questionCircle]" + aria-hidden="true" + [ngbPopover]="popoverTpl" + (click)="$event.preventDefault();"> +</i> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/helper/helper.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/helper/helper.component.scss new file mode 100644 index 000000000..861b607cb --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/helper/helper.component.scss @@ -0,0 +1,7 @@ +@use './src/styles/vendor/variables' as vv; + +i { + color: vv.$primary; + cursor: pointer; + padding-left: 4px; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/helper/helper.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/helper/helper.component.spec.ts new file mode 100644 index 000000000..a7ef4b35e --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/helper/helper.component.spec.ts @@ -0,0 +1,26 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { HelperComponent } from './helper.component'; + +describe('HelperComponent', () => { + let component: HelperComponent; + let fixture: ComponentFixture<HelperComponent>; + + configureTestBed({ + imports: [NgbPopoverModule], + declarations: [HelperComponent] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(HelperComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/helper/helper.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/helper/helper.component.ts new file mode 100644 index 000000000..0028945ba --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/helper/helper.component.ts @@ -0,0 +1,18 @@ +import { Component, Input } from '@angular/core'; + +import { Icons } from '~/app/shared/enum/icons.enum'; + +@Component({ + selector: 'cd-helper', + templateUrl: './helper.component.html', + styleUrls: ['./helper.component.scss'] +}) +export class HelperComponent { + @Input() + class: string; + + @Input() + html: any; + + icons = Icons; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/language-selector/language-selector.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/language-selector/language-selector.component.html new file mode 100644 index 000000000..2ecbbd7cc --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/language-selector/language-selector.component.html @@ -0,0 +1,17 @@ +<div ngbDropdown + display="dynamic" + placement="bottom-right"> + <a ngbDropdownToggle + i18n-title + title="Select a Language"> + {{ allLanguages[selectedLanguage] }} + </a> + <div ngbDropdownMenu> + <ng-container *ngFor="let lang of supportedLanguages | keyvalue"> + <button ngbDropdownItem + (click)="changeLanguage(lang.key)"> + {{ lang.value }} + </button> + </ng-container> + </div> +</div> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/language-selector/language-selector.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/language-selector/language-selector.component.scss new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/language-selector/language-selector.component.scss diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/language-selector/language-selector.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/language-selector/language-selector.component.spec.ts new file mode 100644 index 000000000..5c8334e5a --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/language-selector/language-selector.component.spec.ts @@ -0,0 +1,85 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { LanguageSelectorComponent } from './language-selector.component'; + +describe('LanguageSelectorComponent', () => { + let component: LanguageSelectorComponent; + let fixture: ComponentFixture<LanguageSelectorComponent>; + + configureTestBed({ + declarations: [LanguageSelectorComponent], + imports: [FormsModule, HttpClientTestingModule] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(LanguageSelectorComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + spyOn(component, 'reloadWindow').and.callFake(() => component.ngOnInit()); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should read current language', () => { + expect(component.selectedLanguage).toBe('en-US'); + }); + + const expectLanguageChange = (lang: string) => { + component.changeLanguage(lang); + const cookie = document.cookie.split(';').filter((item) => item.includes(`cd-lang=${lang}`)); + expect(cookie.length).toBe(1); + }; + + it('should change to cs', () => { + expectLanguageChange('cs'); + }); + + it('should change to de', () => { + expectLanguageChange('de'); + }); + + it('should change to es', () => { + expectLanguageChange('es'); + }); + + it('should change to fr', () => { + expectLanguageChange('fr'); + }); + + it('should change to id', () => { + expectLanguageChange('id'); + }); + + it('should change to it', () => { + expectLanguageChange('it'); + }); + + it('should change to ja', () => { + expectLanguageChange('ja'); + }); + + it('should change to ko', () => { + expectLanguageChange('ko'); + }); + + it('should change to pl', () => { + expectLanguageChange('pl'); + }); + + it('should change to pt', () => { + expectLanguageChange('pt'); + }); + + it('should change to zh-Hans', () => { + expectLanguageChange('zh-Hans'); + }); + + it('should change to zh-Hant', () => { + expectLanguageChange('zh-Hant'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/language-selector/language-selector.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/language-selector/language-selector.component.ts new file mode 100644 index 000000000..d747add20 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/language-selector/language-selector.component.ts @@ -0,0 +1,40 @@ +import { Component, OnInit } from '@angular/core'; + +import _ from 'lodash'; + +import { LanguageService } from '~/app/shared/services/language.service'; +import { SupportedLanguages } from './supported-languages.enum'; + +@Component({ + selector: 'cd-language-selector', + templateUrl: './language-selector.component.html', + styleUrls: ['./language-selector.component.scss'] +}) +export class LanguageSelectorComponent implements OnInit { + allLanguages = SupportedLanguages; + supportedLanguages: Record<string, any> = {}; + selectedLanguage: string; + + constructor(private languageService: LanguageService) {} + + ngOnInit() { + this.selectedLanguage = this.languageService.getLocale(); + + this.languageService.getLanguages().subscribe((langs) => { + this.supportedLanguages = _.pick(SupportedLanguages, langs) as Object; + }); + } + + /** + * Jest is being more restricted regarding spying on the reload method. + * This will allow us to spyOn this method instead. + */ + reloadWindow() { + window.location.reload(); + } + + changeLanguage(lang: string) { + this.languageService.setLocale(lang); + this.reloadWindow(); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/language-selector/supported-languages.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/language-selector/supported-languages.enum.ts new file mode 100644 index 000000000..8b573cf64 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/language-selector/supported-languages.enum.ts @@ -0,0 +1,17 @@ +// When adding a new supported language make sure to add a test for it in: +// language-selector.component.spec.ts +export enum SupportedLanguages { + 'cs' = 'Čeština', + 'de' = 'Deutsch', + 'en-US' = 'English', + 'es' = 'Español', + 'fr' = 'Français', + 'id' = 'Bahasa Indonesia', + 'it' = 'Italiano', + 'ja' = '日本語', + 'ko' = '한국어', + 'pl' = 'Polski', + 'pt' = 'Português (brasileiro)', + 'zh-Hans' = '中文 (简体)', + 'zh-Hant' = '中文 (繁體)' +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/loading-panel/loading-panel.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/loading-panel/loading-panel.component.html new file mode 100644 index 000000000..35726cfbd --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/loading-panel/loading-panel.component.html @@ -0,0 +1,9 @@ +<ngb-alert type="info" + [dismissible]="false"> + <strong> + <i [ngClass]="[icons.spinner, icons.spin]" + aria-hidden="true" + class="mr-2"></i> + </strong> + <ng-content></ng-content> +</ngb-alert> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/loading-panel/loading-panel.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/loading-panel/loading-panel.component.scss new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/loading-panel/loading-panel.component.scss diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/loading-panel/loading-panel.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/loading-panel/loading-panel.component.spec.ts new file mode 100644 index 000000000..ffc0aa57b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/loading-panel/loading-panel.component.spec.ts @@ -0,0 +1,26 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { NgbAlertModule } from '@ng-bootstrap/ng-bootstrap'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { LoadingPanelComponent } from './loading-panel.component'; + +describe('LoadingPanelComponent', () => { + let component: LoadingPanelComponent; + let fixture: ComponentFixture<LoadingPanelComponent>; + + configureTestBed({ + declarations: [LoadingPanelComponent], + imports: [NgbAlertModule] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(LoadingPanelComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/loading-panel/loading-panel.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/loading-panel/loading-panel.component.ts new file mode 100644 index 000000000..61fd01904 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/loading-panel/loading-panel.component.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; + +import { Icons } from '~/app/shared/enum/icons.enum'; + +@Component({ + selector: 'cd-loading-panel', + templateUrl: './loading-panel.component.html', + styleUrls: ['./loading-panel.component.scss'] +}) +export class LoadingPanelComponent { + icons = Icons; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/modal/modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/modal/modal.component.html new file mode 100644 index 000000000..657e0d605 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/modal/modal.component.html @@ -0,0 +1,19 @@ +<div [ngClass]="pageURL ? 'modal' : ''"> + <div [ngClass]="pageURL ? 'modal-dialog' : ''"> + <div class="modal-content"> + <div class="modal-header"> + <h4 class="modal-title float-left"> + <ng-content select=".modal-title"></ng-content> + </h4> + <button type="button" + class="close float-right" + aria-label="Close" + (click)="close()"> + <span aria-hidden="true">×</span> + </button> + </div> + + <ng-content select=".modal-content"></ng-content> + </div> + </div> +</div> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/modal/modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/modal/modal.component.scss new file mode 100644 index 000000000..ceeb61427 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/modal/modal.component.scss @@ -0,0 +1,23 @@ +@use './src/styles/defaults/mixins'; + +.modal-header { + @include mixins.hf; + border-radius: 5px 5px 0 0; +} + +::ng-deep cd-modal { + .modal-footer { + @include mixins.hf; + border-radius: 0 0 5px 5px; + } + + .modal-body { + max-height: 70vh; + overflow-x: hidden; + overflow-y: auto; + } +} + +button.close { + outline: none; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/modal/modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/modal/modal.component.spec.ts new file mode 100644 index 000000000..cf08bef10 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/modal/modal.component.spec.ts @@ -0,0 +1,54 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { ModalComponent } from './modal.component'; + +describe('ModalComponent', () => { + let component: ModalComponent; + let fixture: ComponentFixture<ModalComponent>; + let routerNavigateSpy: jasmine.Spy; + + configureTestBed({ + declarations: [ModalComponent], + imports: [RouterTestingModule] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ModalComponent); + component = fixture.componentInstance; + routerNavigateSpy = spyOn(TestBed.inject(Router), 'navigate'); + routerNavigateSpy.and.returnValue(true); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should call the hide callback function', () => { + spyOn(component.hide, 'emit'); + const nativeElement = fixture.nativeElement; + const button = nativeElement.querySelector('button'); + button.dispatchEvent(new Event('click')); + fixture.detectChanges(); + expect(component.hide.emit).toHaveBeenCalled(); + }); + + it('should hide the modal', () => { + component.modalRef = new NgbActiveModal(); + spyOn(component.modalRef, 'close'); + component.close(); + expect(component.modalRef.close).toHaveBeenCalled(); + }); + + it('should hide the routed modal', () => { + component.pageURL = 'hosts'; + component.close(); + expect(routerNavigateSpy).toHaveBeenCalledTimes(1); + expect(routerNavigateSpy).toHaveBeenCalledWith(['hosts', { outlets: { modal: null } }]); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/modal/modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/modal/modal.component.ts new file mode 100644 index 000000000..25e06e62a --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/modal/modal.component.ts @@ -0,0 +1,31 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { Router } from '@angular/router'; + +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; + +@Component({ + selector: 'cd-modal', + templateUrl: './modal.component.html', + styleUrls: ['./modal.component.scss'] +}) +export class ModalComponent { + @Input() + modalRef: NgbActiveModal; + @Input() + pageURL: string; + + /** + * Should be a function that is triggered when the modal is hidden. + */ + @Output() + hide = new EventEmitter(); + + constructor(private router: Router) {} + + close() { + this.pageURL + ? this.router.navigate([this.pageURL, { outlets: { modal: null } }]) + : this.modalRef?.close(); + this.hide.emit(); + } +} 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 000000000..2fbe5d7f8 --- /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 000000000..e69de29bb --- /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 000000000..826a8a5d0 --- /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<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 000000000..297ef2764 --- /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/components/notifications-sidebar/notifications-sidebar.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.html new file mode 100644 index 000000000..bba23747b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.html @@ -0,0 +1,131 @@ +<ng-template #tasksTpl> + <!-- Executing --> + <div *ngFor="let executingTask of executingTasks; trackBy:trackByFn"> + <div class="card tc_task border-0"> + <div class="row no-gutters"> + <div class="col-md-2 text-center"> + <span [ngClass]="[icons.stack, icons.large2x]" + class="text-info"> + <i [ngClass]="[icons.stack2x, icons.circle]"></i> + <i [ngClass]="[icons.stack1x, icons.spinner, icons.spin, icons.inverse]"></i> + </span> + </div> + <div class="col-md-9"> + <div class="card-body p-1"> + <h6 class="card-title bold">{{ executingTask.description }}</h6> + + <div class="mb-1"> + <ngb-progressbar type="info" + [value]="executingTask?.progress" + [striped]="true" + [animated]="true"></ngb-progressbar> + </div> + + <p class="card-text text-muted"> + <small class="date float-left"> + {{ executingTask.begin_time | cdDate }} + </small> + + <span class="float-right"> + {{ executingTask.progress || 0 }} % + </span> + </p> + + </div> + </div> + </div> + </div> + + <hr> + </div> +</ng-template> + +<ng-template #notificationsTpl> + <ng-container *ngIf="notifications.length > 0"> + <button type="button" + class="btn btn-light btn-block" + (click)="removeAll(); $event.stopPropagation()"> + <i [ngClass]="[icons.trash]" + aria-hidden="true"></i> + + <ng-container i18n>Clear notifications</ng-container> + </button> + + <hr> + + <div *ngFor="let notification of notifications; let i = index" + [ngClass]="notification.borderClass"> + <div class="card tc_notification border-0"> + <div class="row no-gutters"> + <div class="col-md-2 text-center"> + <span [ngClass]="[icons.stack, icons.large2x, notification.textClass]"> + <i [ngClass]="[icons.circle, icons.stack2x]"></i> + <i [ngClass]="[icons.stack1x, icons.inverse, notification.iconClass]"></i> + </span> + </div> + <div class="col-md-10"> + <div class="card-body p-1"> + <button class="btn btn-link float-right mt-0 pt-0" + title="Remove notification" + i18n-title + (click)="remove(i); $event.stopPropagation()"> + <i [ngClass]="[icons.trash]"></i> + </button> + + <h6 class="card-title bold">{{ notification.title }}</h6> + <p class="card-text" + [innerHtml]="notification.message"></p> + <p class="card-text text-muted"> + <ng-container *ngIf="notification.duration"> + <small> + <ng-container i18n>Duration:</ng-container> {{ notification.duration | duration }} + </small> + <br> + </ng-container> + <small class="date" + [title]="notification.timestamp | cdDate">{{ notification.timestamp | relativeDate }}</small> + <i class="float-right custom-icon" + [ngClass]="[notification.applicationClass]" + [title]="notification.application"></i> + </p> + </div> + </div> + </div> + </div> + + <hr> + </div> + </ng-container> +</ng-template> + +<ng-template #emptyTpl> + <div *ngIf="notifications.length === 0 && executingTasks.length === 0"> + <div class="message text-center" + i18n>There are no notifications.</div> + </div> +</ng-template> + +<div class="card" + (clickOutside)="closeSidebar()" + [clickOutsideEnabled]="isSidebarOpened"> + <div class="card-header"> + <ng-container i18n>Tasks and Notifications</ng-container> + + <button class="close float-right" + tabindex="-1" + type="button" + (click)="closeSidebar()"> + <span> + <i [ngClass]="icons.close"></i> + </span> + </button> + </div> + + <ngx-simplebar [options]="simplebar"> + <div class="card-body"> + <ng-container *ngTemplateOutlet="tasksTpl"></ng-container> + <ng-container *ngTemplateOutlet="notificationsTpl"></ng-container> + <ng-container *ngTemplateOutlet="emptyTpl"></ng-container> + </div> + </ngx-simplebar> +</div> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.scss new file mode 100644 index 000000000..baa64fa1f --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.scss @@ -0,0 +1,68 @@ +@use './src/styles/vendor/variables' as vv; + +:host { + bottom: 10px; + max-width: 90vw; + position: fixed; + right: -350px; + top: vv.$navbar-height + 10px; + + transition: all 0.6s; + + width: 350px; + + z-index: 9; +} + +:host.active { + right: 20px; +} + +.card { + height: 100%; +} + +.card-body { + padding-left: 0; + padding-right: 5px; + padding-top: 3px; +} + +ngx-simplebar { + height: calc(100% - 42.2px); +} + +.separator { + background-color: vv.$gray-200; + color: vv.$gray-600; + font-size: 1rem; + padding: 5px 12px; +} + +.btn-block { + width: 98%; +} + +.btn-link .fa-trash-o { + color: vv.$black; +} + +table { + width: 100%; +} + +.row { + margin-left: 0; + margin-right: 0; + padding-bottom: 1rem; + padding-top: 1rem; +} + +hr { + margin-bottom: 2px; + margin-top: 2px; +} + +.card-text { + margin-right: 15px; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.spec.ts new file mode 100644 index 000000000..596f3c358 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.spec.ts @@ -0,0 +1,194 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { NgbProgressbarModule } from '@ng-bootstrap/ng-bootstrap'; +import { ClickOutsideModule } from 'ng-click-outside'; +import { ToastrModule } from 'ngx-toastr'; +import { SimplebarAngularModule } from 'simplebar-angular'; + +import { PrometheusService } from '~/app/shared/api/prometheus.service'; +import { RbdService } from '~/app/shared/api/rbd.service'; +import { SettingsService } from '~/app/shared/api/settings.service'; +import { NotificationType } from '~/app/shared/enum/notification-type.enum'; +import { ExecutingTask } from '~/app/shared/models/executing-task'; +import { Permissions } from '~/app/shared/models/permissions'; +import { PipesModule } from '~/app/shared/pipes/pipes.module'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { NotificationService } from '~/app/shared/services/notification.service'; +import { PrometheusAlertService } from '~/app/shared/services/prometheus-alert.service'; +import { PrometheusNotificationService } from '~/app/shared/services/prometheus-notification.service'; +import { SummaryService } from '~/app/shared/services/summary.service'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { NotificationsSidebarComponent } from './notifications-sidebar.component'; + +describe('NotificationsSidebarComponent', () => { + let component: NotificationsSidebarComponent; + let fixture: ComponentFixture<NotificationsSidebarComponent>; + + configureTestBed({ + imports: [ + HttpClientTestingModule, + PipesModule, + NgbProgressbarModule, + RouterTestingModule, + ToastrModule.forRoot(), + NoopAnimationsModule, + SimplebarAngularModule, + ClickOutsideModule + ], + declarations: [NotificationsSidebarComponent], + providers: [PrometheusService, SettingsService, SummaryService, NotificationService, RbdService] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(NotificationsSidebarComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + describe('prometheus alert handling', () => { + let prometheusAlertService: PrometheusAlertService; + let prometheusNotificationService: PrometheusNotificationService; + let prometheusReadPermission: string; + let configOptReadPermission: string; + + const expectPrometheusServicesToBeCalledTimes = (n: number) => { + expect(prometheusNotificationService.refresh).toHaveBeenCalledTimes(n); + expect(prometheusAlertService.refresh).toHaveBeenCalledTimes(n); + }; + + beforeEach(() => { + prometheusReadPermission = 'read'; + configOptReadPermission = 'read'; + spyOn(TestBed.inject(AuthStorageService), 'getPermissions').and.callFake( + () => + new Permissions({ + prometheus: [prometheusReadPermission], + 'config-opt': [configOptReadPermission] + }) + ); + + spyOn(TestBed.inject(PrometheusService), 'ifAlertmanagerConfigured').and.callFake((fn) => + fn() + ); + + prometheusAlertService = TestBed.inject(PrometheusAlertService); + spyOn(prometheusAlertService, 'refresh').and.stub(); + + prometheusNotificationService = TestBed.inject(PrometheusNotificationService); + spyOn(prometheusNotificationService, 'refresh').and.stub(); + }); + + it('should not refresh prometheus services if not allowed', () => { + prometheusReadPermission = ''; + configOptReadPermission = 'read'; + fixture.detectChanges(); + + expectPrometheusServicesToBeCalledTimes(0); + + prometheusReadPermission = 'read'; + configOptReadPermission = ''; + fixture.detectChanges(); + + expectPrometheusServicesToBeCalledTimes(0); + }); + + it('should first refresh prometheus notifications and alerts during init', () => { + fixture.detectChanges(); + + expect(prometheusAlertService.refresh).toHaveBeenCalledTimes(1); + expectPrometheusServicesToBeCalledTimes(1); + }); + + it('should refresh prometheus services every 5s', fakeAsync(() => { + fixture.detectChanges(); + + expectPrometheusServicesToBeCalledTimes(1); + tick(5000); + expectPrometheusServicesToBeCalledTimes(2); + tick(15000); + expectPrometheusServicesToBeCalledTimes(5); + component.ngOnDestroy(); + })); + }); + + describe('Running Tasks', () => { + let summaryService: SummaryService; + + beforeEach(() => { + fixture.detectChanges(); + summaryService = TestBed.inject(SummaryService); + + spyOn(component, '_handleTasks').and.callThrough(); + }); + + it('should handle executing tasks', () => { + const running_tasks = new ExecutingTask('rbd/delete', { + image_spec: 'somePool/someImage' + }); + + summaryService['summaryDataSource'].next({ executing_tasks: [running_tasks] }); + + expect(component._handleTasks).toHaveBeenCalled(); + expect(component.executingTasks.length).toBe(1); + expect(component.executingTasks[0].description).toBe(`Deleting RBD 'somePool/someImage'`); + }); + }); + + describe('Notifications', () => { + it('should fetch latest notifications', fakeAsync(() => { + const notificationService: NotificationService = TestBed.inject(NotificationService); + fixture.detectChanges(); + + expect(component.notifications.length).toBe(0); + + notificationService.show(NotificationType.success, 'Sample title', 'Sample message'); + tick(6000); + expect(component.notifications.length).toBe(1); + expect(component.notifications[0].title).toBe('Sample title'); + })); + }); + + describe('Sidebar', () => { + let notificationService: NotificationService; + + beforeEach(() => { + notificationService = TestBed.inject(NotificationService); + fixture.detectChanges(); + }); + + it('should always close if sidebarSubject value is true', fakeAsync(() => { + // Closed before next value + expect(component.isSidebarOpened).toBeFalsy(); + notificationService.sidebarSubject.next(true); + tick(); + expect(component.isSidebarOpened).toBeFalsy(); + + // Opened before next value + component.isSidebarOpened = true; + expect(component.isSidebarOpened).toBeTruthy(); + notificationService.sidebarSubject.next(true); + tick(); + expect(component.isSidebarOpened).toBeFalsy(); + })); + + it('should toggle sidebar visibility if sidebarSubject value is false', () => { + // Closed before next value + expect(component.isSidebarOpened).toBeFalsy(); + notificationService.sidebarSubject.next(false); + expect(component.isSidebarOpened).toBeTruthy(); + + // Opened before next value + component.isSidebarOpened = true; + expect(component.isSidebarOpened).toBeTruthy(); + notificationService.sidebarSubject.next(false); + expect(component.isSidebarOpened).toBeFalsy(); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.ts new file mode 100644 index 000000000..8c5caf7ff --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.ts @@ -0,0 +1,167 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + HostBinding, + NgZone, + OnDestroy, + OnInit +} from '@angular/core'; + +import { Mutex } from 'async-mutex'; +import _ from 'lodash'; +import moment from 'moment'; +import { Subscription } from 'rxjs'; + +import { Icons } from '~/app/shared/enum/icons.enum'; +import { CdNotification } from '~/app/shared/models/cd-notification'; +import { ExecutingTask } from '~/app/shared/models/executing-task'; +import { FinishedTask } from '~/app/shared/models/finished-task'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { NotificationService } from '~/app/shared/services/notification.service'; +import { PrometheusAlertService } from '~/app/shared/services/prometheus-alert.service'; +import { PrometheusNotificationService } from '~/app/shared/services/prometheus-notification.service'; +import { SummaryService } from '~/app/shared/services/summary.service'; +import { TaskMessageService } from '~/app/shared/services/task-message.service'; + +@Component({ + selector: 'cd-notifications-sidebar', + templateUrl: './notifications-sidebar.component.html', + styleUrls: ['./notifications-sidebar.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class NotificationsSidebarComponent implements OnInit, OnDestroy { + @HostBinding('class.active') isSidebarOpened = false; + + notifications: CdNotification[]; + private interval: number; + private timeout: number; + + executingTasks: ExecutingTask[] = []; + + private subs = new Subscription(); + + icons = Icons; + + // Tasks + last_task = ''; + mutex = new Mutex(); + + simplebar = { + autoHide: false + }; + + constructor( + public notificationService: NotificationService, + private summaryService: SummaryService, + private taskMessageService: TaskMessageService, + private prometheusNotificationService: PrometheusNotificationService, + private authStorageService: AuthStorageService, + private prometheusAlertService: PrometheusAlertService, + private ngZone: NgZone, + private cdRef: ChangeDetectorRef + ) { + this.notifications = []; + } + + ngOnDestroy() { + window.clearInterval(this.interval); + window.clearTimeout(this.timeout); + this.subs.unsubscribe(); + } + + ngOnInit() { + this.last_task = window.localStorage.getItem('last_task'); + + const permissions = this.authStorageService.getPermissions(); + if (permissions.prometheus.read && permissions.configOpt.read) { + this.triggerPrometheusAlerts(); + this.ngZone.runOutsideAngular(() => { + this.interval = window.setInterval(() => { + this.ngZone.run(() => { + this.triggerPrometheusAlerts(); + }); + }, 5000); + }); + } + + this.subs.add( + this.notificationService.data$.subscribe((notifications: CdNotification[]) => { + this.notifications = _.orderBy(notifications, ['timestamp'], ['desc']); + this.cdRef.detectChanges(); + }) + ); + + this.subs.add( + this.notificationService.sidebarSubject.subscribe((forceClose) => { + if (forceClose) { + this.isSidebarOpened = false; + } else { + this.isSidebarOpened = !this.isSidebarOpened; + } + + window.clearTimeout(this.timeout); + this.timeout = window.setTimeout(() => { + this.cdRef.detectChanges(); + }, 0); + }) + ); + + this.subs.add( + this.summaryService.subscribe((summary) => { + this._handleTasks(summary.executing_tasks); + + this.mutex.acquire().then((release) => { + _.filter( + summary.finished_tasks, + (task: FinishedTask) => !this.last_task || moment(task.end_time).isAfter(this.last_task) + ).forEach((task) => { + const config = this.notificationService.finishedTaskToNotification(task, task.success); + const notification = new CdNotification(config); + notification.timestamp = task.end_time; + notification.duration = task.duration; + + if (!this.last_task || moment(task.end_time).isAfter(this.last_task)) { + this.last_task = task.end_time; + window.localStorage.setItem('last_task', this.last_task); + } + + this.notificationService.save(notification); + }); + + this.cdRef.detectChanges(); + + release(); + }); + }) + ); + } + + _handleTasks(executingTasks: ExecutingTask[]) { + for (const excutingTask of executingTasks) { + excutingTask.description = this.taskMessageService.getRunningTitle(excutingTask); + } + this.executingTasks = executingTasks; + } + + private triggerPrometheusAlerts() { + this.prometheusAlertService.refresh(); + this.prometheusNotificationService.refresh(); + } + + removeAll() { + this.notificationService.removeAll(); + } + + remove(index: number) { + this.notificationService.remove(index); + } + + closeSidebar() { + this.isSidebarOpened = false; + } + + trackByFn(index: number) { + return index; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-panel/orchestrator-doc-panel.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-panel/orchestrator-doc-panel.component.html new file mode 100644 index 000000000..f33261d80 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-panel/orchestrator-doc-panel.component.html @@ -0,0 +1,10 @@ +<cd-alert-panel *ngIf="missingFeatures; else elseBlock" + type="info" + i18n>The feature is not supported in the current Orchestrator.</cd-alert-panel> + +<ng-template #elseBlock> + <cd-alert-panel type="info" + i18n>Orchestrator is not available. + Please consult the <cd-doc section="orch"></cd-doc> on how to configure and + enable the functionality.</cd-alert-panel> +</ng-template> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-panel/orchestrator-doc-panel.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-panel/orchestrator-doc-panel.component.scss new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-panel/orchestrator-doc-panel.component.scss diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-panel/orchestrator-doc-panel.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-panel/orchestrator-doc-panel.component.spec.ts new file mode 100644 index 000000000..2a3613474 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-panel/orchestrator-doc-panel.component.spec.ts @@ -0,0 +1,29 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { CephReleaseNamePipe } from '~/app/shared/pipes/ceph-release-name.pipe'; +import { SummaryService } from '~/app/shared/services/summary.service'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { ComponentsModule } from '../components.module'; +import { OrchestratorDocPanelComponent } from './orchestrator-doc-panel.component'; + +describe('OrchestratorDocPanelComponent', () => { + let component: OrchestratorDocPanelComponent; + let fixture: ComponentFixture<OrchestratorDocPanelComponent>; + + configureTestBed({ + imports: [ComponentsModule, HttpClientTestingModule, RouterTestingModule], + providers: [CephReleaseNamePipe, SummaryService] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(OrchestratorDocPanelComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-panel/orchestrator-doc-panel.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-panel/orchestrator-doc-panel.component.ts new file mode 100644 index 000000000..d5bc36ad6 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/orchestrator-doc-panel/orchestrator-doc-panel.component.ts @@ -0,0 +1,13 @@ +import { Component, Input } from '@angular/core'; + +import { OrchestratorFeature } from '~/app/shared/models/orchestrator.enum'; + +@Component({ + selector: 'cd-orchestrator-doc-panel', + templateUrl: './orchestrator-doc-panel.component.html', + styleUrls: ['./orchestrator-doc-panel.component.scss'] +}) +export class OrchestratorDocPanelComponent { + @Input() + missingFeatures: OrchestratorFeature[]; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/pwd-expiration-notification/pwd-expiration-notification.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/pwd-expiration-notification/pwd-expiration-notification.component.html new file mode 100644 index 000000000..b1bc5150a --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/pwd-expiration-notification/pwd-expiration-notification.component.html @@ -0,0 +1,16 @@ +<cd-alert-panel class="no-margin-bottom" + [type]="alertType" + *ngIf="displayNotification" + [showTitle]="false" + size="slim" + [dismissible]="alertType !== 'danger'" + (dismissed)="onDismissed()"> + <div *ngIf="expirationDays === 0" + i18n>Your password will expire in <strong>less than 1</strong> day. Click + <a routerLink="/user-profile/edit" + class="alert-link">here</a> to change it now.</div> + <div *ngIf="expirationDays > 0" + i18n>Your password will expire in <strong>{{ expirationDays }}</strong> day(s). Click + <a routerLink="/user-profile/edit" + class="alert-link">here</a> to change it now.</div> +</cd-alert-panel> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/pwd-expiration-notification/pwd-expiration-notification.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/pwd-expiration-notification/pwd-expiration-notification.component.scss new file mode 100644 index 000000000..dc5cdeb84 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/pwd-expiration-notification/pwd-expiration-notification.component.scss @@ -0,0 +1,3 @@ +.no-margin-bottom { + margin-bottom: 0; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/pwd-expiration-notification/pwd-expiration-notification.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/pwd-expiration-notification/pwd-expiration-notification.component.spec.ts new file mode 100644 index 000000000..597f5bab3 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/pwd-expiration-notification/pwd-expiration-notification.component.spec.ts @@ -0,0 +1,107 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { Component } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Routes } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { NgbAlertModule } from '@ng-bootstrap/ng-bootstrap'; +import { of as observableOf } from 'rxjs'; + +import { SettingsService } from '~/app/shared/api/settings.service'; +import { AlertPanelComponent } from '~/app/shared/components/alert-panel/alert-panel.component'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { PwdExpirationNotificationComponent } from './pwd-expiration-notification.component'; + +describe('PwdExpirationNotificationComponent', () => { + let component: PwdExpirationNotificationComponent; + let fixture: ComponentFixture<PwdExpirationNotificationComponent>; + let settingsService: SettingsService; + let authStorageService: AuthStorageService; + + @Component({ selector: 'cd-fake', template: '' }) + class FakeComponent {} + + const routes: Routes = [{ path: 'login', component: FakeComponent }]; + + const spyOnDate = (fakeDate: string) => { + const dateValue = Date; + spyOn(global, 'Date').and.callFake((date) => new dateValue(date ? date : fakeDate)); + }; + + configureTestBed({ + declarations: [PwdExpirationNotificationComponent, FakeComponent, AlertPanelComponent], + imports: [NgbAlertModule, HttpClientTestingModule, RouterTestingModule.withRoutes(routes)], + providers: [SettingsService, AuthStorageService] + }); + + describe('password expiration date has been set', () => { + beforeEach(() => { + authStorageService = TestBed.inject(AuthStorageService); + settingsService = TestBed.inject(SettingsService); + spyOn(authStorageService, 'getPwdExpirationDate').and.returnValue(1645488000); + spyOn(settingsService, 'getStandardSettings').and.returnValue( + observableOf({ + user_pwd_expiration_warning_1: 10, + user_pwd_expiration_warning_2: 5, + user_pwd_expiration_span: 90 + }) + ); + fixture = TestBed.createComponent(PwdExpirationNotificationComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + component.ngOnInit(); + expect(component).toBeTruthy(); + }); + + it('should set warning levels', () => { + component.ngOnInit(); + expect(component.pwdExpirationSettings.pwdExpirationWarning1).toBe(10); + expect(component.pwdExpirationSettings.pwdExpirationWarning2).toBe(5); + }); + + it('should calculate password expiration in days', () => { + spyOnDate('2022-02-18T00:00:00.000Z'); + component.ngOnInit(); + expect(component['expirationDays']).toBe(4); + }); + + it('should set alert type warning correctly', () => { + spyOnDate('2022-02-14T00:00:00.000Z'); + component.ngOnInit(); + expect(component['alertType']).toBe('warning'); + expect(component.displayNotification).toBeTruthy(); + }); + + it('should set alert type danger correctly', () => { + spyOnDate('2022-02-18T00:00:00.000Z'); + component.ngOnInit(); + expect(component['alertType']).toBe('danger'); + expect(component.displayNotification).toBeTruthy(); + }); + + it('should not display if date is far', () => { + spyOnDate('2022-01-01T00:00:00.000Z'); + component.ngOnInit(); + expect(component.displayNotification).toBeFalsy(); + }); + }); + + describe('password expiration date has not been set', () => { + beforeEach(() => { + authStorageService = TestBed.inject(AuthStorageService); + spyOn(authStorageService, 'getPwdExpirationDate').and.returnValue(null); + fixture = TestBed.createComponent(PwdExpirationNotificationComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should calculate no expirationDays', () => { + component.ngOnInit(); + expect(component['expirationDays']).toBeUndefined(); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/pwd-expiration-notification/pwd-expiration-notification.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/pwd-expiration-notification/pwd-expiration-notification.component.ts new file mode 100644 index 000000000..3dd8b5455 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/pwd-expiration-notification/pwd-expiration-notification.component.ts @@ -0,0 +1,55 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; + +import { SettingsService } from '~/app/shared/api/settings.service'; +import { CdPwdExpirationSettings } from '~/app/shared/models/cd-pwd-expiration-settings'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; + +@Component({ + selector: 'cd-pwd-expiration-notification', + templateUrl: './pwd-expiration-notification.component.html', + styleUrls: ['./pwd-expiration-notification.component.scss'] +}) +export class PwdExpirationNotificationComponent implements OnInit, OnDestroy { + alertType: string; + expirationDays: number; + pwdExpirationSettings: CdPwdExpirationSettings; + displayNotification = false; + + constructor( + private settingsService: SettingsService, + private authStorageService: AuthStorageService + ) {} + + ngOnInit() { + this.settingsService.getStandardSettings().subscribe((pwdExpirationSettings) => { + this.pwdExpirationSettings = new CdPwdExpirationSettings(pwdExpirationSettings); + const pwdExpirationDate = this.authStorageService.getPwdExpirationDate(); + if (pwdExpirationDate) { + this.expirationDays = this.getExpirationDays(pwdExpirationDate); + if (this.expirationDays <= this.pwdExpirationSettings.pwdExpirationWarning2) { + this.alertType = 'danger'; + } else { + this.alertType = 'warning'; + } + this.displayNotification = + this.expirationDays <= this.pwdExpirationSettings.pwdExpirationWarning1; + this.authStorageService.isPwdDisplayedSource.next(this.displayNotification); + } + }); + } + + ngOnDestroy() { + this.authStorageService.isPwdDisplayedSource.next(false); + } + + private getExpirationDays(pwdExpirationDate: number): number { + const current = new Date(); + const expiration = new Date(pwdExpirationDate * 1000); + return Math.floor((expiration.valueOf() - current.valueOf()) / (1000 * 3600 * 24)); + } + + onDismissed(): void { + this.authStorageService.isPwdDisplayedSource.next(false); + this.displayNotification = false; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/refresh-selector/refresh-selector.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/refresh-selector/refresh-selector.component.html new file mode 100644 index 000000000..d33fc9af8 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/refresh-selector/refresh-selector.component.html @@ -0,0 +1,19 @@ +<div class="container-fluid"> + <div class="row"> + <div class="col d-flex justify-content-end"> + <form class="form-inline"> + <label for="refreshInterval" + class="col-form-label my-0 mx-2" + i18n>Refresh</label> + <select id="refreshInterval" + name="refreshInterval" + class="form-control" + (change)="changeRefreshInterval($event.target.value)" + [(ngModel)]="selectedInterval"> + <option *ngFor="let key of intervalKeys" + [value]="intervalList[key]">{{ key }}</option> + </select> + </form> + </div> + </div> +</div> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/refresh-selector/refresh-selector.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/refresh-selector/refresh-selector.component.scss new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/refresh-selector/refresh-selector.component.scss diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/refresh-selector/refresh-selector.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/refresh-selector/refresh-selector.component.spec.ts new file mode 100644 index 000000000..cb98cadd7 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/refresh-selector/refresh-selector.component.spec.ts @@ -0,0 +1,27 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; + +import { RefreshIntervalService } from '~/app/shared/services/refresh-interval.service'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { RefreshSelectorComponent } from './refresh-selector.component'; + +describe('RefreshSelectorComponent', () => { + let component: RefreshSelectorComponent; + let fixture: ComponentFixture<RefreshSelectorComponent>; + + configureTestBed({ + imports: [FormsModule], + declarations: [RefreshSelectorComponent], + providers: [RefreshIntervalService] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(RefreshSelectorComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/refresh-selector/refresh-selector.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/refresh-selector/refresh-selector.component.ts new file mode 100644 index 000000000..080890e26 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/refresh-selector/refresh-selector.component.ts @@ -0,0 +1,32 @@ +import { Component, OnInit } from '@angular/core'; + +import { RefreshIntervalService } from '~/app/shared/services/refresh-interval.service'; + +@Component({ + selector: 'cd-refresh-selector', + templateUrl: './refresh-selector.component.html', + styleUrls: ['./refresh-selector.component.scss'] +}) +export class RefreshSelectorComponent implements OnInit { + selectedInterval: number; + intervalList: { [key: string]: number } = { + '5 s': 5000, + '10 s': 10000, + '15 s': 15000, + '30 s': 30000, + '1 min': 60000, + '3 min': 180000, + '5 min': 300000 + }; + intervalKeys = Object.keys(this.intervalList); + + constructor(private refreshIntervalService: RefreshIntervalService) {} + + ngOnInit() { + this.selectedInterval = this.refreshIntervalService.getRefreshInterval() || 5000; + } + + changeRefreshInterval(interval: number) { + this.refreshIntervalService.setRefreshInterval(interval); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.html new file mode 100644 index 000000000..0f23aee87 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.html @@ -0,0 +1,22 @@ +<cd-select #cdSelect + [data]="data" + [options]="options" + [messages]="messages" + [selectionLimit]="selectionLimit" + [customBadges]="customBadges" + [customBadgeValidators]="customBadgeValidators" + elemClass="mr-2 select-menu-edit" + (selection)="selection.emit($event)"> + <i [ngClass]="[icons.edit]"></i> +</cd-select> + +<span *ngFor="let dataItem of data"> + <span class="badge badge-dark mr-2"> + <span class="mr-2">{{ dataItem }}</span> + <a class="badge-remove" + (click)="cdSelect.removeItem(dataItem)"> + <i [ngClass]="[icons.destroy]" + aria-hidden="true"></i> + </a> + </span> +</span> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.scss new file mode 100644 index 000000000..e1271c5e4 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.scss @@ -0,0 +1,9 @@ +@use './src/styles/vendor/variables' as vv; + +.badge-remove { + color: vv.$white; +} + +i.fa-pencil { + font-size: 1.1rem; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.spec.ts new file mode 100644 index 000000000..ac7323b73 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.spec.ts @@ -0,0 +1,57 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule, Validators } from '@angular/forms'; + +import { NgbPopoverModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { SelectMessages } from '../select/select-messages.model'; +import { SelectComponent } from '../select/select.component'; +import { SelectBadgesComponent } from './select-badges.component'; + +describe('SelectBadgesComponent', () => { + let component: SelectBadgesComponent; + let fixture: ComponentFixture<SelectBadgesComponent>; + + configureTestBed({ + declarations: [SelectBadgesComponent, SelectComponent], + imports: [NgbPopoverModule, NgbTooltipModule, ReactiveFormsModule] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(SelectBadgesComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should reflect the attributes into CdSelect', () => { + const data = ['a', 'b']; + const options = [ + { name: 'option1', description: '', selected: false, enabled: true }, + { name: 'option2', description: '', selected: false, enabled: true } + ]; + const messages = new SelectMessages({ empty: 'foo bar' }); + const selectionLimit = 2; + const customBadges = true; + const customBadgeValidators = [Validators.required]; + + component.data = data; + component.options = options; + component.messages = messages; + component.selectionLimit = selectionLimit; + component.customBadges = customBadges; + component.customBadgeValidators = customBadgeValidators; + + fixture.detectChanges(); + + expect(component.cdSelect.data).toEqual(data); + expect(component.cdSelect.options).toEqual(options); + expect(component.cdSelect.messages).toEqual(messages); + expect(component.cdSelect.selectionLimit).toEqual(selectionLimit); + expect(component.cdSelect.customBadges).toEqual(customBadges); + expect(component.cdSelect.customBadgeValidators).toEqual(customBadgeValidators); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.ts new file mode 100644 index 000000000..b44ecd7e4 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select-badges/select-badges.component.ts @@ -0,0 +1,35 @@ +import { Component, EventEmitter, Input, Output, ViewChild } from '@angular/core'; +import { ValidatorFn } from '@angular/forms'; + +import { Icons } from '~/app/shared/enum/icons.enum'; +import { SelectMessages } from '../select/select-messages.model'; +import { SelectOption } from '../select/select-option.model'; +import { SelectComponent } from '../select/select.component'; + +@Component({ + selector: 'cd-select-badges', + templateUrl: './select-badges.component.html', + styleUrls: ['./select-badges.component.scss'] +}) +export class SelectBadgesComponent { + @Input() + data: Array<string> = []; + @Input() + options: Array<SelectOption> = []; + @Input() + messages = new SelectMessages({}); + @Input() + selectionLimit: number; + @Input() + customBadges = false; + @Input() + customBadgeValidators: ValidatorFn[] = []; + + @Output() + selection = new EventEmitter(); + + @ViewChild('cdSelect', { static: true }) + cdSelect: SelectComponent; + + icons = Icons; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select-messages.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select-messages.model.ts new file mode 100644 index 000000000..7a28ffb5e --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select-messages.model.ts @@ -0,0 +1,23 @@ +import _ from 'lodash'; + +export class SelectMessages { + empty: string; + selectionLimit: any; + customValidations = {}; + filter: string; + add: string; + noOptions: string; + + constructor(messages: {}) { + this.empty = $localize`No items selected.`; + this.selectionLimit = { + tooltip: $localize`Deselect item to select again`, + text: $localize`Selection limit reached` + }; + this.filter = $localize`Filter tags`; + this.add = $localize`Add badge`; // followed by " '{{filter.value}}'" + this.noOptions = $localize`There are no items available.`; + + _.merge(this, messages); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select-option.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select-option.model.ts new file mode 100644 index 000000000..bbd970c6f --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select-option.model.ts @@ -0,0 +1,13 @@ +export class SelectOption { + selected: boolean; + name: string; + description: string; + enabled: boolean; + + constructor(selected: boolean, name: string, description: string, enabled = true) { + this.selected = selected; + this.name = name; + this.description = description; + this.enabled = enabled; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.html new file mode 100644 index 000000000..1533b9439 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.html @@ -0,0 +1,79 @@ +<ng-template #popTemplate> + <form name="form" + #formDir="ngForm" + [formGroup]="form" + novalidate> + <div> + <input type="text" + formControlName="filter" + i18n-placeholder + [placeholder]="messages.filter" + (keyup)="$event.keyCode == 13 ? selectOption() : updateFilter()" + class="form-control text-center" /> + <ng-container *ngFor="let error of Object.keys(messages.customValidations)"> + <span class="invalid-feedback text-center d-block" + *ngIf="form.showError('filter', formDir) && filter.hasError(error)"> + {{ messages.customValidations[error] }} + </span> + </ng-container> + </div> + </form> + <div *ngFor="let option of filteredOptions" + class="select-menu-item" + [ngClass]="{'help-block disabled': (data.length === selectionLimit || !option.enabled) && !option.selected}" + (click)="triggerSelection(option)"> + <div class="select-menu-item-icon"> + <i [ngClass]="[icons.check]" + aria-hidden="true" + *ngIf="option.selected"></i> + + </div> + <div class="select-menu-item-content"> + {{ option.name }} + <ng-container *ngIf="option.description"> + <br> + <small class="form-text text-muted"> + {{ option.description }} + </small> + </ng-container> + </div> + </div> + <div *ngIf="isCreatable()" + class="select-menu-item" + (click)="addCustomOption()"> + <div class="select-menu-item-icon"> + <i [ngClass]="[icons.tag]" + aria-hidden="true"></i> + + </div> + <div class="select-menu-item-content"> + {{ messages.add }} '{{ filter.value }}' + </div> + </div> + <div class="is-invalid" + *ngIf="data.length === selectionLimit"> + <span class="form-text text-muted text-center text-warning" + [ngbTooltip]="messages.selectionLimit.tooltip" + *ngIf="data.length === selectionLimit"> + {{ messages.selectionLimit.text }} + </span> + </div> +</ng-template> + +<a class="select-menu-edit float-left" + [ngClass]="elemClass" + [ngbPopover]="popTemplate" + data-testid="select-menu-edit" + *ngIf="customBadges || options.length > 0"> + <ng-content></ng-content> +</a> + +<span class="form-text text-muted float-left" + *ngIf="data.length === 0 && !(!customBadges && options.length === 0)"> + {{ messages.empty }} +</span> + +<span class="form-text text-muted float-left" + *ngIf="!customBadges && options.length === 0"> + {{ messages.noOptions }} +</span> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.scss new file mode 100644 index 000000000..9a4b45062 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.scss @@ -0,0 +1,26 @@ +@use './src/styles/vendor/variables' as vv; + +.select-menu-item { + border-bottom: 1px solid vv.$datatable-divider-color; + cursor: pointer; + display: block; + font-size: 1rem; + + &:hover { + background-color: vv.$gray-200; + } +} + +.select-menu-item-icon { + float: left; + padding: 0.5em; + width: 3em; +} + +.select-menu-item-content { + padding: 0.5em; + + .form-text { + display: flex; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.spec.ts new file mode 100644 index 000000000..c35ec9091 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.spec.ts @@ -0,0 +1,276 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule, Validators } from '@angular/forms'; + +import { NgbPopoverModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { SelectOption } from './select-option.model'; +import { SelectComponent } from './select.component'; + +describe('SelectComponent', () => { + let component: SelectComponent; + let fixture: ComponentFixture<SelectComponent>; + + const selectOption = (filter: string) => { + component.filter.setValue(filter); + component.updateFilter(); + component.selectOption(); + }; + + configureTestBed({ + declarations: [SelectComponent], + imports: [NgbPopoverModule, NgbTooltipModule, ReactiveFormsModule] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(SelectComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + component.options = [ + { name: 'option1', description: '', selected: false, enabled: true }, + { name: 'option2', description: '', selected: false, enabled: true }, + { name: 'option3', description: '', selected: false, enabled: true } + ]; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should add item', () => { + component.data = []; + component.triggerSelection(component.options[1]); + expect(component.data).toEqual(['option2']); + }); + + it('should update selected', () => { + component.data = ['option2']; + component.ngOnChanges(); + expect(component.options[0].selected).toBe(false); + expect(component.options[1].selected).toBe(true); + }); + + it('should remove item', () => { + component.options.map((option) => { + option.selected = true; + return option; + }); + component.data = ['option1', 'option2', 'option3']; + component.removeItem('option1'); + expect(component.data).toEqual(['option2', 'option3']); + }); + + it('should not remove item that is not selected', () => { + component.options[0].selected = true; + component.data = ['option1']; + component.removeItem('option2'); + expect(component.data).toEqual(['option1']); + }); + + describe('filter values', () => { + beforeEach(() => { + component.ngOnInit(); + }); + + it('shows all options with no value set', () => { + expect(component.filteredOptions).toEqual(component.options); + }); + + it('shows one option that it filtered for', () => { + component.filter.setValue('2'); + component.updateFilter(); + expect(component.filteredOptions).toEqual([component.options[1]]); + }); + + it('shows all options after selecting something', () => { + component.filter.setValue('2'); + component.updateFilter(); + component.selectOption(); + expect(component.filteredOptions).toEqual(component.options); + }); + + it('is not able to create by default with no value set', () => { + component.updateFilter(); + expect(component.isCreatable()).toBeFalsy(); + }); + + it('is not able to create by default with a value set', () => { + component.filter.setValue('2'); + component.updateFilter(); + expect(component.isCreatable()).toBeFalsy(); + }); + }); + + describe('automatically add selected options if not in options array', () => { + beforeEach(() => { + component.data = ['option1', 'option4']; + expect(component.options.length).toBe(3); + }); + + const expectedResult = () => { + expect(component.options.length).toBe(4); + expect(component.options[3]).toEqual(new SelectOption(true, 'option4', '')); + }; + + it('with no extra settings', () => { + component.ngOnInit(); + expectedResult(); + }); + + it('with custom badges', () => { + component.customBadges = true; + component.ngOnInit(); + expectedResult(); + }); + + it('with limit higher than selected', () => { + component.selectionLimit = 3; + component.ngOnInit(); + expectedResult(); + }); + + it('with limit equal to selected', () => { + component.selectionLimit = 2; + component.ngOnInit(); + expectedResult(); + }); + + it('with limit lower than selected', () => { + component.selectionLimit = 1; + component.ngOnInit(); + expectedResult(); + }); + }); + + describe('sorted array and options', () => { + beforeEach(() => { + component.customBadges = true; + component.customBadgeValidators = [Validators.pattern('[A-Za-z0-9_]+')]; + component.data = ['c', 'b']; + component.options = [new SelectOption(true, 'd', ''), new SelectOption(true, 'a', '')]; + component.ngOnInit(); + }); + + it('has a sorted selection', () => { + expect(component.data).toEqual(['a', 'b', 'c', 'd']); + }); + + it('has a sorted options', () => { + const sortedOptions = [ + new SelectOption(true, 'a', ''), + new SelectOption(true, 'b', ''), + new SelectOption(true, 'c', ''), + new SelectOption(true, 'd', '') + ]; + expect(component.options).toEqual(sortedOptions); + }); + + it('has a sorted selection after adding an item', () => { + selectOption('block'); + expect(component.data).toEqual(['a', 'b', 'block', 'c', 'd']); + }); + + it('has a sorted options after adding an item', () => { + selectOption('block'); + const sortedOptions = [ + new SelectOption(true, 'a', ''), + new SelectOption(true, 'b', ''), + new SelectOption(true, 'block', ''), + new SelectOption(true, 'c', ''), + new SelectOption(true, 'd', '') + ]; + expect(component.options).toEqual(sortedOptions); + }); + }); + + describe('with custom options', () => { + beforeEach(() => { + component.customBadges = true; + component.customBadgeValidators = [Validators.pattern('[A-Za-z0-9_]+')]; + component.ngOnInit(); + }); + + it('is not able to create with no value set', () => { + component.updateFilter(); + expect(component.isCreatable()).toBeFalsy(); + }); + + it('is able to create with a valid value set', () => { + component.filter.setValue('2'); + component.updateFilter(); + expect(component.isCreatable()).toBeTruthy(); + }); + + it('is not able to create with a value set that already exist', () => { + component.filter.setValue('option2'); + component.updateFilter(); + expect(component.isCreatable()).toBeFalsy(); + }); + + it('adds custom option', () => { + selectOption('customOption'); + expect(component.options[0]).toEqual({ + name: 'customOption', + description: '', + selected: true, + enabled: true + }); + expect(component.options.length).toBe(4); + expect(component.data).toEqual(['customOption']); + }); + + it('will not add an option that did not pass the validation', () => { + selectOption(' this does not pass '); + expect(component.options.length).toBe(3); + expect(component.data).toEqual([]); + expect(component.filter.invalid).toBeTruthy(); + }); + + it('removes custom item selection by name', () => { + selectOption('customOption'); + component.removeItem('customOption'); + expect(component.data).toEqual([]); + expect(component.options.length).toBe(4); + expect(component.options[0]).toEqual({ + name: 'customOption', + description: '', + selected: false, + enabled: true + }); + }); + + it('will not add an option that is already there', () => { + selectOption('option2'); + expect(component.options.length).toBe(3); + expect(component.data).toEqual(['option2']); + }); + + it('will not add an option twice after each other', () => { + selectOption('onlyOnce'); + expect(component.data).toEqual(['onlyOnce']); + selectOption('onlyOnce'); + expect(component.data).toEqual([]); + selectOption('onlyOnce'); + expect(component.data).toEqual(['onlyOnce']); + expect(component.options.length).toBe(4); + }); + }); + + describe('if the selection limit is reached', function () { + beforeEach(() => { + component.selectionLimit = 2; + component.triggerSelection(component.options[0]); + component.triggerSelection(component.options[1]); + }); + + it('will not select more options', () => { + component.triggerSelection(component.options[2]); + expect(component.data).toEqual(['option1', 'option2']); + }); + + it('will unselect options that are selected', () => { + component.triggerSelection(component.options[1]); + expect(component.data).toEqual(['option1']); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.ts new file mode 100644 index 000000000..3ff19fe04 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/select/select.component.ts @@ -0,0 +1,149 @@ +import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core'; +import { FormControl, ValidatorFn } from '@angular/forms'; + +import _ from 'lodash'; + +import { Icons } from '~/app/shared/enum/icons.enum'; +import { CdFormGroup } from '~/app/shared/forms/cd-form-group'; +import { SelectMessages } from './select-messages.model'; +import { SelectOption } from './select-option.model'; + +@Component({ + selector: 'cd-select', + templateUrl: './select.component.html', + styleUrls: ['./select.component.scss'] +}) +export class SelectComponent implements OnInit, OnChanges { + @Input() + elemClass: string; + @Input() + data: Array<string> = []; + @Input() + options: Array<SelectOption> = []; + @Input() + messages = new SelectMessages({}); + @Input() + selectionLimit: number; + @Input() + customBadges = false; + @Input() + customBadgeValidators: ValidatorFn[] = []; + + @Output() + selection = new EventEmitter(); + + form: CdFormGroup; + filter: FormControl; + Object = Object; + filteredOptions: Array<SelectOption> = []; + icons = Icons; + + ngOnInit() { + this.initFilter(); + if (this.data.length > 0) { + this.initMissingOptions(); + } + this.options = _.sortBy(this.options, ['name']); + this.updateOptions(); + } + + private initFilter() { + this.filter = new FormControl('', { validators: this.customBadgeValidators }); + this.form = new CdFormGroup({ filter: this.filter }); + this.filteredOptions = [...(this.options || [])]; + } + + private initMissingOptions() { + const options = this.options.map((option) => option.name); + const needToCreate = this.data.filter((option) => options.indexOf(option) === -1); + needToCreate.forEach((option) => this.addOption(option)); + this.forceOptionsToReflectData(); + } + + private addOption(name: string) { + this.options.push(new SelectOption(false, name, '')); + this.options = _.sortBy(this.options, ['name']); + this.triggerSelection(this.options.find((option) => option.name === name)); + } + + triggerSelection(option: SelectOption) { + if ( + !option || + (this.selectionLimit && !option.selected && this.data.length >= this.selectionLimit) + ) { + return; + } + option.selected = !option.selected; + this.updateOptions(); + this.selection.emit({ option: option }); + } + + private updateOptions() { + this.data.splice(0, this.data.length); + this.options.forEach((option: SelectOption) => { + if (option.selected) { + this.data.push(option.name); + } + }); + this.updateFilter(); + } + + updateFilter() { + this.filteredOptions = this.options.filter((option) => option.name.includes(this.filter.value)); + } + + private forceOptionsToReflectData() { + this.options.forEach((option) => { + if (this.data.indexOf(option.name) !== -1) { + option.selected = true; + } + }); + } + + ngOnChanges() { + if (this.filter) { + this.updateFilter(); + } + if (!this.options || !this.data || this.data.length === 0) { + return; + } + this.forceOptionsToReflectData(); + } + + selectOption() { + if (this.filteredOptions.length === 0) { + this.addCustomOption(); + } else { + this.triggerSelection(this.filteredOptions[0]); + this.resetFilter(); + } + } + + addCustomOption() { + if (!this.isCreatable()) { + return; + } + this.addOption(this.filter.value); + this.resetFilter(); + } + + isCreatable() { + return ( + this.customBadges && + this.filter.valid && + this.filter.value.length > 0 && + this.filteredOptions.every((option) => option.name !== this.filter.value) + ); + } + + private resetFilter() { + this.filter.setValue(''); + this.updateFilter(); + } + + removeItem(item: string) { + this.triggerSelection( + this.options.find((option: SelectOption) => option.name === item && option.selected) + ); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sparkline/sparkline.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sparkline/sparkline.component.html new file mode 100644 index 000000000..c823605d1 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sparkline/sparkline.component.html @@ -0,0 +1,15 @@ +<div class="chart-container" + [ngStyle]="style"> + <canvas baseChart + #sparkCanvas + [labels]="labels" + [datasets]="datasets" + [options]="options" + [colors]="colors" + [chartType]="'line'"> + </canvas> + <div class="chartjs-tooltip" + #sparkTooltip> + <table></table> + </div> +</div> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sparkline/sparkline.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sparkline/sparkline.component.scss new file mode 100644 index 000000000..25486150b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sparkline/sparkline.component.scss @@ -0,0 +1,5 @@ +@use './src/styles/chart-tooltip'; + +.chart-container { + position: static !important; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sparkline/sparkline.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sparkline/sparkline.component.spec.ts new file mode 100644 index 000000000..b8e731d6e --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sparkline/sparkline.component.spec.ts @@ -0,0 +1,52 @@ +import { NO_ERRORS_SCHEMA, SimpleChange } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe'; +import { FormatterService } from '~/app/shared/services/formatter.service'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { SparklineComponent } from './sparkline.component'; + +describe('SparklineComponent', () => { + let component: SparklineComponent; + let fixture: ComponentFixture<SparklineComponent>; + + configureTestBed({ + declarations: [SparklineComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [DimlessBinaryPipe, FormatterService] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(SparklineComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + expect(component.options.tooltips.custom).toBeDefined(); + }); + + it('should update', () => { + expect(component.datasets).toEqual([{ data: [] }]); + expect(component.labels.length).toBe(0); + + component.data = [11, 22, 33]; + component.ngOnChanges({ data: new SimpleChange(null, component.data, false) }); + + expect(component.datasets).toEqual([{ data: [11, 22, 33] }]); + expect(component.labels.length).toBe(3); + }); + + it('should not transform the label, if not isBinary', () => { + component.isBinary = false; + const result = component.options.tooltips.callbacks.label({ yLabel: 1024 }); + expect(result).toBe(1024); + }); + + it('should transform the label, if isBinary', () => { + component.isBinary = true; + const result = component.options.tooltips.callbacks.label({ yLabel: 1024 }); + expect(result).toBe('1 KiB'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sparkline/sparkline.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sparkline/sparkline.component.ts new file mode 100644 index 000000000..e2f5af5e0 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sparkline/sparkline.component.ts @@ -0,0 +1,130 @@ +import { + Component, + ElementRef, + Input, + OnChanges, + OnInit, + SimpleChanges, + ViewChild +} from '@angular/core'; + +import { ChartTooltip } from '~/app/shared/models/chart-tooltip'; +import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe'; + +@Component({ + selector: 'cd-sparkline', + templateUrl: './sparkline.component.html', + styleUrls: ['./sparkline.component.scss'] +}) +export class SparklineComponent implements OnInit, OnChanges { + @ViewChild('sparkCanvas', { static: true }) + chartCanvasRef: ElementRef; + @ViewChild('sparkTooltip', { static: true }) + chartTooltipRef: ElementRef; + + @Input() + data: any; + @Input() + style = { + height: '30px', + width: '100px' + }; + @Input() + isBinary: boolean; + + public colors: Array<any> = [ + { + backgroundColor: 'rgba(40,140,234,0.2)', + borderColor: 'rgba(40,140,234,1)', + pointBackgroundColor: 'rgba(40,140,234,1)', + pointBorderColor: '#fff', + pointHoverBackgroundColor: '#fff', + pointHoverBorderColor: 'rgba(40,140,234,0.8)' + } + ]; + + options: Record<string, any> = { + animation: { + duration: 0 + }, + responsive: true, + maintainAspectRatio: false, + legend: { + display: false + }, + elements: { + line: { + borderWidth: 1 + } + }, + tooltips: { + enabled: false, + mode: 'index', + intersect: false, + custom: undefined, + callbacks: { + label: (tooltipItem: any) => { + if (this.isBinary) { + return this.dimlessBinaryPipe.transform(tooltipItem.yLabel); + } else { + return tooltipItem.yLabel; + } + }, + title: () => '' + } + }, + scales: { + yAxes: [ + { + display: false + } + ], + xAxes: [ + { + display: false + } + ] + } + }; + + public datasets: Array<any> = [ + { + data: [] + } + ]; + + public labels: Array<any> = []; + + constructor(private dimlessBinaryPipe: DimlessBinaryPipe) {} + + ngOnInit() { + const getStyleTop = (tooltip: any) => { + return tooltip.caretY - tooltip.height - tooltip.yPadding - 5 + 'px'; + }; + + const getStyleLeft = (tooltip: any, positionX: number) => { + return positionX + tooltip.caretX + 'px'; + }; + + const chartTooltip = new ChartTooltip( + this.chartCanvasRef, + this.chartTooltipRef, + getStyleLeft, + getStyleTop + ); + + chartTooltip.customColors = { + backgroundColor: this.colors[0].pointBackgroundColor, + borderColor: this.colors[0].pointBorderColor + }; + + this.options.tooltips.custom = (tooltip: any) => { + chartTooltip.customTooltips(tooltip); + }; + } + + ngOnChanges(changes: SimpleChanges) { + this.datasets[0].data = changes['data'].currentValue; + this.labels = [...Array(changes['data'].currentValue.length)]; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/submit-button/submit-button.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/submit-button/submit-button.component.html new file mode 100644 index 000000000..af557a293 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/submit-button/submit-button.component.html @@ -0,0 +1,11 @@ +<button [type]="type" + class="btn btn-accent tc_submitButton" + [ngClass]="btnClass" + [disabled]="loading || disabled" + (click)="submit($event)" + [attr.aria-label]="ariaLabel"> + <ng-content></ng-content> + <span *ngIf="loading"> + <i [ngClass]="[icons.spinner, icons.spin]"></i> + </span> +</button> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/submit-button/submit-button.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/submit-button/submit-button.component.scss new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/submit-button/submit-button.component.scss diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/submit-button/submit-button.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/submit-button/submit-button.component.spec.ts new file mode 100644 index 000000000..a7b7023d0 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/submit-button/submit-button.component.spec.ts @@ -0,0 +1,27 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormGroup } from '@angular/forms'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { SubmitButtonComponent } from './submit-button.component'; + +describe('SubmitButtonComponent', () => { + let component: SubmitButtonComponent; + let fixture: ComponentFixture<SubmitButtonComponent>; + + configureTestBed({ + declarations: [SubmitButtonComponent] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(SubmitButtonComponent); + component = fixture.componentInstance; + + component.form = new FormGroup({}, {}); + + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/submit-button/submit-button.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/submit-button/submit-button.component.ts new file mode 100644 index 000000000..3309f47ed --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/submit-button/submit-button.component.ts @@ -0,0 +1,99 @@ +import { Component, ElementRef, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { AbstractControl, FormGroup, FormGroupDirective, NgForm } from '@angular/forms'; + +import _ from 'lodash'; + +import { Icons } from '~/app/shared/enum/icons.enum'; + +/** + * This component will render a submit button with the given label. + * + * The button will disabled itself and show a loading icon when the user clicks + * it, usually initiating a request to the server, and it will stay in that + * state until the request is finished. + * + * To indicate that the request failed, returning the button to the enable + * state, you need to insert an error in the form with the 'cdSubmitButton' key. + * p.e.: this.rbdForm.setErrors({'cdSubmitButton': true}); + * + * It will also check if the form is valid, when clicking the button, and will + * focus on the first invalid input. + * + * @export + * @class SubmitButtonComponent + * @implements {OnInit} + */ +@Component({ + selector: 'cd-submit-button', + templateUrl: './submit-button.component.html', + styleUrls: ['./submit-button.component.scss'] +}) +export class SubmitButtonComponent implements OnInit { + @Input() + form: FormGroup | NgForm; + + @Input() + type = 'submit'; + + @Input() + disabled = false; + + // A CSS class string to apply to the button's main element. + @Input() + btnClass: string; + + @Input() + ariaLabel: string; + + @Output() + submitAction = new EventEmitter(); + + loading = false; + icons = Icons; + + constructor(private elRef: ElementRef) {} + + ngOnInit() { + this.form.statusChanges.subscribe(() => { + if (_.has(this.form.errors, 'cdSubmitButton')) { + this.loading = false; + _.unset(this.form.errors, 'cdSubmitButton'); + // Handle Reactive forms. + if (this.form instanceof AbstractControl) { + (<AbstractControl>this.form).updateValueAndValidity(); + } + } + }); + } + + submit($event: any) { + this.focusButton(); + + // Special handling for Template driven forms. + if (this.form instanceof FormGroupDirective) { + (<FormGroupDirective>this.form).onSubmit($event); + } + + if (this.form.invalid) { + this.focusInvalid(); + return; + } + + this.loading = true; + this.submitAction.emit(); + } + + focusButton() { + this.elRef.nativeElement.offsetParent.querySelector(`button[type="${this.type}"]`).focus(); + } + + focusInvalid() { + const target = this.elRef.nativeElement.offsetParent.querySelector( + 'input.ng-invalid, select.ng-invalid' + ); + + if (target) { + target.focus(); + } + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/telemetry-notification/telemetry-notification.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/telemetry-notification/telemetry-notification.component.html new file mode 100644 index 000000000..9af795837 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/telemetry-notification/telemetry-notification.component.html @@ -0,0 +1,12 @@ +<cd-alert-panel *ngIf="displayNotification" + class="no-margin-bottom" + [showTitle]="false" + size="slim" + [type]="notificationSeverity" + [dismissible]="notificationSeverity !== 'danger'" + (dismissed)="onDismissed()"> + <div i18n>The Ceph community needs your help to continue improving: please + <a routerLink="/telemetry" + class="btn activate-button alert-link activate-text">Activate</a> the + <a href="https://docs.ceph.com/en/latest/mgr/telemetry/">Telemetry</a> module.</div> +</cd-alert-panel> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/telemetry-notification/telemetry-notification.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/telemetry-notification/telemetry-notification.component.scss new file mode 100644 index 000000000..cf8aa33d3 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/telemetry-notification/telemetry-notification.component.scss @@ -0,0 +1,17 @@ +@use './src/styles/vendor/variables' as vv; + +.no-margin-bottom { + margin-bottom: 0; +} + +.activate-button { + background-color: vv.$barley-white; + border: vv.$gray-700 solid 0.5px; + border-radius: 10%; + padding: 0.1rem 0.4rem; +} + +.activate-text { + color: vv.$gray-700; + font-weight: bold; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/telemetry-notification/telemetry-notification.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/telemetry-notification/telemetry-notification.component.spec.ts new file mode 100644 index 000000000..e946e79d8 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/telemetry-notification/telemetry-notification.component.spec.ts @@ -0,0 +1,107 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { NgbAlertModule } from '@ng-bootstrap/ng-bootstrap'; +import { ToastrModule } from 'ngx-toastr'; +import { of } from 'rxjs'; + +import { MgrModuleService } from '~/app/shared/api/mgr-module.service'; +import { UserService } from '~/app/shared/api/user.service'; +import { AlertPanelComponent } from '~/app/shared/components/alert-panel/alert-panel.component'; +import { Permissions } from '~/app/shared/models/permissions'; +import { PipesModule } from '~/app/shared/pipes/pipes.module'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { NotificationService } from '~/app/shared/services/notification.service'; +import { TelemetryNotificationService } from '~/app/shared/services/telemetry-notification.service'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { TelemetryNotificationComponent } from './telemetry-notification.component'; + +describe('TelemetryActivationNotificationComponent', () => { + let component: TelemetryNotificationComponent; + let fixture: ComponentFixture<TelemetryNotificationComponent>; + + let authStorageService: AuthStorageService; + let mgrModuleService: MgrModuleService; + let notificationService: NotificationService; + + let isNotificationHiddenSpy: jasmine.Spy; + let getPermissionsSpy: jasmine.Spy; + let getConfigSpy: jasmine.Spy; + + const configOptPermissions: Permissions = new Permissions({ + 'config-opt': ['read', 'create', 'update', 'delete'] + }); + const noConfigOptPermissions: Permissions = new Permissions({}); + const telemetryEnabledConfig = { + enabled: true + }; + const telemetryDisabledConfig = { + enabled: false + }; + + configureTestBed({ + declarations: [TelemetryNotificationComponent, AlertPanelComponent], + imports: [NgbAlertModule, HttpClientTestingModule, ToastrModule.forRoot(), PipesModule], + providers: [MgrModuleService, UserService] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(TelemetryNotificationComponent); + component = fixture.componentInstance; + authStorageService = TestBed.inject(AuthStorageService); + mgrModuleService = TestBed.inject(MgrModuleService); + notificationService = TestBed.inject(NotificationService); + + isNotificationHiddenSpy = spyOn(component, 'isNotificationHidden').and.returnValue(false); + getPermissionsSpy = spyOn(authStorageService, 'getPermissions').and.returnValue( + configOptPermissions + ); + getConfigSpy = spyOn(mgrModuleService, 'getConfig').and.returnValue( + of(telemetryDisabledConfig) + ); + }); + + it('should create', () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it('should not show notification again if the user closed it before', () => { + isNotificationHiddenSpy.and.returnValue(true); + fixture.detectChanges(); + expect(component.displayNotification).toBe(false); + }); + + it('should not show notification for a user without configOpt permissions', () => { + getPermissionsSpy.and.returnValue(noConfigOptPermissions); + fixture.detectChanges(); + expect(component.displayNotification).toBe(false); + }); + + it('should not show notification if the module is enabled already', () => { + getConfigSpy.and.returnValue(of(telemetryEnabledConfig)); + fixture.detectChanges(); + expect(component.displayNotification).toBe(false); + }); + + it('should show the notification if all pre-conditions set accordingly', () => { + fixture.detectChanges(); + expect(component.displayNotification).toBe(true); + }); + + it('should hide the notification if the user closes it', () => { + spyOn(notificationService, 'show'); + fixture.detectChanges(); + component.onDismissed(); + expect(notificationService.show).toHaveBeenCalled(); + expect(localStorage.getItem('telemetry_notification_hidden')).toBe('true'); + }); + + it('should hide the notification if the user logs out', () => { + const telemetryNotificationService = TestBed.inject(TelemetryNotificationService); + spyOn(telemetryNotificationService, 'setVisibility'); + fixture.detectChanges(); + component.ngOnDestroy(); + expect(telemetryNotificationService.setVisibility).toHaveBeenCalledWith(false); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/telemetry-notification/telemetry-notification.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/telemetry-notification/telemetry-notification.component.ts new file mode 100644 index 000000000..33174ce11 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/telemetry-notification/telemetry-notification.component.ts @@ -0,0 +1,62 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; + +import _ from 'lodash'; + +import { MgrModuleService } from '~/app/shared/api/mgr-module.service'; +import { NotificationType } from '~/app/shared/enum/notification-type.enum'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { NotificationService } from '~/app/shared/services/notification.service'; +import { TelemetryNotificationService } from '~/app/shared/services/telemetry-notification.service'; + +@Component({ + selector: 'cd-telemetry-notification', + templateUrl: './telemetry-notification.component.html', + styleUrls: ['./telemetry-notification.component.scss'] +}) +export class TelemetryNotificationComponent implements OnInit, OnDestroy { + displayNotification = false; + notificationSeverity = 'warning'; + + constructor( + private mgrModuleService: MgrModuleService, + private authStorageService: AuthStorageService, + private notificationService: NotificationService, + private telemetryNotificationService: TelemetryNotificationService + ) {} + + ngOnInit() { + this.telemetryNotificationService.update.subscribe((visible: boolean) => { + this.displayNotification = visible; + }); + + if (!this.isNotificationHidden()) { + const configOptPermissions = this.authStorageService.getPermissions().configOpt; + if (_.every(Object.values(configOptPermissions))) { + this.mgrModuleService.getConfig('telemetry').subscribe((options) => { + if (!options['enabled']) { + this.telemetryNotificationService.setVisibility(true); + } + }); + } + } + } + + ngOnDestroy() { + this.telemetryNotificationService.setVisibility(false); + } + + isNotificationHidden(): boolean { + return localStorage.getItem('telemetry_notification_hidden') === 'true'; + } + + onDismissed(): void { + this.telemetryNotificationService.setVisibility(false); + localStorage.setItem('telemetry_notification_hidden', 'true'); + this.notificationService.show( + NotificationType.success, + $localize`Telemetry activation reminder muted`, + $localize`You can activate the module on the Telemetry configuration \ +page (<b>Dashboard Settings</b> -> <b>Telemetry configuration</b>) at any time.` + ); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/usage-bar/usage-bar.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/usage-bar/usage-bar.component.html new file mode 100644 index 000000000..0602a4e59 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/usage-bar/usage-bar.component.html @@ -0,0 +1,27 @@ +<ng-template #usageTooltipTpl> + <table> + <tr> + <td class="text-left">Used: </td> + <td class="text-right"><strong> {{ isBinary ? (used | dimlessBinary) : (used | dimless) }}</strong></td> + </tr> + <tr *ngIf="calculatePerc"> + <td class="text-left">Free: </td> + <td class="'text-right"><strong>{{ isBinary ? (total - used | dimlessBinary) : (total - used | dimless) }}</strong></td> + </tr> + </table> +</ng-template> + +<div class="progress" + data-placement="left" + [ngbTooltip]="usageTooltipTpl"> + <div class="progress-bar bg-info" + [ngClass]="{'bg-warning': usedPercentage/100 >= warningThreshold, 'bg-danger': usedPercentage/100 >= errorThreshold}" + role="progressbar" + [style.width]="usedPercentage + '%'"> + <span>{{ usedPercentage | number: '1.0-' + decimals }}%</span> + </div> + <div class="progress-bar bg-freespace" + role="progressbar" + [style.width]="freePercentage + '%'"> + </div> +</div> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/usage-bar/usage-bar.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/usage-bar/usage-bar.component.scss new file mode 100644 index 000000000..e9d6d2498 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/usage-bar/usage-bar.component.scss @@ -0,0 +1,35 @@ +@use './src/styles/vendor/variables' as vv; + +.bg-info { + background-color: vv.$primary !important; +} + +.bg-warning { + background-color: vv.$warning !important; +} + +.bg-danger { + background-color: vv.$danger !important; +} + +.bg-freespace { + background-color: vv.$gray-400 !important; +} + +.progress { + height: 20px; + margin-bottom: 0; + position: relative; + + div.progress-bar { + position: static; + } + + span { + color: vv.$black; + display: block; + font-weight: normal; + position: absolute; + width: 100%; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/usage-bar/usage-bar.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/usage-bar/usage-bar.component.spec.ts new file mode 100644 index 000000000..45e6a06b6 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/usage-bar/usage-bar.component.spec.ts @@ -0,0 +1,27 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; + +import { PipesModule } from '~/app/shared/pipes/pipes.module'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { UsageBarComponent } from './usage-bar.component'; + +describe('UsageBarComponent', () => { + let component: UsageBarComponent; + let fixture: ComponentFixture<UsageBarComponent>; + + configureTestBed({ + imports: [PipesModule, NgbTooltipModule], + declarations: [UsageBarComponent] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(UsageBarComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/usage-bar/usage-bar.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/usage-bar/usage-bar.component.ts new file mode 100644 index 000000000..203f2c9e0 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/usage-bar/usage-bar.component.ts @@ -0,0 +1,43 @@ +import { Component, Input, OnChanges } from '@angular/core'; + +import _ from 'lodash'; + +@Component({ + selector: 'cd-usage-bar', + templateUrl: './usage-bar.component.html', + styleUrls: ['./usage-bar.component.scss'] +}) +export class UsageBarComponent implements OnChanges { + @Input() + total: number; + @Input() + used: any; + @Input() + warningThreshold: number; + @Input() + errorThreshold: number; + @Input() + isBinary = true; + @Input() + decimals = 0; + @Input() + calculatePerc = true; + + usedPercentage: number; + freePercentage: number; + + ngOnChanges() { + if (this.calculatePerc) { + this.usedPercentage = this.total > 0 ? (this.used / this.total) * 100 : 0; + this.freePercentage = 100 - this.usedPercentage; + } else { + if (this.used) { + this.used = this.used.slice(0, -1); + this.usedPercentage = Number(this.used); + this.freePercentage = 100 - this.usedPercentage; + } else { + this.usedPercentage = 0; + } + } + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/wizard/wizard.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/wizard/wizard.component.html new file mode 100644 index 000000000..25aa3e1df --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/wizard/wizard.component.html @@ -0,0 +1,19 @@ +<div class="card-body"> + <div class="row m-7"> + <nav class="col"> + <ul class="nav nav-pills flex-column" + *ngFor="let step of steps | async; let i = index;"> + <li class="nav-item"> + <a class="nav-link" + (click)="onStepClick(step)" + [ngClass]="{active: currentStep.stepIndex === step.stepIndex}"> + <span class="circle-step" + [ngClass]="{active: currentStep.stepIndex === step.stepIndex}" + i18n>{{ step.stepIndex }}</span> + <span i18n>{{ stepsTitle[i] }}</span> + </a> + </li> + </ul> + </nav> + </div> +</div> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/wizard/wizard.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/wizard/wizard.component.scss new file mode 100644 index 000000000..071b02e4a --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/wizard/wizard.component.scss @@ -0,0 +1,34 @@ +@use './src/styles/vendor/variables' as vv; + +::ng-deep cd-wizard { + width: 15%; +} + +.card-body { + padding-left: 0; +} + +span.circle-step { + background: vv.$gray-500; + border-radius: 0.8em; + color: vv.$white; + display: inline-block; + font-weight: bold; + line-height: 1.6em; + margin-right: 5px; + text-align: center; + width: 1.6em; + + &.active { + background-color: vv.$primary; + } +} + +.nav-pills .nav-link { + background-color: vv.$white; + color: vv.$gray-800; + + &.active { + color: vv.$primary; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/wizard/wizard.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/wizard/wizard.component.spec.ts new file mode 100644 index 000000000..b42578fb7 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/wizard/wizard.component.spec.ts @@ -0,0 +1,25 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SharedModule } from '~/app/shared/shared.module'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { WizardComponent } from './wizard.component'; + +describe('WizardComponent', () => { + let component: WizardComponent; + let fixture: ComponentFixture<WizardComponent>; + + configureTestBed({ + imports: [SharedModule] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(WizardComponent); + component = fixture.componentInstance; + component.stepsTitle = ['Add Hosts', 'Review']; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/wizard/wizard.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/wizard/wizard.component.ts new file mode 100644 index 000000000..d46aa480e --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/wizard/wizard.component.ts @@ -0,0 +1,39 @@ +import { Component, Input, OnDestroy, OnInit } from '@angular/core'; + +import * as _ from 'lodash'; +import { Observable, Subscription } from 'rxjs'; + +import { WizardStepModel } from '~/app/shared/models/wizard-steps'; +import { WizardStepsService } from '~/app/shared/services/wizard-steps.service'; + +@Component({ + selector: 'cd-wizard', + templateUrl: './wizard.component.html', + styleUrls: ['./wizard.component.scss'] +}) +export class WizardComponent implements OnInit, OnDestroy { + @Input() + stepsTitle: string[]; + + steps: Observable<WizardStepModel[]>; + currentStep: WizardStepModel; + currentStepSub: Subscription; + + constructor(private stepsService: WizardStepsService) {} + + ngOnInit(): void { + this.stepsService.setTotalSteps(this.stepsTitle.length); + this.steps = this.stepsService.getSteps(); + this.currentStepSub = this.stepsService.getCurrentStep().subscribe((step: WizardStepModel) => { + this.currentStep = step; + }); + } + + onStepClick(step: WizardStepModel) { + this.stepsService.setCurrentStep(step); + } + + ngOnDestroy(): void { + this.currentStepSub.unsubscribe(); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts new file mode 100644 index 000000000..4248be8f5 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts @@ -0,0 +1,305 @@ +import { Injectable } from '@angular/core'; + +import { environment } from '~/environments/environment'; + +export class AppConstants { + public static readonly organization = 'ceph'; + public static readonly projectName = 'Ceph Dashboard'; + public static readonly license = 'Free software (LGPL 2.1).'; + public static readonly copyright = 'Copyright(c) ' + environment.year + ' Ceph contributors.'; + public static readonly cephLogo = 'assets/Ceph_Logo.svg'; +} + +export enum URLVerbs { + /* Create a new item */ + CREATE = 'create', + + /* Make changes to an existing item */ + EDIT = 'edit', + + /* Make changes to an existing item */ + UPDATE = 'update', + + /* Remove an item from a container WITHOUT deleting it */ + REMOVE = 'remove', + + /* Destroy an existing item */ + DELETE = 'delete', + + /* Add an existing item to a container */ + ADD = 'add', + + /* Non-standard verbs */ + COPY = 'copy', + CLONE = 'clone', + + /* Prometheus wording */ + RECREATE = 'recreate', + EXPIRE = 'expire', + + /* Daemons */ + RESTART = 'Restart' +} + +export enum ActionLabels { + /* Create a new item */ + CREATE = 'Create', + + /* Destroy an existing item */ + DELETE = 'Delete', + + /* Add an existing item to a container */ + ADD = 'Add', + + /* Remove an item from a container WITHOUT deleting it */ + REMOVE = 'Remove', + + /* Make changes to an existing item */ + EDIT = 'Edit', + + /* */ + CANCEL = 'Cancel', + + /* Non-standard actions */ + COPY = 'Copy', + CLONE = 'Clone', + UPDATE = 'Update', + EVICT = 'Evict', + + /* Read-only */ + SHOW = 'Show', + + /* Prometheus wording */ + RECREATE = 'Recreate', + EXPIRE = 'Expire', + + /* Daemons */ + START = 'Start', + STOP = 'Stop', + REDEPLOY = 'Redeploy', + RESTART = 'Restart' +} + +@Injectable({ + providedIn: 'root' +}) +export class ActionLabelsI18n { + /* This service is required as the i18n polyfill does not provide static + translation + */ + CREATE: string; + DELETE: string; + ADD: string; + REMOVE: string; + EDIT: string; + CANCEL: string; + PREVIEW: string; + MOVE: string; + NEXT: string; + BACK: string; + CHANGE: string; + COPY: string; + CLONE: string; + DEEP_SCRUB: string; + DESTROY: string; + EVICT: string; + EXPIRE: string; + FLATTEN: string; + MARK_DOWN: string; + MARK_IN: string; + MARK_LOST: string; + MARK_OUT: string; + PROTECT: string; + PURGE: string; + RECREATE: string; + RENAME: string; + RESTORE: string; + REWEIGHT: string; + ROLLBACK: string; + SCRUB: string; + SET: string; + SUBMIT: string; + SHOW: string; + TRASH: string; + UNPROTECT: string; + UNSET: string; + UPDATE: string; + FLAGS: string; + ENTER_MAINTENANCE: string; + EXIT_MAINTENANCE: string; + REMOVE_SCHEDULING: string; + PROMOTE: string; + DEMOTE: string; + START_DRAIN: string; + STOP_DRAIN: string; + START: string; + STOP: string; + REDEPLOY: string; + RESTART: string; + RESYNC: string; + + constructor() { + /* Create a new item */ + this.CREATE = $localize`Create`; + + /* Destroy an existing item */ + this.DELETE = $localize`Delete`; + + /* Add an existing item to a container */ + this.ADD = $localize`Add`; + this.SET = $localize`Set`; + this.SUBMIT = $localize`Submit`; + + /* Remove an item from a container WITHOUT deleting it */ + this.REMOVE = $localize`Remove`; + this.UNSET = $localize`Unset`; + + /* Make changes to an existing item */ + this.EDIT = $localize`Edit`; + this.UPDATE = $localize`Update`; + this.CANCEL = $localize`Cancel`; + this.PREVIEW = $localize`Preview`; + this.MOVE = $localize`Move`; + + /* Wizard wording */ + this.NEXT = $localize`Next`; + this.BACK = $localize`Back`; + + /* Non-standard actions */ + this.CLONE = $localize`Clone`; + this.COPY = $localize`Copy`; + this.DEEP_SCRUB = $localize`Deep Scrub`; + this.DESTROY = $localize`Destroy`; + this.EVICT = $localize`Evict`; + this.FLATTEN = $localize`Flatten`; + this.MARK_DOWN = $localize`Mark Down`; + this.MARK_IN = $localize`Mark In`; + this.MARK_LOST = $localize`Mark Lost`; + this.MARK_OUT = $localize`Mark Out`; + this.PROTECT = $localize`Protect`; + this.PURGE = $localize`Purge`; + this.RENAME = $localize`Rename`; + this.RESTORE = $localize`Restore`; + this.REWEIGHT = $localize`Reweight`; + this.ROLLBACK = $localize`Rollback`; + this.SCRUB = $localize`Scrub`; + this.SHOW = $localize`Show`; + this.TRASH = $localize`Move to Trash`; + this.UNPROTECT = $localize`Unprotect`; + this.CHANGE = $localize`Change`; + this.FLAGS = $localize`Flags`; + this.ENTER_MAINTENANCE = $localize`Enter Maintenance`; + this.EXIT_MAINTENANCE = $localize`Exit Maintenance`; + + this.START_DRAIN = $localize`Start Drain`; + this.STOP_DRAIN = $localize`Stop Drain`; + this.RESYNC = $localize`Resync`; + /* Prometheus wording */ + this.RECREATE = $localize`Recreate`; + this.EXPIRE = $localize`Expire`; + + this.START = $localize`Start`; + this.STOP = $localize`Stop`; + this.REDEPLOY = $localize`Redeploy`; + this.RESTART = $localize`Restart`; + + this.REMOVE_SCHEDULING = $localize`Remove Scheduling`; + this.PROMOTE = $localize`Promote`; + this.DEMOTE = $localize`Demote`; + } +} + +@Injectable({ + providedIn: 'root' +}) +export class SucceededActionLabelsI18n { + /* This service is required as the i18n polyfill does not provide static + translation + */ + CREATED: string; + DELETED: string; + ADDED: string; + REMOVED: string; + EDITED: string; + CANCELED: string; + PREVIEWED: string; + MOVED: string; + COPIED: string; + CLONED: string; + DEEP_SCRUBBED: string; + DESTROYED: string; + FLATTENED: string; + MARKED_DOWN: string; + MARKED_IN: string; + MARKED_LOST: string; + MARKED_OUT: string; + PROTECTED: string; + PURGED: string; + RENAMED: string; + RESTORED: string; + REWEIGHTED: string; + ROLLED_BACK: string; + SCRUBBED: string; + SHOWED: string; + TRASHED: string; + UNPROTECTED: string; + CHANGE: string; + RECREATED: string; + EXPIRED: string; + MOVE: string; + START: string; + STOP: string; + REDEPLOY: string; + RESTART: string; + + constructor() { + /* Create a new item */ + this.CREATED = $localize`Created`; + + /* Destroy an existing item */ + this.DELETED = $localize`Deleted`; + + /* Add an existing item to a container */ + this.ADDED = $localize`Added`; + + /* Remove an item from a container WITHOUT deleting it */ + this.REMOVED = $localize`Removed`; + + /* Make changes to an existing item */ + this.EDITED = $localize`Edited`; + this.CANCELED = $localize`Canceled`; + this.PREVIEWED = $localize`Previewed`; + this.MOVED = $localize`Moved`; + + /* Non-standard actions */ + this.CLONED = $localize`Cloned`; + this.COPIED = $localize`Copied`; + this.DEEP_SCRUBBED = $localize`Deep Scrubbed`; + this.DESTROYED = $localize`Destroyed`; + this.FLATTENED = $localize`Flattened`; + this.MARKED_DOWN = $localize`Marked Down`; + this.MARKED_IN = $localize`Marked In`; + this.MARKED_LOST = $localize`Marked Lost`; + this.MARKED_OUT = $localize`Marked Out`; + this.PROTECTED = $localize`Protected`; + this.PURGED = $localize`Purged`; + this.RENAMED = $localize`Renamed`; + this.RESTORED = $localize`Restored`; + this.REWEIGHTED = $localize`Reweighted`; + this.ROLLED_BACK = $localize`Rolled back`; + this.SCRUBBED = $localize`Scrubbed`; + this.SHOWED = $localize`Showed`; + this.TRASHED = $localize`Moved to Trash`; + this.UNPROTECTED = $localize`Unprotected`; + this.CHANGE = $localize`Change`; + + /* Prometheus wording */ + this.RECREATED = $localize`Recreated`; + this.EXPIRED = $localize`Expired`; + + this.START = $localize`Start`; + this.STOP = $localize`Stop`; + this.REDEPLOY = $localize`Redeploy`; + this.RESTART = $localize`Restart`; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/datatable.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/datatable.module.ts new file mode 100644 index 000000000..ede8f2368 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/datatable.module.ts @@ -0,0 +1,31 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { RouterModule } from '@angular/router'; + +import { NgbDropdownModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; +import { NgxDatatableModule } from '@swimlane/ngx-datatable'; +import { NgxPipeFunctionModule } from 'ngx-pipe-function'; + +import { ComponentsModule } from '../components/components.module'; +import { PipesModule } from '../pipes/pipes.module'; +import { TableActionsComponent } from './table-actions/table-actions.component'; +import { TableKeyValueComponent } from './table-key-value/table-key-value.component'; +import { TableComponent } from './table/table.component'; + +@NgModule({ + imports: [ + CommonModule, + NgxDatatableModule, + NgxPipeFunctionModule, + FormsModule, + NgbDropdownModule, + NgbTooltipModule, + PipesModule, + ComponentsModule, + RouterModule + ], + declarations: [TableComponent, TableKeyValueComponent, TableActionsComponent], + exports: [TableComponent, NgxDatatableModule, TableKeyValueComponent, TableActionsComponent] +}) +export class DataTableModule {} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-actions/table-actions.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-actions/table-actions.component.html new file mode 100644 index 000000000..905aaa96b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-actions/table-actions.component.html @@ -0,0 +1,43 @@ +<div class="btn-group"> + <ng-container *ngIf="currentAction"> + <button type="button" + title="{{ useDisableDesc(currentAction) }}" + class="btn btn-{{btnColor}}" + [ngClass]="{'disabled': disableSelectionAction(currentAction)}" + (click)="useClickAction(currentAction)" + [routerLink]="useRouterLink(currentAction)" + [attr.aria-label]="currentAction.name" + [preserveFragment]="currentAction.preserveFragment ? '' : null"> + <i [ngClass]="[currentAction.icon]"></i> + <span>{{ currentAction.name }}</span> + </button> + </ng-container> + <div class="btn-group" + ngbDropdown + role="group" + *ngIf="dropDownActions.length > 1" + aria-label="Button group with nested dropdown"> + <button class="btn btn-{{btnColor}} dropdown-toggle-split" + ngbDropdownToggle> + <ng-container *ngIf="dropDownOnly">{{ dropDownOnly }} </ng-container> + <span *ngIf="!dropDownOnly" + class="sr-only"></span> + </button> + <div class="dropdown-menu" + ngbDropdownMenu> + <ng-container *ngFor="let action of dropDownActions"> + <button ngbDropdownItem + class="{{ toClassName(action) }}" + title="{{ useDisableDesc(action) }}" + (click)="useClickAction(action)" + [routerLink]="useRouterLink(action)" + [preserveFragment]="action.preserveFragment ? '' : null" + [disabled]="disableSelectionAction(action)" + [attr.aria-label]="action.name"> + <i [ngClass]="[action.icon, 'action-icon']"></i> + <span>{{ action.name }}</span> + </button> + </ng-container> + </div> + </div> +</div> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-actions/table-actions.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-actions/table-actions.component.scss new file mode 100644 index 000000000..f996de727 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-actions/table-actions.component.scss @@ -0,0 +1,8 @@ +button.disabled { + cursor: default !important; + pointer-events: auto; +} + +.action-icon { + padding-right: 1.5rem; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-actions/table-actions.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-actions/table-actions.component.spec.ts new file mode 100644 index 000000000..81cc1b972 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-actions/table-actions.component.spec.ts @@ -0,0 +1,213 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { NgxPipeFunctionModule } from 'ngx-pipe-function'; + +import { ComponentsModule } from '~/app/shared/components/components.module'; +import { CdTableAction } from '~/app/shared/models/cd-table-action'; +import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; +import { Permission } from '~/app/shared/models/permissions'; +import { configureTestBed, PermissionHelper } from '~/testing/unit-test-helper'; +import { TableActionsComponent } from './table-actions.component'; + +describe('TableActionsComponent', () => { + let component: TableActionsComponent; + let fixture: ComponentFixture<TableActionsComponent>; + let addAction: CdTableAction; + let editAction: CdTableAction; + let protectAction: CdTableAction; + let unprotectAction: CdTableAction; + let deleteAction: CdTableAction; + let copyAction: CdTableAction; + let permissionHelper: PermissionHelper; + + configureTestBed({ + declarations: [TableActionsComponent], + imports: [ComponentsModule, NgxPipeFunctionModule, RouterTestingModule] + }); + + beforeEach(() => { + addAction = { + permission: 'create', + icon: 'fa-plus', + canBePrimary: (selection: CdTableSelection) => !selection.hasSelection, + name: 'Add' + }; + editAction = { + permission: 'update', + icon: 'fa-pencil', + name: 'Edit' + }; + copyAction = { + permission: 'create', + icon: 'fa-copy', + canBePrimary: (selection: CdTableSelection) => selection.hasSingleSelection, + disable: (selection: CdTableSelection) => + !selection.hasSingleSelection || selection.first().cdExecuting, + name: 'Copy' + }; + deleteAction = { + permission: 'delete', + icon: 'fa-times', + canBePrimary: (selection: CdTableSelection) => selection.hasSelection, + disable: (selection: CdTableSelection) => + !selection.hasSelection || selection.first().cdExecuting, + name: 'Delete' + }; + protectAction = { + permission: 'update', + icon: 'fa-lock', + canBePrimary: () => false, + visible: (selection: CdTableSelection) => selection.hasSingleSelection, + name: 'Protect' + }; + unprotectAction = { + permission: 'update', + icon: 'fa-unlock', + canBePrimary: () => false, + visible: (selection: CdTableSelection) => !selection.hasSingleSelection, + name: 'Unprotect' + }; + fixture = TestBed.createComponent(TableActionsComponent); + component = fixture.componentInstance; + component.selection = new CdTableSelection(); + component.permission = new Permission(); + component.permission.read = true; + component.tableActions = [ + addAction, + editAction, + protectAction, + unprotectAction, + copyAction, + deleteAction + ]; + permissionHelper = new PermissionHelper(component.permission); + permissionHelper.setPermissionsAndGetActions(component.tableActions); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should call ngInit without permissions', () => { + component.permission = undefined; + component.ngOnInit(); + expect(component.tableActions).toEqual([]); + expect(component.dropDownActions).toEqual([]); + }); + + describe('useRouterLink', () => { + const testLink = '/api/some/link'; + it('should use a link generated from a function', () => { + addAction.routerLink = () => testLink; + expect(component.useRouterLink(addAction)).toBe(testLink); + }); + + it('should use the link as it is because it is a string', () => { + addAction.routerLink = testLink; + expect(component.useRouterLink(addAction)).toBe(testLink); + }); + + it('should not return anything because no link is defined', () => { + expect(component.useRouterLink(addAction)).toBe(undefined); + }); + + it('should not return anything because the action is disabled', () => { + editAction.routerLink = testLink; + expect(component.useRouterLink(editAction)).toBe(undefined); + }); + }); + + it('should test all TableActions combinations', () => { + const tableActions: TableActionsComponent = permissionHelper.setPermissionsAndGetActions( + component.tableActions + ); + expect(tableActions).toEqual({ + 'create,update,delete': { + actions: ['Add', 'Edit', 'Protect', 'Unprotect', 'Copy', 'Delete'], + primary: { multiple: 'Delete', executing: 'Edit', single: 'Edit', no: 'Add' } + }, + 'create,update': { + actions: ['Add', 'Edit', 'Protect', 'Unprotect', 'Copy'], + primary: { multiple: 'Add', executing: 'Edit', single: 'Edit', no: 'Add' } + }, + 'create,delete': { + actions: ['Add', 'Copy', 'Delete'], + primary: { multiple: 'Delete', executing: 'Copy', single: 'Copy', no: 'Add' } + }, + create: { + actions: ['Add', 'Copy'], + primary: { multiple: 'Add', executing: 'Copy', single: 'Copy', no: 'Add' } + }, + 'update,delete': { + actions: ['Edit', 'Protect', 'Unprotect', 'Delete'], + primary: { multiple: 'Delete', executing: 'Edit', single: 'Edit', no: 'Edit' } + }, + update: { + actions: ['Edit', 'Protect', 'Unprotect'], + primary: { multiple: 'Edit', executing: 'Edit', single: 'Edit', no: 'Edit' } + }, + delete: { + actions: ['Delete'], + primary: { multiple: 'Delete', executing: 'Delete', single: 'Delete', no: 'Delete' } + }, + 'no-permissions': { + actions: [], + primary: { multiple: '', executing: '', single: '', no: '' } + } + }); + }); + + it('should convert any name to a proper CSS class', () => { + expect(component.toClassName({ name: 'Create' } as CdTableAction)).toBe('create'); + expect(component.toClassName({ name: 'Mark x down' } as CdTableAction)).toBe('mark-x-down'); + expect(component.toClassName({ name: '?Su*per!' } as CdTableAction)).toBe('super'); + }); + + describe('useDisableDesc', () => { + it('should return a description if disable method returns a string', () => { + const deleteWithDescAction: CdTableAction = { + permission: 'delete', + icon: 'fa-times', + canBePrimary: (selection: CdTableSelection) => selection.hasSelection, + disable: () => { + return 'Delete action disabled description'; + }, + name: 'DeleteDesc' + }; + + expect(component.useDisableDesc(deleteWithDescAction)).toBe( + 'Delete action disabled description' + ); + }); + + it('should return no description if disable does not return string', () => { + expect(component.useDisableDesc(deleteAction)).toBeUndefined(); + }); + }); + + describe('useClickAction', () => { + const editClickAction: CdTableAction = { + permission: 'update', + icon: 'fa-pencil', + name: 'Edit', + click: () => { + return 'Edit action click'; + } + }; + + it('should call click action if action is not disabled', () => { + editClickAction.disable = () => { + return false; + }; + expect(component.useClickAction(editClickAction)).toBe('Edit action click'); + }); + + it('should not call click action if action is disabled', () => { + editClickAction.disable = () => { + return true; + }; + expect(component.useClickAction(editClickAction)).toBeFalsy(); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-actions/table-actions.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-actions/table-actions.component.ts new file mode 100644 index 000000000..0497f9301 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-actions/table-actions.component.ts @@ -0,0 +1,161 @@ +import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; + +import _ from 'lodash'; + +import { Icons } from '~/app/shared/enum/icons.enum'; +import { CdTableAction } from '~/app/shared/models/cd-table-action'; +import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; +import { Permission } from '~/app/shared/models/permissions'; + +@Component({ + selector: 'cd-table-actions', + templateUrl: './table-actions.component.html', + styleUrls: ['./table-actions.component.scss'] +}) +export class TableActionsComponent implements OnChanges, OnInit { + @Input() + permission: Permission; + @Input() + selection: CdTableSelection; + @Input() + tableActions: CdTableAction[]; + @Input() + btnColor = 'accent'; + + // Use this if you just want to display a drop down button, + // labeled with the given text, with all actions in it. + // This disables the main action button. + @Input() + dropDownOnly?: string; + + currentAction?: CdTableAction; + // Array with all visible actions + dropDownActions: CdTableAction[] = []; + + icons = Icons; + + ngOnInit() { + this.removeActionsWithNoPermissions(); + this.onSelectionChange(); + } + + ngOnChanges(changes: SimpleChanges) { + if (changes.selection) { + this.onSelectionChange(); + } + } + + onSelectionChange(): void { + this.updateDropDownActions(); + this.updateCurrentAction(); + } + + toClassName(action: CdTableAction): string { + return action.name + .replace(/ /g, '-') + .replace(/[^a-z-]/gi, '') + .toLowerCase(); + } + + /** + * Removes all actions from 'tableActions' that need a permission the user doesn't have. + */ + private removeActionsWithNoPermissions() { + if (!this.permission) { + this.tableActions = []; + return; + } + const permissions = Object.keys(this.permission).filter((key) => this.permission[key]); + this.tableActions = this.tableActions.filter((action) => + permissions.includes(action.permission) + ); + } + + private updateDropDownActions(): void { + this.dropDownActions = this.tableActions.filter((action) => + action.visible ? action.visible(this.selection) : action + ); + } + + /** + * Finds the next action that is used as main action for the button + * + * The order of the list is crucial to get the right main action. + * + * Default button conditions of actions: + * - 'create' actions can be used with no or multiple selections + * - 'update' and 'delete' actions can be used with one selection + */ + private updateCurrentAction(): void { + if (this.dropDownOnly) { + this.currentAction = undefined; + return; + } + let buttonAction = this.dropDownActions.find((tableAction) => this.showableAction(tableAction)); + if (!buttonAction && this.dropDownActions.length > 0) { + buttonAction = this.dropDownActions[0]; + } + this.currentAction = buttonAction; + } + + /** + * Determines if action can be used for the button + * + * @param {CdTableAction} action + * @returns {boolean} + */ + private showableAction(action: CdTableAction): boolean { + const condition = action.canBePrimary; + const singleSelection = this.selection.hasSingleSelection; + const defaultCase = action.permission === 'create' ? !singleSelection : singleSelection; + return (condition && condition(this.selection)) || (!condition && defaultCase); + } + + useRouterLink(action: CdTableAction): string { + if (!action.routerLink || this.disableSelectionAction(action)) { + return undefined; + } + return _.isString(action.routerLink) ? action.routerLink : action.routerLink(); + } + + /** + * Determines if an action should be disabled + * + * Default disable conditions of 'update' and 'delete' actions: + * - If no or multiple selections are made + * - If one selection is made, but a task is executed on that item + * + * @param {CdTableAction} action + * @returns {Boolean} + */ + disableSelectionAction(action: CdTableAction): Boolean { + const disable = action.disable; + if (disable) { + return Boolean(disable(this.selection)); + } + const permission = action.permission; + const selected = this.selection.hasSingleSelection && this.selection.first(); + return Boolean( + ['update', 'delete'].includes(permission) && (!selected || selected.cdExecuting) + ); + } + + useClickAction(action: CdTableAction) { + /** + * In order to show tooltips for deactivated menu items, the class + * 'pointer-events: auto;' has been added to the .scss file which also + * re-activates the click-event. + * To prevent calling the click-event on deactivated elements we also have + * to check here if it's disabled. + */ + return !this.disableSelectionAction(action) && action.click && action.click(); + } + + useDisableDesc(action: CdTableAction) { + if (action.disable) { + const result = action.disable(this.selection); + return _.isString(result) ? result : undefined; + } + return undefined; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-key-value/table-key-value.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-key-value/table-key-value.component.html new file mode 100644 index 000000000..2e2dda4e9 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-key-value/table-key-value.component.html @@ -0,0 +1,12 @@ +<cd-table #table + [data]="tableData" + [columns]="columns" + columnMode="flex" + [toolHeader]="false" + [autoReload]="autoReload" + [customCss]="customCss" + [autoSave]="false" + [header]="false" + [footer]="false" + [limit]="0"> +</cd-table> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-key-value/table-key-value.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-key-value/table-key-value.component.scss new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-key-value/table-key-value.component.scss diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-key-value/table-key-value.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-key-value/table-key-value.component.spec.ts new file mode 100644 index 000000000..150d44241 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-key-value/table-key-value.component.spec.ts @@ -0,0 +1,351 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { NgbDropdownModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; +import { NgxDatatableModule } from '@swimlane/ngx-datatable'; +import { NgxPipeFunctionModule } from 'ngx-pipe-function'; + +import { ComponentsModule } from '~/app/shared/components/components.module'; +import { CellTemplate } from '~/app/shared/enum/cell-template.enum'; +import { CdTableColumn } from '~/app/shared/models/cd-table-column'; +import { CdDatePipe } from '~/app/shared/pipes/cd-date.pipe'; +import { PipesModule } from '~/app/shared/pipes/pipes.module'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { TableComponent } from '../table/table.component'; +import { TableKeyValueComponent } from './table-key-value.component'; + +describe('TableKeyValueComponent', () => { + let component: TableKeyValueComponent; + let fixture: ComponentFixture<TableKeyValueComponent>; + + configureTestBed({ + declarations: [TableComponent, TableKeyValueComponent], + imports: [ + FormsModule, + NgxDatatableModule, + ComponentsModule, + RouterTestingModule, + NgbDropdownModule, + PipesModule, + NgbTooltipModule, + NgxPipeFunctionModule + ] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(TableKeyValueComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it('should make key value object pairs out of arrays with length two', () => { + component.data = [ + ['someKey', 0], + ['arrayKey', [1, 2, 3]], + [3, 'something'] + ]; + component.ngOnInit(); + const expected: any = [ + { key: 'arrayKey', value: '1, 2, 3' }, + { key: 'someKey', value: 0 }, + { key: 3, value: 'something' } + ]; + expect(component.tableData).toEqual(expected); + }); + + it('should not show data supposed to be have hidden by key', () => { + component.data = [ + ['a', 1], + ['b', 2] + ]; + component.hideKeys = ['a']; + component.ngOnInit(); + expect(component.tableData).toEqual([{ key: 'b', value: 2 }]); + }); + + it('should remove items with objects as values', () => { + component.data = [ + [3, 'something'], + ['will be removed', { a: 3, b: 4, c: 5 }] + ]; + component.ngOnInit(); + expect(component.tableData).toEqual(<any>[{ key: 3, value: 'something' }]); + }); + + it('makes key value object pairs out of an object', () => { + component.data = { 3: 'something', someKey: 0 }; + component.ngOnInit(); + expect(component.tableData).toEqual([ + { key: '3', value: 'something' }, + { key: 'someKey', value: 0 } + ]); + }); + + it('does nothing if data does not need to be converted', () => { + component.data = [ + { key: 3, value: 'something' }, + { key: 'someKey', value: 0 } + ]; + component.ngOnInit(); + expect(component.tableData).toEqual(component.data); + }); + + it('throws errors if data cannot be converted', () => { + component.data = 38; + expect(() => component.ngOnInit()).toThrowError('Wrong data format'); + component.data = [['someKey', 0, 3]]; + expect(() => component.ngOnInit()).toThrowError( + 'Array contains too many elements (3). Needs to be of type [string, any][]' + ); + }); + + it('tests makePairs()', () => { + const makePairs = (data: any) => component['makePairs'](data); + expect(makePairs([['dash', 'board']])).toEqual([{ key: 'dash', value: 'board' }]); + const pair = [ + { key: 'dash', value: 'board' }, + { key: 'ceph', value: 'mimic' } + ]; + const pairInverse = [ + { key: 'ceph', value: 'mimic' }, + { key: 'dash', value: 'board' } + ]; + expect(makePairs(pair)).toEqual(pairInverse); + expect(makePairs({ dash: 'board' })).toEqual([{ key: 'dash', value: 'board' }]); + expect(makePairs({ dash: 'board', ceph: 'mimic' })).toEqual(pairInverse); + }); + + it('tests makePairsFromArray()', () => { + const makePairsFromArray = (data: any[]) => component['makePairsFromArray'](data); + expect(makePairsFromArray([['dash', 'board']])).toEqual([{ key: 'dash', value: 'board' }]); + const pair = [ + { key: 'dash', value: 'board' }, + { key: 'ceph', value: 'mimic' } + ]; + expect(makePairsFromArray(pair)).toEqual(pair); + }); + + it('tests makePairsFromObject()', () => { + const makePairsFromObject = (data: object) => component['makePairsFromObject'](data); + expect(makePairsFromObject({ dash: 'board' })).toEqual([{ key: 'dash', value: 'board' }]); + expect(makePairsFromObject({ dash: 'board', ceph: 'mimic' })).toEqual([ + { key: 'dash', value: 'board' }, + { key: 'ceph', value: 'mimic' } + ]); + }); + + describe('tests convertValue()', () => { + const convertValue = (data: any) => component['convertValue'](data); + const expectConvertValue = (value: any, expectation: any) => + expect(convertValue(value)).toBe(expectation); + + it('should not convert strings', () => { + expectConvertValue('something', 'something'); + }); + + it('should not convert integers', () => { + expectConvertValue(29, 29); + }); + + it('should convert arrays with any type to strings', () => { + expectConvertValue([1, 2, 3], '1, 2, 3'); + expectConvertValue([{ sth: 'something' }], '{"sth":"something"}'); + expectConvertValue([1, 'two', { 3: 'three' }], '1, two, {"3":"three"}'); + }); + + it('should only convert objects if renderObjects is set to true', () => { + expect(convertValue({ sth: 'something' })).toBe(null); + component.renderObjects = true; + expect(convertValue({ sth: 'something' })).toEqual({ sth: 'something' }); + }); + }); + + describe('automatically pipe UTC dates through cdDate', () => { + let datePipe: CdDatePipe; + + beforeEach(() => { + datePipe = TestBed.inject(CdDatePipe); + spyOn(datePipe, 'transform').and.callThrough(); + }); + + const expectTimeConversion = (date: string) => { + component.data = { dateKey: date }; + component.ngOnInit(); + expect(datePipe.transform).toHaveBeenCalledWith(date); + expect(component.tableData[0].key).not.toBe(date); + }; + + it('converts some date', () => { + expectTimeConversion('2019-04-15 12:26:52.305285'); + }); + + it('converts UTC date', () => { + expectTimeConversion('2019-04-16T12:35:46.646300974Z'); + }); + }); + + describe('render objects', () => { + beforeEach(() => { + component.data = { + options: { + numberKey: 38, + stringKey: 'somethingElse', + objectKey: { + sub1: 12, + sub2: 34, + sub3: 56 + } + }, + otherOptions: { + sub1: { + x: 42 + }, + sub2: { + y: 555 + } + }, + additionalKeyContainingObject: { type: 'none' }, + keyWithEmptyObject: {} + }; + component.renderObjects = true; + }); + + it('with parent key', () => { + component.ngOnInit(); + expect(component.tableData).toEqual([ + { key: 'additionalKeyContainingObject type', value: 'none' }, + { key: 'keyWithEmptyObject', value: '' }, + { key: 'options numberKey', value: 38 }, + { key: 'options objectKey sub1', value: 12 }, + { key: 'options objectKey sub2', value: 34 }, + { key: 'options objectKey sub3', value: 56 }, + { key: 'options stringKey', value: 'somethingElse' }, + { key: 'otherOptions sub1 x', value: 42 }, + { key: 'otherOptions sub2 y', value: 555 } + ]); + }); + + it('without parent key', () => { + component.appendParentKey = false; + component.ngOnInit(); + expect(component.tableData).toEqual([ + { key: 'keyWithEmptyObject', value: '' }, + { key: 'numberKey', value: 38 }, + { key: 'stringKey', value: 'somethingElse' }, + { key: 'sub1', value: 12 }, + { key: 'sub2', value: 34 }, + { key: 'sub3', value: 56 }, + { key: 'type', value: 'none' }, + { key: 'x', value: 42 }, + { key: 'y', value: 555 } + ]); + }); + }); + + describe('subscribe fetchData', () => { + it('should not subscribe fetchData of table', () => { + component.ngOnInit(); + expect(component.table.fetchData.observers.length).toBe(0); + }); + + it('should call fetchData', () => { + let called = false; + component.fetchData.subscribe(() => { + called = true; + }); + component.ngOnInit(); + expect(component.table.fetchData.observers.length).toBe(1); + component.table.fetchData.emit(); + expect(called).toBeTruthy(); + }); + }); + + describe('hide empty items', () => { + beforeEach(() => { + component.data = { + booleanFalse: false, + booleanTrue: true, + string: '', + array: [], + object: {}, + emptyObject: { + string: '', + array: [], + object: {} + }, + someNumber: 0, + someDifferentNumber: 1, + someArray: [0, 1], + someString: '0', + someObject: { + empty: {}, + something: 0.1 + } + }; + component.renderObjects = true; + }); + + it('should show all items as default', () => { + expect(component.hideEmpty).toBe(false); + component.ngOnInit(); + expect(component.tableData).toEqual([ + { key: 'array', value: '' }, + { key: 'booleanFalse', value: false }, + { key: 'booleanTrue', value: true }, + { key: 'emptyObject array', value: '' }, + { key: 'emptyObject object', value: '' }, + { key: 'emptyObject string', value: '' }, + { key: 'object', value: '' }, + { key: 'someArray', value: '0, 1' }, + { key: 'someDifferentNumber', value: 1 }, + { key: 'someNumber', value: 0 }, + { key: 'someObject empty', value: '' }, + { key: 'someObject something', value: 0.1 }, + { key: 'someString', value: '0' }, + { key: 'string', value: '' } + ]); + }); + + it('should hide all empty items', () => { + component.hideEmpty = true; + component.ngOnInit(); + expect(component.tableData).toEqual([ + { key: 'booleanFalse', value: false }, + { key: 'booleanTrue', value: true }, + { key: 'someArray', value: '0, 1' }, + { key: 'someDifferentNumber', value: 1 }, + { key: 'someNumber', value: 0 }, + { key: 'someObject something', value: 0.1 }, + { key: 'someString', value: '0' } + ]); + }); + }); + + describe('columns set up', () => { + let columns: CdTableColumn[]; + + beforeEach(() => { + columns = [ + { prop: 'key', flexGrow: 1, cellTransformation: CellTemplate.bold }, + { prop: 'value', flexGrow: 3 } + ]; + }); + + it('should have the following default column set up', () => { + component.ngOnInit(); + expect(component.columns).toEqual(columns); + }); + + it('should have the following column set up if customCss is defined', () => { + component.customCss = { 'class-name': 42 }; + component.ngOnInit(); + columns[1].cellTransformation = CellTemplate.classAdding; + expect(component.columns).toEqual(columns); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-key-value/table-key-value.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-key-value/table-key-value.component.ts new file mode 100644 index 000000000..0f450ce2a --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table-key-value/table-key-value.component.ts @@ -0,0 +1,224 @@ +import { + Component, + EventEmitter, + Input, + OnChanges, + OnInit, + Output, + ViewChild +} from '@angular/core'; + +import _ from 'lodash'; + +import { CellTemplate } from '~/app/shared/enum/cell-template.enum'; +import { CdTableColumn } from '~/app/shared/models/cd-table-column'; +import { CdDatePipe } from '~/app/shared/pipes/cd-date.pipe'; +import { TableComponent } from '../table/table.component'; + +interface KeyValueItem { + key: string; + value: any; +} + +/** + * Display the given data in a 2 column data table. The left column + * shows the 'key' attribute, the right column the 'value' attribute. + * The data table has the following characteristics: + * - No header and footer is displayed + * - The relation of the width for the columns 'key' and 'value' is 1:3 + * - The 'key' column is displayed in bold text + */ +@Component({ + selector: 'cd-table-key-value', + templateUrl: './table-key-value.component.html', + styleUrls: ['./table-key-value.component.scss'] +}) +export class TableKeyValueComponent implements OnInit, OnChanges { + @ViewChild(TableComponent, { static: true }) + table: TableComponent; + + @Input() + data: any; + @Input() + autoReload: any = 5000; + @Input() + renderObjects = false; + // Only used if objects are rendered + @Input() + appendParentKey = true; + @Input() + hideEmpty = false; + @Input() + hideKeys: string[] = []; // Keys of pairs not to be displayed + + // If set, the classAddingTpl is used to enable different css for different values + @Input() + customCss?: { [css: string]: number | string | ((any: any) => boolean) }; + + columns: Array<CdTableColumn> = []; + tableData: KeyValueItem[]; + + /** + * The function that will be called to update the input data. + */ + @Output() + fetchData = new EventEmitter(); + + constructor(private datePipe: CdDatePipe) {} + + ngOnInit() { + this.columns = [ + { + prop: 'key', + flexGrow: 1, + cellTransformation: CellTemplate.bold + }, + { + prop: 'value', + flexGrow: 3 + } + ]; + if (this.customCss) { + this.columns[1].cellTransformation = CellTemplate.classAdding; + } + // We need to subscribe the 'fetchData' event here and not in the + // HTML template, otherwise the data table will display the loading + // indicator infinitely if data is only bound via '[data]="xyz"'. + // See for 'loadingIndicator' in 'TableComponent::ngOnInit()'. + if (this.fetchData.observers.length > 0) { + this.table.fetchData.subscribe(() => { + // Forward event triggered by the 'cd-table' data table. + this.fetchData.emit(); + }); + } + this.useData(); + } + + ngOnChanges() { + this.useData(); + } + + useData() { + if (!this.data) { + return; // Wait for data + } + let pairs = this.makePairs(this.data); + if (this.hideKeys) { + pairs = pairs.filter((pair) => !this.hideKeys.includes(pair.key)); + } + this.tableData = pairs; + } + + private makePairs(data: any): KeyValueItem[] { + let result: KeyValueItem[] = []; + if (!data) { + return undefined; // Wait for data + } else if (_.isArray(data)) { + result = this.makePairsFromArray(data); + } else if (_.isObject(data)) { + result = this.makePairsFromObject(data); + } else { + throw new Error('Wrong data format'); + } + result = result + .map((item) => { + item.value = this.convertValue(item.value); + return item; + }) + .filter((i) => i.value !== null); + return _.sortBy(this.renderObjects ? this.insertFlattenObjects(result) : result, 'key'); + } + + private makePairsFromArray(data: any[]): KeyValueItem[] { + let temp: any[] = []; + const first = data[0]; + if (_.isArray(first)) { + if (first.length === 2) { + temp = data.map((a) => ({ + key: a[0], + value: a[1] + })); + } else { + throw new Error( + `Array contains too many elements (${first.length}). ` + + `Needs to be of type [string, any][]` + ); + } + } else if (_.isObject(first)) { + if (_.has(first, 'key') && _.has(first, 'value')) { + temp = [...data]; + } else { + temp = data.reduce( + (previous: any[], item) => previous.concat(this.makePairsFromObject(item)), + temp + ); + } + } + return temp; + } + + private makePairsFromObject(data: any): KeyValueItem[] { + return Object.keys(data).map((k) => ({ + key: k, + value: data[k] + })); + } + + private insertFlattenObjects(data: KeyValueItem[]): any[] { + return _.flattenDeep( + data.map((item) => { + const value = item.value; + const isObject = _.isObject(value); + if (!isObject || _.isEmpty(value)) { + if (isObject) { + item.value = ''; + } + return item; + } + return this.splitItemIntoItems(item); + }) + ); + } + + /** + * Split item into items will call _makePairs inside _makePairs (recursion), in oder to split + * the object item up into items as planned. + */ + private splitItemIntoItems(data: { key: string; value: object }): KeyValueItem[] { + return this.makePairs(data.value).map((item) => { + if (this.appendParentKey) { + item.key = data.key + ' ' + item.key; + } + return item; + }); + } + + private convertValue(value: any): KeyValueItem { + if (_.isArray(value)) { + if (_.isEmpty(value) && this.hideEmpty) { + return null; + } + value = value.map((item) => (_.isObject(item) ? JSON.stringify(item) : item)).join(', '); + } else if (_.isObject(value)) { + if ((this.hideEmpty && _.isEmpty(value)) || !this.renderObjects) { + return null; + } + } else if (_.isString(value)) { + if (value === '' && this.hideEmpty) { + return null; + } + if (this.isDate(value)) { + value = this.datePipe.transform(value) || value; + } + } + + return value; + } + + private isDate(s: string) { + const sep = '[ -:.TZ]'; + const n = '\\d{2}' + sep; + // year - m - d - h : m : s . someRest Z (if UTC) + return s.match(new RegExp('^\\d{4}' + sep + n + n + n + n + n + '\\d*' + 'Z?$')); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html new file mode 100644 index 000000000..6212c95c8 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html @@ -0,0 +1,327 @@ +<div class="dataTables_wrapper"> + + <div *ngIf="onlyActionHeader" + class="dataTables_header clearfix"> + <div class="cd-datatable-actions"> + <ng-content select=".only-table-actions"></ng-content> + </div> + </div> + <div class="dataTables_header clearfix" + *ngIf="toolHeader"> + <!-- actions --> + <div class="cd-datatable-actions"> + <ng-content select=".table-actions"></ng-content> + </div> + <!-- end actions --> + + <!-- column filters --> + <div *ngIf="columnFilters.length !== 0" + class="btn-group widget-toolbar"> + <div ngbDropdown + placement="bottom-right" + class="tc_filter_name"> + <button ngbDropdownToggle + class="btn btn-light"> + <i [ngClass]="[icons.large, icons.filter]"></i> + {{ selectedFilter.column.name }} + </button> + <div ngbDropdownMenu> + <ng-container *ngFor="let filter of columnFilters"> + <button ngbDropdownItem + (click)="onSelectFilter(filter); false">{{ filter.column.name }}</button> + </ng-container> + </div> + </div> + + <div ngbDropdown + placement="bottom-right" + class="tc_filter_option"> + <button ngbDropdownToggle + class="btn btn-light" + [class.disabled]="selectedFilter.options.length === 0"> + {{ selectedFilter.value ? selectedFilter.value.formatted: 'Any' }} + </button> + <div ngbDropdownMenu> + <ng-container *ngFor="let option of selectedFilter.options"> + <button ngbDropdownItem + (click)="onChangeFilter(selectedFilter, option); false"> + {{ option.formatted }} + <i *ngIf="selectedFilter.value !== undefined && (selectedFilter.value.raw === option.raw)" + [ngClass]="[icons.check]"></i> + </button> + </ng-container> + </div> + </div> + </div> + <!-- end column filters --> + + <!-- search --> + <div class="input-group search" + *ngIf="searchField"> + <span class="input-group-prepend"> + <span class="input-group-text"> + <i [ngClass]="[icons.search]"></i> + </span> + </span> + <input class="form-control" + type="text" + [(ngModel)]="search" + (keyup)="updateFilter()"> + <div class="input-group-append"> + <button type="button" + class="btn btn-light" + (click)="onClearSearch()"> + <i class="icon-prepend {{ icons.destroy }}"></i> + </button> + </div> + </div> + <!-- end search --> + + <!-- pagination limit --> + <div class="input-group dataTables_paginate" + *ngIf="limit"> + <input class="form-control" + type="number" + min="1" + max="9999" + [value]="userConfig.limit" + (click)="setLimit($event)" + (keyup)="setLimit($event)" + (blur)="setLimit($event)"> + </div> + <!-- end pagination limit--> + + <!-- show hide columns --> + <div class="widget-toolbar"> + <div ngbDropdown + autoClose="outside" + class="tc_menuitem"> + <button ngbDropdownToggle + class="btn btn-light tc_columnBtn"> + <i [ngClass]="[icons.large, icons.table]"></i> + </button> + <div ngbDropdownMenu> + <ng-container *ngFor="let column of columns"> + <button ngbDropdownItem + *ngIf="column.name !== ''" + (click)="toggleColumn(column); false;"> + <div class="custom-control custom-checkbox py-0"> + <input class="custom-control-input" + type="checkbox" + [name]="column.prop" + [id]="column.prop" + [checked]="!column.isHidden"> + <label class="custom-control-label" + [for]="column.prop">{{ column.name }}</label> + </div> + </button> + </ng-container> + </div> + </div> + </div> + <!-- end show hide columns --> + + <!-- refresh button --> + <div class="widget-toolbar tc_refreshBtn" + *ngIf="fetchData.observers.length > 0"> + + <button type="button" + [class]="'btn btn-' + status.type" + [ngbTooltip]="status.msg" + (click)="refreshBtn()"> + <i [ngClass]="[icons.large, icons.refresh]" + [class.fa-spin]="updating || loadingIndicator"></i> + </button> + </div> + <!-- end refresh button --> + </div> + <div class="dataTables_header clearfix" + *ngIf="toolHeader && columnFiltered"> + <!-- filter chips for column filters --> + <div class="filter-chips"> + <span *ngFor="let filter of columnFilters"> + <span *ngIf="filter.value" + class="badge badge-info mr-2"> + <span class="mr-2">{{ filter.column.name }}: {{ filter.value.formatted }}</span> + <a class="badge-remove" + (click)="onChangeFilter(filter); false"> + <i [ngClass]="[icons.destroy]" + aria-hidden="true"></i> + </a> + </span> + </span> + <a class="tc_clearSelections" + href="" + (click)="onClearFilters(); false"> + <ng-container i18n>Clear filters</ng-container> + </a> + </div> + <!-- end filter chips for column filters --> + </div> + <ngx-datatable #table + class="bootstrap cd-datatable" + [cssClasses]="paginationClasses" + [selectionType]="selectionType" + [selected]="selection.selected" + (select)="onSelect($event)" + [sorts]="userConfig.sorts" + (sort)="changeSorting($event)" + [columns]="tableColumns" + [columnMode]="columnMode" + [rows]="rows" + [rowClass]="getRowClass()" + [headerHeight]="header ? 'auto' : 0" + [footerHeight]="footer ? 'auto' : 0" + [count]="count" + [externalPaging]="serverSide" + [externalSorting]="serverSide" + [limit]="userConfig.limit > 0 ? userConfig.limit : undefined" + [offset]="userConfig.offset >= 0 ? userConfig.offset : 0" + (page)="changePage($event)" + [loadingIndicator]="loadingIndicator" + [rowIdentity]="rowIdentity()" + [rowHeight]="'auto'"> + + <!-- Row Detail Template --> + <ngx-datatable-row-detail rowHeight="auto" + #detailRow> + <ng-template let-row="row" + let-expanded="expanded" + ngx-datatable-row-detail-template> + <!-- Table Details --> + <ng-content select="[cdTableDetail]"></ng-content> + </ng-template> + </ngx-datatable-row-detail> + + <ngx-datatable-footer> + <ng-template ngx-datatable-footer-template + let-rowCount="rowCount" + let-pageSize="pageSize" + let-selectedCount="selectedCount" + let-curPage="curPage" + let-offset="offset" + let-isVisible="isVisible"> + <div class="page-count"> + <span *ngIf="selectionType"> + {{ selectedCount }} <ng-container i18n="X selected">selected</ng-container> / + </span> + + <!-- rowCount might have different semantics with or without serverSide. + We treat serverSide (backend-driven tables) as a specific case. + --> + <span *ngIf="!serverSide else serverSideTpl"> + <span *ngIf="rowCount != data?.length"> + {{ rowCount }} <ng-container i18n="X found">found</ng-container> / + </span> + {{ data?.length || 0 }} <ng-container i18n="X total">total</ng-container> + </span> + + <ng-template #serverSideTpl> + {{ data?.length || 0 }} <ng-container i18n="X found">found</ng-container> / + {{ rowCount }} <ng-container i18n="X total">total</ng-container> + </ng-template> + </div> + <datatable-pager [pagerLeftArrowIcon]="paginationClasses.pagerPrevious" + [pagerRightArrowIcon]="paginationClasses.pagerNext" + [pagerPreviousIcon]="paginationClasses.pagerLeftArrow" + [pagerNextIcon]="paginationClasses.pagerRightArrow" + [page]="curPage" + [size]="pageSize" + [count]="rowCount" + [hidden]="!((rowCount / pageSize) > 1)" + (change)="table.onFooterPage($event)"> + </datatable-pager> + </ng-template> + </ngx-datatable-footer> + </ngx-datatable> +</div> + +<!-- cell templates that can be accessed from outside --> +<ng-template #tableCellBoldTpl + let-value="value"> + <strong>{{ value }}</strong> +</ng-template> + +<ng-template #sparklineTpl + let-row="row" + let-value="value"> + <cd-sparkline [data]="value" + [isBinary]="row.cdIsBinary"></cd-sparkline> +</ng-template> + +<ng-template #routerLinkTpl + let-row="row" + let-value="value"> + <a [routerLink]="[row.cdLink]" + [queryParams]="row.cdParams">{{ value }}</a> +</ng-template> + +<ng-template #checkIconTpl + let-value="value"> + <i [ngClass]="[icons.check]" + [hidden]="!(value | boolean)"></i> +</ng-template> + +<ng-template #perSecondTpl + let-row="row" + let-value="value"> + {{ value | dimless }} /s +</ng-template> + +<ng-template #executingTpl + let-column="column" + let-row="row" + let-value="value"> + <i [ngClass]="[icons.spinner, icons.spin]" + *ngIf="row.cdExecuting"></i> + <span [ngClass]="column?.customTemplateConfig?.valueClass"> + {{ value }} + </span> + <span *ngIf="row.cdExecuting" + [ngClass]="column?.customTemplateConfig?.executingClass ? column.customTemplateConfig.executingClass : 'text-muted italic'">({{ row.cdExecuting }})</span> +</ng-template> + +<ng-template #classAddingTpl + let-value="value"> + <span class="{{ value | pipeFunction:useCustomClass:this }}">{{ value }}</span> +</ng-template> + +<ng-template #badgeTpl + let-column="column" + let-value="value"> + <span *ngFor="let item of (value | array); last as last"> + <span class="badge" + [ngClass]="(column?.customTemplateConfig?.map && column?.customTemplateConfig?.map[item]?.class) ? column.customTemplateConfig.map[item].class : (column?.customTemplateConfig?.class ? column.customTemplateConfig.class : 'badge-primary')" + *ngIf="(column?.customTemplateConfig?.map && column?.customTemplateConfig?.map[item]?.value) ? column.customTemplateConfig.map[item].value : column?.customTemplateConfig?.prefix ? column.customTemplateConfig.prefix + item : item"> + {{ (column?.customTemplateConfig?.map && column?.customTemplateConfig?.map[item]?.value) ? column.customTemplateConfig.map[item].value : column?.customTemplateConfig?.prefix ? column.customTemplateConfig.prefix + item : item }} + </span> + <span *ngIf="!last"> </span> + </span> +</ng-template> + +<ng-template #mapTpl + let-column="column" + let-value="value"> + <span>{{ value | map:column?.customTemplateConfig }}</span> +</ng-template> + +<ng-template #truncateTpl + let-column="column" + let-value="value"> + <span data-toggle="tooltip" + [title]="value">{{ value | truncate:column?.customTemplateConfig?.length:column?.customTemplateConfig?.omission }}</span> +</ng-template> + +<ng-template #rowDetailsTpl + let-row="row" + let-isExpanded="expanded" + ngx-datatable-cell-template> + <a href="javascript:void(0)" + [class.expand-collapse-icon-right]="!isExpanded" + [class.expand-collapse-icon-down]="isExpanded" + class="expand-collapse-icon tc_expand-collapse" + title="Expand/Collapse Row" + i18n-title + (click)="toggleExpandRow(row, isExpanded, $event)"> + </a> +</ng-template> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.scss new file mode 100644 index 000000000..57b8e48de --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.scss @@ -0,0 +1,295 @@ +@use './src/styles/vendor/variables' as vv; +@use './src/styles/defaults/mixins'; + +@mixin row-details-icon { + color: vv.$gray-900; + font-family: 'ForkAwesome', sans-serif; + font-size: 1rem; + line-height: 1; +} + +.dataTables_wrapper { + margin-bottom: 25px; + // after bootstrap 8.0 the details table started to + // have an issue where the columns keep expanding to + // infinity. + // https://github.com/ceph/ceph/pull/40618#pullrequestreview-629010639 + // making the max-width to 99.9% solves the issue as a temporary fix + // until we get a conclusive fix, this needs to be kept. + max-width: 99.9%; + + .separator { + border-left: 1px solid vv.$datatable-divider-color; + display: inline-block; + height: 30px; + margin-left: 5px; + padding-left: 5px; + vertical-align: middle; + } + + .widget-toolbar { + border-left: 1px solid vv.$datatable-divider-color; + float: right; + padding: 0 8px; + + .form-check { + padding-left: 0; + } + } + + .dataTables_length > input { + line-height: 25px; + text-align: right; + } +} + +.dataTables_header { + background-color: vv.$gray-100; + border: 1px solid vv.$gray-400; + border-bottom: 0; + padding: 5px; + position: relative; + + .cd-datatable-actions { + float: left; + } + + .form-group { + padding-left: 8px; + } + + .input-group { + border-left: 1px solid vv.$datatable-divider-color; + float: right; + max-width: 250px; + padding-left: 8px; + padding-right: 8px; + width: 40%; + + .form-control { + height: 30px; + } + } + + .input-group.dataTables_paginate { + min-width: 85px; + padding-right: 8px; + width: 8%; + } + + .filter-chips { + float: right; + padding: 0 8px; + + .badge-remove { + color: vv.$white; + } + } +} + +::ng-deep cd-table .cd-datatable { + border: 1px solid vv.$gray-400; + margin-bottom: 0; + max-width: none !important; + + .progress-linear { + display: block; + height: 5px; + margin: 0; + padding: 0; + position: relative; + width: 100%; + + .container { + background-color: vv.$primary; + + .bar { + background-color: vv.$primary; + height: 100%; + left: 0; + overflow: hidden; + position: absolute; + width: 100%; + } + + .bar::before { + animation: progress-loading 3s linear infinite; + background-color: vv.$primary; + content: ''; + display: block; + height: 100%; + left: -200px; + position: absolute; + width: 200px; + } + } + } + + .datatable-header { + background-clip: padding-box; + background-color: vv.$gray-100; + background-image: linear-gradient(to bottom, vv.$gray-100 0, vv.$gray-200 100%); + background-repeat: repeat-x; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffafafa', endColorstr='#ffededed', GradientType=0); + + .sort-asc, + .sort-desc { + color: vv.$primary; + } + + .datatable-header-cell { + @include mixins.table-cell; + + font-weight: bold; + text-align: left; + + .datatable-header-cell-label { + &::after { + font-family: ForkAwesome; + font-weight: 400; + height: 9px; + left: 10px; + line-height: 12px; + position: relative; + vertical-align: baseline; + width: 12px; + } + } + + &.sortable { + .datatable-header-cell-label::after { + content: ' \f0dc'; + } + + &.sort-active { + &.sort-asc .datatable-header-cell-label::after { + content: ' \f160'; + } + + &.sort-desc .datatable-header-cell-label::after { + content: ' \f161'; + } + } + } + + &:first-child { + border-left: 0; + } + } + } + + .datatable-body { + margin-bottom: -6px; + + .empty-row { + background-color: lighten(vv.$primary, 45%); + font-style: italic; + font-weight: bold; + padding-bottom: 5px; + padding-top: 5px; + text-align: center; + } + + .datatable-body-row { + &.clickable:hover .datatable-row-group { + background-color: lighten(vv.$primary, 45%); + transition-duration: 0.3s; + transition-property: background; + transition-timing-function: linear; + } + + &.datatable-row-even { + background-color: vv.$white; + } + + &.datatable-row-odd { + background-color: vv.$gray-100; + } + + &.active, + &.active:hover { + background-color: lighten(vv.$primary, 35%); + } + + .datatable-body-cell { + @include mixins.table-cell; + + &:first-child { + border-left: 0; + } + + .datatable-body-cell-label { + display: block; + height: 100%; + } + } + } + + .datatable-row-detail { + border-bottom: 2px solid vv.$gray-400; + overflow-y: visible !important; + padding: 20px; + } + + .expand-collapse-icon { + display: block; + height: 100%; + text-align: center; + + &:hover { + text-decoration: none; + } + } + + .expand-collapse-icon-right::before { + @include row-details-icon; + content: '\f105'; + } + + .expand-collapse-icon-down::before { + @include row-details-icon; + content: '\f107'; + } + } + + .datatable-footer { + .selected-count, + .page-count { + font-style: italic; + min-height: 2rem; + padding-left: 0.3rem; + padding-top: 0.3rem; + } + } + + .cd-datatable-checkbox { + text-align: center; + } +} + +@keyframes progress-loading { + from { + left: -200px; + width: 15%; + } + + 50% { + width: 30%; + } + + 70% { + width: 70%; + } + + 80% { + left: 50%; + } + + 95% { + left: 120%; + } + + to { + left: 100%; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.spec.ts new file mode 100644 index 000000000..f0f649780 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.spec.ts @@ -0,0 +1,799 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { NgbDropdownModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; +import { NgxDatatableModule } from '@swimlane/ngx-datatable'; +import _ from 'lodash'; +import { NgxPipeFunctionModule } from 'ngx-pipe-function'; + +import { ComponentsModule } from '~/app/shared/components/components.module'; +import { CellTemplate } from '~/app/shared/enum/cell-template.enum'; +import { CdTableColumnFilter } from '~/app/shared/models/cd-table-column-filter'; +import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context'; +import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; +import { PipesModule } from '~/app/shared/pipes/pipes.module'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { TableComponent } from './table.component'; + +describe('TableComponent', () => { + let component: TableComponent; + let fixture: ComponentFixture<TableComponent>; + + const createFakeData = (n: number) => { + const data = []; + for (let i = 0; i < n; i++) { + data.push({ + a: i, + b: i * 10, + c: !!(i % 2) + }); + } + return data; + }; + + const clearLocalStorage = () => { + component.localStorage.clear(); + }; + + configureTestBed({ + declarations: [TableComponent], + imports: [ + BrowserAnimationsModule, + NgxDatatableModule, + NgxPipeFunctionModule, + FormsModule, + ComponentsModule, + RouterTestingModule, + NgbDropdownModule, + PipesModule, + NgbTooltipModule + ] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(TableComponent); + component = fixture.componentInstance; + + component.data = createFakeData(10); + component.localColumns = component.columns = [ + { prop: 'a', name: 'Index', filterable: true }, + { prop: 'b', name: 'Index times ten' }, + { prop: 'c', name: 'Odd?', filterable: true } + ]; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should force an identifier', () => { + component.identifier = 'x'; + component.forceIdentifier = true; + component.ngOnInit(); + expect(component.identifier).toBe('x'); + expect(component.sorts[0].prop).toBe('a'); + expect(component.sorts).toEqual(component.createSortingDefinition('a')); + }); + + it('should have rows', () => { + component.useData(); + expect(component.data.length).toBe(10); + expect(component.rows.length).toBe(component.data.length); + }); + + it('should have an int in setLimit parsing a string', () => { + expect(component.limit).toBe(10); + expect(component.limit).toEqual(jasmine.any(Number)); + + const e = { target: { value: '1' } }; + component.setLimit(e); + expect(component.userConfig.limit).toBe(1); + expect(component.userConfig.limit).toEqual(jasmine.any(Number)); + e.target.value = '-20'; + component.setLimit(e); + expect(component.userConfig.limit).toBe(1); + }); + + it('should prevent propagation of mouseenter event', (done) => { + let wasCalled = false; + const mouseEvent = new MouseEvent('mouseenter'); + mouseEvent.stopPropagation = () => { + wasCalled = true; + }; + spyOn(component.table.element, 'addEventListener').and.callFake((eventName, fn) => { + fn(mouseEvent); + expect(eventName).toBe('mouseenter'); + expect(wasCalled).toBe(true); + done(); + }); + component.ngOnInit(); + }); + + it('should call updateSelection on init', () => { + component.updateSelection.subscribe((selection: CdTableSelection) => { + expect(selection.hasSelection).toBeFalsy(); + expect(selection.hasSingleSelection).toBeFalsy(); + expect(selection.hasMultiSelection).toBeFalsy(); + expect(selection.selected.length).toBe(0); + }); + component.ngOnInit(); + }); + + describe('test column filtering', () => { + let filterIndex: CdTableColumnFilter; + let filterOdd: CdTableColumnFilter; + let filterCustom: CdTableColumnFilter; + + const expectColumnFilterCreated = ( + filter: CdTableColumnFilter, + prop: string, + options: string[], + value?: { raw: string; formatted: string } + ) => { + expect(filter.column.prop).toBe(prop); + expect(_.map(filter.options, 'raw')).toEqual(options); + expect(filter.value).toEqual(value); + }; + + const expectColumnFiltered = ( + changes: { filter: CdTableColumnFilter; value?: string }[], + results: any[], + search: string = '' + ) => { + component.search = search; + _.forEach(changes, (change) => { + component.onChangeFilter( + change.filter, + change.value ? { raw: change.value, formatted: change.value } : undefined + ); + }); + expect(component.rows).toEqual(results); + component.onClearSearch(); + component.onClearFilters(); + }; + + describe('with visible columns', () => { + beforeEach(() => { + component.initColumnFilters(); + component.updateColumnFilterOptions(); + filterIndex = component.columnFilters[0]; + filterOdd = component.columnFilters[1]; + }); + + it('should have filters initialized', () => { + expect(component.columnFilters.length).toBe(2); + expectColumnFilterCreated( + filterIndex, + 'a', + _.map(component.data, (row) => _.toString(row.a)) + ); + expectColumnFilterCreated(filterOdd, 'c', ['false', 'true']); + }); + + it('should add filters', () => { + // single + expectColumnFiltered([{ filter: filterIndex, value: '1' }], [{ a: 1, b: 10, c: true }]); + + // multiple + expectColumnFiltered( + [ + { filter: filterOdd, value: 'false' }, + { filter: filterIndex, value: '2' } + ], + [{ a: 2, b: 20, c: false }] + ); + + // Clear should work + expect(component.rows).toEqual(component.data); + }); + + it('should remove filters', () => { + // single + expectColumnFiltered( + [ + { filter: filterOdd, value: 'true' }, + { filter: filterIndex, value: '1' }, + { filter: filterIndex, value: undefined } + ], + [ + { a: 1, b: 10, c: true }, + { a: 3, b: 30, c: true }, + { a: 5, b: 50, c: true }, + { a: 7, b: 70, c: true }, + { a: 9, b: 90, c: true } + ] + ); + + // multiple + expectColumnFiltered( + [ + { filter: filterOdd, value: 'true' }, + { filter: filterIndex, value: '1' }, + { filter: filterIndex, value: undefined }, + { filter: filterOdd, value: undefined } + ], + component.data + ); + + // a selected filter should be removed if it's selected again + expectColumnFiltered( + [ + { filter: filterOdd, value: 'true' }, + { filter: filterIndex, value: '1' }, + { filter: filterIndex, value: '1' } + ], + [ + { a: 1, b: 10, c: true }, + { a: 3, b: 30, c: true }, + { a: 5, b: 50, c: true }, + { a: 7, b: 70, c: true }, + { a: 9, b: 90, c: true } + ] + ); + }); + + it('should search from filtered rows', () => { + expectColumnFiltered( + [{ filter: filterOdd, value: 'true' }], + [{ a: 9, b: 90, c: true }], + '9' + ); + + // Clear should work + expect(component.rows).toEqual(component.data); + }); + }); + + describe('with custom columns', () => { + beforeEach(() => { + // create a new additional column in data + for (let i = 0; i < component.data.length; i++) { + const row = component.data[i]; + row['d'] = row.a; + } + // create a custom column filter + component.extraFilterableColumns = [ + { + name: 'd less than 5', + prop: 'd', + filterOptions: ['yes', 'no'], + filterInitValue: 'yes', + filterPredicate: (row, value) => { + if (value === 'yes') { + return row.d < 5; + } else { + return row.d >= 5; + } + } + } + ]; + component.initColumnFilters(); + component.updateColumnFilterOptions(); + filterIndex = component.columnFilters[0]; + filterOdd = component.columnFilters[1]; + filterCustom = component.columnFilters[2]; + }); + + it('should have filters initialized', () => { + expect(component.columnFilters.length).toBe(3); + expectColumnFilterCreated(filterCustom, 'd', ['yes', 'no'], { + raw: 'yes', + formatted: 'yes' + }); + component.useData(); + expect(component.rows).toEqual(_.slice(component.data, 0, 5)); + }); + + it('should remove filters', () => { + expectColumnFiltered([{ filter: filterCustom, value: 'no' }], _.slice(component.data, 5)); + }); + }); + }); + + describe('test search', () => { + const expectSearch = (keyword: string, expectedResult: object[]) => { + component.search = keyword; + component.updateFilter(); + expect(component.rows).toEqual(expectedResult); + component.onClearSearch(); + }; + + describe('searchableObjects', () => { + const testObject = { + obj: { + min: 8, + max: 123 + } + }; + + beforeEach(() => { + component.data = [testObject]; + component.localColumns = [{ prop: 'obj', name: 'Object' }]; + }); + + it('should not search through objects as default case', () => { + expect(component.searchableObjects).toBe(false); + expectSearch('8', []); + }); + + it('should search through objects if searchableObjects is set to true', () => { + component.searchableObjects = true; + expectSearch('28', []); + expectSearch('8', [testObject]); + expectSearch('123', [testObject]); + expectSearch('max', [testObject]); + }); + }); + + it('should find a particular number', () => { + expectSearch('5', [{ a: 5, b: 50, c: true }]); + expectSearch('9', [{ a: 9, b: 90, c: true }]); + }); + + it('should find boolean values', () => { + expectSearch('true', [ + { a: 1, b: 10, c: true }, + { a: 3, b: 30, c: true }, + { a: 5, b: 50, c: true }, + { a: 7, b: 70, c: true }, + { a: 9, b: 90, c: true } + ]); + expectSearch('false', [ + { a: 0, b: 0, c: false }, + { a: 2, b: 20, c: false }, + { a: 4, b: 40, c: false }, + { a: 6, b: 60, c: false }, + { a: 8, b: 80, c: false } + ]); + }); + + it('should test search keyword preparation', () => { + const prepare = TableComponent.prepareSearch; + const expected = ['a', 'b', 'c']; + expect(prepare('a b c')).toEqual(expected); + expect(prepare('a,, b,, c')).toEqual(expected); + expect(prepare('a,,,, b,,, c')).toEqual(expected); + expect(prepare('a+b c')).toEqual(['a+b', 'c']); + expect(prepare('a,,,+++b,,, c')).toEqual(['a+++b', 'c']); + expect(prepare('"a b c" "d e f", "g, h i"')).toEqual(['a+b+c', 'd+e++f', 'g+h+i']); + }); + + it('should search for multiple values', () => { + expectSearch('2 20 false', [{ a: 2, b: 20, c: false }]); + expectSearch('false 2', [{ a: 2, b: 20, c: false }]); + }); + + it('should filter by column', () => { + expectSearch('index:5', [{ a: 5, b: 50, c: true }]); + expectSearch('times:50', [{ a: 5, b: 50, c: true }]); + expectSearch('times:50 index:5', [{ a: 5, b: 50, c: true }]); + expectSearch('Odd?:true', [ + { a: 1, b: 10, c: true }, + { a: 3, b: 30, c: true }, + { a: 5, b: 50, c: true }, + { a: 7, b: 70, c: true }, + { a: 9, b: 90, c: true } + ]); + component.data = createFakeData(100); + expectSearch('index:1 odd:true times:110', [{ a: 11, b: 110, c: true }]); + }); + + it('should search through arrays', () => { + component.localColumns = [ + { prop: 'a', name: 'Index' }, + { prop: 'b', name: 'ArrayColumn' } + ]; + + component.data = [ + { a: 1, b: ['foo', 'bar'] }, + { a: 2, b: ['baz', 'bazinga'] } + ]; + expectSearch('bar', [{ a: 1, b: ['foo', 'bar'] }]); + expectSearch('arraycolumn:bar arraycolumn:foo', [{ a: 1, b: ['foo', 'bar'] }]); + expectSearch('arraycolumn:baz arraycolumn:inga', [{ a: 2, b: ['baz', 'bazinga'] }]); + + component.data = [ + { a: 1, b: [1, 2] }, + { a: 2, b: [3, 4] } + ]; + expectSearch('arraycolumn:1 arraycolumn:2', [{ a: 1, b: [1, 2] }]); + }); + + it('should search with spaces', () => { + const expectedResult = [{ a: 2, b: 20, c: false }]; + expectSearch(`'Index times ten':20`, expectedResult); + expectSearch('index+times+ten:20', expectedResult); + }); + + it('should filter results although column name is incomplete', () => { + component.data = createFakeData(3); + expectSearch(`'Index times ten'`, []); + expectSearch(`'Ind'`, []); + expectSearch(`'Ind:'`, [ + { a: 0, b: 0, c: false }, + { a: 1, b: 10, c: true }, + { a: 2, b: 20, c: false } + ]); + }); + + it('should search if column name is incomplete', () => { + const expectedData = [ + { a: 0, b: 0, c: false }, + { a: 1, b: 10, c: true }, + { a: 2, b: 20, c: false } + ]; + component.data = _.clone(expectedData); + expectSearch('inde', []); + expectSearch('index:', expectedData); + expectSearch('index times te', []); + }); + + it('should restore full table after search', () => { + component.useData(); + expect(component.rows.length).toBe(10); + component.search = '3'; + component.updateFilter(); + expect(component.rows.length).toBe(1); + component.onClearSearch(); + expect(component.rows.length).toBe(10); + }); + + it('should work with undefined data', () => { + component.data = undefined; + component.search = '3'; + component.updateFilter(); + expect(component.rows).toBeUndefined(); + }); + }); + + describe('after ngInit', () => { + const toggleColumn = (prop: string, checked: boolean) => { + component.toggleColumn({ + prop: prop, + isHidden: checked + }); + }; + + const equalStorageConfig = () => { + expect(JSON.stringify(component.userConfig)).toBe( + component.localStorage.getItem(component.tableName) + ); + }; + + beforeEach(() => { + component.ngOnInit(); + }); + + it('should have updated the column definitions', () => { + expect(component.localColumns[0].flexGrow).toBe(1); + expect(component.localColumns[1].flexGrow).toBe(2); + expect(component.localColumns[2].flexGrow).toBe(2); + expect(component.localColumns[2].resizeable).toBe(false); + }); + + it('should have table columns', () => { + expect(component.tableColumns.length).toBe(3); + expect(component.tableColumns).toEqual(component.localColumns); + }); + + it('should have a unique identifier which it searches for', () => { + expect(component.identifier).toBe('a'); + expect(component.userConfig.sorts[0].prop).toBe('a'); + expect(component.userConfig.sorts).toEqual(component.createSortingDefinition('a')); + equalStorageConfig(); + }); + + it('should remove column "a"', () => { + expect(component.userConfig.sorts[0].prop).toBe('a'); + toggleColumn('a', false); + expect(component.userConfig.sorts[0].prop).toBe('b'); + expect(component.tableColumns.length).toBe(2); + equalStorageConfig(); + }); + + it('should not be able to remove all columns', () => { + expect(component.userConfig.sorts[0].prop).toBe('a'); + toggleColumn('a', false); + toggleColumn('b', false); + toggleColumn('c', false); + expect(component.userConfig.sorts[0].prop).toBe('c'); + expect(component.tableColumns.length).toBe(1); + equalStorageConfig(); + }); + + it('should enable column "a" again', () => { + expect(component.userConfig.sorts[0].prop).toBe('a'); + toggleColumn('a', false); + toggleColumn('a', true); + expect(component.userConfig.sorts[0].prop).toBe('b'); + expect(component.tableColumns.length).toBe(3); + equalStorageConfig(); + }); + + it('should toggle on off columns', () => { + for (const column of component.columns) { + component.toggleColumn(column); + expect(column.isHidden).toBeTruthy(); + component.toggleColumn(column); + expect(column.isHidden).toBeFalsy(); + } + }); + + afterEach(() => { + clearLocalStorage(); + }); + }); + + describe('test cell transformations', () => { + interface ExecutingTemplateConfig { + valueClass?: string; + executingClass?: string; + } + + const testExecutingTemplate = (templateConfig?: ExecutingTemplateConfig) => { + const state = 'updating'; + const value = component.data[0].a; + + component.autoReload = -1; + component.columns[0].cellTransformation = CellTemplate.executing; + if (templateConfig) { + component.columns[0].customTemplateConfig = templateConfig; + } + component.data[0].cdExecuting = state; + fixture.detectChanges(); + + const elements = fixture.debugElement + .query(By.css('datatable-body-row datatable-body-cell')) + .queryAll(By.css('span')); + expect(elements.length).toBe(2); + + // Value + const valueElement = elements[0]; + if (templateConfig?.valueClass) { + templateConfig.valueClass.split(' ').forEach((clz) => { + expect(valueElement.classes).toHaveProperty(clz); + }); + } + expect(valueElement.nativeElement.textContent.trim()).toBe(`${value}`); + // Executing state + const executingElement = elements[1]; + if (templateConfig?.executingClass) { + templateConfig.executingClass.split(' ').forEach((clz) => { + expect(executingElement.classes).toHaveProperty(clz); + }); + } + expect(executingElement.nativeElement.textContent.trim()).toBe(`(${state})`); + }; + + it('should display executing template', () => { + testExecutingTemplate(); + }); + + it('should display executing template with custom classes', () => { + testExecutingTemplate({ valueClass: 'a b', executingClass: 'c d' }); + }); + }); + + describe('test unselect functionality of rows', () => { + beforeEach(() => { + component.autoReload = -1; + component.selectionType = 'single'; + fixture.detectChanges(); + }); + + it('should unselect row on clicking on it again', () => { + const rowCellDebugElement = fixture.debugElement.query(By.css('datatable-body-cell')); + + rowCellDebugElement.triggerEventHandler('click', null); + expect(component.selection.selected.length).toEqual(1); + + rowCellDebugElement.triggerEventHandler('click', null); + expect(component.selection.selected.length).toEqual(0); + }); + }); + + describe('reload data', () => { + beforeEach(() => { + component.ngOnInit(); + component.data = []; + component['updating'] = false; + }); + + it('should call fetchData callback function', () => { + component.fetchData.subscribe((context: any) => { + expect(context instanceof CdTableFetchDataContext).toBeTruthy(); + }); + component.reloadData(); + }); + + it('should call error function', () => { + component.data = createFakeData(5); + component.fetchData.subscribe((context: any) => { + context.error(); + expect(component.status.type).toBe('danger'); + expect(component.data.length).toBe(0); + expect(component.loadingIndicator).toBeFalsy(); + expect(component['updating']).toBeFalsy(); + }); + component.reloadData(); + }); + + it('should call error function with custom config', () => { + component.data = createFakeData(10); + component.fetchData.subscribe((context: any) => { + context.errorConfig.resetData = false; + context.errorConfig.displayError = false; + context.error(); + expect(component.status.type).toBe('danger'); + expect(component.data.length).toBe(10); + expect(component.loadingIndicator).toBeFalsy(); + expect(component['updating']).toBeFalsy(); + }); + component.reloadData(); + }); + + it('should update selection on refresh - "onChange"', () => { + spyOn(component, 'onSelect').and.callThrough(); + component.data = createFakeData(10); + component.selection.selected = [_.clone(component.data[1])]; + component.updateSelectionOnRefresh = 'onChange'; + component.updateSelected(); + expect(component.onSelect).toHaveBeenCalledTimes(0); + component.data[1].d = !component.data[1].d; + component.updateSelected(); + expect(component.onSelect).toHaveBeenCalled(); + }); + + it('should update selection on refresh - "always"', () => { + spyOn(component, 'onSelect').and.callThrough(); + component.data = createFakeData(10); + component.selection.selected = [_.clone(component.data[1])]; + component.updateSelectionOnRefresh = 'always'; + component.updateSelected(); + expect(component.onSelect).toHaveBeenCalled(); + component.data[1].d = !component.data[1].d; + component.updateSelected(); + expect(component.onSelect).toHaveBeenCalled(); + }); + + it('should update selection on refresh - "never"', () => { + spyOn(component, 'onSelect').and.callThrough(); + component.data = createFakeData(10); + component.selection.selected = [_.clone(component.data[1])]; + component.updateSelectionOnRefresh = 'never'; + component.updateSelected(); + expect(component.onSelect).toHaveBeenCalledTimes(0); + component.data[1].d = !component.data[1].d; + component.updateSelected(); + expect(component.onSelect).toHaveBeenCalledTimes(0); + }); + + afterEach(() => { + clearLocalStorage(); + }); + }); + + describe('useCustomClass', () => { + beforeEach(() => { + component.customCss = { + 'badge badge-danger': 'active', + 'secret secret-number': 123.456, + btn: (v) => _.isString(v) && v.startsWith('http'), + secure: (v) => _.isString(v) && v.startsWith('https') + }; + }); + + it('should throw an error if custom classes are not set', () => { + component.customCss = undefined; + expect(() => component.useCustomClass('active')).toThrowError('Custom classes are not set!'); + }); + + it('should not return any class', () => { + ['', 'something', 123, { complex: 1 }, [1, 2, 3]].forEach((value) => + expect(component.useCustomClass(value)).toBe(undefined) + ); + }); + + it('should match a string and return the corresponding class', () => { + expect(component.useCustomClass('active')).toBe('badge badge-danger'); + }); + + it('should match a number and return the corresponding class', () => { + expect(component.useCustomClass(123.456)).toBe('secret secret-number'); + }); + + it('should match against a function and return the corresponding class', () => { + expect(component.useCustomClass('http://no.ssl')).toBe('btn'); + }); + + it('should match against multiple functions and return the corresponding classes', () => { + expect(component.useCustomClass('https://secure.it')).toBe('btn secure'); + }); + }); + + describe('test expand and collapse feature', () => { + beforeEach(() => { + spyOn(component.setExpandedRow, 'emit'); + component.table = { + rowDetail: { collapseAllRows: jest.fn(), toggleExpandRow: jest.fn() } + } as any; + + // Setup table + component.identifier = 'a'; + component.data = createFakeData(10); + + // Select item + component.expanded = _.clone(component.data[1]); + }); + + describe('update expanded on refresh', () => { + const updateExpendedOnState = (state: 'always' | 'never' | 'onChange') => { + component.updateExpandedOnRefresh = state; + component.updateExpanded(); + }; + + beforeEach(() => { + // Mock change + component.data[1].b = 'test'; + }); + + it('refreshes "always"', () => { + updateExpendedOnState('always'); + expect(component.expanded.b).toBe('test'); + expect(component.setExpandedRow.emit).toHaveBeenCalled(); + }); + + it('refreshes "onChange"', () => { + updateExpendedOnState('onChange'); + expect(component.expanded.b).toBe('test'); + expect(component.setExpandedRow.emit).toHaveBeenCalled(); + }); + + it('does not refresh "onChange" if data is equal', () => { + component.data[1].b = 10; // Reverts change + updateExpendedOnState('onChange'); + expect(component.expanded.b).toBe(10); + expect(component.setExpandedRow.emit).not.toHaveBeenCalled(); + }); + + it('"never" refreshes', () => { + updateExpendedOnState('never'); + expect(component.expanded.b).toBe(10); + expect(component.setExpandedRow.emit).not.toHaveBeenCalled(); + }); + }); + + it('should open the table details and close other expanded rows', () => { + component.toggleExpandRow(component.expanded, false, new Event('click')); + expect(component.expanded).toEqual({ a: 1, b: 10, c: true }); + expect(component.table.rowDetail.collapseAllRows).toHaveBeenCalled(); + expect(component.setExpandedRow.emit).toHaveBeenCalledWith(component.expanded); + expect(component.table.rowDetail.toggleExpandRow).toHaveBeenCalled(); + }); + + it('should close the current table details expansion', () => { + component.toggleExpandRow(component.expanded, true, new Event('click')); + expect(component.expanded).toBeUndefined(); + expect(component.setExpandedRow.emit).toHaveBeenCalledWith(undefined); + expect(component.table.rowDetail.toggleExpandRow).toHaveBeenCalled(); + }); + + it('should not select the row when the row is expanded', () => { + expect(component.selection.selected).toEqual([]); + component.toggleExpandRow(component.data[1], false, new Event('click')); + expect(component.selection.selected).toEqual([]); + }); + + it('should not change selection when expanding different row', () => { + expect(component.selection.selected).toEqual([]); + expect(component.expanded).toEqual(component.data[1]); + component.selection.selected = [component.data[2]]; + component.toggleExpandRow(component.data[3], false, new Event('click')); + expect(component.selection.selected).toEqual([component.data[2]]); + expect(component.expanded).toEqual(component.data[3]); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts new file mode 100644 index 000000000..6a37468c2 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts @@ -0,0 +1,927 @@ +import { + AfterContentChecked, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + EventEmitter, + Input, + OnChanges, + OnDestroy, + OnInit, + Output, + PipeTransform, + SimpleChanges, + TemplateRef, + ViewChild +} from '@angular/core'; + +import { + DatatableComponent, + getterForProp, + SortDirection, + SortPropDir, + TableColumnProp +} from '@swimlane/ngx-datatable'; +import _ from 'lodash'; +import { Observable, of, Subject, Subscription } from 'rxjs'; + +import { TableStatus } from '~/app/shared/classes/table-status'; +import { CellTemplate } from '~/app/shared/enum/cell-template.enum'; +import { Icons } from '~/app/shared/enum/icons.enum'; +import { CdTableColumn } from '~/app/shared/models/cd-table-column'; +import { CdTableColumnFilter } from '~/app/shared/models/cd-table-column-filter'; +import { CdTableColumnFiltersChange } from '~/app/shared/models/cd-table-column-filters-change'; +import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context'; +import { PageInfo } from '~/app/shared/models/cd-table-paging'; +import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; +import { CdUserConfig } from '~/app/shared/models/cd-user-config'; +import { TimerService } from '~/app/shared/services/timer.service'; + +@Component({ + selector: 'cd-table', + templateUrl: './table.component.html', + styleUrls: ['./table.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class TableComponent implements AfterContentChecked, OnInit, OnChanges, OnDestroy { + @ViewChild(DatatableComponent, { static: true }) + table: DatatableComponent; + @ViewChild('tableCellBoldTpl', { static: true }) + tableCellBoldTpl: TemplateRef<any>; + @ViewChild('sparklineTpl', { static: true }) + sparklineTpl: TemplateRef<any>; + @ViewChild('routerLinkTpl', { static: true }) + routerLinkTpl: TemplateRef<any>; + @ViewChild('checkIconTpl', { static: true }) + checkIconTpl: TemplateRef<any>; + @ViewChild('perSecondTpl', { static: true }) + perSecondTpl: TemplateRef<any>; + @ViewChild('executingTpl', { static: true }) + executingTpl: TemplateRef<any>; + @ViewChild('classAddingTpl', { static: true }) + classAddingTpl: TemplateRef<any>; + @ViewChild('badgeTpl', { static: true }) + badgeTpl: TemplateRef<any>; + @ViewChild('mapTpl', { static: true }) + mapTpl: TemplateRef<any>; + @ViewChild('truncateTpl', { static: true }) + truncateTpl: TemplateRef<any>; + @ViewChild('rowDetailsTpl', { static: true }) + rowDetailsTpl: TemplateRef<any>; + + // This is the array with the items to be shown. + @Input() + data: any[]; + // Each item -> { prop: 'attribute name', name: 'display name' } + @Input() + columns: CdTableColumn[]; + // Each item -> { prop: 'attribute name', dir: 'asc'||'desc'} + @Input() + sorts?: SortPropDir[]; + // Method used for setting column widths. + @Input() + columnMode? = 'flex'; + // Display only actions in header (make sure to disable toolHeader) and use ".only-table-actions" + @Input() + onlyActionHeader? = false; + // Display the tool header, including reload button, pagination and search fields? + @Input() + toolHeader? = true; + // Display search field inside tool header? + @Input() + searchField? = true; + // Display the table header? + @Input() + header? = true; + // Display the table footer? + @Input() + footer? = true; + // Page size to show. Set to 0 to show unlimited number of rows. + @Input() + limit? = 10; + @Input() + maxLimit? = 9999; + // Has the row details? + @Input() + hasDetails = false; + + /** + * Auto reload time in ms - per default every 5s + * You can set it to 0, undefined or false to disable the auto reload feature in order to + * trigger 'fetchData' if the reload button is clicked. + * You can set it to a negative number to, on top of disabling the auto reload, + * prevent triggering fetchData when initializing the table. + */ + @Input() + autoReload = 5000; + + // Which row property is unique for a row. If the identifier is not specified in any + // column, then the property name of the first column is used. Defaults to 'id'. + @Input() + identifier = 'id'; + // If 'true', then the specified identifier is used anyway, although it is not specified + // in any column. Defaults to 'false'. + @Input() + forceIdentifier = false; + // Allows other components to specify which type of selection they want, + // e.g. 'single' or 'multi'. + @Input() + selectionType: string = undefined; + // By default selected item details will be updated on table refresh, if data has changed + @Input() + updateSelectionOnRefresh: 'always' | 'never' | 'onChange' = 'onChange'; + // By default expanded item details will be updated on table refresh, if data has changed + @Input() + updateExpandedOnRefresh: 'always' | 'never' | 'onChange' = 'onChange'; + + @Input() + autoSave = true; + + // Enable this in order to search through the JSON of any used object. + @Input() + searchableObjects = false; + + // Only needed to set if the classAddingTpl is used + @Input() + customCss?: { [css: string]: number | string | ((any: any) => boolean) }; + + // Columns that aren't displayed but can be used as filters + @Input() + extraFilterableColumns: CdTableColumn[] = []; + + @Input() + status = new TableStatus(); + + // Support server-side pagination/sorting/etc. + @Input() + serverSide = false; + + /* + Only required when serverSide is enabled. + It should be provided by the server via "X-Total-Count" HTTP Header + */ + @Input() + count = 0; + + /** + * Should be a function to update the input data if undefined nothing will be triggered + * + * Sometimes it's useful to only define fetchData once. + * Example: + * Usage of multiple tables with data which is updated by the same function + * What happens: + * The function is triggered through one table and all tables will update + */ + @Output() + fetchData = new EventEmitter<CdTableFetchDataContext>(); + + /** + * This should be defined if you need access to the selection object. + * + * Each time the table selection changes, this will be triggered and + * the new selection object will be sent. + * + * @memberof TableComponent + */ + @Output() + updateSelection = new EventEmitter(); + + @Output() + setExpandedRow = new EventEmitter(); + + /** + * This should be defined if you need access to the applied column filters. + * + * Each time the column filters changes, this will be triggered and + * the column filters change event will be sent. + * + * @memberof TableComponent + */ + @Output() columnFiltersChanged = new EventEmitter<CdTableColumnFiltersChange>(); + + /** + * Use this variable to access the selected row(s). + */ + selection = new CdTableSelection(); + + /** + * Use this variable to access the expanded row + */ + expanded: any = undefined; + + /** + * To prevent making changes to the original columns list, that might change + * how the table is renderer a second time, we now clone that list into a + * local variable and only use the clone. + */ + localColumns: CdTableColumn[]; + tableColumns: CdTableColumn[]; + icons = Icons; + cellTemplates: { + [key: string]: TemplateRef<any>; + } = {}; + search = ''; + rows: any[] = []; + loadingIndicator = true; + paginationClasses = { + pagerLeftArrow: Icons.leftArrowDouble, + pagerRightArrow: Icons.rightArrowDouble, + pagerPrevious: Icons.leftArrow, + pagerNext: Icons.rightArrow + }; + userConfig: CdUserConfig = {}; + tableName: string; + localStorage = window.localStorage; + private saveSubscriber: Subscription; + private reloadSubscriber: Subscription; + private updating = false; + + // Internal variable to check if it is necessary to recalculate the + // table columns after the browser window has been resized. + private currentWidth: number; + + columnFilters: CdTableColumnFilter[] = []; + selectedFilter: CdTableColumnFilter; + get columnFiltered(): boolean { + return _.some(this.columnFilters, (filter) => { + return filter.value !== undefined; + }); + } + + constructor( + // private ngZone: NgZone, + private cdRef: ChangeDetectorRef, + private timerService: TimerService + ) {} + + static prepareSearch(search: string) { + search = search.toLowerCase().replace(/,/g, ''); + if (search.match(/['"][^'"]+['"]/)) { + search = search.replace(/['"][^'"]+['"]/g, (match: string) => { + return match.replace(/(['"])([^'"]+)(['"])/g, '$2').replace(/ /g, '+'); + }); + } + return search.split(' ').filter((word) => word); + } + + ngOnInit() { + this.localColumns = _.clone(this.columns); + // debounce reloadData method so that search doesn't run api requests + // for every keystroke + if (this.serverSide) { + this.reloadData = _.debounce(this.reloadData, 1000); + } + + // ngx-datatable triggers calculations each time mouse enters a row, + // this will prevent that. + this.table.element.addEventListener('mouseenter', (e) => e.stopPropagation()); + this._addTemplates(); + if (!this.sorts) { + // Check whether the specified identifier exists. + const exists = _.findIndex(this.localColumns, ['prop', this.identifier]) !== -1; + // Auto-build the sorting configuration. If the specified identifier doesn't exist, + // then use the property of the first column. + this.sorts = this.createSortingDefinition( + exists ? this.identifier : this.localColumns[0].prop + '' + ); + // If the specified identifier doesn't exist and it is not forced to use it anyway, + // then use the property of the first column. + if (!exists && !this.forceIdentifier) { + this.identifier = this.localColumns[0].prop + ''; + } + } + + this.initUserConfig(); + this.localColumns.forEach((c) => { + if (c.cellTransformation) { + c.cellTemplate = this.cellTemplates[c.cellTransformation]; + } + if (!c.flexGrow) { + c.flexGrow = c.prop + '' === this.identifier ? 1 : 2; + } + if (!c.resizeable) { + c.resizeable = false; + } + }); + + this.initExpandCollapseColumn(); // If rows have details, add a column to expand or collapse the rows + this.initCheckboxColumn(); + this.filterHiddenColumns(); + this.initColumnFilters(); + this.updateColumnFilterOptions(); + // Notify all subscribers to reset their current selection. + this.updateSelection.emit(new CdTableSelection()); + // Load the data table content every N ms or at least once. + // Force showing the loading indicator if there are subscribers to the fetchData + // event. This is necessary because it has been set to False in useData() when + // this method was triggered by ngOnChanges(). + if (this.fetchData.observers.length > 0) { + this.loadingIndicator = true; + } + if (_.isInteger(this.autoReload) && this.autoReload > 0) { + this.reloadSubscriber = this.timerService + .get(() => of(0), this.autoReload) + .subscribe(() => { + this.reloadData(); + }); + } else if (!this.autoReload) { + this.reloadData(); + } else { + this.useData(); + } + + if (this.selectionType === 'single') { + this.table.selectCheck = this.singleSelectCheck.bind(this); + } + } + + initUserConfig() { + if (this.autoSave) { + this.tableName = this._calculateUniqueTableName(this.localColumns); + this._loadUserConfig(); + this._initUserConfigAutoSave(); + } + if (!this.userConfig.limit) { + this.userConfig.limit = this.limit; + } + if (!(this.userConfig.offset >= 0)) { + this.userConfig.offset = this.table.offset; + } + if (!this.userConfig.search) { + this.userConfig.search = this.search; + } + if (!this.userConfig.sorts) { + this.userConfig.sorts = this.sorts; + } + if (!this.userConfig.columns) { + this.updateUserColumns(); + } else { + this.userConfig.columns.forEach((col) => { + for (let i = 0; i < this.localColumns.length; i++) { + if (this.localColumns[i].prop === col.prop) { + this.localColumns[i].isHidden = col.isHidden; + } + } + }); + } + } + + _calculateUniqueTableName(columns: any[]) { + const stringToNumber = (s: string) => { + if (!_.isString(s)) { + return 0; + } + let result = 0; + for (let i = 0; i < s.length; i++) { + result += s.charCodeAt(i) * i; + } + return result; + }; + return columns + .reduce( + (result, value, index) => + (stringToNumber(value.prop) + stringToNumber(value.name)) * (index + 1) + result, + 0 + ) + .toString(); + } + + _loadUserConfig() { + const loaded = this.localStorage.getItem(this.tableName); + if (loaded) { + this.userConfig = JSON.parse(loaded); + } + } + + _initUserConfigAutoSave() { + const source: Observable<any> = new Observable(this._initUserConfigProxy.bind(this)); + this.saveSubscriber = source.subscribe(this._saveUserConfig.bind(this)); + } + + _initUserConfigProxy(observer: Subject<any>) { + this.userConfig = new Proxy(this.userConfig, { + set(config, prop: string, value) { + config[prop] = value; + observer.next(config); + return true; + } + }); + } + + _saveUserConfig(config: any) { + this.localStorage.setItem(this.tableName, JSON.stringify(config)); + } + + updateUserColumns() { + this.userConfig.columns = this.localColumns.map((c) => ({ + prop: c.prop, + name: c.name, + isHidden: !!c.isHidden + })); + } + + /** + * Add a column containing a checkbox if selectionType is 'multiClick'. + */ + initCheckboxColumn() { + if (this.selectionType === 'multiClick') { + this.localColumns.unshift({ + prop: undefined, + resizeable: false, + sortable: false, + draggable: false, + checkboxable: true, + canAutoResize: false, + cellClass: 'cd-datatable-checkbox', + width: 30 + }); + } + } + + /** + * Add a column to expand and collapse the table row if it 'hasDetails' + */ + initExpandCollapseColumn() { + if (this.hasDetails) { + this.localColumns.unshift({ + prop: undefined, + resizeable: false, + sortable: false, + draggable: false, + isHidden: false, + canAutoResize: false, + cellClass: 'cd-datatable-expand-collapse', + width: 40, + cellTemplate: this.rowDetailsTpl + }); + } + } + + filterHiddenColumns() { + this.tableColumns = this.localColumns.filter((c) => !c.isHidden); + } + + initColumnFilters() { + let filterableColumns = _.filter(this.localColumns, { filterable: true }); + filterableColumns = [...filterableColumns, ...this.extraFilterableColumns]; + this.columnFilters = filterableColumns.map((col: CdTableColumn) => { + return { + column: col, + options: [], + value: col.filterInitValue + ? this.createColumnFilterOption(col.filterInitValue, col.pipe) + : undefined + }; + }); + this.selectedFilter = _.first(this.columnFilters); + } + + private createColumnFilterOption( + value: any, + pipe?: PipeTransform + ): { raw: string; formatted: string } { + return { + raw: _.toString(value), + formatted: pipe ? pipe.transform(value) : _.toString(value) + }; + } + + updateColumnFilterOptions() { + // update all possible values in a column + this.columnFilters.forEach((filter) => { + let values: any[] = []; + + if (_.isUndefined(filter.column.filterOptions)) { + // only allow types that can be easily converted into string + const pre = _.filter(_.map(this.data, filter.column.prop), (v) => { + return (_.isString(v) && v !== '') || _.isBoolean(v) || _.isFinite(v) || _.isDate(v); + }); + values = _.sortedUniq(pre.sort()); + } else { + values = filter.column.filterOptions; + } + + const options = values.map((v) => this.createColumnFilterOption(v, filter.column.pipe)); + + // In case a previous value is not available anymore + if (filter.value && _.isUndefined(_.find(options, { raw: filter.value.raw }))) { + filter.value = undefined; + } + + filter.options = options; + }); + } + + onSelectFilter(filter: CdTableColumnFilter) { + this.selectedFilter = filter; + } + + onChangeFilter(filter: CdTableColumnFilter, option?: { raw: string; formatted: string }) { + filter.value = _.isEqual(filter.value, option) ? undefined : option; + this.updateFilter(); + } + + doColumnFiltering() { + const appliedFilters: CdTableColumnFiltersChange['filters'] = []; + let data = [...this.data]; + let dataOut: any[] = []; + this.columnFilters.forEach((filter) => { + if (filter.value === undefined) { + return; + } + appliedFilters.push({ + name: filter.column.name, + prop: filter.column.prop, + value: filter.value + }); + // Separate data to filtered and filtered-out parts. + const parts = _.partition(data, (row) => { + // Use getter from ngx-datatable to handle props like 'sys_api.size' + const valueGetter = getterForProp(filter.column.prop); + const value = valueGetter(row, filter.column.prop); + if (_.isUndefined(filter.column.filterPredicate)) { + // By default, test string equal + return `${value}` === filter.value.raw; + } else { + // Use custom function to filter + return filter.column.filterPredicate(row, filter.value.raw); + } + }); + data = parts[0]; + dataOut = [...dataOut, ...parts[1]]; + }); + + this.columnFiltersChanged.emit({ + filters: appliedFilters, + data: data, + dataOut: dataOut + }); + + // Remove the selection if previously-selected rows are filtered out. + _.forEach(this.selection.selected, (selectedItem) => { + if (_.find(data, { [this.identifier]: selectedItem[this.identifier] }) === undefined) { + this.selection = new CdTableSelection(); + this.onSelect(this.selection); + } + }); + return data; + } + + ngOnDestroy() { + if (this.reloadSubscriber) { + this.reloadSubscriber.unsubscribe(); + } + if (this.saveSubscriber) { + this.saveSubscriber.unsubscribe(); + } + } + + ngAfterContentChecked() { + // If the data table is not visible, e.g. another tab is active, and the + // browser window gets resized, the table and its columns won't get resized + // automatically if the tab gets visible again. + // https://github.com/swimlane/ngx-datatable/issues/193 + // https://github.com/swimlane/ngx-datatable/issues/193#issuecomment-329144543 + if (this.table && this.table.element.clientWidth !== this.currentWidth) { + this.currentWidth = this.table.element.clientWidth; + // Recalculate the sizes of the grid. + this.table.recalculate(); + // Mark the datatable as changed, Angular's change-detection will + // do the rest for us => the grid will be redrawn. + // Note, the ChangeDetectorRef variable is private, so we need to + // use this workaround to access it and make TypeScript happy. + const cdRef = _.get(this.table, 'cd'); + cdRef.markForCheck(); + } + } + + _addTemplates() { + this.cellTemplates.bold = this.tableCellBoldTpl; + this.cellTemplates.checkIcon = this.checkIconTpl; + this.cellTemplates.sparkline = this.sparklineTpl; + this.cellTemplates.routerLink = this.routerLinkTpl; + this.cellTemplates.perSecond = this.perSecondTpl; + this.cellTemplates.executing = this.executingTpl; + this.cellTemplates.classAdding = this.classAddingTpl; + this.cellTemplates.badge = this.badgeTpl; + this.cellTemplates.map = this.mapTpl; + this.cellTemplates.truncate = this.truncateTpl; + } + + useCustomClass(value: any): string { + if (!this.customCss) { + throw new Error('Custom classes are not set!'); + } + const classes = Object.keys(this.customCss); + const css = Object.values(this.customCss) + .map((v, i) => ((_.isFunction(v) && v(value)) || v === value) && classes[i]) + .filter((x) => x) + .join(' '); + return _.isEmpty(css) ? undefined : css; + } + + ngOnChanges(changes: SimpleChanges) { + if (changes.data && changes.data.currentValue) { + this.useData(); + } + } + + setLimit(e: any) { + const value = Number(e.target.value); + if (value > 0) { + if (this.maxLimit && value > this.maxLimit) { + this.userConfig.limit = this.maxLimit; + // change input field to maxLimit + e.srcElement.value = this.maxLimit; + } else { + this.userConfig.limit = value; + } + } + if (this.serverSide) { + this.reloadData(); + } + } + + reloadData() { + if (!this.updating) { + this.status = new TableStatus(); + const context = new CdTableFetchDataContext(() => { + // Do we have to display the error panel? + if (!!context.errorConfig.displayError) { + this.status = new TableStatus('danger', $localize`Failed to load data.`); + } + // Force data table to show no data? + if (context.errorConfig.resetData) { + this.data = []; + } + // Stop the loading indicator and reset the data table + // to the correct state. + this.useData(); + }); + context.pageInfo.offset = this.userConfig.offset; + context.pageInfo.limit = this.userConfig.limit; + context.search = this.userConfig.search; + if (this.userConfig.sorts?.length) { + const sort = this.userConfig.sorts[0]; + context.sort = `${sort.dir === 'desc' ? '-' : '+'}${sort.prop}`; + } + this.fetchData.emit(context); + this.updating = true; + } + } + + refreshBtn() { + this.loadingIndicator = true; + this.reloadData(); + } + + changePage(pageInfo: PageInfo) { + this.userConfig.offset = pageInfo.offset; + this.userConfig.limit = pageInfo.limit; + if (this.serverSide) { + this.reloadData(); + } + } + rowIdentity() { + return (row: any) => { + const id = row[this.identifier]; + if (_.isUndefined(id)) { + throw new Error(`Wrong identifier "${this.identifier}" -> "${id}"`); + } + return id; + }; + } + + useData() { + if (!this.data) { + return; // Wait for data + } + this.updateColumnFilterOptions(); + this.updateFilter(); + this.reset(); + this.updateSelected(); + this.updateExpanded(); + } + + /** + * Reset the data table to correct state. This includes: + * - Disable loading indicator + * - Reset 'Updating' flag + */ + reset() { + this.loadingIndicator = false; + this.updating = false; + } + + /** + * After updating the data, we have to update the selected items + * because details may have changed, + * or some selected items may have been removed. + */ + updateSelected() { + if (this.updateSelectionOnRefresh === 'never') { + return; + } + const newSelected = new Set(); + this.selection.selected.forEach((selectedItem) => { + for (const row of this.data) { + if (selectedItem[this.identifier] === row[this.identifier]) { + newSelected.add(row); + } + } + }); + const newSelectedArray = Array.from(newSelected.values()); + if ( + this.updateSelectionOnRefresh === 'onChange' && + _.isEqual(this.selection.selected, newSelectedArray) + ) { + return; + } + this.selection.selected = newSelectedArray; + this.onSelect(this.selection); + } + + updateExpanded() { + if (_.isUndefined(this.expanded) || this.updateExpandedOnRefresh === 'never') { + return; + } + + const expandedId = this.expanded[this.identifier]; + const newExpanded = _.find(this.data, (row) => expandedId === row[this.identifier]); + + if (this.updateExpandedOnRefresh === 'onChange' && _.isEqual(this.expanded, newExpanded)) { + return; + } + + this.expanded = newExpanded; + this.setExpandedRow.emit(newExpanded); + } + + onSelect($event: any) { + // Ensure we do not process DOM 'select' events. + // https://github.com/swimlane/ngx-datatable/issues/899 + if (_.has($event, 'selected')) { + this.selection.selected = $event['selected']; + } + this.updateSelection.emit(_.clone(this.selection)); + } + + private singleSelectCheck(row: any) { + return this.selection.selected.indexOf(row) === -1; + } + + toggleColumn(column: CdTableColumn) { + const prop: TableColumnProp = column.prop; + const hide = !column.isHidden; + if (hide && this.tableColumns.length === 1) { + column.isHidden = true; + return; + } + _.find(this.localColumns, (c: CdTableColumn) => c.prop === prop).isHidden = hide; + this.updateColumns(); + } + + updateColumns() { + this.updateUserColumns(); + this.filterHiddenColumns(); + const sortProp = this.userConfig.sorts[0].prop; + if (!_.find(this.tableColumns, (c: CdTableColumn) => c.prop === sortProp)) { + this.userConfig.sorts = this.createSortingDefinition(this.tableColumns[0].prop); + } + this.table.recalculate(); + this.cdRef.detectChanges(); + } + + createSortingDefinition(prop: TableColumnProp): SortPropDir[] { + return [ + { + prop: prop, + dir: SortDirection.asc + } + ]; + } + + changeSorting({ sorts }: any) { + this.userConfig.sorts = sorts; + if (this.serverSide) { + this.userConfig.offset = 0; + this.reloadData(); + } + } + + onClearSearch() { + this.search = ''; + this.updateFilter(); + } + + onClearFilters() { + this.columnFilters.forEach((filter) => { + filter.value = undefined; + }); + this.selectedFilter = _.first(this.columnFilters); + this.updateFilter(); + } + + updateFilter() { + if (this.serverSide) { + if (this.userConfig.search !== this.search) { + // if we don't go back to the first page it will try load + // a page which could not exists with an especific search + this.userConfig.offset = 0; + this.userConfig.limit = this.limit; + this.userConfig.search = this.search; + this.updating = false; + this.reloadData(); + } + this.rows = this.data; + } else { + let rows = this.columnFilters.length !== 0 ? this.doColumnFiltering() : this.data; + + if (this.search.length > 0 && rows) { + const columns = this.localColumns.filter( + (c) => c.cellTransformation !== CellTemplate.sparkline + ); + // update the rows + rows = this.subSearch(rows, TableComponent.prepareSearch(this.search), columns); + // Whenever the filter changes, always go back to the first page + this.table.offset = 0; + } + + this.rows = rows; + } + } + + subSearch(data: any[], currentSearch: string[], columns: CdTableColumn[]): any[] { + if (currentSearch.length === 0 || data.length === 0) { + return data; + } + const searchTerms: string[] = currentSearch.pop().replace(/\+/g, ' ').split(':'); + const columnsClone = [...columns]; + if (searchTerms.length === 2) { + columns = columnsClone.filter((c) => c.name.toLowerCase().indexOf(searchTerms[0]) !== -1); + } + data = this.basicDataSearch(_.last(searchTerms), data, columns); + // Checks if user searches for column but he is still typing + return this.subSearch(data, currentSearch, columnsClone); + } + + basicDataSearch(searchTerm: string, rows: any[], columns: CdTableColumn[]) { + if (searchTerm.length === 0) { + return rows; + } + return rows.filter((row) => { + return ( + columns.filter((col) => { + let cellValue: any = _.get(row, col.prop); + + if (!_.isUndefined(col.pipe)) { + cellValue = col.pipe.transform(cellValue); + } + if (_.isUndefined(cellValue) || _.isNull(cellValue)) { + return false; + } + + if (_.isArray(cellValue)) { + cellValue = cellValue.join(' '); + } else if (_.isNumber(cellValue) || _.isBoolean(cellValue)) { + cellValue = cellValue.toString(); + } + + if (_.isObjectLike(cellValue)) { + if (this.searchableObjects) { + cellValue = JSON.stringify(cellValue); + } else { + return false; + } + } + + return cellValue.toLowerCase().indexOf(searchTerm) !== -1; + }).length > 0 + ); + }); + } + + getRowClass() { + // Return the function used to populate a row's CSS classes. + return () => { + return { + clickable: !_.isUndefined(this.selectionType) + }; + }; + } + + toggleExpandRow(row: any, isExpanded: boolean, event: any) { + event.stopPropagation(); + if (!isExpanded) { + // If current row isn't expanded, collapse others + this.expanded = row; + this.table.rowDetail.collapseAllRows(); + this.setExpandedRow.emit(row); + } else { + // If all rows are closed, emit undefined + this.expanded = undefined; + this.setExpandedRow.emit(undefined); + } + this.table.rowDetail.toggleExpandRow(row); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/decorators/cd-encode.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/decorators/cd-encode.spec.ts new file mode 100644 index 000000000..49b504fd6 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/decorators/cd-encode.spec.ts @@ -0,0 +1,41 @@ +import { cdEncode, cdEncodeNot } from './cd-encode'; + +describe('cdEncode', () => { + @cdEncode + class ClassA { + x2: string; + y2: string; + + methodA(x1: string, @cdEncodeNot y1: string) { + this.x2 = x1; + this.y2 = y1; + } + } + + class ClassB { + x2: string; + y2: string; + + @cdEncode + methodB(x1: string, @cdEncodeNot y1: string) { + this.x2 = x1; + this.y2 = y1; + } + } + + const word = 'a+b/c-d'; + + it('should encode all params of ClassA, with exception of y1', () => { + const a = new ClassA(); + a.methodA(word, word); + expect(a.x2).toBe('a%2Bb%2Fc-d'); + expect(a.y2).toBe(word); + }); + + it('should encode all params of methodB, with exception of y1', () => { + const b = new ClassB(); + b.methodB(word, word); + expect(b.x2).toBe('a%2Bb%2Fc-d'); + expect(b.y2).toBe(word); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/decorators/cd-encode.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/decorators/cd-encode.ts new file mode 100644 index 000000000..afff2ec6d --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/decorators/cd-encode.ts @@ -0,0 +1,80 @@ +import _ from 'lodash'; + +/** + * This decorator can be used in a class or method. + * It will encode all the string parameters of all the methods of a class + * or, if applied on a method, the specified method. + * + * @export + * @param {Function} [target=null] + * @returns {*} + */ +export function cdEncode(...args: any[]): any { + switch (args.length) { + case 1: + return encodeClass.apply(undefined, args); + case 3: + return encodeMethod.apply(undefined, args); + default: + throw new Error(); + } +} + +/** + * This decorator can be used in parameters only. + * It will exclude the parameter from being encode. + * This should be used in parameters that are going + * to be sent in the request's body. + * + * @export + * @param {Object} target + * @param {string} propertyKey + * @param {number} index + */ +export function cdEncodeNot(target: object, propertyKey: string, index: number) { + const metadataKey = `__ignore_${propertyKey}`; + if (Array.isArray(target[metadataKey])) { + target[metadataKey].push(index); + } else { + target[metadataKey] = [index]; + } +} + +function encodeClass(target: Function) { + for (const propertyName of Object.getOwnPropertyNames(target.prototype)) { + const descriptor = Object.getOwnPropertyDescriptor(target.prototype, propertyName); + + const isMethod = descriptor.value instanceof Function; + const isConstructor = propertyName === 'constructor'; + if (!isMethod || isConstructor) { + continue; + } + + encodeMethod(target.prototype, propertyName, descriptor); + Object.defineProperty(target.prototype, propertyName, descriptor); + } +} + +function encodeMethod(target: any, propertyKey: string, descriptor: PropertyDescriptor) { + if (descriptor === undefined) { + descriptor = Object.getOwnPropertyDescriptor(target, propertyKey); + } + const originalMethod = descriptor.value; + + descriptor.value = function () { + const metadataKey = `__ignore_${propertyKey}`; + const indices: number[] = target[metadataKey] || []; + const args = []; + + for (let i = 0; i < arguments.length; i++) { + if (_.isString(arguments[i]) && indices.indexOf(i) === -1) { + args[i] = encodeURIComponent(arguments[i]); + } else { + args[i] = arguments[i]; + } + } + + const result = originalMethod.apply(this, args); + return result; + }; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/autofocus.directive.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/autofocus.directive.spec.ts new file mode 100644 index 000000000..9ef2078ec --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/autofocus.directive.spec.ts @@ -0,0 +1,90 @@ +import { Component } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { AutofocusDirective } from './autofocus.directive'; + +@Component({ + template: ` + <form> + <input id="x" type="text" /> + <input id="y" type="password" autofocus /> + </form> + ` +}) +export class PasswordFormComponent {} + +@Component({ + template: ` + <form> + <input id="x" type="checkbox" [autofocus]="edit" /> + <input id="y" type="text" /> + </form> + ` +}) +export class CheckboxFormComponent { + public edit = true; +} + +@Component({ + template: ` + <form> + <input id="x" type="text" [autofocus]="foo" /> + </form> + ` +}) +export class TextFormComponent { + foo() { + return false; + } +} + +describe('AutofocusDirective', () => { + configureTestBed({ + declarations: [ + AutofocusDirective, + CheckboxFormComponent, + PasswordFormComponent, + TextFormComponent + ] + }); + + it('should create an instance', () => { + const directive = new AutofocusDirective(null); + expect(directive).toBeTruthy(); + }); + + it('should focus the password form field', () => { + const fixture: ComponentFixture<PasswordFormComponent> = TestBed.createComponent( + PasswordFormComponent + ); + fixture.detectChanges(); + const focused = fixture.debugElement.query(By.css(':focus')); + expect(focused.attributes.id).toBe('y'); + expect(focused.attributes.type).toBe('password'); + const element = document.getElementById('y'); + expect(element === document.activeElement).toBeTruthy(); + }); + + it('should focus the checkbox form field', () => { + const fixture: ComponentFixture<CheckboxFormComponent> = TestBed.createComponent( + CheckboxFormComponent + ); + fixture.detectChanges(); + const focused = fixture.debugElement.query(By.css(':focus')); + expect(focused.attributes.id).toBe('x'); + expect(focused.attributes.type).toBe('checkbox'); + const element = document.getElementById('x'); + expect(element === document.activeElement).toBeTruthy(); + }); + + it('should not focus the text form field', () => { + const fixture: ComponentFixture<TextFormComponent> = TestBed.createComponent(TextFormComponent); + fixture.detectChanges(); + const focused = fixture.debugElement.query(By.css(':focus')); + expect(focused).toBeNull(); + const element = document.getElementById('x'); + expect(element !== document.activeElement).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/autofocus.directive.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/autofocus.directive.ts new file mode 100644 index 000000000..dc34b9f3c --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/autofocus.directive.ts @@ -0,0 +1,28 @@ +import { AfterViewInit, Directive, ElementRef, Input } from '@angular/core'; + +import _ from 'lodash'; + +@Directive({ + selector: '[autofocus]' // tslint:disable-line +}) +export class AutofocusDirective implements AfterViewInit { + private focus = true; + + constructor(private elementRef: ElementRef) {} + + ngAfterViewInit() { + const el: HTMLInputElement = this.elementRef.nativeElement; + if (this.focus && _.isFunction(el.focus)) { + el.focus(); + } + } + + @Input() + public set autofocus(condition: any) { + if (_.isBoolean(condition)) { + this.focus = condition; + } else if (_.isFunction(condition)) { + this.focus = condition(); + } + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dimless-binary-per-second.directive.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dimless-binary-per-second.directive.spec.ts new file mode 100644 index 000000000..858becc45 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dimless-binary-per-second.directive.spec.ts @@ -0,0 +1,12 @@ +import { DimlessBinaryPerSecondDirective } from './dimless-binary-per-second.directive'; + +export class MockElementRef { + nativeElement: {}; +} + +describe('DimlessBinaryPerSecondDirective', () => { + it('should create an instance', () => { + const directive = new DimlessBinaryPerSecondDirective(new MockElementRef(), null, null, null); + expect(directive).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dimless-binary-per-second.directive.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dimless-binary-per-second.directive.ts new file mode 100644 index 000000000..a90e2b8f8 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dimless-binary-per-second.directive.ts @@ -0,0 +1,132 @@ +import { + Directive, + ElementRef, + EventEmitter, + HostListener, + Input, + OnInit, + Output +} from '@angular/core'; +import { NgControl } from '@angular/forms'; + +import _ from 'lodash'; + +import { DimlessBinaryPerSecondPipe } from '../pipes/dimless-binary-per-second.pipe'; +import { FormatterService } from '../services/formatter.service'; + +@Directive({ + selector: '[cdDimlessBinaryPerSecond]' +}) +export class DimlessBinaryPerSecondDirective implements OnInit { + @Output() + ngModelChange: EventEmitter<any> = new EventEmitter(); + + /** + * Event emitter for letting this directive know that the data has (asynchronously) been loaded + * and the value needs to be adapted by this directive. + */ + @Input() + ngDataReady: EventEmitter<any>; + + /** + * Minimum size in bytes. + * If user enter a value lower than <minBytes>, + * the model will automatically be update to <minBytes>. + * + * If <roundPower> is used, this value should be a power of <roundPower>. + * + * Example: + * Given minBytes=4096 (4KiB), if user type 1KiB, then model will be updated to 4KiB + */ + @Input() + minBytes: number; + + /** + * Maximum size in bytes. + * If user enter a value greater than <maxBytes>, + * the model will automatically be update to <maxBytes>. + * + * If <roundPower> is used, this value should be a power of <roundPower>. + * + * Example: + * Given maxBytes=3145728 (3MiB), if user type 4MiB, then model will be updated to 3MiB + */ + @Input() + maxBytes: number; + + /** + * Value will be rounded up the nearest power of <roundPower> + * + * Example: + * Given roundPower=2, if user type 7KiB, then model will be updated to 8KiB + * Given roundPower=2, if user type 5KiB, then model will be updated to 4KiB + */ + @Input() + roundPower: number; + + /** + * Default unit that should be used when user do not type a unit. + * By default, "MiB" will be used. + * + * Example: + * Given defaultUnit=null, if user type 7, then model will be updated to 7MiB + * Given defaultUnit=k, if user type 7, then model will be updated to 7KiB + */ + @Input() + defaultUnit: string; + + private el: HTMLInputElement; + + constructor( + private elementRef: ElementRef, + private control: NgControl, + private dimlessBinaryPerSecondPipe: DimlessBinaryPerSecondPipe, + private formatter: FormatterService + ) { + this.el = this.elementRef.nativeElement; + } + + ngOnInit() { + this.setValue(this.el.value); + if (this.ngDataReady) { + this.ngDataReady.subscribe(() => this.setValue(this.el.value)); + } + } + + setValue(value: string) { + if (/^[\d.]+$/.test(value)) { + value += this.defaultUnit || 'm'; + } + const size = this.formatter.toBytes(value, 0); + const roundedSize = this.round(size); + this.el.value = this.dimlessBinaryPerSecondPipe.transform(roundedSize); + if (size !== null) { + this.ngModelChange.emit(this.el.value); + this.control.control.setValue(this.el.value); + } else { + this.ngModelChange.emit(null); + this.control.control.setValue(null); + } + } + + round(size: number) { + if (size !== null && size !== 0) { + if (!_.isUndefined(this.minBytes) && size < this.minBytes) { + return this.minBytes; + } + if (!_.isUndefined(this.maxBytes) && size > this.maxBytes) { + return this.maxBytes; + } + if (!_.isUndefined(this.roundPower)) { + const power = Math.round(Math.log(size) / Math.log(this.roundPower)); + return Math.pow(this.roundPower, power); + } + } + return size; + } + + @HostListener('blur', ['$event.target.value']) + onBlur(value: string) { + this.setValue(value); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dimless-binary.directive.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dimless-binary.directive.spec.ts new file mode 100644 index 000000000..5822e7d97 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dimless-binary.directive.spec.ts @@ -0,0 +1,12 @@ +import { DimlessBinaryDirective } from './dimless-binary.directive'; + +export class MockElementRef { + nativeElement: {}; +} + +describe('DimlessBinaryDirective', () => { + it('should create an instance', () => { + const directive = new DimlessBinaryDirective(new MockElementRef(), null, null, null); + expect(directive).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dimless-binary.directive.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dimless-binary.directive.ts new file mode 100644 index 000000000..1c27ae1ce --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dimless-binary.directive.ts @@ -0,0 +1,122 @@ +import { + Directive, + ElementRef, + EventEmitter, + HostListener, + Input, + OnInit, + Output +} from '@angular/core'; +import { NgControl } from '@angular/forms'; + +import _ from 'lodash'; + +import { DimlessBinaryPipe } from '../pipes/dimless-binary.pipe'; +import { FormatterService } from '../services/formatter.service'; + +@Directive({ + selector: '[cdDimlessBinary]' +}) +export class DimlessBinaryDirective implements OnInit { + @Output() + ngModelChange: EventEmitter<any> = new EventEmitter(); + + /** + * Minimum size in bytes. + * If user enter a value lower than <minBytes>, + * the model will automatically be update to <minBytes>. + * + * If <roundPower> is used, this value should be a power of <roundPower>. + * + * Example: + * Given minBytes=4096 (4KiB), if user type 1KiB, then model will be updated to 4KiB + */ + @Input() + minBytes: number; + + /** + * Maximum size in bytes. + * If user enter a value greater than <maxBytes>, + * the model will automatically be update to <maxBytes>. + * + * If <roundPower> is used, this value should be a power of <roundPower>. + * + * Example: + * Given maxBytes=3145728 (3MiB), if user type 4MiB, then model will be updated to 3MiB + */ + @Input() + maxBytes: number; + + /** + * Value will be rounded up the nearest power of <roundPower> + * + * Example: + * Given roundPower=2, if user type 7KiB, then model will be updated to 8KiB + * Given roundPower=2, if user type 5KiB, then model will be updated to 4KiB + */ + @Input() + roundPower: number; + + /** + * Default unit that should be used when user do not type a unit. + * By default, "MiB" will be used. + * + * Example: + * Given defaultUnit=null, if user type 7, then model will be updated to 7MiB + * Given defaultUnit=k, if user type 7, then model will be updated to 7KiB + */ + @Input() + defaultUnit: string; + + private el: HTMLInputElement; + + constructor( + private elementRef: ElementRef, + private control: NgControl, + private dimlessBinaryPipe: DimlessBinaryPipe, + private formatter: FormatterService + ) { + this.el = this.elementRef.nativeElement; + } + + ngOnInit() { + this.setValue(this.el.value); + } + + setValue(value: string) { + if (/^[\d.]+$/.test(value)) { + value += this.defaultUnit || 'm'; + } + const size = this.formatter.toBytes(value); + const roundedSize = this.round(size); + this.el.value = this.dimlessBinaryPipe.transform(roundedSize); + if (size !== null) { + this.ngModelChange.emit(this.el.value); + this.control.control.setValue(this.el.value); + } else { + this.ngModelChange.emit(null); + this.control.control.setValue(null); + } + } + + round(size: number) { + if (size !== null && size !== 0) { + if (!_.isUndefined(this.minBytes) && size < this.minBytes) { + return this.minBytes; + } + if (!_.isUndefined(this.maxBytes) && size > this.maxBytes) { + return this.maxBytes; + } + if (!_.isUndefined(this.roundPower)) { + const power = Math.round(Math.log(size) / Math.log(this.roundPower)); + return Math.pow(this.roundPower, power); + } + } + return size; + } + + @HostListener('blur', ['$event.target.value']) + onBlur(value: string) { + this.setValue(value); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/directives.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/directives.module.ts new file mode 100644 index 000000000..00e5635d3 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/directives.module.ts @@ -0,0 +1,53 @@ +import { NgModule } from '@angular/core'; + +import { AutofocusDirective } from './autofocus.directive'; +import { DimlessBinaryPerSecondDirective } from './dimless-binary-per-second.directive'; +import { DimlessBinaryDirective } from './dimless-binary.directive'; +import { FormInputDisableDirective } from './form-input-disable.directive'; +import { FormLoadingDirective } from './form-loading.directive'; +import { FormScopeDirective } from './form-scope.directive'; +import { IopsDirective } from './iops.directive'; +import { MillisecondsDirective } from './milliseconds.directive'; +import { CdFormControlDirective } from './ng-bootstrap-form-validation/cd-form-control.directive'; +import { CdFormGroupDirective } from './ng-bootstrap-form-validation/cd-form-group.directive'; +import { CdFormValidationDirective } from './ng-bootstrap-form-validation/cd-form-validation.directive'; +import { PasswordButtonDirective } from './password-button.directive'; +import { StatefulTabDirective } from './stateful-tab.directive'; +import { TrimDirective } from './trim.directive'; + +@NgModule({ + imports: [], + declarations: [ + AutofocusDirective, + DimlessBinaryDirective, + DimlessBinaryPerSecondDirective, + PasswordButtonDirective, + TrimDirective, + MillisecondsDirective, + IopsDirective, + FormLoadingDirective, + StatefulTabDirective, + FormInputDisableDirective, + FormScopeDirective, + CdFormControlDirective, + CdFormGroupDirective, + CdFormValidationDirective + ], + exports: [ + AutofocusDirective, + DimlessBinaryDirective, + DimlessBinaryPerSecondDirective, + PasswordButtonDirective, + TrimDirective, + MillisecondsDirective, + IopsDirective, + FormLoadingDirective, + StatefulTabDirective, + FormInputDisableDirective, + FormScopeDirective, + CdFormControlDirective, + CdFormGroupDirective, + CdFormValidationDirective + ] +}) +export class DirectivesModule {} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/form-input-disable.directive.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/form-input-disable.directive.spec.ts new file mode 100644 index 000000000..a79043b78 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/form-input-disable.directive.spec.ts @@ -0,0 +1,75 @@ +import { Component, DebugElement, Input } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { Permission } from '../models/permissions'; +import { AuthStorageService } from '../services/auth-storage.service'; +import { FormInputDisableDirective } from './form-input-disable.directive'; +import { FormScopeDirective } from './form-scope.directive'; + +@Component({ + template: ` + <form cdFormScope="osd"> + <input type="checkbox" /> + </form> + ` +}) +export class FormDisableComponent {} + +class MockFormScopeDirective { + @Input() cdFormScope = 'osd'; +} + +describe('FormInputDisableDirective', () => { + let fakePermissions: Permission; + let authStorageService: AuthStorageService; + let directive: FormInputDisableDirective; + let fixture: ComponentFixture<FormDisableComponent>; + let inputElement: DebugElement; + configureTestBed({ + declarations: [FormScopeDirective, FormInputDisableDirective, FormDisableComponent] + }); + + beforeEach(() => { + directive = new FormInputDisableDirective( + new MockFormScopeDirective(), + new AuthStorageService(), + null + ); + + fakePermissions = { + create: false, + update: false, + read: false, + delete: false + }; + authStorageService = TestBed.inject(AuthStorageService); + spyOn(authStorageService, 'getPermissions').and.callFake(() => ({ + osd: fakePermissions + })); + + fixture = TestBed.createComponent(FormDisableComponent); + inputElement = fixture.debugElement.query(By.css('input')); + }); + + afterEach(() => { + directive = null; + }); + + it('should create an instance', () => { + expect(directive).toBeTruthy(); + }); + + it('should disable the input if update permission is false', () => { + fixture.detectChanges(); + expect(inputElement.nativeElement.disabled).toBeTruthy(); + }); + + it('should not disable the input if update permission is true', () => { + fakePermissions.update = true; + fakePermissions.read = false; + fixture.detectChanges(); + expect(inputElement.nativeElement.disabled).toBeFalsy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/form-input-disable.directive.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/form-input-disable.directive.ts new file mode 100644 index 000000000..3e3f83bc5 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/form-input-disable.directive.ts @@ -0,0 +1,27 @@ +import { AfterViewInit, Directive, ElementRef, Optional } from '@angular/core'; + +import { Permissions } from '../models/permissions'; +import { AuthStorageService } from '../services/auth-storage.service'; +import { FormScopeDirective } from './form-scope.directive'; + +@Directive({ + selector: + 'input:not([cdNoFormInputDisable]), select:not([cdNoFormInputDisable]), button:not([cdNoFormInputDisable]), [cdFormInputDisable]' +}) +export class FormInputDisableDirective implements AfterViewInit { + permissions: Permissions; + + constructor( + @Optional() private formScope: FormScopeDirective, + private authStorageService: AuthStorageService, + private elementRef: ElementRef + ) {} + + ngAfterViewInit() { + this.permissions = this.authStorageService.getPermissions(); + const service_name = this.formScope?.cdFormScope; + if (service_name && !this.permissions?.[service_name]?.update) { + this.elementRef.nativeElement.disabled = true; + } + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/form-loading.directive.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/form-loading.directive.spec.ts new file mode 100644 index 000000000..8bc3b05a2 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/form-loading.directive.spec.ts @@ -0,0 +1,89 @@ +import { Component } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; + +import { NgbAlertModule } from '@ng-bootstrap/ng-bootstrap'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { AlertPanelComponent } from '../components/alert-panel/alert-panel.component'; +import { LoadingPanelComponent } from '../components/loading-panel/loading-panel.component'; +import { CdForm } from '../forms/cd-form'; +import { SharedModule } from '../shared.module'; +import { FormLoadingDirective } from './form-loading.directive'; + +@Component({ selector: 'cd-test-cmp', template: '<span *cdFormLoading="loading">foo</span>' }) +class TestComponent extends CdForm { + constructor() { + super(); + } +} + +describe('FormLoadingDirective', () => { + let component: TestComponent; + let fixture: ComponentFixture<any>; + + const expectShown = (elem: number, error: number, loading: number) => { + expect(fixture.debugElement.queryAll(By.css('span')).length).toEqual(elem); + expect(fixture.debugElement.queryAll(By.css('cd-alert-panel')).length).toEqual(error); + expect(fixture.debugElement.queryAll(By.css('cd-loading-panel')).length).toEqual(loading); + }; + + configureTestBed( + { + declarations: [TestComponent], + imports: [SharedModule, NgbAlertModule] + }, + [LoadingPanelComponent, AlertPanelComponent] + ); + + afterEach(() => { + fixture = null; + }); + + beforeEach(() => { + fixture = TestBed.createComponent(TestComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create an instance', () => { + const directive = new FormLoadingDirective(null, null, null); + expect(directive).toBeTruthy(); + }); + + it('should show loading component by default', () => { + expectShown(0, 0, 1); + + const alert = fixture.debugElement.nativeElement.querySelector('cd-loading-panel ngb-alert'); + expect(alert.textContent).toBe('Loading form data...'); + }); + + it('should show error component when calling loadingError()', () => { + component.loadingError(); + fixture.detectChanges(); + + expectShown(0, 1, 0); + + const alert = fixture.debugElement.nativeElement.querySelector( + 'cd-alert-panel .alert-panel-text' + ); + expect(alert.textContent).toBe('Form data could not be loaded.'); + }); + + it('should show original component when calling loadingReady()', () => { + component.loadingReady(); + fixture.detectChanges(); + + expectShown(1, 0, 0); + + const alert = fixture.debugElement.nativeElement.querySelector('span'); + expect(alert.textContent).toBe('foo'); + }); + + it('should show nothing when calling loadingNone()', () => { + component.loadingNone(); + fixture.detectChanges(); + + expectShown(0, 0, 0); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/form-loading.directive.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/form-loading.directive.ts new file mode 100644 index 000000000..e83614b84 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/form-loading.directive.ts @@ -0,0 +1,51 @@ +import { + ComponentFactoryResolver, + Directive, + Input, + TemplateRef, + ViewContainerRef +} from '@angular/core'; + +import { AlertPanelComponent } from '../components/alert-panel/alert-panel.component'; +import { LoadingPanelComponent } from '../components/loading-panel/loading-panel.component'; +import { LoadingStatus } from '../forms/cd-form'; + +@Directive({ + selector: '[cdFormLoading]' +}) +export class FormLoadingDirective { + constructor( + private templateRef: TemplateRef<any>, + private viewContainer: ViewContainerRef, + private componentFactoryResolver: ComponentFactoryResolver + ) {} + + @Input('cdFormLoading') set cdFormLoading(condition: LoadingStatus) { + let factory: any; + let content: any; + + this.viewContainer.clear(); + + switch (condition) { + case LoadingStatus.Loading: + factory = this.componentFactoryResolver.resolveComponentFactory(LoadingPanelComponent); + content = this.resolveNgContent($localize`Loading form data...`); + this.viewContainer.createComponent(factory, null, null, content); + break; + case LoadingStatus.Ready: + this.viewContainer.createEmbeddedView(this.templateRef); + break; + case LoadingStatus.Error: + factory = this.componentFactoryResolver.resolveComponentFactory(AlertPanelComponent); + content = this.resolveNgContent($localize`Form data could not be loaded.`); + const componentRef = this.viewContainer.createComponent(factory, null, null, content); + (<AlertPanelComponent>componentRef.instance).type = 'error'; + break; + } + } + + resolveNgContent(content: string) { + const element = document.createTextNode(content); + return [[element]]; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/form-scope.directive.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/form-scope.directive.spec.ts new file mode 100644 index 000000000..2cf882ece --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/form-scope.directive.spec.ts @@ -0,0 +1,8 @@ +import { FormScopeDirective } from './form-scope.directive'; + +describe('UpdateOnlyDirective', () => { + it('should create an instance', () => { + const directive = new FormScopeDirective(); + expect(directive).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/form-scope.directive.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/form-scope.directive.ts new file mode 100644 index 000000000..8ae3f8489 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/form-scope.directive.ts @@ -0,0 +1,8 @@ +import { Directive, Input } from '@angular/core'; + +@Directive({ + selector: '[cdFormScope]' +}) +export class FormScopeDirective { + @Input() cdFormScope: any; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/iops.directive.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/iops.directive.spec.ts new file mode 100644 index 000000000..9c1641ded --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/iops.directive.spec.ts @@ -0,0 +1,8 @@ +import { IopsDirective } from './iops.directive'; + +describe('IopsDirective', () => { + it('should create an instance', () => { + const directive = new IopsDirective(null, null); + expect(directive).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/iops.directive.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/iops.directive.ts new file mode 100644 index 000000000..4faf69164 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/iops.directive.ts @@ -0,0 +1,31 @@ +import { Directive, EventEmitter, HostListener, Input, OnInit } from '@angular/core'; +import { NgControl } from '@angular/forms'; + +import { FormatterService } from '../services/formatter.service'; + +@Directive({ + selector: '[cdIops]' +}) +export class IopsDirective implements OnInit { + @Input() + ngDataReady: EventEmitter<any>; + + constructor(private formatter: FormatterService, private ngControl: NgControl) {} + + setValue(value: string): void { + const iops = this.formatter.toIops(value); + this.ngControl.control.setValue(`${iops} IOPS`); + } + + ngOnInit(): void { + this.setValue(this.ngControl.value); + if (this.ngDataReady) { + this.ngDataReady.subscribe(() => this.setValue(this.ngControl.value)); + } + } + + @HostListener('blur', ['$event.target.value']) + onUpdate(value: string) { + this.setValue(value); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/milliseconds.directive.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/milliseconds.directive.spec.ts new file mode 100644 index 000000000..503802056 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/milliseconds.directive.spec.ts @@ -0,0 +1,8 @@ +import { MillisecondsDirective } from './milliseconds.directive'; + +describe('MillisecondsDirective', () => { + it('should create an instance', () => { + const directive = new MillisecondsDirective(null, null); + expect(directive).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/milliseconds.directive.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/milliseconds.directive.ts new file mode 100644 index 000000000..d5bb4aff5 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/milliseconds.directive.ts @@ -0,0 +1,31 @@ +import { Directive, EventEmitter, HostListener, Input, OnInit } from '@angular/core'; +import { NgControl } from '@angular/forms'; + +import { FormatterService } from '../services/formatter.service'; + +@Directive({ + selector: '[cdMilliseconds]' +}) +export class MillisecondsDirective implements OnInit { + @Input() + ngDataReady: EventEmitter<any>; + + constructor(private control: NgControl, private formatter: FormatterService) {} + + setValue(value: string): void { + const ms = this.formatter.toMilliseconds(value); + this.control.control.setValue(`${ms} ms`); + } + + ngOnInit(): void { + this.setValue(this.control.value); + if (this.ngDataReady) { + this.ngDataReady.subscribe(() => this.setValue(this.control.value)); + } + } + + @HostListener('blur', ['$event.target.value']) + onUpdate(value: string) { + this.setValue(value); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/ng-bootstrap-form-validation/cd-form-control.directive.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/ng-bootstrap-form-validation/cd-form-control.directive.spec.ts new file mode 100644 index 000000000..dd588ae7b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/ng-bootstrap-form-validation/cd-form-control.directive.spec.ts @@ -0,0 +1,37 @@ +/** + * MIT License + * + * Copyright (c) 2017 Kevin Kipp + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + * + * Based on https://github.com/third774/ng-bootstrap-form-validation + */ + +import { NgForm } from '@angular/forms'; + +import { CdFormControlDirective } from './cd-form-control.directive'; + +describe('CdFormControlDirective', () => { + it('should create an instance', () => { + const directive = new CdFormControlDirective(new NgForm([], [])); + expect(directive).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/ng-bootstrap-form-validation/cd-form-control.directive.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/ng-bootstrap-form-validation/cd-form-control.directive.ts new file mode 100644 index 000000000..86afc72a5 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/ng-bootstrap-form-validation/cd-form-control.directive.ts @@ -0,0 +1,82 @@ +/** + * MIT License + * + * Copyright (c) 2017 Kevin Kipp + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + * + * Based on https://github.com/third774/ng-bootstrap-form-validation + */ + +import { Directive, Host, HostBinding, Input, Optional, SkipSelf } from '@angular/core'; +import { ControlContainer, FormControl } from '@angular/forms'; + +export function controlPath(name: string, parent: ControlContainer): string[] { + // tslint:disable-next-line:no-non-null-assertion + return [...parent.path!, name]; +} + +@Directive({ + // tslint:disable-next-line:directive-selector + selector: '.form-control,.form-check-input,.custom-control-input' +}) +export class CdFormControlDirective { + @Input() + formControlName: string; + @Input() + formControl: string; + + @HostBinding('class.is-valid') + get validClass() { + if (!this.control) { + return false; + } + return this.control.valid && (this.control.touched || this.control.dirty); + } + + @HostBinding('class.is-invalid') + get invalidClass() { + if (!this.control) { + return false; + } + return this.control.invalid && this.control.touched && this.control.dirty; + } + + get path() { + return controlPath(this.formControlName, this.parent); + } + + get control(): FormControl { + return this.formDirective && this.formDirective.getControl(this); + } + + get formDirective(): any { + return this.parent ? this.parent.formDirective : null; + } + + constructor( + // this value might be null, but we union type it as such until + // this issue is resolved: https://github.com/angular/angular/issues/25544 + @Optional() + @Host() + @SkipSelf() + private parent: ControlContainer + ) {} +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/ng-bootstrap-form-validation/cd-form-group.directive.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/ng-bootstrap-form-validation/cd-form-group.directive.spec.ts new file mode 100644 index 000000000..40aa251cd --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/ng-bootstrap-form-validation/cd-form-group.directive.spec.ts @@ -0,0 +1,37 @@ +/** + * MIT License + * + * Copyright (c) 2017 Kevin Kipp + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + * + * Based on https://github.com/third774/ng-bootstrap-form-validation + */ + +import { ElementRef } from '@angular/core'; + +import { CdFormGroupDirective } from './cd-form-group.directive'; + +describe('CdFormGroupDirective', () => { + it('should create an instance', () => { + const directive = new CdFormGroupDirective(new ElementRef(null)); + expect(directive).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/ng-bootstrap-form-validation/cd-form-group.directive.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/ng-bootstrap-form-validation/cd-form-group.directive.ts new file mode 100644 index 000000000..5f6b11de1 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/ng-bootstrap-form-validation/cd-form-group.directive.ts @@ -0,0 +1,76 @@ +/** + * MIT License + * + * Copyright (c) 2017 Kevin Kipp + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + * + * Based on https://github.com/third774/ng-bootstrap-form-validation + */ + +import { + ContentChildren, + Directive, + ElementRef, + HostBinding, + Input, + QueryList +} from '@angular/core'; +import { FormControlName } from '@angular/forms'; + +@Directive({ + // tslint:disable-next-line:directive-selector + selector: '.form-group' +}) +export class CdFormGroupDirective { + @ContentChildren(FormControlName) + formControlNames: QueryList<FormControlName>; + + @Input() + validationDisabled = false; + + @HostBinding('class.has-error') + get hasErrors() { + return ( + this.formControlNames.some((c) => !c.valid && c.dirty && c.touched) && + !this.validationDisabled + ); + } + + @HostBinding('class.has-success') + get hasSuccess() { + return ( + !this.formControlNames.some((c) => !c.valid) && + this.formControlNames.some((c) => c.dirty && c.touched) && + !this.validationDisabled + ); + } + + constructor(private elRef: ElementRef) {} + + get label() { + const label = this.elRef.nativeElement.querySelector('label'); + return label && label.textContent ? label.textContent.trim() : 'This field'; + } + + get isDirtyAndTouched() { + return this.formControlNames.some((c) => c.dirty && c.touched); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/ng-bootstrap-form-validation/cd-form-validation.directive.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/ng-bootstrap-form-validation/cd-form-validation.directive.spec.ts new file mode 100644 index 000000000..c4b0f424b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/ng-bootstrap-form-validation/cd-form-validation.directive.spec.ts @@ -0,0 +1,35 @@ +/** + * MIT License + * + * Copyright (c) 2017 Kevin Kipp + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + * + * Based on https://github.com/third774/ng-bootstrap-form-validation + */ + +import { CdFormValidationDirective } from './cd-form-validation.directive'; + +describe('CdFormValidationDirective', () => { + it('should create an instance', () => { + const directive = new CdFormValidationDirective(); + expect(directive).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/ng-bootstrap-form-validation/cd-form-validation.directive.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/ng-bootstrap-form-validation/cd-form-validation.directive.ts new file mode 100644 index 000000000..a88011d35 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/ng-bootstrap-form-validation/cd-form-validation.directive.ts @@ -0,0 +1,62 @@ +/** + * MIT License + * + * Copyright (c) 2017 Kevin Kipp + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + * + * Based on https://github.com/third774/ng-bootstrap-form-validation + */ + +import { Directive, EventEmitter, HostListener, Input, Output } from '@angular/core'; +import { AbstractControl, FormArray, FormControl, FormGroup } from '@angular/forms'; + +@Directive({ + // tslint:disable-next-line:directive-selector + selector: '[formGroup]' +}) +export class CdFormValidationDirective { + @Input() + formGroup: FormGroup; + @Output() + validSubmit = new EventEmitter<any>(); + + @HostListener('submit') + onSubmit() { + this.markAsTouchedAndDirty(this.formGroup); + if (this.formGroup.valid) { + this.validSubmit.emit(this.formGroup.value); + } + } + + markAsTouchedAndDirty(control: AbstractControl) { + if (control instanceof FormGroup) { + Object.keys(control.controls).forEach((key) => + this.markAsTouchedAndDirty(control.controls[key]) + ); + } else if (control instanceof FormArray) { + control.controls.forEach((c) => this.markAsTouchedAndDirty(c)); + } else if (control instanceof FormControl && control.enabled) { + control.markAsDirty(); + control.markAsTouched(); + control.updateValueAndValidity(); + } + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/password-button.directive.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/password-button.directive.spec.ts new file mode 100644 index 000000000..1fc8f9c7c --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/password-button.directive.spec.ts @@ -0,0 +1,8 @@ +import { PasswordButtonDirective } from './password-button.directive'; + +describe('PasswordButtonDirective', () => { + it('should create an instance', () => { + const directive = new PasswordButtonDirective(null, null); + expect(directive).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/password-button.directive.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/password-button.directive.ts new file mode 100644 index 000000000..d9129858a --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/password-button.directive.ts @@ -0,0 +1,45 @@ +import { Directive, ElementRef, HostListener, Input, OnInit, Renderer2 } from '@angular/core'; + +@Directive({ + selector: '[cdPasswordButton]' +}) +export class PasswordButtonDirective implements OnInit { + private iElement: HTMLElement; + + @Input() + private cdPasswordButton: string; + + constructor(private elementRef: ElementRef, private renderer: Renderer2) {} + + ngOnInit() { + this.renderer.setAttribute(this.elementRef.nativeElement, 'tabindex', '-1'); + this.iElement = this.renderer.createElement('i'); + this.renderer.addClass(this.iElement, 'fa'); + this.renderer.appendChild(this.elementRef.nativeElement, this.iElement); + this.update(); + } + + private getInputElement() { + return document.getElementById(this.cdPasswordButton) as HTMLInputElement; + } + + private update() { + const inputElement = this.getInputElement(); + if (inputElement && inputElement.type === 'text') { + this.renderer.removeClass(this.iElement, 'fa-eye'); + this.renderer.addClass(this.iElement, 'fa-eye-slash'); + } else { + this.renderer.removeClass(this.iElement, 'fa-eye-slash'); + this.renderer.addClass(this.iElement, 'fa-eye'); + } + } + + @HostListener('click') + onClick() { + const inputElement = this.getInputElement(); + // Modify the type of the input field. + inputElement.type = inputElement.type === 'password' ? 'text' : 'password'; + // Update the button icon/tooltip. + this.update(); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/stateful-tab.directive.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/stateful-tab.directive.spec.ts new file mode 100644 index 000000000..5cebefbc9 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/stateful-tab.directive.spec.ts @@ -0,0 +1,28 @@ +import { NgbConfig, NgbNav, NgbNavChangeEvent, NgbNavConfig } from '@ng-bootstrap/ng-bootstrap'; + +import { StatefulTabDirective } from './stateful-tab.directive'; + +describe('StatefulTabDirective', () => { + it('should create an instance', () => { + const directive = new StatefulTabDirective(null); + expect(directive).toBeTruthy(); + }); + + it('should get and select active tab', () => { + const nav = new NgbNav('tablist', new NgbNavConfig(new NgbConfig()), <any>null, null); + spyOn(nav, 'select'); + const directive = new StatefulTabDirective(nav); + directive.cdStatefulTab = 'bar'; + window.localStorage.setItem('tabset_bar', 'foo'); + directive.ngOnInit(); + expect(nav.select).toHaveBeenCalledWith('foo'); + }); + + it('should store active tab', () => { + const directive = new StatefulTabDirective(null); + directive.cdStatefulTab = 'bar'; + const event: NgbNavChangeEvent<string> = { activeId: '', nextId: 'xyz', preventDefault: null }; + directive.onNavChange(event); + expect(window.localStorage.getItem('tabset_bar')).toBe('xyz'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/stateful-tab.directive.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/stateful-tab.directive.ts new file mode 100644 index 000000000..cf6f27e95 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/stateful-tab.directive.ts @@ -0,0 +1,31 @@ +import { Directive, Host, HostListener, Input, OnInit, Optional } from '@angular/core'; + +import { NgbNav, NgbNavChangeEvent } from '@ng-bootstrap/ng-bootstrap'; + +@Directive({ + selector: '[cdStatefulTab]' +}) +export class StatefulTabDirective implements OnInit { + @Input() + cdStatefulTab: string; + + private localStorage = window.localStorage; + + constructor(@Optional() @Host() private nav: NgbNav) {} + + ngOnInit() { + // Is an activate tab identifier stored in the local storage? + const activeId = this.localStorage.getItem(`tabset_${this.cdStatefulTab}`); + if (activeId) { + this.nav.select(activeId); + } + } + + @HostListener('navChange', ['$event']) + onNavChange(event: NgbNavChangeEvent) { + // Store the current active tab identifier in the local storage. + if (this.cdStatefulTab && event.nextId) { + this.localStorage.setItem(`tabset_${this.cdStatefulTab}`, event.nextId); + } + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/trim.directive.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/trim.directive.spec.ts new file mode 100644 index 000000000..daef6b3c8 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/trim.directive.spec.ts @@ -0,0 +1,50 @@ +import { Component } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { By } from '@angular/platform-browser'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { CdFormGroup } from '../forms/cd-form-group'; +import { TrimDirective } from './trim.directive'; + +@Component({ + template: ` + <form [formGroup]="trimForm"> + <input type="text" formControlName="trimInput" cdTrim /> + </form> + ` +}) +export class TrimComponent { + trimForm: CdFormGroup; + constructor() { + this.trimForm = new CdFormGroup({ + trimInput: new FormControl() + }); + } +} + +describe('TrimDirective', () => { + configureTestBed({ + imports: [FormsModule, ReactiveFormsModule], + declarations: [TrimDirective, TrimComponent] + }); + + it('should create an instance', () => { + const directive = new TrimDirective(null); + expect(directive).toBeTruthy(); + }); + + it('should trim', () => { + const fixture: ComponentFixture<TrimComponent> = TestBed.createComponent(TrimComponent); + const component: TrimComponent = fixture.componentInstance; + const inputElement: HTMLInputElement = fixture.debugElement.query(By.css('input')) + .nativeElement; + fixture.detectChanges(); + + inputElement.value = ' a b '; + inputElement.dispatchEvent(new Event('input')); + const expectedValue = 'a b'; + expect(inputElement.value).toBe(expectedValue); + expect(component.trimForm.getValue('trimInput')).toBe(expectedValue); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/trim.directive.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/trim.directive.ts new file mode 100644 index 000000000..4b3604e43 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/trim.directive.ts @@ -0,0 +1,21 @@ +import { Directive, HostListener } from '@angular/core'; +import { NgControl } from '@angular/forms'; + +import _ from 'lodash'; + +@Directive({ + selector: '[cdTrim]' +}) +export class TrimDirective { + constructor(private ngControl: NgControl) {} + + @HostListener('input', ['$event.target.value']) + onInput(value: string) { + this.setValue(value); + } + + setValue(value: string): void { + value = _.isString(value) ? value.trim() : value; + this.ngControl.control.setValue(value); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/cell-template.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/cell-template.enum.ts new file mode 100644 index 000000000..73ce1f239 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/cell-template.enum.ts @@ -0,0 +1,54 @@ +export enum CellTemplate { + bold = 'bold', + sparkline = 'sparkline', + perSecond = 'perSecond', + checkIcon = 'checkIcon', + routerLink = 'routerLink', + // Display the cell with an executing state. The state can be set to the `cdExecuting` + // attribute of table rows. + // It supports an optional custom configuration: + // { + // ... + // cellTransformation: CellTemplate.executing, + // customTemplateConfig: { + // valueClass?: string; // Cell value classes. + // executingClass?: string; // Executing state classes. + // } + executing = 'executing', + classAdding = 'classAdding', + // Display the cell value as a badge. The template + // supports an optional custom configuration: + // { + // ... + // cellTransformation: CellTemplate.badge, + // customTemplateConfig: { + // class?: string; // Additional class name. + // prefix?: any; // Prefix of the value to be displayed. + // // 'map' and 'prefix' exclude each other. + // map?: { + // [key: any]: { value: any, class?: string } + // } + // } + // } + badge = 'badge', + // Maps the value using the given dictionary. + // { + // ... + // cellTransformation: CellTemplate.map, + // customTemplateConfig: { + // [key: any]: any + // } + // } + map = 'map', + // Truncates string if it's longer than the given maximum + // string length. + // { + // ... + // cellTransformation: CellTemplate.truncate, + // customTemplateConfig: { + // length?: number; // Defaults to 30. + // omission?: string; // Defaults to empty string. + // } + // } + truncate = 'truncate' +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/components.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/components.enum.ts new file mode 100644 index 000000000..bf8daafc2 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/components.enum.ts @@ -0,0 +1,9 @@ +export enum Components { + auth = 'Login', + cephfs = 'CephFS', + rbd = 'RBD', + pool = 'Pool', + osd = 'OSD', + role = 'Role', + user = 'User' +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/health-color.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/health-color.enum.ts new file mode 100644 index 000000000..042394225 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/health-color.enum.ts @@ -0,0 +1,5 @@ +export enum HealthColor { + HEALTH_ERR = 'health-color-error', + HEALTH_WARN = 'health-color-warning', + HEALTH_OK = 'health-color-healthy' +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts new file mode 100644 index 000000000..6b65f04e8 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts @@ -0,0 +1,84 @@ +export enum Icons { + /* Icons for Symbol */ + add = 'fa fa-plus', // Create, Add + addCircle = 'fa fa-plus-circle', // Plus with Circle + minusCircle = 'fa fa-minus-circle', // Minus with Circle + edit = 'fa fa-pencil', // Edit, Edit Mode, Rename + destroy = 'fa fa-times', // Destroy, Remove, Delete + destroyCircle = 'fa fa-times-circle', // Destroy, Remove, Delete + exchange = 'fa fa-exchange', // Edit-Peer + copy = 'fa fa-copy', // Copy + clipboard = 'fa fa-clipboard', // Clipboard + flatten = 'fa fa-chain-broken', // Flatten, Link broken, Mark Lost + trash = 'fa fa-trash-o', // Move to trash + lock = 'fa fa-lock', // Protect + unlock = 'fa fa-unlock', // Unprotect + clone = 'fa fa-clone', // clone + undo = 'fa fa-undo', // Rollback, Restore + search = 'fa fa-search', // Search + start = 'fa fa-play', // Enable + stop = 'fa fa-stop', // Disable + analyse = 'fa fa-stethoscope', // Scrub + deepCheck = 'fa fa-cog', // Deep Scrub, Setting, Configuration + reweight = 'fa fa-balance-scale', // Reweight + left = 'fa fa-arrow-left', // Mark out + right = 'fa fa-arrow-right', // Mark in + down = 'fa fa-arrow-down', // Mark Down + erase = 'fa fa-eraser', // Purge color: bd.$white; + + user = 'fa fa-user', // User, Initiators + users = 'fa fa-users', // Users, Groups + share = 'fa fa-share-alt', // share + key = 'fa fa-key-modern', // S3 Keys, Swift Keys, Authentication + warning = 'fa fa-exclamation-triangle', // Notification warning + info = 'fa fa-info', // Notification information + infoCircle = 'fa fa-info-circle', // Info on landing page + questionCircle = 'fa fa-question-circle-o', + check = 'fa fa-check', // Notification check + show = 'fa fa-eye', // Show + paragraph = 'fa fa-paragraph', // Silence Matcher - Attribute name + terminal = 'fa fa-terminal', // Silence Matcher - Value + magic = 'fa fa-magic', // Silence Matcher - Regex checkbox + hourglass = 'fa fa-hourglass-o', // Task + filledHourglass = 'fa fa-hourglass', // Task + table = 'fa fa-table', // Table, + spinner = 'fa fa-spinner', // spinner, Load + refresh = 'fa fa-refresh', // Refresh + bullseye = 'fa fa-bullseye', // Target + disk = 'fa fa-hdd-o', // Hard disk, disks + server = 'fa fa-server', // Server, Portal + filter = 'fa fa-filter', // Filter + lineChart = 'fa fa-line-chart', // Line chart + signOut = 'fa fa-sign-out', // Sign Out + health = 'fa fa-heartbeat', // Health + circle = 'fa fa-circle', // Circle + bell = 'fa fa-bell', // Notification + tag = 'fa fa-tag', // Tag, Badge + leftArrow = 'fa fa-angle-left', // Left facing angle + rightArrow = 'fa fa-angle-right', // Right facing angle + leftArrowDouble = 'fa fa-angle-double-left', // Left facing Double angle + rightArrowDouble = 'fa fa-angle-double-right', // Left facing Double angle + flag = 'fa fa-flag', // OSD configuration + clearFilters = 'fa fa-window-close', // Clear filters, solid x + download = 'fa fa-download', // Download + upload = 'fa fa-upload', // Upload + close = 'fa fa-times', // Close + json = 'fa fa-file-code-o', // JSON file + text = 'fa fa-file-text', // Text file + wrench = 'fa fa-wrench', // Configuration Error + enter = 'fa fa-sign-in', // Enter + exit = 'fa fa-sign-out', // Exit + restart = 'fa fa-history', // Restart + deploy = 'fa fa-cube', // Deploy, Redeploy + + /* Icons for special effect */ + large = 'fa fa-lg', // icon becomes 33% larger + large2x = 'fa fa-2x', // icon becomes 50% larger + large3x = 'fa fa-3x', // icon becomes 3 times larger + stack = 'fa fa-stack', // To stack multiple icons + stack1x = 'fa fa-stack-1x', // To stack regularly sized icon + stack2x = 'fa fa-stack-2x', // To stack regularly sized icon + pulse = 'fa fa-pulse', // To have spinner rotate with 8 steps + spin = 'fa fa-spin', // To get any icon to rotate + inverse = 'fa fa-inverse' // To get an alternative icon color +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/notification-type.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/notification-type.enum.ts new file mode 100644 index 000000000..c82929fb5 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/notification-type.enum.ts @@ -0,0 +1,5 @@ +export enum NotificationType { + error, + info, + success +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/unix_errno.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/unix_errno.enum.ts new file mode 100644 index 000000000..98bcb689f --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/unix_errno.enum.ts @@ -0,0 +1,4 @@ +// http://www.virtsync.com/c-error-codes-include-errno +export enum UnixErrno { + EEXIST = 17 // File exists +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/view-cache-status.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/view-cache-status.enum.ts new file mode 100644 index 000000000..169059c44 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/view-cache-status.enum.ts @@ -0,0 +1,6 @@ +export enum ViewCacheStatus { + ValueOk = 0, + ValueStale = 1, + ValueNone = 2, + ValueException = 3 +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-form-builder.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-form-builder.spec.ts new file mode 100644 index 000000000..188294b82 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-form-builder.spec.ts @@ -0,0 +1,33 @@ +import { Validators } from '@angular/forms'; + +import { CdFormBuilder } from './cd-form-builder'; +import { CdFormGroup } from './cd-form-group'; + +describe('cd-form-builder', () => { + let service: CdFormBuilder; + + beforeEach(() => { + service = new CdFormBuilder(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should create a nested CdFormGroup', () => { + const form = service.group({ + nested: service.group({ + a: [null], + b: ['sth'], + c: [2, [Validators.min(3)]] + }), + d: [{ e: 3 }], + f: [true] + }); + expect(form.constructor).toBe(CdFormGroup); + expect(form instanceof CdFormGroup).toBeTruthy(); + expect(form.getValue('b')).toBe('sth'); + expect(form.getValue('d')).toEqual({ e: 3 }); + expect(form.get('c').valid).toBeFalsy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-form-builder.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-form-builder.ts new file mode 100644 index 000000000..9741b1e63 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-form-builder.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@angular/core'; +import { AbstractControlOptions, FormBuilder } from '@angular/forms'; + +import { CdFormGroup } from './cd-form-group'; + +/** + * CdFormBuilder extends FormBuilder to create an CdFormGroup based form. + */ +@Injectable({ + providedIn: 'root' +}) +export class CdFormBuilder extends FormBuilder { + group( + controlsConfig: { [key: string]: any }, + extra: AbstractControlOptions | null = null + ): CdFormGroup { + const form = super.group(controlsConfig, extra); + return new CdFormGroup(form.controls, form.validator, form.asyncValidator); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-form-group.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-form-group.spec.ts new file mode 100644 index 000000000..240da3af8 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-form-group.spec.ts @@ -0,0 +1,184 @@ +import { AbstractControl, FormControl, FormGroup, NgForm } from '@angular/forms'; + +import { CdFormGroup } from './cd-form-group'; + +describe('CdFormGroup', () => { + let form: CdFormGroup; + + const createTestForm = (controlName: string, value: any): FormGroup => + new FormGroup({ + [controlName]: new FormControl(value) + }); + + describe('test get and getValue in nested forms', () => { + let formA: FormGroup; + let formB: FormGroup; + let formC: FormGroup; + + beforeEach(() => { + formA = createTestForm('a', 'a'); + formB = createTestForm('b', 'b'); + formC = createTestForm('c', 'c'); + form = new CdFormGroup({ + formA: formA, + formB: formB, + formC: formC + }); + }); + + it('should find controls out of every form', () => { + expect(form.get('a')).toBe(formA.get('a')); + expect(form.get('b')).toBe(formB.get('b')); + expect(form.get('c')).toBe(formC.get('c')); + }); + + it('should throw an error if element could be found', () => { + expect(() => form.get('d')).toThrowError(`Control 'd' could not be found!`); + expect(() => form.get('sth')).toThrowError(`Control 'sth' could not be found!`); + }); + }); + + describe('CdFormGroup tests', () => { + let x: CdFormGroup, nested: CdFormGroup, a: FormControl, c: FormGroup; + + beforeEach(() => { + a = new FormControl('a'); + x = new CdFormGroup({ + a: a + }); + nested = new CdFormGroup({ + lev1: new CdFormGroup({ + lev2: new FormControl('lev2') + }) + }); + c = createTestForm('c', 'c'); + form = new CdFormGroup({ + nested: nested, + cdform: x, + b: new FormControl('b'), + formC: c + }); + }); + + it('should return single value from "a" control in not nested form "x"', () => { + expect(x.get('a')).toBe(a); + expect(x.getValue('a')).toBe('a'); + }); + + it('should return control "a" out of form "x" in nested form', () => { + expect(form.get('a')).toBe(a); + expect(form.getValue('a')).toBe('a'); + }); + + it('should return value "b" that is not nested in nested form', () => { + expect(form.getValue('b')).toBe('b'); + }); + + it('return value "c" out of normal form group "c" in nested form', () => { + expect(form.getValue('c')).toBe('c'); + }); + + it('should return "lev2" value', () => { + expect(form.getValue('lev2')).toBe('lev2'); + }); + + it('should nested throw an error if control could not be found', () => { + expect(() => form.get('d')).toThrowError(`Control 'd' could not be found!`); + expect(() => form.getValue('sth')).toThrowError(`Control 'sth' could not be found!`); + }); + }); + + describe('test different values for getValue', () => { + beforeEach(() => { + form = new CdFormGroup({ + form_undefined: createTestForm('undefined', undefined), + form_null: createTestForm('null', null), + form_emptyObject: createTestForm('emptyObject', {}), + form_filledObject: createTestForm('filledObject', { notEmpty: 1 }), + form_number0: createTestForm('number0', 0), + form_number1: createTestForm('number1', 1), + form_emptyString: createTestForm('emptyString', ''), + form_someString1: createTestForm('someString1', 's'), + form_someString2: createTestForm('someString2', 'sth'), + form_floating: createTestForm('floating', 0.1), + form_false: createTestForm('false', false), + form_true: createTestForm('true', true) + }); + }); + + it('returns objects', () => { + expect(form.getValue('null')).toBe(null); + expect(form.getValue('emptyObject')).toEqual({}); + expect(form.getValue('filledObject')).toEqual({ notEmpty: 1 }); + }); + + it('returns set numbers', () => { + expect(form.getValue('number0')).toBe(0); + expect(form.getValue('number1')).toBe(1); + expect(form.getValue('floating')).toBe(0.1); + }); + + it('returns strings', () => { + expect(form.getValue('emptyString')).toBe(''); + expect(form.getValue('someString1')).toBe('s'); + expect(form.getValue('someString2')).toBe('sth'); + }); + + it('returns booleans', () => { + expect(form.getValue('true')).toBe(true); + expect(form.getValue('false')).toBe(false); + }); + + it('returns null if control was set as undefined', () => { + expect(form.getValue('undefined')).toBe(null); + }); + + it('returns a falsy value for null, undefined, false and 0', () => { + expect(form.getValue('false')).toBeFalsy(); + expect(form.getValue('null')).toBeFalsy(); + expect(form.getValue('number0')).toBeFalsy(); + }); + }); + + describe('should test showError', () => { + let formDir: NgForm; + let test: AbstractControl; + + beforeEach(() => { + formDir = new NgForm([], []); + form = new CdFormGroup({ + test_form: createTestForm('test', '') + }); + test = form.get('test'); + test.setErrors({ someError: 'failed' }); + }); + + it('should not show an error if not dirty and not submitted', () => { + expect(form.showError('test', formDir)).toBe(false); + }); + + it('should show an error if dirty', () => { + test.markAsDirty(); + expect(form.showError('test', formDir)).toBe(true); + }); + + it('should show an error if submitted', () => { + expect(form.showError('test', <NgForm>{ submitted: true })).toBe(true); + }); + + it('should not show an error if no error exits', () => { + test.setErrors(null); + expect(form.showError('test', <NgForm>{ submitted: true })).toBe(false); + test.markAsDirty(); + expect(form.showError('test', formDir)).toBe(false); + }); + + it('should not show error if the given error is not there', () => { + expect(form.showError('test', <NgForm>{ submitted: true }, 'someOtherError')).toBe(false); + }); + + it('should show error if the given error is there', () => { + expect(form.showError('test', <NgForm>{ submitted: true }, 'someError')).toBe(true); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-form-group.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-form-group.ts new file mode 100644 index 000000000..9869f398c --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-form-group.ts @@ -0,0 +1,75 @@ +import { + AbstractControl, + AbstractControlOptions, + AsyncValidatorFn, + FormGroup, + NgForm, + ValidatorFn +} from '@angular/forms'; + +/** + * CdFormGroup extends FormGroup with a few new methods that will help form development. + */ +export class CdFormGroup extends FormGroup { + constructor( + public controls: { [key: string]: AbstractControl }, + validatorOrOpts?: ValidatorFn | ValidatorFn[] | AbstractControlOptions | null, + asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null + ) { + super(controls, validatorOrOpts, asyncValidator); + } + + /** + * Get a control out of any control even if its nested in other CdFormGroups or a FormGroup + */ + get(controlName: string): AbstractControl { + const control = this._get(controlName); + if (!control) { + throw new Error(`Control '${controlName}' could not be found!`); + } + return control; + } + + _get(controlName: string): AbstractControl { + return ( + super.get(controlName) || + Object.values(this.controls) + .filter((c) => c.get) + .map((form) => { + if (form instanceof CdFormGroup) { + return form._get(controlName); + } + return form.get(controlName); + }) + .find((c) => Boolean(c)) + ); + } + + /** + * Get the value of a control + */ + getValue(controlName: string): any { + return this.get(controlName).value; + } + + /** + * Sets a control without triggering a value changes event + * + * Very useful if a function is called through a value changes event but the value + * should be changed within the call. + */ + silentSet(controlName: string, value: any) { + this.get(controlName).setValue(value, { emitEvent: false }); + } + + /** + * Indicates errors of the control in templates + */ + showError(controlName: string, form: NgForm, errorName?: string): boolean { + const control = this.get(controlName); + return ( + (form.submitted || control.dirty) && + (errorName ? control.hasError(errorName) : control.invalid) + ); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-form.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-form.spec.ts new file mode 100644 index 000000000..445c31faf --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-form.spec.ts @@ -0,0 +1,32 @@ +import { CdForm, LoadingStatus } from './cd-form'; + +describe('CdForm', () => { + let form: CdForm; + + beforeEach(() => { + form = new CdForm(); + }); + + describe('loading', () => { + it('should start in loading state', () => { + expect(form.loading).toBe(LoadingStatus.Loading); + }); + + it('should change to ready when calling loadingReady', () => { + form.loadingReady(); + expect(form.loading).toBe(LoadingStatus.Ready); + }); + + it('should change to error state calling loadingError', () => { + form.loadingError(); + expect(form.loading).toBe(LoadingStatus.Error); + }); + + it('should change to loading state calling loadingStart', () => { + form.loadingError(); + expect(form.loading).toBe(LoadingStatus.Error); + form.loadingStart(); + expect(form.loading).toBe(LoadingStatus.Loading); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-form.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-form.ts new file mode 100644 index 000000000..6fcb40e7d --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-form.ts @@ -0,0 +1,26 @@ +export enum LoadingStatus { + Loading, + Ready, + Error, + None +} + +export class CdForm { + loading = LoadingStatus.Loading; + + loadingStart() { + this.loading = LoadingStatus.Loading; + } + + loadingReady() { + this.loading = LoadingStatus.Ready; + } + + loadingError() { + this.loading = LoadingStatus.Error; + } + + loadingNone() { + this.loading = LoadingStatus.None; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.spec.ts new file mode 100644 index 000000000..5cf90fdea --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.spec.ts @@ -0,0 +1,906 @@ +import { fakeAsync, tick } from '@angular/core/testing'; +import { FormControl, Validators } from '@angular/forms'; + +import _ from 'lodash'; +import { of as observableOf } from 'rxjs'; + +import { RgwBucketService } from '~/app/shared/api/rgw-bucket.service'; +import { CdFormGroup } from '~/app/shared/forms/cd-form-group'; +import { CdValidators } from '~/app/shared/forms/cd-validators'; +import { FormHelper } from '~/testing/unit-test-helper'; + +let mockBucketExists = observableOf(true); +jest.mock('~/app/shared/api/rgw-bucket.service', () => { + return { + RgwBucketService: jest.fn().mockImplementation(() => { + return { + exists: () => mockBucketExists + }; + }) + }; +}); + +describe('CdValidators', () => { + let formHelper: FormHelper; + let form: CdFormGroup; + + const expectValid = (value: any) => formHelper.expectValidChange('x', value); + const expectPatternError = (value: any) => formHelper.expectErrorChange('x', value, 'pattern'); + const updateValidity = (controlName: string) => form.get(controlName).updateValueAndValidity(); + + beforeEach(() => { + form = new CdFormGroup({ + x: new FormControl() + }); + formHelper = new FormHelper(form); + }); + + describe('email', () => { + beforeEach(() => { + form.get('x').setValidators(CdValidators.email); + }); + + it('should not error on an empty email address', () => { + expectValid(''); + }); + + it('should not error on valid email address', () => { + expectValid('dashboard@ceph.com'); + }); + + it('should error on invalid email address', () => { + formHelper.expectErrorChange('x', 'xyz', 'email'); + }); + }); + + describe('ip validator', () => { + describe('IPv4', () => { + beforeEach(() => { + form.get('x').setValidators(CdValidators.ip(4)); + }); + + it('should not error on empty addresses', () => { + expectValid(''); + }); + + it('should accept valid address', () => { + expectValid('19.117.23.141'); + }); + + it('should error containing whitespace', () => { + expectPatternError('155.144.133.122 '); + expectPatternError('155. 144.133 .122'); + expectPatternError(' 155.144.133.122'); + }); + + it('should error containing invalid char', () => { + expectPatternError('155.144.eee.122 '); + expectPatternError('155.1?.133 .1&2'); + }); + + it('should error containing blocks higher than 255', () => { + expectPatternError('155.270.133.122'); + expectPatternError('155.144.133.290'); + }); + }); + + describe('IPv4', () => { + beforeEach(() => { + form.get('x').setValidators(CdValidators.ip(6)); + }); + + it('should not error on empty IPv6 addresses', () => { + expectValid(''); + }); + + it('should accept valid IPv6 address', () => { + expectValid('c4dc:1475:cb0b:24ed:3c80:468b:70cd:1a95'); + }); + + it('should error on IPv6 address containing too many blocks', () => { + formHelper.expectErrorChange( + 'x', + 'c4dc:14753:cb0b:24ed:3c80:468b:70cd:1a95:a3f3', + 'pattern' + ); + }); + + it('should error on IPv6 address containing more than 4 digits per block', () => { + expectPatternError('c4dc:14753:cb0b:24ed:3c80:468b:70cd:1a95'); + }); + + it('should error on IPv6 address containing whitespace', () => { + expectPatternError('c4dc:14753:cb0b:24ed:3c80:468b:70cd:1a95 '); + expectPatternError('c4dc:14753 :cb0b:24ed:3c80 :468b:70cd :1a95'); + expectPatternError(' c4dc:14753:cb0b:24ed:3c80:468b:70cd:1a95'); + }); + + it('should error on IPv6 address containing invalid char', () => { + expectPatternError('c4dx:14753:cb0b:24ed:3c80:468b:70cd:1a95'); + expectPatternError('c4da:14753:cb0b:24ed:3$80:468b:70cd:1a95'); + }); + }); + + it('should accept valid IPv4/6 addresses if not protocol version is given', () => { + const x = form.get('x'); + x.setValidators(CdValidators.ip()); + expectValid('19.117.23.141'); + expectValid('c4dc:1475:cb0b:24ed:3c80:468b:70cd:1a95'); + }); + }); + + describe('uuid validator', () => { + const expectUuidError = (value: string) => + formHelper.expectErrorChange('x', value, 'invalidUuid', true); + beforeEach(() => { + form.get('x').setValidators(CdValidators.uuid()); + }); + + it('should accept empty value', () => { + expectValid(''); + }); + + it('should accept valid version 1 uuid', () => { + expectValid('171af0b2-c305-11e8-a355-529269fb1459'); + }); + + it('should accept valid version 4 uuid', () => { + expectValid('e33bbcb6-fcc3-40b1-ae81-3f81706a35d5'); + }); + + it('should error on uuid containing too many blocks', () => { + expectUuidError('e33bbcb6-fcc3-40b1-ae81-3f81706a35d5-23d3'); + }); + + it('should error on uuid containing too many chars in block', () => { + expectUuidError('aae33bbcb6-fcc3-40b1-ae81-3f81706a35d5'); + }); + + it('should error on uuid containing invalid char', () => { + expectUuidError('x33bbcb6-fcc3-40b1-ae81-3f81706a35d5'); + expectUuidError('$33bbcb6-fcc3-40b1-ae81-3f81706a35d5'); + }); + }); + + describe('number validator', () => { + beforeEach(() => { + form.get('x').setValidators(CdValidators.number()); + }); + + it('should accept empty value', () => { + expectValid(''); + }); + + it('should accept numbers', () => { + expectValid(42); + expectValid(-42); + expectValid('42'); + }); + + it('should error on decimal numbers', () => { + expectPatternError(42.3); + expectPatternError(-42.3); + expectPatternError('42.3'); + }); + + it('should error on chars', () => { + expectPatternError('char'); + expectPatternError('42char'); + }); + + it('should error on whitespaces', () => { + expectPatternError('42 '); + expectPatternError('4 2'); + }); + }); + + describe('number validator (without negative values)', () => { + beforeEach(() => { + form.get('x').setValidators(CdValidators.number(false)); + }); + + it('should accept positive numbers', () => { + expectValid(42); + expectValid('42'); + }); + + it('should error on negative numbers', () => { + expectPatternError(-42); + expectPatternError('-42'); + }); + }); + + describe('decimal number validator', () => { + beforeEach(() => { + form.get('x').setValidators(CdValidators.decimalNumber()); + }); + + it('should accept empty value', () => { + expectValid(''); + }); + + it('should accept numbers and decimal numbers', () => { + expectValid(42); + expectValid(-42); + expectValid(42.3); + expectValid(-42.3); + expectValid('42'); + expectValid('42.3'); + }); + + it('should error on chars', () => { + expectPatternError('42e'); + expectPatternError('e42.3'); + }); + + it('should error on whitespaces', () => { + expectPatternError('42.3 '); + expectPatternError('42 .3'); + }); + }); + + describe('decimal number validator (without negative values)', () => { + beforeEach(() => { + form.get('x').setValidators(CdValidators.decimalNumber(false)); + }); + + it('should accept positive numbers and decimals', () => { + expectValid(42); + expectValid(42.3); + expectValid('42'); + expectValid('42.3'); + }); + + it('should error on negative numbers and decimals', () => { + expectPatternError(-42); + expectPatternError('-42'); + expectPatternError(-42.3); + expectPatternError('-42.3'); + }); + }); + + describe('requiredIf', () => { + beforeEach(() => { + form = new CdFormGroup({ + a: new FormControl(''), + b: new FormControl('xyz'), + x: new FormControl(true), + y: new FormControl('abc'), + z: new FormControl('') + }); + formHelper = new FormHelper(form); + }); + + it('should not error because all conditions are fulfilled', () => { + formHelper.setValue('z', 'zyx'); + const validatorFn = CdValidators.requiredIf({ + x: true, + y: 'abc' + }); + expect(validatorFn(form.get('z'))).toBeNull(); + }); + + it('should not error because of unmet prerequisites', () => { + // Define prereqs that do not match the current values of the form fields. + const validatorFn = CdValidators.requiredIf({ + x: false, + y: 'xyz' + }); + // The validator must succeed because the prereqs do not match, so the + // validation of the 'z' control will be skipped. + expect(validatorFn(form.get('z'))).toBeNull(); + }); + + it('should error because of an empty value', () => { + // Define prereqs that force the validator to validate the value of + // the 'z' control. + const validatorFn = CdValidators.requiredIf({ + x: true, + y: 'abc' + }); + // The validator must fail because the value of control 'z' is empty. + expect(validatorFn(form.get('z'))).toEqual({ required: true }); + }); + + it('should not error because of unsuccessful condition', () => { + formHelper.setValue('z', 'zyx'); + // Define prereqs that force the validator to validate the value of + // the 'z' control. + const validatorFn = CdValidators.requiredIf( + { + x: true, + z: 'zyx' + }, + () => false + ); + expect(validatorFn(form.get('z'))).toBeNull(); + }); + + it('should error because of successful condition', () => { + const conditionFn = (value: string) => { + return value === 'abc'; + }; + // Define prereqs that force the validator to validate the value of + // the 'y' control. + const validatorFn = CdValidators.requiredIf( + { + x: true, + z: '' + }, + conditionFn + ); + expect(validatorFn(form.get('y'))).toEqual({ required: true }); + }); + + it('should process extended prerequisites (1)', () => { + const validatorFn = CdValidators.requiredIf({ + y: { op: '!empty' } + }); + expect(validatorFn(form.get('z'))).toEqual({ required: true }); + }); + + it('should process extended prerequisites (2)', () => { + const validatorFn = CdValidators.requiredIf({ + y: { op: '!empty' } + }); + expect(validatorFn(form.get('b'))).toBeNull(); + }); + + it('should process extended prerequisites (3)', () => { + const validatorFn = CdValidators.requiredIf({ + y: { op: 'minLength', arg1: 2 } + }); + expect(validatorFn(form.get('z'))).toEqual({ required: true }); + }); + + it('should process extended prerequisites (4)', () => { + const validatorFn = CdValidators.requiredIf({ + z: { op: 'empty' } + }); + expect(validatorFn(form.get('a'))).toEqual({ required: true }); + }); + + it('should process extended prerequisites (5)', () => { + const validatorFn = CdValidators.requiredIf({ + z: { op: 'empty' } + }); + expect(validatorFn(form.get('y'))).toBeNull(); + }); + + it('should process extended prerequisites (6)', () => { + const validatorFn = CdValidators.requiredIf({ + y: { op: 'empty' } + }); + expect(validatorFn(form.get('z'))).toBeNull(); + }); + + it('should process extended prerequisites (7)', () => { + const validatorFn = CdValidators.requiredIf({ + y: { op: 'minLength', arg1: 4 } + }); + expect(validatorFn(form.get('z'))).toBeNull(); + }); + + it('should process extended prerequisites (8)', () => { + const validatorFn = CdValidators.requiredIf({ + x: { op: 'equal', arg1: true } + }); + expect(validatorFn(form.get('z'))).toEqual({ required: true }); + }); + + it('should process extended prerequisites (9)', () => { + const validatorFn = CdValidators.requiredIf({ + b: { op: '!equal', arg1: 'abc' } + }); + expect(validatorFn(form.get('z'))).toEqual({ required: true }); + }); + }); + + describe('custom validation', () => { + beforeEach(() => { + form = new CdFormGroup({ + x: new FormControl( + 3, + CdValidators.custom('odd', (x: number) => x % 2 === 1) + ), + y: new FormControl( + 5, + CdValidators.custom('not-dividable-by-x', (y: number) => { + const x = (form && form.get('x').value) || 1; + return y % x !== 0; + }) + ) + }); + formHelper = new FormHelper(form); + }); + + it('should test error and valid condition for odd x', () => { + formHelper.expectError('x', 'odd'); + expectValid(4); + }); + + it('should test error and valid condition for y if its dividable by x', () => { + updateValidity('y'); + formHelper.expectError('y', 'not-dividable-by-x'); + formHelper.expectValidChange('y', 6); + }); + }); + + describe('validate if condition', () => { + beforeEach(() => { + form = new CdFormGroup({ + x: new FormControl(3), + y: new FormControl(5) + }); + CdValidators.validateIf(form.get('x'), () => ((form && form.get('y').value) || 0) > 10, [ + CdValidators.custom('min', (x: number) => x < 7), + CdValidators.custom('max', (x: number) => x > 12) + ]); + formHelper = new FormHelper(form); + }); + + it('should test min error', () => { + formHelper.setValue('y', 11); + updateValidity('x'); + formHelper.expectError('x', 'min'); + }); + + it('should test max error', () => { + formHelper.setValue('y', 11); + formHelper.setValue('x', 13); + formHelper.expectError('x', 'max'); + }); + + it('should test valid number with validation', () => { + formHelper.setValue('y', 11); + formHelper.setValue('x', 12); + formHelper.expectValid('x'); + }); + + it('should validate automatically if dependency controls are defined', () => { + CdValidators.validateIf( + form.get('x'), + () => ((form && form.getValue('y')) || 0) > 10, + [Validators.min(7), Validators.max(12)], + undefined, + [form.get('y')] + ); + + formHelper.expectValid('x'); + formHelper.setValue('y', 13); + formHelper.expectError('x', 'min'); + }); + + it('should always validate the permanentValidators', () => { + CdValidators.validateIf( + form.get('x'), + () => ((form && form.getValue('y')) || 0) > 10, + [Validators.min(7), Validators.max(12)], + [Validators.required], + [form.get('y')] + ); + + formHelper.expectValid('x'); + formHelper.setValue('x', ''); + formHelper.expectError('x', 'required'); + }); + }); + + describe('match', () => { + let y: FormControl; + + beforeEach(() => { + y = new FormControl('aaa'); + form = new CdFormGroup({ + x: new FormControl('aaa'), + y: y + }); + formHelper = new FormHelper(form); + }); + + it('should error when values are different', () => { + formHelper.setValue('y', 'aab'); + CdValidators.match('x', 'y')(form); + formHelper.expectValid('x'); + formHelper.expectError('y', 'match'); + }); + + it('should not error when values are equal', () => { + CdValidators.match('x', 'y')(form); + formHelper.expectValid('x'); + formHelper.expectValid('y'); + }); + + it('should unset error when values are equal', () => { + y.setErrors({ match: true }); + CdValidators.match('x', 'y')(form); + formHelper.expectValid('x'); + formHelper.expectValid('y'); + }); + + it('should keep other existing errors', () => { + y.setErrors({ match: true, notUnique: true }); + CdValidators.match('x', 'y')(form); + formHelper.expectValid('x'); + formHelper.expectError('y', 'notUnique'); + }); + }); + + describe('unique', () => { + beforeEach(() => { + form = new CdFormGroup({ + x: new FormControl( + '', + null, + CdValidators.unique((value) => { + return observableOf('xyz' === value); + }) + ) + }); + formHelper = new FormHelper(form); + }); + + it('should not error because of empty input', () => { + expectValid(''); + }); + + it('should not error because of not existing input', fakeAsync(() => { + formHelper.setValue('x', 'abc', true); + tick(500); + formHelper.expectValid('x'); + })); + + it('should error because of already existing input', fakeAsync(() => { + formHelper.setValue('x', 'xyz', true); + tick(500); + formHelper.expectError('x', 'notUnique'); + })); + }); + + describe('composeIf', () => { + beforeEach(() => { + form = new CdFormGroup({ + x: new FormControl(true), + y: new FormControl('abc'), + z: new FormControl('') + }); + formHelper = new FormHelper(form); + }); + + it('should not error because all conditions are fulfilled', () => { + formHelper.setValue('z', 'zyx'); + const validatorFn = CdValidators.composeIf( + { + x: true, + y: 'abc' + }, + [Validators.required] + ); + expect(validatorFn(form.get('z'))).toBeNull(); + }); + + it('should not error because of unmet prerequisites', () => { + // Define prereqs that do not match the current values of the form fields. + const validatorFn = CdValidators.composeIf( + { + x: false, + y: 'xyz' + }, + [Validators.required] + ); + // The validator must succeed because the prereqs do not match, so the + // validation of the 'z' control will be skipped. + expect(validatorFn(form.get('z'))).toBeNull(); + }); + + it('should error because of an empty value', () => { + // Define prereqs that force the validator to validate the value of + // the 'z' control. + const validatorFn = CdValidators.composeIf( + { + x: true, + y: 'abc' + }, + [Validators.required] + ); + // The validator must fail because the value of control 'z' is empty. + expect(validatorFn(form.get('z'))).toEqual({ required: true }); + }); + }); + + describe('dimmlessBinary validators', () => { + const i18nMock = (a: string, b: { value: string }) => a.replace('{{value}}', b.value); + + beforeEach(() => { + form = new CdFormGroup({ + x: new FormControl('2 KiB', [CdValidators.binaryMin(1024), CdValidators.binaryMax(3072)]) + }); + formHelper = new FormHelper(form); + }); + + it('should not raise exception an exception for valid change', () => { + formHelper.expectValidChange('x', '2.5 KiB'); + }); + + it('should not raise minimum error', () => { + formHelper.expectErrorChange('x', '0.5 KiB', 'binaryMin'); + expect(form.get('x').getError('binaryMin')(i18nMock)).toBe( + 'Size has to be at least 1 KiB or more' + ); + }); + + it('should not raise maximum error', () => { + formHelper.expectErrorChange('x', '4 KiB', 'binaryMax'); + expect(form.get('x').getError('binaryMax')(i18nMock)).toBe( + 'Size has to be at most 3 KiB or less' + ); + }); + }); + + describe('passwordPolicy', () => { + let valid: boolean; + let callbackCalled: boolean; + + const fakeUserService = { + validatePassword: () => { + return observableOf({ valid: valid, credits: 17, valuation: 'foo' }); + } + }; + + beforeEach(() => { + callbackCalled = false; + form = new CdFormGroup({ + x: new FormControl( + '', + null, + CdValidators.passwordPolicy( + fakeUserService, + () => 'admin', + () => { + callbackCalled = true; + } + ) + ) + }); + formHelper = new FormHelper(form); + }); + + it('should not error because of empty input', () => { + expectValid(''); + expect(callbackCalled).toBeTruthy(); + }); + + it('should not error because password matches the policy', fakeAsync(() => { + valid = true; + formHelper.setValue('x', 'abc', true); + tick(500); + formHelper.expectValid('x'); + })); + + it('should error because password does not match the policy', fakeAsync(() => { + valid = false; + formHelper.setValue('x', 'xyz', true); + tick(500); + formHelper.expectError('x', 'passwordPolicy'); + })); + + it('should call the callback function', fakeAsync(() => { + formHelper.setValue('x', 'xyz', true); + tick(500); + expect(callbackCalled).toBeTruthy(); + })); + + describe('sslCert validator', () => { + beforeEach(() => { + form.get('x').setValidators(CdValidators.sslCert()); + }); + + it('should not error because of empty input', () => { + expectValid(''); + }); + + it('should accept SSL certificate', () => { + expectValid( + '-----BEGIN CERTIFICATE-----\n' + + 'MIIC1TCCAb2gAwIBAgIJAM33ZCMvOLVdMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNV\n' + + '...\n' + + '3Ztorm2A5tFB\n' + + '-----END CERTIFICATE-----\n' + + '\n' + ); + }); + + it('should error on invalid SSL certificate (1)', () => { + expectPatternError( + 'MIIC1TCCAb2gAwIBAgIJAM33ZCMvOLVdMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNV\n' + + '...\n' + + '3Ztorm2A5tFB\n' + + '-----END CERTIFICATE-----\n' + + '\n' + ); + }); + + it('should error on invalid SSL certificate (2)', () => { + expectPatternError( + '-----BEGIN CERTIFICATE-----\n' + + 'MIIC1TCCAb2gAwIBAgIJAM33ZCMvOLVdMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNV\n' + ); + }); + }); + + describe('sslPrivKey validator', () => { + beforeEach(() => { + form.get('x').setValidators(CdValidators.sslPrivKey()); + }); + + it('should not error because of empty input', () => { + expectValid(''); + }); + + it('should accept SSL private key', () => { + expectValid( + '-----BEGIN RSA PRIVATE KEY-----\n' + + 'MIIEpQIBAAKCAQEA5VwkMK63D7AoGJVbVpgiV3XlEC1rwwOEpHPZW9F3ZW1fYS1O\n' + + '...\n' + + 'SA4Jbana77S7adg919vNBCLWPAeoN44lI2+B1Ub5DxSnOpBf+zKiScU=\n' + + '-----END RSA PRIVATE KEY-----\n' + + '\n' + ); + }); + + it('should error on invalid SSL private key (1)', () => { + expectPatternError( + 'MIIEpQIBAAKCAQEA5VwkMK63D7AoGJVbVpgiV3XlEC1rwwOEpHPZW9F3ZW1fYS1O\n' + + '...\n' + + 'SA4Jbana77S7adg919vNBCLWPAeoN44lI2+B1Ub5DxSnOpBf+zKiScU=\n' + + '-----END RSA PRIVATE KEY-----\n' + + '\n' + ); + }); + + it('should error on invalid SSL private key (2)', () => { + expectPatternError( + '-----BEGIN RSA PRIVATE KEY-----\n' + + 'MIIEpQIBAAKCAQEA5VwkMK63D7AoGJVbVpgiV3XlEC1rwwOEpHPZW9F3ZW1fYS1O\n' + + '...\n' + + 'SA4Jbana77S7adg919vNBCLWPAeoN44lI2+B1Ub5DxSnOpBf+zKiScU=\n' + ); + }); + }); + }); + describe('bucket', () => { + const testValidator = (name: string, valid: boolean, expectedError?: string) => { + formHelper.setValue('x', name, true); + tick(); + if (valid) { + formHelper.expectValid('x'); + } else { + formHelper.expectError('x', expectedError); + } + }; + + describe('bucketName', () => { + beforeEach(() => { + form = new CdFormGroup({ + x: new FormControl('', null, CdValidators.bucketName()) + }); + formHelper = new FormHelper(form); + }); + + it('bucket name cannot be empty', fakeAsync(() => { + testValidator('', false, 'required'); + })); + + it('bucket names cannot be formatted as IP address', fakeAsync(() => { + const testIPs = ['1.1.1.01', '001.1.1.01', '127.0.0.1']; + for (const ip of testIPs) { + testValidator(ip, false, 'ipAddress'); + } + })); + + it('bucket name must be >= 3 characters long (1/2)', fakeAsync(() => { + testValidator('ab', false, 'shouldBeInRange'); + })); + + it('bucket name must be >= 3 characters long (2/2)', fakeAsync(() => { + testValidator('abc', true); + })); + + it('bucket name must be <= than 63 characters long (1/2)', fakeAsync(() => { + testValidator(_.repeat('a', 64), false, 'shouldBeInRange'); + })); + + it('bucket name must be <= than 63 characters long (2/2)', fakeAsync(() => { + testValidator(_.repeat('a', 63), true); + })); + + it('bucket names must not contain uppercase characters or underscores (1/2)', fakeAsync(() => { + testValidator('iAmInvalid', false, 'bucketNameInvalid'); + })); + + it('bucket names can only contain lowercase letters, numbers, periods and hyphens', fakeAsync(() => { + testValidator('bk@2', false, 'bucketNameInvalid'); + })); + + it('bucket names must not contain uppercase characters or underscores (2/2)', fakeAsync(() => { + testValidator('i_am_invalid', false, 'bucketNameInvalid'); + })); + + it('bucket names must start and end with letters or numbers', fakeAsync(() => { + testValidator('abcd-', false, 'lowerCaseOrNumber'); + })); + + it('bucket labels cannot be empty', fakeAsync(() => { + testValidator('bk.', false, 'onlyLowerCaseAndNumbers'); + })); + + it('bucket names with invalid labels (1/3)', fakeAsync(() => { + testValidator('abc.1def.Ghi2', false, 'bucketNameInvalid'); + })); + + it('bucket names with invalid labels (2/3)', fakeAsync(() => { + testValidator('abc.1_xy', false, 'bucketNameInvalid'); + })); + + it('bucket names with invalid labels (3/3)', fakeAsync(() => { + testValidator('abc.*def', false, 'bucketNameInvalid'); + })); + + it('bucket names must be a series of one or more labels and can contain lowercase letters, numbers, and hyphens (1/3)', fakeAsync(() => { + testValidator('xyz.abc', true); + })); + + it('bucket names must be a series of one or more labels and can contain lowercase letters, numbers, and hyphens (2/3)', fakeAsync(() => { + testValidator('abc.1-def', true); + })); + + it('bucket names must be a series of one or more labels and can contain lowercase letters, numbers, and hyphens (3/3)', fakeAsync(() => { + testValidator('abc.ghi2', true); + })); + + it('bucket names must be unique', fakeAsync(() => { + testValidator('bucket-name-is-unique', true); + })); + + it('bucket names must not contain spaces', fakeAsync(() => { + testValidator('bucket name with spaces', false, 'bucketNameInvalid'); + })); + }); + + describe('bucketExistence', () => { + const rgwBucketService = new RgwBucketService(undefined, undefined); + + beforeEach(() => { + form = new CdFormGroup({ + x: new FormControl('', null, CdValidators.bucketExistence(false, rgwBucketService)) + }); + formHelper = new FormHelper(form); + }); + + it('bucket name cannot be empty', fakeAsync(() => { + testValidator('', false, 'required'); + })); + + it('bucket name should not exist but it does', fakeAsync(() => { + testValidator('testName', false, 'bucketNameNotAllowed'); + })); + + it('bucket name should not exist and it does not', fakeAsync(() => { + mockBucketExists = observableOf(false); + testValidator('testName', true); + })); + + it('bucket name should exist but it does not', fakeAsync(() => { + form.get('x').setAsyncValidators(CdValidators.bucketExistence(true, rgwBucketService)); + mockBucketExists = observableOf(false); + testValidator('testName', false, 'bucketNameNotAllowed'); + })); + + it('bucket name should exist and it does', fakeAsync(() => { + form.get('x').setAsyncValidators(CdValidators.bucketExistence(true, rgwBucketService)); + mockBucketExists = observableOf(true); + testValidator('testName', true); + })); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts new file mode 100644 index 000000000..22371a50f --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts @@ -0,0 +1,612 @@ +import { + AbstractControl, + AsyncValidatorFn, + ValidationErrors, + ValidatorFn, + Validators +} from '@angular/forms'; + +import _ from 'lodash'; +import { Observable, of as observableOf, timer as observableTimer } from 'rxjs'; +import { map, switchMapTo, take } from 'rxjs/operators'; + +import { RgwBucketService } from '~/app/shared/api/rgw-bucket.service'; +import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe'; +import { FormatterService } from '~/app/shared/services/formatter.service'; + +export function isEmptyInputValue(value: any): boolean { + return value == null || value.length === 0; +} + +export type existsServiceFn = (value: any) => Observable<boolean>; + +export class CdValidators { + /** + * Validator that performs email validation. In contrast to the Angular + * email validator an empty email will not be handled as invalid. + */ + static email(control: AbstractControl): ValidationErrors | null { + // Exit immediately if value is empty. + if (isEmptyInputValue(control.value)) { + return null; + } + return Validators.email(control); + } + + /** + * Validator function in order to validate IP addresses. + * @param {number} version determines the protocol version. It needs to be set to 4 for IPv4 and + * to 6 for IPv6 validation. For any other number (it's also the default case) it will return a + * function to validate the input string against IPv4 OR IPv6. + * @returns {ValidatorFn} A validator function that returns an error map containing `pattern` + * if the validation check fails, otherwise `null`. + */ + static ip(version: number = 0): ValidatorFn { + // prettier-ignore + const ipv4Rgx = + /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/i; + const ipv6Rgx = /^(?:[a-f0-9]{1,4}:){7}[a-f0-9]{1,4}$/i; + + if (version === 4) { + return Validators.pattern(ipv4Rgx); + } else if (version === 6) { + return Validators.pattern(ipv6Rgx); + } else { + return Validators.pattern(new RegExp(ipv4Rgx.source + '|' + ipv6Rgx.source)); + } + } + + /** + * Validator function in order to validate numbers. + * @returns {ValidatorFn} A validator function that returns an error map containing `pattern` + * if the validation check fails, otherwise `null`. + */ + static number(allowsNegative: boolean = true): ValidatorFn { + if (allowsNegative) { + return Validators.pattern(/^-?[0-9]+$/i); + } else { + return Validators.pattern(/^[0-9]+$/i); + } + } + + /** + * Validator function in order to validate decimal numbers. + * @returns {ValidatorFn} A validator function that returns an error map containing `pattern` + * if the validation check fails, otherwise `null`. + */ + static decimalNumber(allowsNegative: boolean = true): ValidatorFn { + if (allowsNegative) { + return Validators.pattern(/^-?[0-9]+(.[0-9]+)?$/i); + } else { + return Validators.pattern(/^[0-9]+(.[0-9]+)?$/i); + } + } + + /** + * Validator that performs SSL certificate validation. + * @returns {ValidatorFn} A validator function that returns an error map containing `pattern` + * if the validation check fails, otherwise `null`. + */ + static sslCert(): ValidatorFn { + return Validators.pattern( + /^-----BEGIN CERTIFICATE-----(\n|\r|\f)((.+)?((\n|\r|\f).+)*)(\n|\r|\f)-----END CERTIFICATE-----[\n\r\f]*$/ + ); + } + + /** + * Validator that performs SSL private key validation. + * @returns {ValidatorFn} A validator function that returns an error map containing `pattern` + * if the validation check fails, otherwise `null`. + */ + static sslPrivKey(): ValidatorFn { + return Validators.pattern( + /^-----BEGIN RSA PRIVATE KEY-----(\n|\r|\f)((.+)?((\n|\r|\f).+)*)(\n|\r|\f)-----END RSA PRIVATE KEY-----[\n\r\f]*$/ + ); + } + + /** + * Validator that performs SSL certificate validation of pem format. + * @returns {ValidatorFn} A validator function that returns an error map containing `pattern` + * if the validation check fails, otherwise `null`. + */ + static pemCert(): ValidatorFn { + return Validators.pattern(/^-----BEGIN .+-----$.+^-----END .+-----$/ms); + } + + /** + * Validator that requires controls to fulfill the specified condition if + * the specified prerequisites matches. If the prerequisites are fulfilled, + * then the given function is executed and if it succeeds, the 'required' + * validation error will be returned, otherwise null. + * @param {Object} prerequisites An object containing the prerequisites. + * To do additional checks rather than checking for equality you can + * use the extended prerequisite syntax: + * 'field_name': { 'op': '<OPERATOR>', arg1: '<OPERATOR_ARGUMENT>' } + * The following operators are supported: + * * empty + * * !empty + * * equal + * * !equal + * * minLength + * ### Example + * ```typescript + * { + * 'generate_key': true, + * 'username': 'Max Mustermann' + * } + * ``` + * ### Example - Extended prerequisites + * ```typescript + * { + * 'generate_key': { 'op': 'equal', 'arg1': true }, + * 'username': { 'op': 'minLength', 'arg1': 5 } + * } + * ``` + * Only if all prerequisites are fulfilled, then the validation of the + * control will be triggered. + * @param {Function | undefined} condition The function to be executed when all + * prerequisites are fulfilled. If not set, then the {@link isEmptyInputValue} + * function will be used by default. The control's value is used as function + * argument. The function must return true to set the validation error. + * @return {ValidatorFn} Returns the validator function. + */ + static requiredIf(prerequisites: object, condition?: Function | undefined): ValidatorFn { + let isWatched = false; + + return (control: AbstractControl): ValidationErrors | null => { + if (!isWatched && control.parent) { + Object.keys(prerequisites).forEach((key) => { + control.parent.get(key).valueChanges.subscribe(() => { + control.updateValueAndValidity({ emitEvent: false }); + }); + }); + + isWatched = true; + } + + // Check if all prerequisites met. + if ( + !Object.keys(prerequisites).every((key) => { + if (!control.parent) { + return false; + } + const value = control.parent.get(key).value; + const prerequisite = prerequisites[key]; + if (_.isObjectLike(prerequisite)) { + let result = false; + switch (prerequisite['op']) { + case 'empty': + result = _.isEmpty(value); + break; + case '!empty': + result = !_.isEmpty(value); + break; + case 'equal': + result = value === prerequisite['arg1']; + break; + case '!equal': + result = value !== prerequisite['arg1']; + break; + case 'minLength': + if (_.isString(value)) { + result = value.length >= prerequisite['arg1']; + } + break; + } + return result; + } + return value === prerequisite; + }) + ) { + return null; + } + const success = _.isFunction(condition) + ? condition.call(condition, control.value) + : isEmptyInputValue(control.value); + return success ? { required: true } : null; + }; + } + + /** + * Compose multiple validators into a single function that returns the union of + * the individual error maps for the provided control when the given prerequisites + * are fulfilled. + * + * @param {Object} prerequisites An object containing the prerequisites as + * key/value pairs. + * ### Example + * ```typescript + * { + * 'generate_key': true, + * 'username': 'Max Mustermann' + * } + * ``` + * @param {ValidatorFn[]} validators List of validators that should be taken + * into action when the prerequisites are met. + * @return {ValidatorFn} Returns the validator function. + */ + static composeIf(prerequisites: object, validators: ValidatorFn[]): ValidatorFn { + let isWatched = false; + return (control: AbstractControl): ValidationErrors | null => { + if (!isWatched && control.parent) { + Object.keys(prerequisites).forEach((key) => { + control.parent.get(key).valueChanges.subscribe(() => { + control.updateValueAndValidity({ emitEvent: false }); + }); + }); + isWatched = true; + } + // Check if all prerequisites are met. + if ( + !Object.keys(prerequisites).every((key) => { + return control.parent && control.parent.get(key).value === prerequisites[key]; + }) + ) { + return null; + } + return Validators.compose(validators)(control); + }; + } + + /** + * Custom validation by passing a name for the error and a function as error condition. + * + * @param {string} error + * @param {Function} condition - a truthy return value will trigger the error + * @returns {ValidatorFn} + */ + static custom(error: string, condition: Function): ValidatorFn { + return (control: AbstractControl): { [key: string]: any } => { + const value = condition.call(this, control.value); + if (value) { + return { [error]: value }; + } + return null; + }; + } + + /** + * Validate form control if condition is true with validators. + * + * @param {AbstractControl} formControl + * @param {Function} condition + * @param {ValidatorFn[]} conditionalValidators List of validators that should only be tested + * when the condition is met + * @param {ValidatorFn[]} permanentValidators List of validators that should always be tested + * @param {AbstractControl[]} watchControls List of controls that the condition depend on. + * Every time one of this controls value is updated, the validation will be triggered + */ + static validateIf( + formControl: AbstractControl, + condition: Function, + conditionalValidators: ValidatorFn[], + permanentValidators: ValidatorFn[] = [], + watchControls: AbstractControl[] = [] + ) { + conditionalValidators = conditionalValidators.concat(permanentValidators); + + formControl.setValidators((control: AbstractControl): { + [key: string]: any; + } => { + const value = condition.call(this); + if (value) { + return Validators.compose(conditionalValidators)(control); + } + if (permanentValidators.length > 0) { + return Validators.compose(permanentValidators)(control); + } + return null; + }); + + watchControls.forEach((control: AbstractControl) => { + control.valueChanges.subscribe(() => { + formControl.updateValueAndValidity({ emitEvent: false }); + }); + }); + } + + /** + * Validator that requires that both specified controls have the same value. + * Error will be added to the `path2` control. + * @param {string} path1 A dot-delimited string that define the path to the control. + * @param {string} path2 A dot-delimited string that define the path to the control. + * @return {ValidatorFn} Returns a validator function that always returns `null`. + * If the validation fails an error map with the `match` property will be set + * on the `path2` control. + */ + static match(path1: string, path2: string): ValidatorFn { + return (control: AbstractControl): { [key: string]: any } => { + const ctrl1 = control.get(path1); + const ctrl2 = control.get(path2); + if (!ctrl1 || !ctrl2) { + return null; + } + if (ctrl1.value !== ctrl2.value) { + ctrl2.setErrors({ match: true }); + } else { + const hasError = ctrl2.hasError('match'); + if (hasError) { + // Remove the 'match' error. If no more errors exists, then set + // the error value to 'null', otherwise the field is still marked + // as invalid. + const errors = ctrl2.errors; + _.unset(errors, 'match'); + ctrl2.setErrors(_.isEmpty(_.keys(errors)) ? null : errors); + } + } + return null; + }; + } + + /** + * Asynchronous validator that requires the control's value to be unique. + * The validation is only executed after the specified delay. Every + * keystroke during this delay will restart the timer. + * @param serviceFn {existsServiceFn} The service function that is + * called to check whether the given value exists. It must return + * boolean 'true' if the given value exists, otherwise 'false'. + * @param serviceFnThis {any} The object to be used as the 'this' object + * when calling the serviceFn function. Defaults to null. + * @param {number|Date} dueTime The delay time to wait before the + * serviceFn call is executed. This is useful to prevent calls on + * every keystroke. Defaults to 500. + * @return {AsyncValidatorFn} Returns an asynchronous validator function + * that returns an error map with the `notUnique` property if the + * validation check succeeds, otherwise `null`. + */ + static unique( + serviceFn: existsServiceFn, + serviceFnThis: any = null, + usernameFn?: Function, + uidField = false + ): AsyncValidatorFn { + let uName: string; + return (control: AbstractControl): Observable<ValidationErrors | null> => { + // Exit immediately if user has not interacted with the control yet + // or the control value is empty. + if (control.pristine || isEmptyInputValue(control.value)) { + return observableOf(null); + } + uName = control.value; + if (_.isFunction(usernameFn) && usernameFn() !== null && usernameFn() !== '') { + if (uidField) { + uName = `${control.value}$${usernameFn()}`; + } else { + uName = `${usernameFn()}$${control.value}`; + } + } + + return observableTimer().pipe( + switchMapTo(serviceFn.call(serviceFnThis, uName)), + map((resp: boolean) => { + if (!resp) { + return null; + } else { + return { notUnique: true }; + } + }), + take(1) + ); + }; + } + + /** + * Validator function for UUIDs. + * @param required - Defines if it is mandatory to fill in the UUID + * @return Validator function that returns an error object containing `invalidUuid` if the + * validation failed, `null` otherwise. + */ + static uuid(required = false): ValidatorFn { + const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + return (control: AbstractControl): { [key: string]: any } | null => { + if (control.pristine && control.untouched) { + return null; + } else if (!required && !control.value) { + return null; + } else if (uuidRe.test(control.value)) { + return null; + } + return { invalidUuid: 'This is not a valid UUID' }; + }; + } + + /** + * A simple minimum validator vor cd-binary inputs. + * + * To use the validation message pass I18n into the function as it cannot + * be called in a static one. + */ + static binaryMin(bytes: number): ValidatorFn { + return (control: AbstractControl): { [key: string]: () => string } | null => { + const formatterService = new FormatterService(); + const currentBytes = new FormatterService().toBytes(control.value); + if (bytes <= currentBytes) { + return null; + } + const value = new DimlessBinaryPipe(formatterService).transform(bytes); + return { + binaryMin: () => $localize`Size has to be at least ${value} or more` + }; + }; + } + + /** + * A simple maximum validator vor cd-binary inputs. + * + * To use the validation message pass I18n into the function as it cannot + * be called in a static one. + */ + static binaryMax(bytes: number): ValidatorFn { + return (control: AbstractControl): { [key: string]: () => string } | null => { + const formatterService = new FormatterService(); + const currentBytes = formatterService.toBytes(control.value); + if (bytes >= currentBytes) { + return null; + } + const value = new DimlessBinaryPipe(formatterService).transform(bytes); + return { + binaryMax: () => $localize`Size has to be at most ${value} or less` + }; + }; + } + + /** + * Asynchronous validator that checks if the password meets the password + * policy. + * @param userServiceThis The object to be used as the 'this' object + * when calling the 'validatePassword' method of the 'UserService'. + * @param usernameFn Function to get the username that should be + * taken into account. + * @param callback Callback function that is called after the validation + * has been done. + * @return {AsyncValidatorFn} Returns an asynchronous validator function + * that returns an error map with the `passwordPolicy` property if the + * validation check fails, otherwise `null`. + */ + static passwordPolicy( + userServiceThis: any, + usernameFn?: Function, + callback?: (valid: boolean, credits?: number, valuation?: string) => void + ): AsyncValidatorFn { + return (control: AbstractControl): Observable<ValidationErrors | null> => { + if (control.pristine || control.value === '') { + if (_.isFunction(callback)) { + callback(true, 0); + } + return observableOf(null); + } + let username; + if (_.isFunction(usernameFn)) { + username = usernameFn(); + } + return observableTimer(500).pipe( + switchMapTo(_.invoke(userServiceThis, 'validatePassword', control.value, username)), + map((resp: { valid: boolean; credits: number; valuation: string }) => { + if (_.isFunction(callback)) { + callback(resp.valid, resp.credits, resp.valuation); + } + if (resp.valid) { + return null; + } else { + return { passwordPolicy: true }; + } + }), + take(1) + ); + }; + } + + /** + * Validate the bucket name. In general, bucket names should follow domain + * name constraints: + * - Bucket names must be unique. + * - Bucket names cannot be formatted as IP address. + * - Bucket names can be between 3 and 63 characters long. + * - Bucket names must not contain uppercase characters or underscores. + * - Bucket names must start with a lowercase letter or number. + * - Bucket names must be a series of one or more labels. Adjacent + * labels are separated by a single period (.). Bucket names can + * contain lowercase letters, numbers, and hyphens. Each label must + * start and end with a lowercase letter or a number. + */ + static bucketName(): AsyncValidatorFn { + return (control: AbstractControl): Observable<ValidationErrors | null> => { + if (control.pristine || !control.value) { + return observableOf({ required: true }); + } + const constraints = []; + let errorName: string; + // - Bucket names cannot be formatted as IP address. + constraints.push(() => { + const ipv4Rgx = /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/i; + const ipv6Rgx = /^(?:[a-f0-9]{1,4}:){7}[a-f0-9]{1,4}$/i; + const name = control.value; + let notIP = true; + if (ipv4Rgx.test(name) || ipv6Rgx.test(name)) { + errorName = 'ipAddress'; + notIP = false; + } + return notIP; + }); + // - Bucket names can be between 3 and 63 characters long. + constraints.push((name: string) => { + if (!_.inRange(name.length, 3, 64)) { + errorName = 'shouldBeInRange'; + return false; + } + // Bucket names can only contain lowercase letters, numbers, periods and hyphens. + if (!/^[0-9a-z.-]+$/.test(control.value)) { + errorName = 'bucketNameInvalid'; + return false; + } + return true; + }); + // - Bucket names must not contain uppercase characters or underscores. + // - Bucket names must start with a lowercase letter or number. + // - Bucket names must be a series of one or more labels. Adjacent + // labels are separated by a single period (.). Bucket names can + // contain lowercase letters, numbers, and hyphens. Each label must + // start and end with a lowercase letter or a number. + constraints.push((name: string) => { + const labels = _.split(name, '.'); + return _.every(labels, (label) => { + // Bucket names must not contain uppercase characters or underscores. + if (label !== _.toLower(label) || label.includes('_')) { + errorName = 'containsUpperCase'; + return false; + } + // Bucket labels can contain lowercase letters, numbers, and hyphens. + if (!/^[0-9a-z-]+$/.test(label)) { + errorName = 'onlyLowerCaseAndNumbers'; + return false; + } + // Each label must start and end with a lowercase letter or a number. + return _.every([0, label.length - 1], (index) => { + errorName = 'lowerCaseOrNumber'; + return /[a-z]/.test(label[index]) || _.isInteger(_.parseInt(label[index])); + }); + }); + }); + if (!_.every(constraints, (func: Function) => func(control.value))) { + return observableOf( + (() => { + switch (errorName) { + case 'onlyLowerCaseAndNumbers': + return { onlyLowerCaseAndNumbers: true }; + case 'shouldBeInRange': + return { shouldBeInRange: true }; + case 'ipAddress': + return { ipAddress: true }; + case 'containsUpperCase': + return { containsUpperCase: true }; + case 'lowerCaseOrNumber': + return { lowerCaseOrNumber: true }; + default: + return { bucketNameInvalid: true }; + } + })() + ); + } + + return observableOf(null); + }; + } + + static bucketExistence( + requiredExistenceResult: boolean, + rgwBucketService: RgwBucketService + ): AsyncValidatorFn { + return (control: AbstractControl): Observable<ValidationErrors | null> => { + if (control.pristine || !control.value) { + return observableOf({ required: true }); + } + return rgwBucketService + .exists(control.value) + .pipe( + map((existenceResult: boolean) => + existenceResult === requiredExistenceResult ? null : { bucketNameNotAllowed: true } + ) + ); + }; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/alertmanager-silence.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/alertmanager-silence.ts new file mode 100644 index 000000000..b7b886295 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/alertmanager-silence.ts @@ -0,0 +1,23 @@ +export class AlertmanagerSilenceMatcher { + name: string; + value: any; + isRegex: boolean; +} + +export class AlertmanagerSilenceMatcherMatch { + status: string; + cssClass: string; +} + +export class AlertmanagerSilence { + id?: string; + matchers: AlertmanagerSilenceMatcher[]; + startsAt: string; // DateStr + endsAt: string; // DateStr + updatedAt?: string; // DateStr + createdBy: string; + comment: string; + status?: { + state: 'expired' | 'active' | 'pending'; + }; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/breadcrumbs.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/breadcrumbs.ts new file mode 100644 index 000000000..10e799929 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/breadcrumbs.ts @@ -0,0 +1,59 @@ +/* +The MIT License + +Copyright (c) 2017 (null) McNull https://github.com/McNull + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + */ + +import { ActivatedRouteSnapshot, Resolve, UrlSegment } from '@angular/router'; + +import { Observable, of } from 'rxjs'; + +export class BreadcrumbsResolver implements Resolve<IBreadcrumb[]> { + public resolve( + route: ActivatedRouteSnapshot + ): Observable<IBreadcrumb[]> | Promise<IBreadcrumb[]> | IBreadcrumb[] { + const data = route.routeConfig.data; + const path = data.path === null ? null : this.getFullPath(route); + + const text = + typeof data.breadcrumbs === 'string' + ? data.breadcrumbs + : data.breadcrumbs.text || data.text || path; + + const crumbs: IBreadcrumb[] = [{ text: text, path: path }]; + + return of(crumbs); + } + + public getFullPath(route: ActivatedRouteSnapshot): string { + const relativePath = (segments: UrlSegment[]) => + segments.reduce((a, v) => (a += '/' + v.path), ''); + const fullPath = (routes: ActivatedRouteSnapshot[]) => + routes.reduce((a, v) => (a += relativePath(v.url)), ''); + + return fullPath(route.pathFromRoot); + } +} + +export interface IBreadcrumb { + text: string; + path: string; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-form-modal-field-config.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-form-modal-field-config.ts new file mode 100644 index 000000000..e327be59a --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-form-modal-field-config.ts @@ -0,0 +1,32 @@ +import { ValidatorFn } from '@angular/forms'; + +export class CdFormModalFieldConfig { + // --- Generic field properties --- + name: string; + // 'binary' will use cdDimlessBinary directive on input element + // 'select' will use select element + type: 'number' | 'text' | 'binary' | 'select' | 'select-badges'; + label?: string; + required?: boolean; + value?: any; + errors?: { [errorName: string]: string }; + validators: ValidatorFn[]; + + // --- Specific field properties --- + typeConfig?: { + [prop: string]: any; + // 'select': + // --------- + // placeholder?: string; + // options?: Array<{ + // text: string; + // value: any; + // }>; + // + // 'select-badges': + // ---------------- + // customBadges: boolean; + // options: Array<SelectOption>; + // messages: SelectMessages; + }; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-notification.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-notification.spec.ts new file mode 100644 index 000000000..df6e8899b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-notification.spec.ts @@ -0,0 +1,95 @@ +import { NotificationType } from '../enum/notification-type.enum'; +import { CdNotification, CdNotificationConfig } from './cd-notification'; + +describe('cd-notification classes', () => { + const expectObject = (something: object, expected: object) => { + Object.keys(expected).forEach((key) => expect(something[key]).toBe(expected[key])); + }; + + // As these Models have a view methods they need to be tested + describe('CdNotificationConfig', () => { + it('should create a new config without any parameters', () => { + expectObject(new CdNotificationConfig(), { + application: 'Ceph', + applicationClass: 'ceph-icon', + message: undefined, + options: undefined, + title: undefined, + type: 1 + }); + }); + + it('should create a new config with parameters', () => { + expectObject( + new CdNotificationConfig( + NotificationType.error, + 'Some Alert', + 'Something failed', + undefined, + 'Prometheus' + ), + { + application: 'Prometheus', + applicationClass: 'prometheus-icon', + message: 'Something failed', + options: undefined, + title: 'Some Alert', + type: 0 + } + ); + }); + }); + + describe('CdNotification', () => { + beforeEach(() => { + const baseTime = new Date('2022-02-22'); + spyOn(global, 'Date').and.returnValue(baseTime); + }); + + it('should create a new config without any parameters', () => { + expectObject(new CdNotification(), { + application: 'Ceph', + applicationClass: 'ceph-icon', + iconClass: 'fa fa-info', + message: undefined, + options: undefined, + textClass: 'text-info', + timestamp: '2022-02-22T00:00:00.000Z', + title: undefined, + type: 1 + }); + }); + + it('should create a new config with parameters', () => { + expectObject( + new CdNotification( + new CdNotificationConfig( + NotificationType.error, + 'Some Alert', + 'Something failed', + undefined, + 'Prometheus' + ) + ), + { + application: 'Prometheus', + applicationClass: 'prometheus-icon', + iconClass: 'fa fa-exclamation-triangle', + message: 'Something failed', + options: undefined, + textClass: 'text-danger', + timestamp: '2022-02-22T00:00:00.000Z', + title: 'Some Alert', + type: 0 + } + ); + }); + + it('should expect the right success classes', () => { + expectObject(new CdNotification(new CdNotificationConfig(NotificationType.success)), { + iconClass: 'fa fa-check', + textClass: 'text-success' + }); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-notification.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-notification.ts new file mode 100644 index 000000000..c283c5d80 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-notification.ts @@ -0,0 +1,48 @@ +import { IndividualConfig } from 'ngx-toastr'; + +import { Icons } from '../enum/icons.enum'; +import { NotificationType } from '../enum/notification-type.enum'; + +export class CdNotificationConfig { + applicationClass: string; + isFinishedTask = false; + + private classes = { + Ceph: 'ceph-icon', + Prometheus: 'prometheus-icon' + }; + + constructor( + public type: NotificationType = NotificationType.info, + public title?: string, + public message?: string, // Use this for additional information only + public options?: any | IndividualConfig, + public application: string = 'Ceph' + ) { + this.applicationClass = this.classes[this.application]; + } +} + +export class CdNotification extends CdNotificationConfig { + timestamp: string; + textClass: string; + iconClass: string; + duration: number; + borderClass: string; + + private textClasses = ['text-danger', 'text-info', 'text-success']; + private iconClasses = [Icons.warning, Icons.info, Icons.check]; + private borderClasses = ['border-danger', 'border-info', 'border-success']; + + constructor(private config: CdNotificationConfig = new CdNotificationConfig()) { + super(config.type, config.title, config.message, config.options, config.application); + delete this.config; + /* string representation of the Date object so it can be directly compared + with the timestamps parsed from localStorage */ + this.timestamp = new Date().toJSON(); + this.iconClass = this.iconClasses[this.type]; + this.textClass = this.textClasses[this.type]; + this.borderClass = this.borderClasses[this.type]; + this.isFinishedTask = config.isFinishedTask; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-pwd-expiration-settings.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-pwd-expiration-settings.ts new file mode 100644 index 000000000..53b9d14fd --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-pwd-expiration-settings.ts @@ -0,0 +1,11 @@ +export class CdPwdExpirationSettings { + pwdExpirationSpan = 0; + pwdExpirationWarning1: number; + pwdExpirationWarning2: number; + + constructor(settings: { [key: string]: any }) { + this.pwdExpirationSpan = settings.user_pwd_expiration_span; + this.pwdExpirationWarning1 = settings.user_pwd_expiration_warning_1; + this.pwdExpirationWarning2 = settings.user_pwd_expiration_warning_2; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-pwd-policy-settings.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-pwd-policy-settings.ts new file mode 100644 index 000000000..fef570f21 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-pwd-policy-settings.ts @@ -0,0 +1,23 @@ +export class CdPwdPolicySettings { + pwdPolicyEnabled: boolean; + pwdPolicyMinLength: number; + pwdPolicyCheckLengthEnabled: boolean; + pwdPolicyCheckOldpwdEnabled: boolean; + pwdPolicyCheckUsernameEnabled: boolean; + pwdPolicyCheckExclusionListEnabled: boolean; + pwdPolicyCheckRepetitiveCharsEnabled: boolean; + pwdPolicyCheckSequentialCharsEnabled: boolean; + pwdPolicyCheckComplexityEnabled: boolean; + + constructor(settings: { [key: string]: any }) { + this.pwdPolicyEnabled = settings.pwd_policy_enabled; + this.pwdPolicyMinLength = settings.pwd_policy_min_length; + this.pwdPolicyCheckLengthEnabled = settings.pwd_policy_check_length_enabled; + this.pwdPolicyCheckOldpwdEnabled = settings.pwd_policy_check_oldpwd_enabled; + this.pwdPolicyCheckUsernameEnabled = settings.pwd_policy_check_username_enabled; + this.pwdPolicyCheckExclusionListEnabled = settings.pwd_policy_check_exclusion_list_enabled; + this.pwdPolicyCheckRepetitiveCharsEnabled = settings.pwd_policy_check_repetitive_chars_enabled; + this.pwdPolicyCheckSequentialCharsEnabled = settings.pwd_policy_check_sequential_chars_enabled; + this.pwdPolicyCheckComplexityEnabled = settings.pwd_policy_check_complexity_enabled; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-action.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-action.ts new file mode 100644 index 000000000..70f06e506 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-action.ts @@ -0,0 +1,44 @@ +import { CdTableSelection } from './cd-table-selection'; + +export class CdTableAction { + // It's possible to assign a string + // or a function that returns the link if it has to be dynamic + // or none if it's not needed + routerLink?: string | Function; + + preserveFragment? = false; + + // This is the function that will be triggered on a click event if defined + click?: Function; + + permission: 'create' | 'update' | 'delete' | 'read'; + + // The name of the action + name: string; + + // The font awesome icon that will be used + icon: string; + + /** + * You can define the condition to disable the action. + * By default all 'update' and 'delete' actions will only be enabled + * if one selection is made and no task is running on the selected item.` + * + * In some cases you might want to give the user a hint why a button is + * disabled. This is achieved by returning a string. + * */ + disable?: (_: CdTableSelection) => boolean | string; + + /** + * Defines if the button can become 'primary' (displayed as button and not + * 'hidden' in the menu). Only one button can be primary at a time. By + * default all 'create' actions can be the action button if no or multiple + * items are selected. Also, all 'update' and 'delete' actions can be the + * action button by default, provided only one item is selected. + */ + canBePrimary?: (_: CdTableSelection) => boolean; + + // In some rare cases you want to hide a action that can be used by the user for example + // if one action can lock the item and another action unlocks it + visible?: (_: CdTableSelection) => boolean; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-column-filter.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-column-filter.ts new file mode 100644 index 000000000..ccdbe82fc --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-column-filter.ts @@ -0,0 +1,7 @@ +import { CdTableColumn } from './cd-table-column'; + +export interface CdTableColumnFilter { + column: CdTableColumn; + options: { raw: string; formatted: string }[]; // possible options of a filter + value?: { raw: string; formatted: string }; // selected option +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-column-filters-change.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-column-filters-change.ts new file mode 100644 index 000000000..17601f0ad --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-column-filters-change.ts @@ -0,0 +1,22 @@ +import { TableColumnProp } from '@swimlane/ngx-datatable'; + +export interface CdTableColumnFiltersChange { + /** + * Applied filters. + */ + filters: { + name: string; + prop: TableColumnProp; + value: { raw: string; formatted: string }; + }[]; + + /** + * Filtered data. + */ + data: any[]; + + /** + * Filtered out data. + */ + dataOut: any[]; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-column.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-column.ts new file mode 100644 index 000000000..4ed5fdd58 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-column.ts @@ -0,0 +1,38 @@ +import { TableColumn, TableColumnProp } from '@swimlane/ngx-datatable'; + +import { CellTemplate } from '../enum/cell-template.enum'; + +export interface CdTableColumn extends TableColumn { + cellTransformation?: CellTemplate; + isHidden?: boolean; + prop: TableColumnProp; // Enforces properties to get sortable columns + customTemplateConfig?: any; // Custom configuration used by cell templates. + + /** + * Add a filter for the column if true. + * + * By default, options for the filter are deduced from values of the column. + */ + filterable?: boolean; + + /** + * Use these options for filter rather than deducing from values of the column. + * + * If there is a pipe function associated with the column, pipe function is applied + * to the options before displaying them. + */ + filterOptions?: any[]; + + /** + * Default applied option, should be value in filterOptions. + */ + filterInitValue?: any; + + /** + * Specify a custom function for filtering. + * + * By default, the filter compares if values are string-equal with options. Specify + * a customize function if that's not desired. Return true to include a row. + */ + filterPredicate?: (row: any, value: any) => boolean; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-fetch-data-context.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-fetch-data-context.ts new file mode 100644 index 000000000..7937d82e6 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-fetch-data-context.ts @@ -0,0 +1,44 @@ +import { HttpParams } from '@angular/common/http'; + +import { PageInfo } from './cd-table-paging'; + +export class CdTableFetchDataContext { + errorConfig = { + resetData: true, // Force data table to show no data + displayError: true // Show an error panel above the data table + }; + + /** + * The function that should be called from within the error handler + * of the 'fetchData' function to display the error panel and to + * reset the data table to the correct state. + */ + error: Function; + pageInfo: PageInfo = new PageInfo(); + search = ''; + sort = '+name'; + + constructor(error: () => void) { + this.error = error; + } + + toParams(): HttpParams { + if (this.pageInfo.limit === null) { + this.pageInfo.limit = 0; + } + if (!this.search) { + this.search = ''; + } + if (!this.sort || this.sort.length < 2) { + this.sort = '+name'; + } + return new HttpParams({ + fromObject: { + offset: String(this.pageInfo.offset * this.pageInfo.limit), + limit: String(this.pageInfo.limit), + search: this.search, + sort: this.sort + } + }); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-paging.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-paging.ts new file mode 100644 index 000000000..3693b527d --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-paging.ts @@ -0,0 +1,20 @@ +export const PAGE_LIMIT = 10; + +export class PageInfo { + // Total number of rows in a table + count: number; + + // Current page (current row = offset x limit or pageSize) + offset = 0; + + // Max. number of rows fetched from the server + limit: number = PAGE_LIMIT; + + /* + pageSize and limit can be decoupled if hybrid server-side and client-side + are used. A use-case would be to reduce the amount of queries: that is, + the pageSize (client-side paging) might be 10, but the back-end queries + could have a limit of 100. That would avoid triggering requests + */ + pageSize: number = PAGE_LIMIT; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-selection.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-selection.ts new file mode 100644 index 000000000..bbe1e5088 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-table-selection.ts @@ -0,0 +1,45 @@ +export class CdTableSelection { + private _selected: any[] = []; + hasMultiSelection: boolean; + hasSingleSelection: boolean; + hasSelection: boolean; + + constructor(rows?: any[]) { + if (rows) { + this._selected = rows; + } + this.update(); + } + + /** + * Recalculate the variables based on the current number + * of selected rows. + */ + private update() { + this.hasSelection = this._selected.length > 0; + this.hasSingleSelection = this._selected.length === 1; + this.hasMultiSelection = this._selected.length > 1; + } + + set selected(selection: any[]) { + this._selected = selection; + this.update(); + } + + get selected() { + return this._selected; + } + + add(row: any) { + this._selected.push(row); + this.update(); + } + + /** + * Get the first selected row. + * @return {any | null} + */ + first() { + return this.hasSelection ? this._selected[0] : null; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-user-config.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-user-config.ts new file mode 100644 index 000000000..edd1af784 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-user-config.ts @@ -0,0 +1,11 @@ +import { SortPropDir } from '@swimlane/ngx-datatable'; + +import { CdTableColumn } from './cd-table-column'; + +export interface CdUserConfig { + limit?: number; + offset?: number; + search?: string; + sorts?: SortPropDir[]; + columns?: CdTableColumn[]; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs-directory-models.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs-directory-models.ts new file mode 100644 index 000000000..92186aecc --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs-directory-models.ts @@ -0,0 +1,21 @@ +import { TreeStatus } from '@swimlane/ngx-datatable'; + +export class CephfsSnapshot { + name: string; + path: string; + created: string; +} + +export class CephfsQuotas { + max_bytes?: number; + max_files?: number; +} + +export class CephfsDir { + name: string; + path: string; + quotas: CephfsQuotas; + snapshots: CephfsSnapshot[]; + parent: string; + treeStatus?: TreeStatus; // Needed for table tree view +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/chart-tooltip.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/chart-tooltip.ts new file mode 100644 index 000000000..93a259e79 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/chart-tooltip.ts @@ -0,0 +1,115 @@ +import { ElementRef } from '@angular/core'; + +export class ChartTooltip { + tooltipEl: any; + chartEl: any; + getStyleLeft: Function; + getStyleTop: Function; + customColors: Record<string, any> = { + backgroundColor: undefined, + borderColor: undefined + }; + checkOffset = false; + + /** + * Creates an instance of ChartTooltip. + * @param {ElementRef} chartCanvas Canvas Element + * @param {ElementRef} chartTooltip Tooltip Element + * @param {Function} getStyleLeft Function that calculates the value of Left + * @param {Function} getStyleTop Function that calculates the value of Top + * @memberof ChartTooltip + */ + constructor( + chartCanvas: ElementRef, + chartTooltip: ElementRef, + getStyleLeft: Function, + getStyleTop: Function + ) { + this.chartEl = chartCanvas.nativeElement; + this.getStyleLeft = getStyleLeft; + this.getStyleTop = getStyleTop; + this.tooltipEl = chartTooltip.nativeElement; + } + + /** + * Implementation of a ChartJS custom tooltip function. + * + * @param {any} tooltip + * @memberof ChartTooltip + */ + customTooltips(tooltip: any) { + // Hide if no tooltip + if (tooltip.opacity === 0) { + this.tooltipEl.style.opacity = 0; + return; + } + + // Set caret Position + this.tooltipEl.classList.remove('above', 'below', 'no-transform'); + if (tooltip.yAlign) { + this.tooltipEl.classList.add(tooltip.yAlign); + } else { + this.tooltipEl.classList.add('no-transform'); + } + + // Set Text + if (tooltip.body) { + const titleLines = tooltip.title || []; + const bodyLines = tooltip.body.map((bodyItem: any) => { + return bodyItem.lines; + }); + + let innerHtml = '<thead>'; + + titleLines.forEach((title: string) => { + innerHtml += '<tr><th>' + this.getTitle(title) + '</th></tr>'; + }); + innerHtml += '</thead><tbody>'; + + bodyLines.forEach((body: string, i: number) => { + const colors = tooltip.labelColors[i]; + let style = 'background:' + (this.customColors.backgroundColor || colors.backgroundColor); + style += '; border-color:' + (this.customColors.borderColor || colors.borderColor); + style += '; border-width: 2px'; + const span = '<span class="chartjs-tooltip-key" style="' + style + '"></span>'; + innerHtml += '<tr><td nowrap>' + span + this.getBody(body) + '</td></tr>'; + }); + innerHtml += '</tbody>'; + + const tableRoot = this.tooltipEl.querySelector('table'); + tableRoot.innerHTML = innerHtml; + } + + const positionY = this.chartEl.offsetTop; + const positionX = this.chartEl.offsetLeft; + + // Display, position, and set styles for font + if (this.checkOffset) { + const halfWidth = tooltip.width / 2; + this.tooltipEl.classList.remove('transform-left'); + this.tooltipEl.classList.remove('transform-right'); + if (tooltip.caretX - halfWidth < 0) { + this.tooltipEl.classList.add('transform-left'); + } else if (tooltip.caretX + halfWidth > this.chartEl.width) { + this.tooltipEl.classList.add('transform-right'); + } + } + + this.tooltipEl.style.left = this.getStyleLeft(tooltip, positionX); + this.tooltipEl.style.top = this.getStyleTop(tooltip, positionY); + + this.tooltipEl.style.opacity = 1; + this.tooltipEl.style.fontFamily = tooltip._fontFamily; + this.tooltipEl.style.fontSize = tooltip.fontSize; + this.tooltipEl.style.fontStyle = tooltip._fontStyle; + this.tooltipEl.style.padding = tooltip.yPadding + 'px ' + tooltip.xPadding + 'px'; + } + + getBody(body: string) { + return body; + } + + getTitle(title: string) { + return title; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/configuration.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/configuration.ts new file mode 100644 index 000000000..0a8e403d7 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/configuration.ts @@ -0,0 +1,43 @@ +export enum RbdConfigurationSourceField { + global = 0, + pool = 1, + image = 2 +} + +export enum RbdConfigurationType { + bps, + iops, + milliseconds +} + +/** + * This configuration can also be set on a pool level. + */ +export interface RbdConfigurationEntry { + name: string; + source: RbdConfigurationSourceField; + value: any; + type?: RbdConfigurationType; // Non-external field. + description?: string; // Non-external field. + displayName?: string; // Non-external field. Nice name for the UI which is added in the UI. +} + +/** + * This object contains additional information injected into the elements retrieved by the service. + */ +export interface RbdConfigurationExtraField { + name: string; + displayName: string; + description: string; + type: RbdConfigurationType; + readOnly?: boolean; +} + +/** + * Represents a set of data to be used for editing or creating configuration options + */ +export interface RbdConfigurationSection { + heading: string; + class: string; + options: RbdConfigurationExtraField[]; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/credentials.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/credentials.ts new file mode 100644 index 000000000..2c2b7d76e --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/credentials.ts @@ -0,0 +1,4 @@ +export class Credentials { + username: string; + password: string; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/crush-node.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/crush-node.ts new file mode 100644 index 000000000..a8c8288b6 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/crush-node.ts @@ -0,0 +1,17 @@ +export class CrushNode { + id: number; + name: string; + type: string; + type_id: number; + // For nodes with leafs (Buckets) + children?: number[]; // Holds node id's of children + // For non root nodes + pool_weights?: object; + // For leafs (Devices) + device_class?: string; + crush_weight?: number; + exists?: number; + primary_affinity?: number; + reweight?: number; + status?: string; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/crush-rule.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/crush-rule.ts new file mode 100644 index 000000000..83c1db6b6 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/crush-rule.ts @@ -0,0 +1,18 @@ +import { CrushStep } from './crush-step'; + +export class CrushRule { + max_size: number; + usable_size?: number; + min_size: number; + rule_id: number; + rule_name: string; + ruleset: number; + steps: CrushStep[]; +} + +export class CrushRuleConfig { + root: string; // The name of the node under which data should be placed. + name: string; + failure_domain: string; // The type of CRUSH nodes across which we should separate replicas. + device_class?: string; // The device class data should be placed on. +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/crush-step.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/crush-step.ts new file mode 100644 index 000000000..3c46a7cd6 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/crush-step.ts @@ -0,0 +1,7 @@ +export class CrushStep { + op: string; + item_name?: string; + item?: number; + type?: string; + num?: number; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/daemon.interface.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/daemon.interface.ts new file mode 100644 index 000000000..c69a27851 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/daemon.interface.ts @@ -0,0 +1,12 @@ +export interface Daemon { + nodename: string; + container_id: string; + container_image_id: string; + container_image_name: string; + daemon_id: string; + daemon_type: string; + version: string; + status: number; + status_desc: string; + last_refresh: Date; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/devices.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/devices.ts new file mode 100644 index 000000000..69ab3f5f3 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/devices.ts @@ -0,0 +1,25 @@ +/** + * Fields returned by the back-end. + */ +export interface CephDevice { + devid: string; + location: { host: string; dev: string }[]; + daemons: string[]; + life_expectancy_min?: string; + life_expectancy_max?: string; + life_expectancy_stamp?: string; + life_expectancy_enabled?: boolean; +} + +/** + * Fields added by the front-end. Fields may be empty if no expectancy is provided for the + * CephDevice interface. + */ +export interface CdDevice extends CephDevice { + life_expectancy_weeks?: { + max: number; + min: number; + }; + state?: 'good' | 'warning' | 'bad' | 'stale' | 'unknown'; + readableDaemons?: string; // Human readable daemons (which can wrap lines inside the table cell) +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/erasure-code-profile.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/erasure-code-profile.ts new file mode 100644 index 000000000..ea9985ccd --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/erasure-code-profile.ts @@ -0,0 +1,17 @@ +export class ErasureCodeProfile { + name: string; + plugin: string; + k?: number; + m?: number; + c?: number; + l?: number; + d?: number; + packetsize?: number; + technique?: string; + scalar_mds?: 'jerasure' | 'isa' | 'shec'; + 'crush-root'?: string; + 'crush-locality'?: string; + 'crush-failure-domain'?: string; + 'crush-device-class'?: string; + 'directory'?: string; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/executing-task.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/executing-task.ts new file mode 100644 index 000000000..27dc5968e --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/executing-task.ts @@ -0,0 +1,6 @@ +import { Task } from './task'; + +export class ExecutingTask extends Task { + begin_time: number; + progress: number; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/finished-task.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/finished-task.ts new file mode 100644 index 000000000..9e7dd5f98 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/finished-task.ts @@ -0,0 +1,15 @@ +import { Task } from './task'; +import { TaskException } from './task-exception'; + +export class FinishedTask extends Task { + begin_time: string; + end_time: string; + exception: TaskException; + latency: number; + progress: number; + ret_value: any; + success: boolean; + duration: number; + + errorMessage: string; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/flag.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/flag.ts new file mode 100644 index 000000000..075decbf7 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/flag.ts @@ -0,0 +1,8 @@ +export class Flag { + code: 'noout' | 'noin' | 'nodown' | 'noup'; + name: string; + description: string; + value: boolean; + clusterWide: boolean; + indeterminate: boolean; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/image-spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/image-spec.ts new file mode 100644 index 000000000..8b56b291c --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/image-spec.ts @@ -0,0 +1,25 @@ +export class ImageSpec { + static fromString(imageSpec: string) { + const imageSpecSplited = imageSpec.split('/'); + + const poolName = imageSpecSplited[0]; + const namespace = imageSpecSplited.length >= 3 ? imageSpecSplited[1] : null; + const imageName = imageSpecSplited.length >= 3 ? imageSpecSplited[2] : imageSpecSplited[1]; + + return new this(poolName, namespace, imageName); + } + + constructor(public poolName: string, public namespace: string, public imageName: string) {} + + private getNameSpace() { + return this.namespace ? `${this.namespace}/` : ''; + } + + toString() { + return `${this.poolName}/${this.getNameSpace()}${this.imageName}`; + } + + toStringEncoded() { + return encodeURIComponent(`${this.poolName}/${this.getNameSpace()}${this.imageName}`); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/inventory-device-type.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/inventory-device-type.model.ts new file mode 100644 index 000000000..2155c2d87 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/inventory-device-type.model.ts @@ -0,0 +1,9 @@ +import { InventoryDevice } from '~/app/ceph/cluster/inventory/inventory-devices/inventory-device.model'; + +export interface InventoryDeviceType { + type: string; + capacity: number; + devices: InventoryDevice[]; + canSelect: boolean; + totalDevices: number; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/login-response.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/login-response.ts new file mode 100644 index 000000000..12b4b8348 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/login-response.ts @@ -0,0 +1,7 @@ +export class LoginResponse { + username: string; + permissions: object; + pwdExpirationDate: number; + sso: boolean; + pwdUpdateRequired: boolean; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/mirroring-summary.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/mirroring-summary.ts new file mode 100644 index 000000000..5487fab0a --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/mirroring-summary.ts @@ -0,0 +1,5 @@ +export interface MirroringSummary { + content_data?: any; + site_name?: any; + status?: any; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/orchestrator.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/orchestrator.enum.ts new file mode 100644 index 000000000..22101caaa --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/orchestrator.enum.ts @@ -0,0 +1,25 @@ +export enum OrchestratorFeature { + HOST_LIST = 'get_hosts', + HOST_ADD = 'add_host', + HOST_REMOVE = 'remove_host', + HOST_LABEL_ADD = 'add_host_label', + HOST_LABEL_REMOVE = 'remove_host_label', + HOST_MAINTENANCE_ENTER = 'enter_host_maintenance', + HOST_MAINTENANCE_EXIT = 'exit_host_maintenance', + HOST_FACTS = 'get_facts', + HOST_DRAIN = 'drain_host', + + SERVICE_LIST = 'describe_service', + SERVICE_CREATE = 'apply', + SERVICE_EDIT = 'apply', + SERVICE_DELETE = 'remove_service', + SERVICE_RELOAD = 'service_action', + DAEMON_LIST = 'list_daemons', + + OSD_GET_REMOVE_STATUS = 'remove_osds_status', + OSD_CREATE = 'apply_drivegroups', + OSD_DELETE = 'remove_osds', + + DEVICE_LIST = 'get_inventory', + DEVICE_BLINK_LIGHT = 'blink_device_light' +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/orchestrator.interface.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/orchestrator.interface.ts new file mode 100644 index 000000000..4eceba8c0 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/orchestrator.interface.ts @@ -0,0 +1,9 @@ +export interface OrchestratorStatus { + available: boolean; + message: string; + features: { + [feature: string]: { + available: boolean; + }; + }; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/osd-deployment-options.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/osd-deployment-options.ts new file mode 100644 index 000000000..cae869efe --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/osd-deployment-options.ts @@ -0,0 +1,24 @@ +export enum OsdDeploymentOptions { + COST_CAPACITY = 'cost_capacity', + THROUGHPUT = 'throughput_optimized', + IOPS = 'iops_optimized' +} + +export interface DeploymentOption { + name: OsdDeploymentOptions; + title: string; + desc: string; + capacity: number; + available: boolean; + hdd_used: number; + used: number; + nvme_used: number; + ssd_used: number; +} + +export interface DeploymentOptions { + options: { + [key in OsdDeploymentOptions]: DeploymentOption; + }; + recommended_option: OsdDeploymentOptions; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/osd-settings.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/osd-settings.ts new file mode 100644 index 000000000..b7bc10fc0 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/osd-settings.ts @@ -0,0 +1,4 @@ +export class OsdSettings { + nearfull_ratio: number; + full_ratio: number; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/permission.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/permission.spec.ts new file mode 100644 index 000000000..fb2c90469 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/permission.spec.ts @@ -0,0 +1,62 @@ +import { Permissions } from './permissions'; + +describe('cd-notification classes', () => { + it('should show empty permissions', () => { + expect(new Permissions({})).toEqual({ + cephfs: { create: false, delete: false, read: false, update: false }, + configOpt: { create: false, delete: false, read: false, update: false }, + grafana: { create: false, delete: false, read: false, update: false }, + hosts: { create: false, delete: false, read: false, update: false }, + iscsi: { create: false, delete: false, read: false, update: false }, + log: { create: false, delete: false, read: false, update: false }, + manager: { create: false, delete: false, read: false, update: false }, + monitor: { create: false, delete: false, read: false, update: false }, + nfs: { create: false, delete: false, read: false, update: false }, + osd: { create: false, delete: false, read: false, update: false }, + pool: { create: false, delete: false, read: false, update: false }, + prometheus: { create: false, delete: false, read: false, update: false }, + rbdImage: { create: false, delete: false, read: false, update: false }, + rbdMirroring: { create: false, delete: false, read: false, update: false }, + rgw: { create: false, delete: false, read: false, update: false }, + user: { create: false, delete: false, read: false, update: false } + }); + }); + + it('should show full permissions', () => { + const fullyGranted = { + cephfs: ['create', 'read', 'update', 'delete'], + 'config-opt': ['create', 'read', 'update', 'delete'], + grafana: ['create', 'read', 'update', 'delete'], + hosts: ['create', 'read', 'update', 'delete'], + iscsi: ['create', 'read', 'update', 'delete'], + log: ['create', 'read', 'update', 'delete'], + manager: ['create', 'read', 'update', 'delete'], + monitor: ['create', 'read', 'update', 'delete'], + osd: ['create', 'read', 'update', 'delete'], + pool: ['create', 'read', 'update', 'delete'], + prometheus: ['create', 'read', 'update', 'delete'], + 'rbd-image': ['create', 'read', 'update', 'delete'], + 'rbd-mirroring': ['create', 'read', 'update', 'delete'], + rgw: ['create', 'read', 'update', 'delete'], + user: ['create', 'read', 'update', 'delete'] + }; + expect(new Permissions(fullyGranted)).toEqual({ + cephfs: { create: true, delete: true, read: true, update: true }, + configOpt: { create: true, delete: true, read: true, update: true }, + grafana: { create: true, delete: true, read: true, update: true }, + hosts: { create: true, delete: true, read: true, update: true }, + iscsi: { create: true, delete: true, read: true, update: true }, + log: { create: true, delete: true, read: true, update: true }, + manager: { create: true, delete: true, read: true, update: true }, + monitor: { create: true, delete: true, read: true, update: true }, + nfs: { create: false, delete: false, read: false, update: false }, + osd: { create: true, delete: true, read: true, update: true }, + pool: { create: true, delete: true, read: true, update: true }, + prometheus: { create: true, delete: true, read: true, update: true }, + rbdImage: { create: true, delete: true, read: true, update: true }, + rbdMirroring: { create: true, delete: true, read: true, update: true }, + rgw: { create: true, delete: true, read: true, update: true }, + user: { create: true, delete: true, read: true, update: true } + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/permissions.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/permissions.ts new file mode 100644 index 000000000..3f2c87ed1 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/permissions.ts @@ -0,0 +1,50 @@ +export class Permission { + read: boolean; + create: boolean; + update: boolean; + delete: boolean; + + constructor(serverPermission: Array<string> = []) { + ['read', 'create', 'update', 'delete'].forEach( + (permission) => (this[permission] = serverPermission.includes(permission)) + ); + } +} + +export class Permissions { + hosts: Permission; + configOpt: Permission; + pool: Permission; + osd: Permission; + monitor: Permission; + rbdImage: Permission; + iscsi: Permission; + rbdMirroring: Permission; + rgw: Permission; + cephfs: Permission; + manager: Permission; + log: Permission; + user: Permission; + grafana: Permission; + prometheus: Permission; + nfs: Permission; + + constructor(serverPermissions: any) { + this.hosts = new Permission(serverPermissions['hosts']); + this.configOpt = new Permission(serverPermissions['config-opt']); + this.pool = new Permission(serverPermissions['pool']); + this.osd = new Permission(serverPermissions['osd']); + this.monitor = new Permission(serverPermissions['monitor']); + this.rbdImage = new Permission(serverPermissions['rbd-image']); + this.iscsi = new Permission(serverPermissions['iscsi']); + this.rbdMirroring = new Permission(serverPermissions['rbd-mirroring']); + this.rgw = new Permission(serverPermissions['rgw']); + this.cephfs = new Permission(serverPermissions['cephfs']); + this.manager = new Permission(serverPermissions['manager']); + this.log = new Permission(serverPermissions['log']); + this.user = new Permission(serverPermissions['user']); + this.grafana = new Permission(serverPermissions['grafana']); + this.prometheus = new Permission(serverPermissions['prometheus']); + this.nfs = new Permission(serverPermissions['nfs-ganesha']); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/pool-form-info.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/pool-form-info.ts new file mode 100644 index 000000000..c5cc0bb6d --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/pool-form-info.ts @@ -0,0 +1,20 @@ +import { CrushNode } from './crush-node'; +import { CrushRule } from './crush-rule'; +import { ErasureCodeProfile } from './erasure-code-profile'; + +export class PoolFormInfo { + pool_names: string[]; + osd_count: number; + is_all_bluestore: boolean; + bluestore_compression_algorithm: string; + compression_algorithms: string[]; + compression_modes: string[]; + crush_rules_replicated: CrushRule[]; + crush_rules_erasure: CrushRule[]; + pg_autoscale_default_mode: string; + pg_autoscale_modes: string[]; + erasure_code_profiles: ErasureCodeProfile[]; + used_rules: { [rule_name: string]: string[] }; + used_profiles: { [profile_name: string]: string[] }; + nodes: CrushNode[]; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/prometheus-alerts.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/prometheus-alerts.ts new file mode 100644 index 000000000..1239dcccd --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/prometheus-alerts.ts @@ -0,0 +1,84 @@ +export class PrometheusAlertLabels { + alertname: string; + instance: string; + job: string; + severity: string; +} + +class Annotations { + description: string; +} + +class CommonAlertmanagerAlert { + labels: PrometheusAlertLabels; + annotations: Annotations; + startsAt: string; // Date string + endsAt: string; // Date string + generatorURL: string; +} + +class PrometheusAlert { + labels: PrometheusAlertLabels; + annotations: Annotations; + state: 'pending' | 'firing'; + activeAt: string; // Date string + value: number; +} + +export interface PrometheusRuleGroup { + name: string; + file: string; + rules: PrometheusRule[]; +} + +export class PrometheusRule { + name: string; // => PrometheusAlertLabels.alertname + query: string; + duration: 10; + labels: { + severity: string; // => PrometheusAlertLabels.severity + }; + annotations: Annotations; + alerts: PrometheusAlert[]; // Shows only active alerts + health: string; + type: string; + group?: string; // Added field for flattened list +} + +export class AlertmanagerAlert extends CommonAlertmanagerAlert { + status: { + state: 'unprocessed' | 'active' | 'suppressed'; + silencedBy: null | string[]; + inhibitedBy: null | string[]; + }; + receivers: string[]; + fingerprint: string; +} + +export class AlertmanagerNotificationAlert extends CommonAlertmanagerAlert { + status: 'firing' | 'resolved'; +} + +export class AlertmanagerNotification { + status: 'firing' | 'resolved'; + groupLabels: object; + commonAnnotations: object; + groupKey: string; + notified: string; + id: string; + alerts: AlertmanagerNotificationAlert[]; + version: string; + receiver: string; + externalURL: string; + commonLabels: { + severity: string; + }; +} + +export class PrometheusCustomAlert { + status: 'resolved' | 'unprocessed' | 'active' | 'suppressed'; + name: string; + url: string; + description: string; + fingerprint?: string | boolean; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/service.interface.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/service.interface.ts new file mode 100644 index 000000000..dd64422e1 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/service.interface.ts @@ -0,0 +1,45 @@ +export interface CephServiceStatus { + container_image_id: string; + container_image_name: string; + size: number; + running: number; + last_refresh: Date; + created: Date; +} + +// This will become handy when creating arbitrary services +export interface CephServiceSpec { + service_name: string; + service_type: string; + service_id: string; + unmanaged: boolean; + status: CephServiceStatus; + spec: CephServiceAdditionalSpec; + placement: CephServicePlacement; +} + +export interface CephServiceAdditionalSpec { + backend_service: string; + api_user: string; + api_password: string; + api_port: number; + api_secure: boolean; + rgw_frontend_port: number; + trusted_ip_list: string[]; + virtual_ip: string; + frontend_port: number; + monitor_port: number; + virtual_interface_networks: string[]; + pool: string; + rgw_frontend_ssl_certificate: string; + ssl: boolean; + ssl_cert: string; + ssl_key: string; +} + +export interface CephServicePlacement { + count: number; + placement: string; + hosts: string[]; + label: string; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/smart.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/smart.ts new file mode 100644 index 000000000..f553652bc --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/smart.ts @@ -0,0 +1,253 @@ +export interface SmartAttribute { + flags: { + auto_keep: boolean; + error_rate: boolean; + event_count: boolean; + performance: boolean; + prefailure: boolean; + string: string; + updated_online: boolean; + value: number; + }; + id: number; + name: string; + raw: { string: string; value: number }; + thresh: number; + value: number; + when_failed: string; + worst: number; +} + +/** + * The error structure returned from the back-end if SMART data couldn't be + * retrieved. + */ +export interface SmartError { + dev: string; + error: string; + nvme_smart_health_information_add_log_error: string; + nvme_smart_health_information_add_log_error_code: number; + nvme_vendor: string; + smartctl_error_code: number; + smartctl_output: string; +} + +/** + * Common smartctl output structure. + */ +interface SmartCtlOutput { + argv: string[]; + build_info: string; + exit_status: number; + output: string[]; + platform_info: string; + svn_revision: string; + version: number[]; +} + +/** + * Common smartctl device structure. + */ +interface SmartCtlDevice { + info_name: string; + name: string; + protocol: string; + type: string; +} + +/** + * smartctl data structure shared among HDD/NVMe. + */ +interface SmartCtlBaseDataV1 { + device: SmartCtlDevice; + firmware_version: string; + json_format_version: number[]; + local_time: { asctime: string; time_t: number }; + logical_block_size: number; + model_name: string; + nvme_smart_health_information_add_log_error: string; + nvme_smart_health_information_add_log_error_code: number; + nvme_vendor: string; + power_cycle_count: number; + power_on_time: { hours: number }; + serial_number: string; + smart_status: { passed: boolean; nvme?: { value: number } }; + smartctl: SmartCtlOutput; + temperature: { current: number }; + user_capacity: { blocks: number; bytes: number }; +} + +export interface RVWAttributes { + correction_algorithm_invocations: number; + errors_corrected_by_eccdelayed: number; + errors_corrected_by_eccfast: number; + errors_corrected_by_rereads_rewrites: number; + gigabytes_processed: number; + total_errors_corrected: number; + total_uncorrected_errors: number; +} + +/** + * Result structure of `smartctl` applied on an SCSI. Returned by the back-end. + */ +export interface IscsiSmartDataV1 extends SmartCtlBaseDataV1 { + scsi_error_counter_log: { + read: RVWAttributes[]; + }; + scsi_grown_defect_list: number; +} + +/** + * Result structure of `smartctl` applied on an HDD. Returned by the back-end. + */ +export interface AtaSmartDataV1 extends SmartCtlBaseDataV1 { + ata_sct_capabilities: { + data_table_supported: boolean; + error_recovery_control_supported: boolean; + feature_control_supported: boolean; + value: number; + }; + ata_smart_attributes: { + revision: number; + table: SmartAttribute[]; + }; + ata_smart_data: { + capabilities: { + attribute_autosave_enabled: boolean; + conveyance_self_test_supported: boolean; + error_logging_supported: boolean; + exec_offline_immediate_supported: boolean; + gp_logging_supported: boolean; + offline_is_aborted_upon_new_cmd: boolean; + offline_surface_scan_supported: boolean; + selective_self_test_supported: boolean; + self_tests_supported: boolean; + values: number[]; + }; + offline_data_collection: { + completion_seconds: number; + status: { string: string; value: number }; + }; + self_test: { + polling_minutes: { conveyance: number; extended: number; short: number }; + status: { passed: boolean; string: string; value: number }; + }; + }; + ata_smart_error_log: { summary: { count: number; revision: number } }; + ata_smart_selective_self_test_log: { + flags: { remainder_scan_enabled: boolean; value: number }; + power_up_scan_resume_minutes: number; + revision: number; + table: { + lba_max: number; + lba_min: number; + status: { string: string; value: number }; + }[]; + }; + ata_smart_self_test_log: { standard: { count: number; revision: number } }; + ata_version: { major_value: number; minor_value: number; string: string }; + in_smartctl_database: boolean; + interface_speed: { + current: { + bits_per_unit: number; + sata_value: number; + string: string; + units_per_second: number; + }; + max: { + bits_per_unit: number; + sata_value: number; + string: string; + units_per_second: number; + }; + }; + model_family: string; + physical_block_size: number; + rotation_rate: number; + sata_version: { string: string; value: number }; + smart_status: { passed: boolean }; + smartctl: SmartCtlOutput; + wwn: { id: number; naa: number; oui: number }; +} + +/** + * Result structure of `smartctl` returned by Ceph and then back-end applied on + * an NVMe. + */ +export interface NvmeSmartDataV1 extends SmartCtlBaseDataV1 { + nvme_controller_id: number; + nvme_ieee_oui_identifier: number; + nvme_namespaces: { + capacity: { blocks: number; bytes: number }; + eui64: { ext_id: number; oui: number }; + formatted_lba_size: number; + id: number; + size: { blocks: number; bytes: number }; + utilization: { blocks: number; bytes: number }; + }[]; + nvme_number_of_namespaces: number; + nvme_pci_vendor: { id: number; subsystem_id: number }; + nvme_smart_health_information_log: { + available_spare: number; + available_spare_threshold: number; + controller_busy_time: number; + critical_comp_time: number; + critical_warning: number; + data_units_read: number; + data_units_written: number; + host_reads: number; + host_writes: number; + media_errors: number; + num_err_log_entries: number; + percentage_used: number; + power_cycles: number; + power_on_hours: number; + temperature: number; + temperature_sensors: number[]; + unsafe_shutdowns: number; + warning_temp_time: number; + }; + nvme_total_capacity: number; + nvme_unallocated_capacity: number; +} + +/** + * The shared fields each result has after it has been processed by the front-end. + */ +interface SmartBasicResult { + device: string; + identifier: string; +} + +/** + * The SMART data response structure of the back-end. Per device it will either + * contain the structure for a HDD, NVMe or an error. + */ +export interface SmartDataResponseV1 { + [deviceId: string]: AtaSmartDataV1 | NvmeSmartDataV1 | SmartError; +} + +/** + * The SMART data result after it has been processed by the front-end. + */ +export interface SmartDataResult extends SmartBasicResult { + info: { [key: string]: any }; + smart: { + attributes?: any; + data?: any; + nvmeData?: any; + scsi_error_counter_log?: any; + scsi_grown_defect_list?: any; + }; +} + +/** + * The SMART error result after is has been processed by the front-end. If SMART + * data couldn't be retrieved, this is the structure which is returned. + */ +export interface SmartErrorResult extends SmartBasicResult { + error: string; + smartctl_error_code: number; + smartctl_output: string; + userMessage: string; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/summary.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/summary.model.ts new file mode 100644 index 000000000..f2854a0eb --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/summary.model.ts @@ -0,0 +1,15 @@ +import { ExecutingTask } from './executing-task'; +import { FinishedTask } from './finished-task'; + +export class Summary { + executing_tasks?: ExecutingTask[]; + filesystems?: any[]; + finished_tasks?: FinishedTask[]; + have_mon_connection?: boolean; + health_status?: string; + mgr_host?: string; + mgr_id?: string; + rbd_mirroring?: any; + rbd_pools?: any[]; + version?: string; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/task-exception.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/task-exception.ts new file mode 100644 index 000000000..ba38e4aab --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/task-exception.ts @@ -0,0 +1,9 @@ +import { Task } from './task'; + +export class TaskException { + status: number; + code: number; + component: string; + detail: string; + task: Task; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/task.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/task.ts new file mode 100644 index 000000000..0adec5a0f --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/task.ts @@ -0,0 +1,10 @@ +export class Task { + constructor(name?: string, metadata?: object) { + this.name = name; + this.metadata = metadata; + } + name: string; + metadata: object; + + description: string; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/wizard-steps.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/wizard-steps.ts new file mode 100644 index 000000000..177feb486 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/wizard-steps.ts @@ -0,0 +1,4 @@ +export interface WizardStepModel { + stepIndex: number; + isComplete: boolean; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/array.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/array.pipe.spec.ts new file mode 100755 index 000000000..610e22c43 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/array.pipe.spec.ts @@ -0,0 +1,21 @@ +import { ArrayPipe } from './array.pipe'; + +describe('ArrayPipe', () => { + const pipe = new ArrayPipe(); + + it('create an instance', () => { + expect(pipe).toBeTruthy(); + }); + + it('transforms string to array', () => { + expect(pipe.transform('foo')).toStrictEqual(['foo']); + }); + + it('transforms array to array', () => { + expect(pipe.transform(['foo'], true)).toStrictEqual([['foo']]); + }); + + it('do not transforms array to array', () => { + expect(pipe.transform(['foo'])).toStrictEqual(['foo']); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/array.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/array.pipe.ts new file mode 100755 index 000000000..f82e35316 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/array.pipe.ts @@ -0,0 +1,26 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +import _ from 'lodash'; + +/** + * Convert the given value to an array. + */ +@Pipe({ + name: 'array' +}) +export class ArrayPipe implements PipeTransform { + /** + * Convert the given value into an array. If the value is already an + * array, then nothing happens, except the `force` flag is set. + * @param value The value to process. + * @param force Convert the specified value to an array, either it is + * already an array. + */ + transform(value: any, force = false): any[] { + let result = value; + if (!_.isArray(value) || (_.isArray(value) && force)) { + result = [value]; + } + return result; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/boolean-text.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/boolean-text.pipe.spec.ts new file mode 100644 index 000000000..a0b8019a7 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/boolean-text.pipe.spec.ts @@ -0,0 +1,37 @@ +import { BooleanTextPipe } from './boolean-text.pipe'; + +describe('BooleanTextPipe', () => { + let pipe: BooleanTextPipe; + + beforeEach(() => { + pipe = new BooleanTextPipe(); + }); + + it('create an instance', () => { + expect(pipe).toBeTruthy(); + }); + + it('transforms true', () => { + expect(pipe.transform(true)).toEqual('Yes'); + }); + + it('transforms true, alternative text', () => { + expect(pipe.transform(true, 'foo')).toEqual('foo'); + }); + + it('transforms 1', () => { + expect(pipe.transform(1)).toEqual('Yes'); + }); + + it('transforms false', () => { + expect(pipe.transform(false)).toEqual('No'); + }); + + it('transforms false, alternative text', () => { + expect(pipe.transform(false, 'foo', 'bar')).toEqual('bar'); + }); + + it('transforms 0', () => { + expect(pipe.transform(0)).toEqual('No'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/boolean-text.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/boolean-text.pipe.ts new file mode 100644 index 000000000..70432f9be --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/boolean-text.pipe.ts @@ -0,0 +1,14 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'booleanText' +}) +export class BooleanTextPipe implements PipeTransform { + transform( + value: any, + truthyText: string = $localize`Yes`, + falsyText: string = $localize`No` + ): string { + return Boolean(value) ? truthyText : falsyText; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/boolean.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/boolean.pipe.spec.ts new file mode 100755 index 000000000..36c5ed021 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/boolean.pipe.spec.ts @@ -0,0 +1,57 @@ +import { BooleanPipe } from './boolean.pipe'; + +describe('BooleanPipe', () => { + const pipe = new BooleanPipe(); + + it('create an instance', () => { + expect(pipe).toBeTruthy(); + }); + + it('transforms to false [1/4]', () => { + expect(pipe.transform('n')).toBe(false); + }); + + it('transforms to false [2/4]', () => { + expect(pipe.transform(false)).toBe(false); + }); + + it('transforms to false [3/4]', () => { + expect(pipe.transform('bar')).toBe(false); + }); + + it('transforms to false [4/4]', () => { + expect(pipe.transform(2)).toBe(false); + }); + + it('transforms to true [1/8]', () => { + expect(pipe.transform(true)).toBe(true); + }); + + it('transforms to true [2/8]', () => { + expect(pipe.transform(1)).toBe(true); + }); + + it('transforms to true [3/8]', () => { + expect(pipe.transform('y')).toBe(true); + }); + + it('transforms to true [4/8]', () => { + expect(pipe.transform('yes')).toBe(true); + }); + + it('transforms to true [5/8]', () => { + expect(pipe.transform('t')).toBe(true); + }); + + it('transforms to true [6/8]', () => { + expect(pipe.transform('true')).toBe(true); + }); + + it('transforms to true [7/8]', () => { + expect(pipe.transform('on')).toBe(true); + }); + + it('transforms to true [8/8]', () => { + expect(pipe.transform('1')).toBe(true); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/boolean.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/boolean.pipe.ts new file mode 100755 index 000000000..b94a40bc4 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/boolean.pipe.ts @@ -0,0 +1,26 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +/** + * Convert the given value to a boolean value. + */ +@Pipe({ + name: 'boolean' +}) +export class BooleanPipe implements PipeTransform { + transform(value: any): boolean { + let result = false; + switch (value) { + case true: + case 1: + case 'y': + case 'yes': + case 't': + case 'true': + case 'on': + case '1': + result = true; + break; + } + return result; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/cd-date.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/cd-date.pipe.spec.ts new file mode 100644 index 000000000..b67ed62c8 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/cd-date.pipe.spec.ts @@ -0,0 +1,24 @@ +import { DatePipe } from '@angular/common'; + +import moment from 'moment'; + +import { CdDatePipe } from './cd-date.pipe'; + +describe('CdDatePipe', () => { + const datePipe = new DatePipe('en-US'); + let pipe = new CdDatePipe(datePipe); + + it('create an instance', () => { + pipe = new CdDatePipe(datePipe); + expect(pipe).toBeTruthy(); + }); + + it('transforms without value', () => { + expect(pipe.transform('')).toBe(''); + }); + + it('transforms with some date', () => { + const result = moment(1527085564486).format('M/D/YY LTS'); + expect(pipe.transform(1527085564486)).toBe(result); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/cd-date.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/cd-date.pipe.ts new file mode 100644 index 000000000..911f32041 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/cd-date.pipe.ts @@ -0,0 +1,20 @@ +import { DatePipe } from '@angular/common'; +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'cdDate' +}) +export class CdDatePipe implements PipeTransform { + constructor(private datePipe: DatePipe) {} + + transform(value: any): any { + if (value === null || value === '') { + return ''; + } + return ( + this.datePipe.transform(value, 'shortDate') + + ' ' + + this.datePipe.transform(value, 'mediumTime') + ); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/ceph-release-name.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/ceph-release-name.pipe.spec.ts new file mode 100644 index 000000000..3e1f1f7ca --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/ceph-release-name.pipe.spec.ts @@ -0,0 +1,28 @@ +import { CephReleaseNamePipe } from './ceph-release-name.pipe'; + +describe('CephReleaseNamePipe', () => { + const pipe = new CephReleaseNamePipe(); + + it('create an instance', () => { + expect(pipe).toBeTruthy(); + }); + + it('recognizes a stable release', () => { + const value = + 'ceph version 13.2.1 \ + (5533ecdc0fda920179d7ad84e0aa65a127b20d77) mimic (stable)'; + expect(pipe.transform(value)).toBe('mimic'); + }); + + it('recognizes a development release as the main branch', () => { + const value = + 'ceph version 13.1.0-534-g23d3751b89 \ + (23d3751b897b31d2bda57aeaf01acb5ff3c4a9cd) nautilus (dev)'; + expect(pipe.transform(value)).toBe('main'); + }); + + it('transforms with wrong version format', () => { + const value = 'foo'; + expect(pipe.transform(value)).toBe('foo'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/ceph-release-name.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/ceph-release-name.pipe.ts new file mode 100644 index 000000000..c63c794a9 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/ceph-release-name.pipe.ts @@ -0,0 +1,24 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'cephReleaseName' +}) +export class CephReleaseNamePipe implements PipeTransform { + transform(value: any): any { + // Expect "ceph version 13.1.0-419-g251e2515b5 + // (251e2515b563856349498c6caf34e7a282f62937) nautilus (dev)" + const result = /ceph version\s+[^ ]+\s+\(.+\)\s+(.+)\s+\((.+)\)/.exec(value); + if (result) { + if (result[2] === 'dev') { + // Assume this is actually main + return 'main'; + } else { + // Return the "nautilus" part + return result[1]; + } + } else { + // Unexpected format, pass it through + return value; + } + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/ceph-short-version.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/ceph-short-version.pipe.spec.ts new file mode 100644 index 000000000..0242839df --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/ceph-short-version.pipe.spec.ts @@ -0,0 +1,21 @@ +import { CephShortVersionPipe } from './ceph-short-version.pipe'; + +describe('CephShortVersionPipe', () => { + const pipe = new CephShortVersionPipe(); + + it('create an instance', () => { + expect(pipe).toBeTruthy(); + }); + + it('transforms with correct version format', () => { + const value = + 'ceph version 13.1.0-534-g23d3751b89 \ + (23d3751b897b31d2bda57aeaf01acb5ff3c4a9cd) nautilus (dev)'; + expect(pipe.transform(value)).toBe('13.1.0-534-g23d3751b89'); + }); + + it('transforms with wrong version format', () => { + const value = 'foo'; + expect(pipe.transform(value)).toBe('foo'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/ceph-short-version.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/ceph-short-version.pipe.ts new file mode 100644 index 000000000..03e75dfb3 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/ceph-short-version.pipe.ts @@ -0,0 +1,18 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'cephShortVersion' +}) +export class CephShortVersionPipe implements PipeTransform { + transform(value: any): any { + // Expect "ceph version 1.2.3-g9asdasd (as98d7a0s8d7)" + const result = /ceph version\s+([^ ]+)\s+\(.+\)/.exec(value); + if (result) { + // Return the "1.2.3-g9asdasd" part + return result[1]; + } else { + // Unexpected format, pass it through + return value; + } + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/dimless-binary-per-second.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/dimless-binary-per-second.pipe.ts new file mode 100644 index 000000000..21b596317 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/dimless-binary-per-second.pipe.ts @@ -0,0 +1,24 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +import { FormatterService } from '../services/formatter.service'; + +@Pipe({ + name: 'dimlessBinaryPerSecond' +}) +export class DimlessBinaryPerSecondPipe implements PipeTransform { + constructor(private formatter: FormatterService) {} + + transform(value: any): any { + return this.formatter.format_number(value, 1024, [ + 'B/s', + 'kB/s', + 'MB/s', + 'GB/s', + 'TB/s', + 'PB/s', + 'EB/s', + 'ZB/s', + 'YB/s' + ]); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/dimless-binary.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/dimless-binary.pipe.spec.ts new file mode 100644 index 000000000..caf51f578 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/dimless-binary.pipe.spec.ts @@ -0,0 +1,56 @@ +import { FormatterService } from '../services/formatter.service'; +import { DimlessBinaryPipe } from './dimless-binary.pipe'; + +describe('DimlessBinaryPipe', () => { + const formatterService = new FormatterService(); + const pipe = new DimlessBinaryPipe(formatterService); + + it('create an instance', () => { + expect(pipe).toBeTruthy(); + }); + + it('transforms 1024^0', () => { + const value = Math.pow(1024, 0); + expect(pipe.transform(value)).toBe('1 B'); + }); + + it('transforms 1024^1', () => { + const value = Math.pow(1024, 1); + expect(pipe.transform(value)).toBe('1 KiB'); + }); + + it('transforms 1024^2', () => { + const value = Math.pow(1024, 2); + expect(pipe.transform(value)).toBe('1 MiB'); + }); + + it('transforms 1024^3', () => { + const value = Math.pow(1024, 3); + expect(pipe.transform(value)).toBe('1 GiB'); + }); + + it('transforms 1024^4', () => { + const value = Math.pow(1024, 4); + expect(pipe.transform(value)).toBe('1 TiB'); + }); + + it('transforms 1024^5', () => { + const value = Math.pow(1024, 5); + expect(pipe.transform(value)).toBe('1 PiB'); + }); + + it('transforms 1024^6', () => { + const value = Math.pow(1024, 6); + expect(pipe.transform(value)).toBe('1 EiB'); + }); + + it('transforms 1024^7', () => { + const value = Math.pow(1024, 7); + expect(pipe.transform(value)).toBe('1 ZiB'); + }); + + it('transforms 1024^8', () => { + const value = Math.pow(1024, 8); + expect(pipe.transform(value)).toBe('1 YiB'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/dimless-binary.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/dimless-binary.pipe.ts new file mode 100644 index 000000000..cf5d2cdec --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/dimless-binary.pipe.ts @@ -0,0 +1,24 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +import { FormatterService } from '../services/formatter.service'; + +@Pipe({ + name: 'dimlessBinary' +}) +export class DimlessBinaryPipe implements PipeTransform { + constructor(private formatter: FormatterService) {} + + transform(value: any): any { + return this.formatter.format_number(value, 1024, [ + 'B', + 'KiB', + 'MiB', + 'GiB', + 'TiB', + 'PiB', + 'EiB', + 'ZiB', + 'YiB' + ]); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/dimless.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/dimless.pipe.spec.ts new file mode 100644 index 000000000..8d01678f7 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/dimless.pipe.spec.ts @@ -0,0 +1,56 @@ +import { FormatterService } from '../services/formatter.service'; +import { DimlessPipe } from './dimless.pipe'; + +describe('DimlessPipe', () => { + const formatterService = new FormatterService(); + const pipe = new DimlessPipe(formatterService); + + it('create an instance', () => { + expect(pipe).toBeTruthy(); + }); + + it('transforms 1000^0', () => { + const value = Math.pow(1000, 0); + expect(pipe.transform(value)).toBe('1'); + }); + + it('transforms 1000^1', () => { + const value = Math.pow(1000, 1); + expect(pipe.transform(value)).toBe('1 k'); + }); + + it('transforms 1000^2', () => { + const value = Math.pow(1000, 2); + expect(pipe.transform(value)).toBe('1 M'); + }); + + it('transforms 1000^3', () => { + const value = Math.pow(1000, 3); + expect(pipe.transform(value)).toBe('1 G'); + }); + + it('transforms 1000^4', () => { + const value = Math.pow(1000, 4); + expect(pipe.transform(value)).toBe('1 T'); + }); + + it('transforms 1000^5', () => { + const value = Math.pow(1000, 5); + expect(pipe.transform(value)).toBe('1 P'); + }); + + it('transforms 1000^6', () => { + const value = Math.pow(1000, 6); + expect(pipe.transform(value)).toBe('1 E'); + }); + + it('transforms 1000^7', () => { + const value = Math.pow(1000, 7); + expect(pipe.transform(value)).toBe('1 Z'); + }); + + it('transforms 1000^8', () => { + const value = Math.pow(1000, 8); + expect(pipe.transform(value)).toBe('1 Y'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/dimless.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/dimless.pipe.ts new file mode 100644 index 000000000..1be11590d --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/dimless.pipe.ts @@ -0,0 +1,14 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +import { FormatterService } from '../services/formatter.service'; + +@Pipe({ + name: 'dimless' +}) +export class DimlessPipe implements PipeTransform { + constructor(private formatter: FormatterService) {} + + transform(value: any): any { + return this.formatter.format_number(value, 1000, ['', 'k', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y']); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/duration.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/duration.pipe.spec.ts new file mode 100644 index 000000000..1b0e22578 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/duration.pipe.spec.ts @@ -0,0 +1,17 @@ +import { DurationPipe } from './duration.pipe'; + +describe('DurationPipe', () => { + const pipe = new DurationPipe(); + + it('create an instance', () => { + expect(pipe).toBeTruthy(); + }); + + it('transforms seconds into a human readable duration', () => { + expect(pipe.transform(0)).toBe('1 second'); + expect(pipe.transform(6)).toBe('6 seconds'); + expect(pipe.transform(60)).toBe('1 minute'); + expect(pipe.transform(600)).toBe('10 minutes'); + expect(pipe.transform(6000)).toBe('1 hour 40 minutes'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/duration.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/duration.pipe.ts new file mode 100644 index 000000000..4675fc0f6 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/duration.pipe.ts @@ -0,0 +1,37 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'duration', + pure: false +}) +export class DurationPipe implements PipeTransform { + /** + * Translates seconds into human readable format of seconds, minutes, hours, days, and years + * source: https://stackoverflow.com/a/34270811 + * + * @param {number} seconds The number of seconds to be processed + * @return {string} The phrase describing the the amount of time + */ + transform(seconds: number): string { + const levels = [ + [`${Math.floor(seconds / 31536000)}`, 'years'], + [`${Math.floor((seconds % 31536000) / 86400)}`, 'days'], + [`${Math.floor((seconds % 86400) / 3600)}`, 'hours'], + [`${Math.floor((seconds % 3600) / 60)}`, 'minutes'], + [`${Math.floor(seconds % 60)}`, 'seconds'] + ]; + let returntext = ''; + + for (let i = 0, max = levels.length; i < max; i++) { + if (levels[i][0] === '0') { + continue; + } + returntext += + ' ' + + levels[i][0] + + ' ' + + (levels[i][0] === '1' ? levels[i][1].substr(0, levels[i][1].length - 1) : levels[i][1]); + } + return returntext.trim() || '1 second'; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/empty.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/empty.pipe.spec.ts new file mode 100644 index 000000000..e73420f6a --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/empty.pipe.spec.ts @@ -0,0 +1,18 @@ +import { EmptyPipe } from './empty.pipe'; + +describe('EmptyPipe', () => { + const pipe = new EmptyPipe(); + + it('create an instance', () => { + expect(pipe).toBeTruthy(); + }); + + it('transforms with empty value', () => { + expect(pipe.transform(undefined)).toBe('-'); + }); + + it('transforms with some value', () => { + const value = 'foo'; + expect(pipe.transform(value)).toBe('foo'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/empty.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/empty.pipe.ts new file mode 100644 index 000000000..fb753e8d9 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/empty.pipe.ts @@ -0,0 +1,12 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +import _ from 'lodash'; + +@Pipe({ + name: 'empty' +}) +export class EmptyPipe implements PipeTransform { + transform(value: any): any { + return _.isUndefined(value) || _.isNull(value) ? '-' : value; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/encode-uri.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/encode-uri.pipe.spec.ts new file mode 100644 index 000000000..a43674093 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/encode-uri.pipe.spec.ts @@ -0,0 +1,13 @@ +import { EncodeUriPipe } from './encode-uri.pipe'; + +describe('EncodeUriPipe', () => { + it('create an instance', () => { + const pipe = new EncodeUriPipe(); + expect(pipe).toBeTruthy(); + }); + + it('should transforms the value', () => { + const pipe = new EncodeUriPipe(); + expect(pipe.transform('rbd/name')).toBe('rbd%2Fname'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/encode-uri.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/encode-uri.pipe.ts new file mode 100644 index 000000000..48fbf1668 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/encode-uri.pipe.ts @@ -0,0 +1,10 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'encodeUri' +}) +export class EncodeUriPipe implements PipeTransform { + transform(value: any): any { + return encodeURIComponent(value); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/filter.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/filter.pipe.spec.ts new file mode 100644 index 000000000..58d7ff95f --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/filter.pipe.spec.ts @@ -0,0 +1,54 @@ +import { FilterPipe } from './filter.pipe'; + +describe('FilterPipe', () => { + const pipe = new FilterPipe(); + + it('create an instance', () => { + expect(pipe).toBeTruthy(); + }); + + it('filter words with "foo"', () => { + const value = ['foo', 'bar', 'foobar']; + const filters = [ + { + value: 'foo', + applyFilter: (row: any[], val: any) => { + return row.indexOf(val) !== -1; + } + } + ]; + expect(pipe.transform(value, filters)).toEqual(['foo', 'foobar']); + }); + + it('filter words with "foo" and "bar"', () => { + const value = ['foo', 'bar', 'foobar']; + const filters = [ + { + value: 'foo', + applyFilter: (row: any[], val: any) => { + return row.indexOf(val) !== -1; + } + }, + { + value: 'bar', + applyFilter: (row: any[], val: any) => { + return row.indexOf(val) !== -1; + } + } + ]; + expect(pipe.transform(value, filters)).toEqual(['foobar']); + }); + + it('filter with no value', () => { + const value = ['foo', 'bar', 'foobar']; + const filters = [ + { + value: '', + applyFilter: () => { + return false; + } + } + ]; + expect(pipe.transform(value, filters)).toEqual(['foo', 'bar', 'foobar']); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/filter.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/filter.pipe.ts new file mode 100644 index 000000000..313ac4c0d --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/filter.pipe.ts @@ -0,0 +1,25 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'filter' +}) +export class FilterPipe implements PipeTransform { + transform(value: any, args?: any): any { + return value.filter((row: any) => { + let result = true; + + args.forEach((filter: any): boolean | void => { + if (!filter.value) { + return undefined; + } + + result = result && filter.applyFilter(row, filter.value); + if (!result) { + return result; + } + }); + + return result; + }); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/health-color.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/health-color.pipe.spec.ts new file mode 100644 index 000000000..f5e937ce3 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/health-color.pipe.spec.ts @@ -0,0 +1,47 @@ +import { CssHelper } from '~/app/shared/classes/css-helper'; +import { HealthColorPipe } from '~/app/shared/pipes/health-color.pipe'; + +class CssHelperStub extends CssHelper { + propertyValue(propertyName: string) { + if (propertyName === 'health-color-healthy') { + return 'fakeGreen'; + } + if (propertyName === 'health-color-warning') { + return 'fakeOrange'; + } + if (propertyName === 'health-color-error') { + return 'fakeRed'; + } + return ''; + } +} + +describe('HealthColorPipe', () => { + const pipe = new HealthColorPipe(new CssHelperStub()); + + it('create an instance', () => { + expect(pipe).toBeTruthy(); + }); + + it('transforms "HEALTH_OK"', () => { + expect(pipe.transform('HEALTH_OK')).toEqual({ + color: 'fakeGreen' + }); + }); + + it('transforms "HEALTH_WARN"', () => { + expect(pipe.transform('HEALTH_WARN')).toEqual({ + color: 'fakeOrange' + }); + }); + + it('transforms "HEALTH_ERR"', () => { + expect(pipe.transform('HEALTH_ERR')).toEqual({ + color: 'fakeRed' + }); + }); + + it('transforms others', () => { + expect(pipe.transform('abc')).toBe(null); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/health-color.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/health-color.pipe.ts new file mode 100644 index 000000000..d046fa15a --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/health-color.pipe.ts @@ -0,0 +1,17 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +import { CssHelper } from '~/app/shared/classes/css-helper'; +import { HealthColor } from '~/app/shared/enum/health-color.enum'; + +@Pipe({ + name: 'healthColor' +}) +export class HealthColorPipe implements PipeTransform { + constructor(private cssHelper: CssHelper) {} + + transform(value: any): any { + return Object.keys(HealthColor).includes(value as HealthColor) + ? { color: this.cssHelper.propertyValue(HealthColor[value]) } + : null; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/iops.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/iops.pipe.spec.ts new file mode 100644 index 000000000..dac353ddf --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/iops.pipe.spec.ts @@ -0,0 +1,8 @@ +import { IopsPipe } from './iops.pipe'; + +describe('IopsPipe', () => { + it('create an instance', () => { + const pipe = new IopsPipe(); + expect(pipe).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/iops.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/iops.pipe.ts new file mode 100644 index 000000000..9644801f8 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/iops.pipe.ts @@ -0,0 +1,10 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'iops' +}) +export class IopsPipe implements PipeTransform { + transform(value: any): any { + return `${value} IOPS`; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/iscsi-backstore.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/iscsi-backstore.pipe.spec.ts new file mode 100644 index 000000000..c82e37554 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/iscsi-backstore.pipe.spec.ts @@ -0,0 +1,17 @@ +import { IscsiBackstorePipe } from './iscsi-backstore.pipe'; + +describe('IscsiBackstorePipe', () => { + const pipe = new IscsiBackstorePipe(); + + it('create an instance', () => { + expect(pipe).toBeTruthy(); + }); + + it('transforms "user:rbd"', () => { + expect(pipe.transform('user:rbd')).toBe('user:rbd (tcmu-runner)'); + }); + + it('transforms "other"', () => { + expect(pipe.transform('other')).toBe('other'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/iscsi-backstore.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/iscsi-backstore.pipe.ts new file mode 100644 index 000000000..19a0d66c1 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/iscsi-backstore.pipe.ts @@ -0,0 +1,15 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'iscsiBackstore' +}) +export class IscsiBackstorePipe implements PipeTransform { + transform(value: any): any { + switch (value) { + case 'user:rbd': + return 'user:rbd (tcmu-runner)'; + default: + return value; + } + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/join.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/join.pipe.spec.ts new file mode 100644 index 000000000..01bccbc2d --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/join.pipe.spec.ts @@ -0,0 +1,13 @@ +import { JoinPipe } from './join.pipe'; + +describe('ListPipe', () => { + const pipe = new JoinPipe(); + + it('create an instance', () => { + expect(pipe).toBeTruthy(); + }); + + it('transforms "[1,2,3]"', () => { + expect(pipe.transform([1, 2, 3])).toBe('1, 2, 3'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/join.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/join.pipe.ts new file mode 100644 index 000000000..68610846e --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/join.pipe.ts @@ -0,0 +1,10 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'join' +}) +export class JoinPipe implements PipeTransform { + transform(value: Array<any>): string { + return value.join(', '); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/log-priority.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/log-priority.pipe.spec.ts new file mode 100644 index 000000000..45d677c2a --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/log-priority.pipe.spec.ts @@ -0,0 +1,32 @@ +import { LogPriorityPipe } from './log-priority.pipe'; + +describe('LogPriorityPipe', () => { + const pipe = new LogPriorityPipe(); + + it('create an instance', () => { + expect(pipe).toBeTruthy(); + }); + + it('transforms "INF"', () => { + const value = '[INF]'; + const result = 'info'; + expect(pipe.transform(value)).toEqual(result); + }); + + it('transforms "WRN"', () => { + const value = '[WRN]'; + const result = 'warn'; + expect(pipe.transform(value)).toEqual(result); + }); + + it('transforms "ERR"', () => { + const value = '[ERR]'; + const result = 'err'; + expect(pipe.transform(value)).toEqual(result); + }); + + it('transforms others', () => { + const value = '[foo]'; + expect(pipe.transform(value)).toBe(''); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/log-priority.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/log-priority.pipe.ts new file mode 100644 index 000000000..0c51c867b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/log-priority.pipe.ts @@ -0,0 +1,20 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'logPriority' +}) +export class LogPriorityPipe implements PipeTransform { + transform(value: any): any { + if (value === '[DBG]') { + return 'debug'; + } else if (value === '[INF]') { + return 'info'; + } else if (value === '[WRN]') { + return 'warn'; + } else if (value === '[ERR]') { + return 'err'; + } else { + return ''; // Inherit + } + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/map.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/map.pipe.spec.ts new file mode 100644 index 000000000..337d5c37b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/map.pipe.spec.ts @@ -0,0 +1,25 @@ +import { MapPipe } from './map.pipe'; + +describe('MapPipe', () => { + const pipe = new MapPipe(); + + it('create an instance', () => { + expect(pipe).toBeTruthy(); + }); + + it('map value [1]', () => { + expect(pipe.transform('foo')).toBe('foo'); + }); + + it('map value [2]', () => { + expect(pipe.transform('foo', { '-1': 'disabled', 0: 'unlimited' })).toBe('foo'); + }); + + it('map value [3]', () => { + expect(pipe.transform(-1, { '-1': 'disabled', 0: 'unlimited' })).toBe('disabled'); + }); + + it('map value [4]', () => { + expect(pipe.transform(0, { '-1': 'disabled', 0: 'unlimited' })).toBe('unlimited'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/map.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/map.pipe.ts new file mode 100644 index 000000000..1c0839d08 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/map.pipe.ts @@ -0,0 +1,15 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +import _ from 'lodash'; + +@Pipe({ + name: 'map' +}) +export class MapPipe implements PipeTransform { + transform(value: string | number, map?: object): any { + if (!_.isPlainObject(map)) { + return value; + } + return _.get(map, value, value); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/milliseconds.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/milliseconds.pipe.spec.ts new file mode 100644 index 000000000..cea4bb13f --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/milliseconds.pipe.spec.ts @@ -0,0 +1,8 @@ +import { MillisecondsPipe } from './milliseconds.pipe'; + +describe('MillisecondsPipe', () => { + it('create an instance', () => { + const pipe = new MillisecondsPipe(); + expect(pipe).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/milliseconds.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/milliseconds.pipe.ts new file mode 100644 index 000000000..b0dc68604 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/milliseconds.pipe.ts @@ -0,0 +1,10 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'milliseconds' +}) +export class MillisecondsPipe implements PipeTransform { + transform(value: any): any { + return `${value} ms`; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/not-available.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/not-available.pipe.spec.ts new file mode 100644 index 000000000..06279a5ea --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/not-available.pipe.spec.ts @@ -0,0 +1,30 @@ +import { NotAvailablePipe } from './not-available.pipe'; + +describe('NotAvailablePipe', () => { + let pipe: NotAvailablePipe; + + beforeEach(() => { + pipe = new NotAvailablePipe(); + }); + + it('create an instance', () => { + expect(pipe).toBeTruthy(); + }); + + it('transforms not available (1)', () => { + expect(pipe.transform('')).toBe('n/a'); + }); + + it('transforms not available (2)', () => { + expect(pipe.transform('', 'Unknown')).toBe('Unknown'); + }); + + it('transform not necessary (1)', () => { + expect(pipe.transform(0)).toBe(0); + expect(pipe.transform(1)).toBe(1); + }); + + it('transform not necessary (2)', () => { + expect(pipe.transform('foo')).toBe('foo'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/not-available.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/not-available.pipe.ts new file mode 100644 index 000000000..9d0222724 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/not-available.pipe.ts @@ -0,0 +1,15 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +import _ from 'lodash'; + +@Pipe({ + name: 'notAvailable' +}) +export class NotAvailablePipe implements PipeTransform { + transform(value: any, text?: string): any { + if (value === '') { + return _.defaultTo(text, $localize`n/a`); + } + return value; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/ordinal.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/ordinal.pipe.spec.ts new file mode 100644 index 000000000..7e1cdbc8d --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/ordinal.pipe.spec.ts @@ -0,0 +1,8 @@ +import { OrdinalPipe } from './ordinal.pipe'; + +describe('OrdinalPipe', () => { + it('create an instance', () => { + const pipe = new OrdinalPipe(); + expect(pipe).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/ordinal.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/ordinal.pipe.ts new file mode 100644 index 000000000..da89a0240 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/ordinal.pipe.ts @@ -0,0 +1,25 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'ordinal' +}) +export class OrdinalPipe implements PipeTransform { + transform(value: any): any { + const num = parseInt(value, 10); + if (isNaN(num)) { + return value; + } + return ( + value + + (Math.floor(num / 10) === 1 + ? 'th' + : num % 10 === 1 + ? 'st' + : num % 10 === 2 + ? 'nd' + : num % 10 === 3 + ? 'rd' + : 'th') + ); + } +} 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 new file mode 100755 index 000000000..508a29e98 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pipes.module.ts @@ -0,0 +1,125 @@ +import { CommonModule, DatePipe } from '@angular/common'; +import { NgModule } from '@angular/core'; + +import { ArrayPipe } from './array.pipe'; +import { BooleanTextPipe } from './boolean-text.pipe'; +import { BooleanPipe } from './boolean.pipe'; +import { CdDatePipe } from './cd-date.pipe'; +import { CephReleaseNamePipe } from './ceph-release-name.pipe'; +import { CephShortVersionPipe } from './ceph-short-version.pipe'; +import { DimlessBinaryPerSecondPipe } from './dimless-binary-per-second.pipe'; +import { DimlessBinaryPipe } from './dimless-binary.pipe'; +import { DimlessPipe } from './dimless.pipe'; +import { DurationPipe } from './duration.pipe'; +import { EmptyPipe } from './empty.pipe'; +import { EncodeUriPipe } from './encode-uri.pipe'; +import { FilterPipe } from './filter.pipe'; +import { HealthColorPipe } from './health-color.pipe'; +import { IopsPipe } from './iops.pipe'; +import { IscsiBackstorePipe } from './iscsi-backstore.pipe'; +import { JoinPipe } from './join.pipe'; +import { LogPriorityPipe } from './log-priority.pipe'; +import { MapPipe } from './map.pipe'; +import { MillisecondsPipe } from './milliseconds.pipe'; +import { NotAvailablePipe } from './not-available.pipe'; +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 { SearchHighlightPipe } from './search-highlight.pipe'; +import { TruncatePipe } from './truncate.pipe'; +import { UpperFirstPipe } from './upper-first.pipe'; + +@NgModule({ + imports: [CommonModule], + declarations: [ + ArrayPipe, + BooleanPipe, + BooleanTextPipe, + DimlessBinaryPipe, + DimlessBinaryPerSecondPipe, + HealthColorPipe, + DimlessPipe, + CephShortVersionPipe, + CephReleaseNamePipe, + RelativeDatePipe, + IscsiBackstorePipe, + JoinPipe, + LogPriorityPipe, + FilterPipe, + CdDatePipe, + EmptyPipe, + EncodeUriPipe, + RoundPipe, + OrdinalPipe, + MillisecondsPipe, + NotAvailablePipe, + IopsPipe, + UpperFirstPipe, + RbdConfigurationSourcePipe, + DurationPipe, + MapPipe, + TruncatePipe, + SanitizeHtmlPipe, + SearchHighlightPipe + ], + exports: [ + ArrayPipe, + BooleanPipe, + BooleanTextPipe, + DimlessBinaryPipe, + DimlessBinaryPerSecondPipe, + HealthColorPipe, + DimlessPipe, + CephShortVersionPipe, + CephReleaseNamePipe, + RelativeDatePipe, + IscsiBackstorePipe, + JoinPipe, + LogPriorityPipe, + FilterPipe, + CdDatePipe, + EmptyPipe, + EncodeUriPipe, + RoundPipe, + OrdinalPipe, + MillisecondsPipe, + NotAvailablePipe, + IopsPipe, + UpperFirstPipe, + RbdConfigurationSourcePipe, + DurationPipe, + MapPipe, + TruncatePipe, + SanitizeHtmlPipe, + SearchHighlightPipe + ], + providers: [ + ArrayPipe, + BooleanPipe, + BooleanTextPipe, + DatePipe, + CephShortVersionPipe, + CephReleaseNamePipe, + DimlessBinaryPipe, + DimlessBinaryPerSecondPipe, + DimlessPipe, + RelativeDatePipe, + IscsiBackstorePipe, + JoinPipe, + LogPriorityPipe, + CdDatePipe, + EmptyPipe, + EncodeUriPipe, + OrdinalPipe, + IopsPipe, + MillisecondsPipe, + NotAvailablePipe, + UpperFirstPipe, + MapPipe, + TruncatePipe, + SanitizeHtmlPipe + ] +}) +export class PipesModule {} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/rbd-configuration-source.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/rbd-configuration-source.pipe.spec.ts new file mode 100644 index 000000000..9c0346bd6 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/rbd-configuration-source.pipe.spec.ts @@ -0,0 +1,22 @@ +import { RbdConfigurationSourcePipe } from './rbd-configuration-source.pipe'; + +describe('RbdConfigurationSourcePipePipe', () => { + let pipe: RbdConfigurationSourcePipe; + + beforeEach(() => { + pipe = new RbdConfigurationSourcePipe(); + }); + + it('create an instance', () => { + expect(pipe).toBeTruthy(); + }); + + it('should transform correctly', () => { + expect(pipe.transform('foo')).not.toBeDefined(); + expect(pipe.transform(-1)).not.toBeDefined(); + expect(pipe.transform(0)).toBe('global'); + expect(pipe.transform(1)).toBe('pool'); + expect(pipe.transform(2)).toBe('image'); + expect(pipe.transform(-3)).not.toBeDefined(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/rbd-configuration-source.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/rbd-configuration-source.pipe.ts new file mode 100644 index 000000000..bb42d3f1c --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/rbd-configuration-source.pipe.ts @@ -0,0 +1,15 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'rbdConfigurationSource' +}) +export class RbdConfigurationSourcePipe implements PipeTransform { + transform(value: any): any { + const sourceMap = { + 0: 'global', + 1: 'pool', + 2: 'image' + }; + return sourceMap[value]; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/relative-date.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/relative-date.pipe.spec.ts new file mode 100644 index 000000000..a12d3c2a1 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/relative-date.pipe.spec.ts @@ -0,0 +1,44 @@ +import moment from 'moment'; + +import { RelativeDatePipe } from './relative-date.pipe'; + +describe('RelativeDatePipe', () => { + const pipe = new RelativeDatePipe(); + + it('create an instance', () => { + expect(pipe).toBeTruthy(); + }); + + it('transforms date into a human readable relative time (1)', () => { + const date: Date = moment().subtract(130, 'seconds').toDate(); + expect(pipe.transform(date)).toBe('2 minutes ago'); + }); + + it('transforms date into a human readable relative time (2)', () => { + const date: Date = moment().subtract(65, 'minutes').toDate(); + expect(pipe.transform(date)).toBe('An hour ago'); + }); + + it('transforms date into a human readable relative time (3)', () => { + const date: string = moment().subtract(130, 'minutes').toISOString(); + expect(pipe.transform(date)).toBe('2 hours ago'); + }); + + it('transforms date into a human readable relative time (4)', () => { + const date: string = moment().subtract(30, 'seconds').toISOString(); + expect(pipe.transform(date, false)).toBe('a few seconds ago'); + }); + + it('transforms date into a human readable relative time (5)', () => { + const date: number = moment().subtract(3, 'days').unix(); + expect(pipe.transform(date)).toBe('3 days ago'); + }); + + it('invalid input (1)', () => { + expect(pipe.transform('')).toBe(''); + }); + + it('invalid input (2)', () => { + expect(pipe.transform('2011-10-10T10:20:90')).toBe(''); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/relative-date.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/relative-date.pipe.ts new file mode 100644 index 000000000..f802b6b2a --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/relative-date.pipe.ts @@ -0,0 +1,57 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +import _ from 'lodash'; +import moment from 'moment'; + +moment.updateLocale('en', { + relativeTime: { + future: $localize`in %s`, + past: $localize`%s ago`, + s: $localize`a few seconds`, + ss: $localize`%d seconds`, + m: $localize`a minute`, + mm: $localize`%d minutes`, + h: $localize`an hour`, + hh: $localize`%d hours`, + d: $localize`a day`, + dd: $localize`%d days`, + w: $localize`a week`, + ww: $localize`%d weeks`, + M: $localize`a month`, + MM: $localize`%d months`, + y: $localize`a year`, + yy: $localize`%d years` + } +}); + +@Pipe({ + name: 'relativeDate', + pure: false +}) +export class RelativeDatePipe implements PipeTransform { + /** + * Convert a time into a human readable form, e.g. '2 minutes ago'. + * @param {Date | string | number} value The date to convert, should be + * an ISO8601 string, an Unix timestamp (seconds) or Date object. + * @param {boolean} upperFirst Set to `true` to start the sentence + * upper case. Defaults to `true`. + * @return {string} The time in human readable form or an empty string + * on failure (e.g. invalid input). + */ + transform(value: Date | string | number, upperFirst = true): string { + let date: moment.Moment; + if (_.isNumber(value)) { + date = moment.unix(value); + } else { + date = moment(value); + } + if (!date.isValid()) { + return ''; + } + let relativeDate: string = date.fromNow(); + if (upperFirst) { + relativeDate = _.upperFirst(relativeDate); + } + return relativeDate; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/round.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/round.pipe.spec.ts new file mode 100644 index 000000000..602045263 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/round.pipe.spec.ts @@ -0,0 +1,13 @@ +import { RoundPipe } from './round.pipe'; + +describe('RoundPipe', () => { + const pipe = new RoundPipe(); + + it('create an instance', () => { + expect(pipe).toBeTruthy(); + }); + + it('transforms "1500"', () => { + expect(pipe.transform(1.52, 1)).toEqual(1.5); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/round.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/round.pipe.ts new file mode 100644 index 000000000..077831ac2 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/round.pipe.ts @@ -0,0 +1,12 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +import _ from 'lodash'; + +@Pipe({ + name: 'round' +}) +export class RoundPipe implements PipeTransform { + transform(value: any, precision: number): any { + return _.round(value, precision); + } +} 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 000000000..719f32ee5 --- /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 000000000..f6a8b0c9e --- /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/pipes/search-highlight.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/search-highlight.pipe.spec.ts new file mode 100644 index 000000000..73f8e55ed --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/search-highlight.pipe.spec.ts @@ -0,0 +1,41 @@ +import { TestBed } from '@angular/core/testing'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { SearchHighlightPipe } from './search-highlight.pipe'; + +describe('SearchHighlightPipe', () => { + let pipe: SearchHighlightPipe; + + configureTestBed({ + providers: [SearchHighlightPipe] + }); + + beforeEach(() => { + pipe = TestBed.inject(SearchHighlightPipe); + }); + + it('create an instance', () => { + expect(pipe).toBeTruthy(); + }); + + it('transforms with a matching keyword ', () => { + const value = 'overall HEALTH_WARN Dashboard debug mode is enabled'; + const args = 'Dashboard'; + const expected = 'overall HEALTH_WARN <mark>Dashboard</mark> debug mode is enabled'; + + expect(pipe.transform(value, args)).toEqual(expected); + }); + + it('transforms with a matching keyword having regex character', () => { + const value = 'loreum ipsum .? dolor sit amet'; + const args = '.?'; + const expected = 'loreum ipsum <mark>.?</mark> dolor sit amet'; + + expect(pipe.transform(value, args)).toEqual(expected); + }); + + it('transforms with empty search keyword', () => { + const value = 'overall HEALTH_WARN Dashboard debug mode is enabled'; + expect(pipe.transform(value, '')).toBe(value); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/search-highlight.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/search-highlight.pipe.ts new file mode 100644 index 000000000..c00cc46c6 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/search-highlight.pipe.ts @@ -0,0 +1,26 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'searchHighlight' +}) +export class SearchHighlightPipe implements PipeTransform { + transform(value: string, args: string): string { + if (!args) { + return value; + } + args = this.escapeRegExp(args); + const regex = new RegExp(args, 'gi'); + const match = value.match(regex); + + if (!match) { + return value; + } + + return value.replace(regex, '<mark>$&</mark>'); + } + + private escapeRegExp(str: string) { + // $& means the whole matched string + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/truncate.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/truncate.pipe.spec.ts new file mode 100644 index 000000000..cc0b2fc70 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/truncate.pipe.spec.ts @@ -0,0 +1,21 @@ +import { TruncatePipe } from './truncate.pipe'; + +describe('TruncatePipe', () => { + const pipe = new TruncatePipe(); + + it('create an instance', () => { + expect(pipe).toBeTruthy(); + }); + + it('should truncate string (1)', () => { + expect(pipe.transform('fsdfdsfs asdasd', 5, '')).toEqual('fsdfd'); + }); + + it('should truncate string (2)', () => { + expect(pipe.transform('fsdfdsfs asdasd', 10, '...')).toEqual('fsdfdsf...'); + }); + + it('should not truncate number', () => { + expect(pipe.transform(2, 6, '...')).toBe(2); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/truncate.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/truncate.pipe.ts new file mode 100644 index 000000000..ff49c6386 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/truncate.pipe.ts @@ -0,0 +1,16 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +import _ from 'lodash'; + +@Pipe({ + name: 'truncate' +}) +export class TruncatePipe implements PipeTransform { + transform(value: any, length: number, omission?: string): any { + if (!_.isString(value)) { + return value; + } + omission = _.defaultTo(omission, ''); + return _.truncate(value, { length, omission }); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/upper-first.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/upper-first.pipe.spec.ts new file mode 100644 index 000000000..072baa04b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/upper-first.pipe.spec.ts @@ -0,0 +1,17 @@ +import { UpperFirstPipe } from './upper-first.pipe'; + +describe('UpperFirstPipe', () => { + const pipe = new UpperFirstPipe(); + + it('create an instance', () => { + expect(pipe).toBeTruthy(); + }); + + it('transforms "foo"', () => { + expect(pipe.transform('foo')).toEqual('Foo'); + }); + + it('transforms "BAR"', () => { + expect(pipe.transform('BAR')).toEqual('BAR'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/upper-first.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/upper-first.pipe.ts new file mode 100644 index 000000000..b73b1bc20 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/upper-first.pipe.ts @@ -0,0 +1,12 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +import _ from 'lodash'; + +@Pipe({ + name: 'upperFirst' +}) +export class UpperFirstPipe implements PipeTransform { + transform(value: string): string { + return _.upperFirst(value); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/rxjs/operators/page-visibilty.operator.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/rxjs/operators/page-visibilty.operator.ts new file mode 100644 index 000000000..22644dcf2 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/rxjs/operators/page-visibilty.operator.ts @@ -0,0 +1,20 @@ +import { fromEvent, Observable, partition } from 'rxjs'; +import { repeatWhen, shareReplay, takeUntil } from 'rxjs/operators'; + +export function whenPageVisible() { + const visibilitychange$ = fromEvent(document, 'visibilitychange').pipe( + shareReplay({ refCount: true, bufferSize: 1 }) + ); + + const [pageVisible$, pageHidden$] = partition( + visibilitychange$, + () => document.visibilityState === 'visible' + ); + + return function <T>(source: Observable<T>) { + return source.pipe( + takeUntil(pageHidden$), + repeatWhen(() => pageVisible$) + ); + }; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/api-interceptor.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/api-interceptor.service.spec.ts new file mode 100644 index 000000000..ba7c30f49 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/api-interceptor.service.spec.ts @@ -0,0 +1,227 @@ +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { Router } from '@angular/router'; + +import { ToastrService } from 'ngx-toastr'; + +import { AppModule } from '~/app/app.module'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { NotificationType } from '../enum/notification-type.enum'; +import { CdNotification, CdNotificationConfig } from '../models/cd-notification'; +import { ApiInterceptorService } from './api-interceptor.service'; +import { NotificationService } from './notification.service'; + +describe('ApiInterceptorService', () => { + let notificationService: NotificationService; + let httpTesting: HttpTestingController; + let httpClient: HttpClient; + let router: Router; + const url = 'api/xyz'; + + const httpError = (error: any, errorOpts: object, done = (_resp: any): any => undefined) => { + httpClient.get(url).subscribe( + () => true, + (resp) => { + // Error must have been forwarded by the interceptor. + expect(resp instanceof HttpErrorResponse).toBeTruthy(); + done(resp); + } + ); + httpTesting.expectOne(url).error(error, errorOpts); + }; + + const runRouterTest = (errorOpts: object, expectedCallParams: any[]) => { + httpError(new ErrorEvent('abc'), errorOpts); + httpTesting.verify(); + expect(router.navigate).toHaveBeenCalledWith(...expectedCallParams); + }; + + const runNotificationTest = ( + error: any, + errorOpts: object, + expectedCallParams: CdNotification + ) => { + httpError(error, errorOpts); + httpTesting.verify(); + expect(notificationService.show).toHaveBeenCalled(); + expect(notificationService.save).toHaveBeenCalledWith(expectedCallParams); + }; + + const createCdNotification = ( + type: NotificationType, + title?: string, + message?: string, + options?: any, + application?: string + ) => { + return new CdNotification(new CdNotificationConfig(type, title, message, options, application)); + }; + + configureTestBed({ + imports: [AppModule, HttpClientTestingModule], + providers: [ + NotificationService, + { + provide: ToastrService, + useValue: { + error: () => true + } + } + ] + }); + + beforeEach(() => { + const baseTime = new Date('2022-02-22'); + spyOn(global, 'Date').and.returnValue(baseTime); + + httpClient = TestBed.inject(HttpClient); + httpTesting = TestBed.inject(HttpTestingController); + + notificationService = TestBed.inject(NotificationService); + spyOn(notificationService, 'show').and.callThrough(); + spyOn(notificationService, 'save'); + + router = TestBed.inject(Router); + spyOn(router, 'navigate'); + }); + + it('should be created', () => { + const service = TestBed.inject(ApiInterceptorService); + expect(service).toBeTruthy(); + }); + + describe('test different error behaviours', () => { + beforeEach(() => { + spyOn(window, 'setTimeout').and.callFake((fn) => fn()); + }); + + it('should redirect 401', () => { + runRouterTest( + { + status: 401 + }, + [['/login']] + ); + }); + + it('should redirect 403', () => { + runRouterTest( + { + status: 403 + }, + [['error'], {'state': {'header': 'Access Denied', 'icon': 'fa fa-lock', 'message': 'Sorry, you don’t have permission to view this page or resource.', 'source': 'forbidden'}}] // prettier-ignore + ); + }); + + it('should show notification (error string)', () => { + runNotificationTest( + 'foobar', + { + status: 500, + statusText: 'Foo Bar' + }, + createCdNotification(0, '500 - Foo Bar', 'foobar') + ); + }); + + it('should show notification (error object, triggered from backend)', () => { + runNotificationTest( + { detail: 'abc' }, + { + status: 504, + statusText: 'AAA bbb CCC' + }, + createCdNotification(0, '504 - AAA bbb CCC', 'abc') + ); + }); + + it('should show notification (error object with unknown keys)', () => { + runNotificationTest( + { type: 'error' }, + { + status: 0, + statusText: 'Unknown Error', + message: 'Http failure response for (unknown url): 0 Unknown Error', + name: 'HttpErrorResponse', + ok: false, + url: null + }, + createCdNotification( + 0, + '0 - Unknown Error', + 'Http failure response for api/xyz: 0 Unknown Error' + ) + ); + }); + + it('should show notification (undefined error)', () => { + runNotificationTest( + undefined, + { + status: 502 + }, + createCdNotification(0, '502 - Unknown Error', 'Http failure response for api/xyz: 502 ') + ); + }); + + it('should show 400 notification', () => { + spyOn(notificationService, 'notifyTask'); + httpError({ task: { name: 'mytask', metadata: { component: 'foobar' } } }, { status: 400 }); + httpTesting.verify(); + expect(notificationService.show).toHaveBeenCalledTimes(0); + expect(notificationService.notifyTask).toHaveBeenCalledWith({ + exception: { task: { metadata: { component: 'foobar' }, name: 'mytask' } }, + metadata: { component: 'foobar' }, + name: 'mytask', + success: false + }); + }); + }); + + describe('interceptor error handling', () => { + const expectSaveToHaveBeenCalled = (called: boolean) => { + tick(510); + if (called) { + expect(notificationService.save).toHaveBeenCalled(); + } else { + expect(notificationService.save).not.toHaveBeenCalled(); + } + }; + + it('should show default behaviour', fakeAsync(() => { + httpError(undefined, { status: 500 }); + expectSaveToHaveBeenCalled(true); + })); + + it('should prevent the default behaviour with preventDefault', fakeAsync(() => { + httpError(undefined, { status: 500 }, (resp) => resp.preventDefault()); + expectSaveToHaveBeenCalled(false); + })); + + it('should be able to use preventDefault with 400 errors', fakeAsync(() => { + httpError( + { task: { name: 'someName', metadata: { component: 'someComponent' } } }, + { status: 400 }, + (resp) => resp.preventDefault() + ); + expectSaveToHaveBeenCalled(false); + })); + + it('should prevent the default behaviour by status code', fakeAsync(() => { + httpError(undefined, { status: 500 }, (resp) => resp.ignoreStatusCode(500)); + expectSaveToHaveBeenCalled(false); + })); + + it('should use different application icon (default Ceph) in error message', fakeAsync(() => { + const msg = 'Cannot connect to Alertmanager'; + httpError(undefined, { status: 500 }, (resp) => { + (resp.application = 'Prometheus'), (resp.message = msg); + }); + expectSaveToHaveBeenCalled(true); + expect(notificationService.save).toHaveBeenCalledWith( + createCdNotification(0, '500 - Unknown Error', msg, undefined, 'Prometheus') + ); + })); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/api-interceptor.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/api-interceptor.service.ts new file mode 100644 index 000000000..fb7a9f733 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/api-interceptor.service.ts @@ -0,0 +1,133 @@ +import { + HttpErrorResponse, + HttpEvent, + HttpHandler, + HttpInterceptor, + HttpRequest +} from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Router } from '@angular/router'; + +import _ from 'lodash'; +import { Observable, throwError as observableThrowError } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { CdHelperClass } from '~/app/shared/classes/cd-helper.class'; +import { NotificationType } from '../enum/notification-type.enum'; +import { CdNotificationConfig } from '../models/cd-notification'; +import { FinishedTask } from '../models/finished-task'; +import { AuthStorageService } from './auth-storage.service'; +import { NotificationService } from './notification.service'; + +export class CdHttpErrorResponse extends HttpErrorResponse { + preventDefault: Function; + ignoreStatusCode: Function; +} + +@Injectable({ + providedIn: 'root' +}) +export class ApiInterceptorService implements HttpInterceptor { + constructor( + private router: Router, + private authStorageService: AuthStorageService, + public notificationService: NotificationService + ) {} + + intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> { + const acceptHeader = request.headers.get('Accept'); + let reqWithVersion: HttpRequest<any>; + if (acceptHeader && acceptHeader.startsWith('application/vnd.ceph.api.v')) { + reqWithVersion = request.clone(); + } else { + reqWithVersion = request.clone({ + setHeaders: { + Accept: CdHelperClass.cdVersionHeader('1', '0') + } + }); + } + return next.handle(reqWithVersion).pipe( + catchError((resp: CdHttpErrorResponse) => { + if (resp instanceof HttpErrorResponse) { + let timeoutId: number; + switch (resp.status) { + case 400: + const finishedTask = new FinishedTask(); + + const task = resp.error.task; + if (_.isPlainObject(task)) { + task.metadata.component = task.metadata.component || resp.error.component; + + finishedTask.name = task.name; + finishedTask.metadata = task.metadata; + } else { + finishedTask.metadata = resp.error; + } + + finishedTask.success = false; + finishedTask.exception = resp.error; + timeoutId = this.notificationService.notifyTask(finishedTask); + break; + case 401: + this.authStorageService.remove(); + this.router.navigate(['/login']); + break; + case 403: + this.router.navigate(['error'], { + state: { + message: $localize`Sorry, you don’t have permission to view this page or resource.`, + header: $localize`Access Denied`, + icon: 'fa fa-lock', + source: 'forbidden' + } + }); + break; + default: + timeoutId = this.prepareNotification(resp); + } + + /** + * Decorated preventDefault method (in case error previously had + * preventDefault method defined). If called, it will prevent a + * notification to be shown. + */ + resp.preventDefault = () => { + this.notificationService.cancel(timeoutId); + }; + + /** + * If called, it will prevent a notification for the specific status code. + * @param {number} status The status code to be ignored. + */ + resp.ignoreStatusCode = function (status: number) { + if (this.status === status) { + this.preventDefault(); + } + }; + } + // Return the error to the method that called it. + return observableThrowError(resp); + }) + ); + } + + private prepareNotification(resp: any): number { + return this.notificationService.show(() => { + let message = ''; + if (_.isPlainObject(resp.error) && _.isString(resp.error.detail)) { + message = resp.error.detail; // Error was triggered by the backend. + } else if (_.isString(resp.error)) { + message = resp.error; + } else if (_.isString(resp.message)) { + message = resp.message; + } + return new CdNotificationConfig( + NotificationType.error, + `${resp.status} - ${resp.statusText}`, + message, + undefined, + resp['application'] + ); + }); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/auth-guard.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/auth-guard.service.spec.ts new file mode 100644 index 000000000..22a6e8139 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/auth-guard.service.spec.ts @@ -0,0 +1,54 @@ +import { Component, NgZone } from '@angular/core'; +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { ActivatedRouteSnapshot, Router, RouterStateSnapshot, Routes } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { AuthGuardService } from './auth-guard.service'; +import { AuthStorageService } from './auth-storage.service'; + +describe('AuthGuardService', () => { + let service: AuthGuardService; + let authStorageService: AuthStorageService; + let ngZone: NgZone; + let route: ActivatedRouteSnapshot; + let state: RouterStateSnapshot; + + @Component({ selector: 'cd-login', template: '' }) + class LoginComponent {} + + const routes: Routes = [{ path: 'login', component: LoginComponent }]; + + configureTestBed({ + imports: [RouterTestingModule.withRoutes(routes)], + providers: [AuthGuardService, AuthStorageService], + declarations: [LoginComponent] + }); + + beforeEach(() => { + service = TestBed.inject(AuthGuardService); + authStorageService = TestBed.inject(AuthStorageService); + ngZone = TestBed.inject(NgZone); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should allow the user if loggedIn', () => { + route = null; + state = { url: '/', root: null }; + spyOn(authStorageService, 'isLoggedIn').and.returnValue(true); + expect(service.canActivate(route, state)).toBe(true); + }); + + it('should prevent user if not loggedIn and redirect to login page', fakeAsync(() => { + const router = TestBed.inject(Router); + state = { url: '/pool', root: null }; + ngZone.run(() => { + expect(service.canActivate(route, state)).toBe(false); + }); + tick(); + expect(router.url).toBe('/login?returnUrl=%2Fpool'); + })); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/auth-guard.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/auth-guard.service.ts new file mode 100644 index 000000000..61c06c81d --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/auth-guard.service.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@angular/core'; +import { + ActivatedRouteSnapshot, + CanActivate, + CanActivateChild, + Router, + RouterStateSnapshot +} from '@angular/router'; + +import { AuthStorageService } from './auth-storage.service'; + +@Injectable({ + providedIn: 'root' +}) +export class AuthGuardService implements CanActivate, CanActivateChild { + constructor(private router: Router, private authStorageService: AuthStorageService) {} + + canActivate(_route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { + if (this.authStorageService.isLoggedIn()) { + return true; + } + this.router.navigate(['/login'], { queryParams: { returnUrl: state.url } }); + return false; + } + + canActivateChild(childRoute: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean { + return this.canActivate(childRoute, state); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/auth-storage.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/auth-storage.service.spec.ts new file mode 100644 index 000000000..f202c095f --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/auth-storage.service.spec.ts @@ -0,0 +1,47 @@ +import { AuthStorageService } from './auth-storage.service'; + +describe('AuthStorageService', () => { + let service: AuthStorageService; + const username = 'foobar'; + + beforeEach(() => { + service = new AuthStorageService(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should store username', () => { + service.set(username, ''); + expect(localStorage.getItem('dashboard_username')).toBe(username); + }); + + it('should remove username', () => { + service.set(username, ''); + service.remove(); + expect(localStorage.getItem('dashboard_username')).toBe(null); + }); + + it('should be loggedIn', () => { + service.set(username, ''); + expect(service.isLoggedIn()).toBe(true); + }); + + it('should not be loggedIn', () => { + service.remove(); + expect(service.isLoggedIn()).toBe(false); + }); + + it('should be SSO', () => { + service.set(username, {}, true); + expect(localStorage.getItem('sso')).toBe('true'); + expect(service.isSSO()).toBe(true); + }); + + it('should not be SSO', () => { + service.set(username); + expect(localStorage.getItem('sso')).toBe('false'); + expect(service.isSSO()).toBe(false); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/auth-storage.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/auth-storage.service.ts new file mode 100644 index 000000000..15e21f9ed --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/auth-storage.service.ts @@ -0,0 +1,59 @@ +import { Injectable } from '@angular/core'; + +import { BehaviorSubject } from 'rxjs'; + +import { Permissions } from '../models/permissions'; + +@Injectable({ + providedIn: 'root' +}) +export class AuthStorageService { + isPwdDisplayedSource = new BehaviorSubject(false); + isPwdDisplayed$ = this.isPwdDisplayedSource.asObservable(); + + set( + username: string, + permissions = {}, + sso = false, + pwdExpirationDate: number = null, + pwdUpdateRequired: boolean = false + ) { + localStorage.setItem('dashboard_username', username); + localStorage.setItem('dashboard_permissions', JSON.stringify(new Permissions(permissions))); + localStorage.setItem('user_pwd_expiration_date', String(pwdExpirationDate)); + localStorage.setItem('user_pwd_update_required', String(pwdUpdateRequired)); + localStorage.setItem('sso', String(sso)); + } + + remove() { + localStorage.removeItem('dashboard_username'); + localStorage.removeItem('user_pwd_expiration_data'); + localStorage.removeItem('user_pwd_update_required'); + } + + isLoggedIn() { + return localStorage.getItem('dashboard_username') !== null; + } + + getUsername() { + return localStorage.getItem('dashboard_username'); + } + + getPermissions(): Permissions { + return JSON.parse( + localStorage.getItem('dashboard_permissions') || JSON.stringify(new Permissions({})) + ); + } + + getPwdExpirationDate(): number { + return Number(localStorage.getItem('user_pwd_expiration_date')); + } + + getPwdUpdateRequired(): boolean { + return localStorage.getItem('user_pwd_update_required') === 'true'; + } + + isSSO() { + return localStorage.getItem('sso') === 'true'; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/cd-table-server-side.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/cd-table-server-side.service.spec.ts new file mode 100644 index 000000000..dbe7bb452 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/cd-table-server-side.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { CdTableServerSideService } from './cd-table-server-side.service'; + +describe('CdTableServerSideService', () => { + let service: CdTableServerSideService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(CdTableServerSideService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/cd-table-server-side.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/cd-table-server-side.service.ts new file mode 100644 index 000000000..56bf807a6 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/cd-table-server-side.service.ts @@ -0,0 +1,14 @@ +import { HttpResponse } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root' +}) +export class CdTableServerSideService { + /* tslint:disable:no-empty */ + constructor() {} + + static getCount(resp: HttpResponse<any>): number { + return Number(resp.headers?.get('X-Total-Count')); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/change-password-guard.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/change-password-guard.service.spec.ts new file mode 100644 index 000000000..12800d112 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/change-password-guard.service.spec.ts @@ -0,0 +1,68 @@ +import { Component, NgZone } from '@angular/core'; +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { ActivatedRouteSnapshot, Router, RouterStateSnapshot, Routes } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { AuthStorageService } from './auth-storage.service'; +import { ChangePasswordGuardService } from './change-password-guard.service'; + +describe('ChangePasswordGuardService', () => { + let service: ChangePasswordGuardService; + let authStorageService: AuthStorageService; + let ngZone: NgZone; + let route: ActivatedRouteSnapshot; + let state: RouterStateSnapshot; + + @Component({ selector: 'cd-login-password-form', template: '' }) + class LoginPasswordFormComponent {} + + const routes: Routes = [{ path: 'login-change-password', component: LoginPasswordFormComponent }]; + + configureTestBed({ + imports: [RouterTestingModule.withRoutes(routes)], + providers: [ChangePasswordGuardService, AuthStorageService], + declarations: [LoginPasswordFormComponent] + }); + + beforeEach(() => { + service = TestBed.inject(ChangePasswordGuardService); + authStorageService = TestBed.inject(AuthStorageService); + ngZone = TestBed.inject(NgZone); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should do nothing (not logged in)', () => { + spyOn(authStorageService, 'isLoggedIn').and.returnValue(false); + expect(service.canActivate(route, state)).toBeTruthy(); + }); + + it('should do nothing (SSO enabled)', () => { + spyOn(authStorageService, 'isLoggedIn').and.returnValue(true); + spyOn(authStorageService, 'isSSO').and.returnValue(true); + expect(service.canActivate(route, state)).toBeTruthy(); + }); + + it('should do nothing (no update pwd required)', () => { + spyOn(authStorageService, 'isLoggedIn').and.returnValue(true); + spyOn(authStorageService, 'getPwdUpdateRequired').and.returnValue(false); + expect(service.canActivate(route, state)).toBeTruthy(); + }); + + it('should redirect to change password page by preserving the query params', fakeAsync(() => { + route = null; + state = { url: '/host', root: null }; + spyOn(authStorageService, 'isLoggedIn').and.returnValue(true); + spyOn(authStorageService, 'isSSO').and.returnValue(false); + spyOn(authStorageService, 'getPwdUpdateRequired').and.returnValue(true); + const router = TestBed.inject(Router); + ngZone.run(() => { + expect(service.canActivate(route, state)).toBeFalsy(); + }); + tick(); + expect(router.url).toBe('/login-change-password?returnUrl=%2Fhost'); + })); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/change-password-guard.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/change-password-guard.service.ts new file mode 100644 index 000000000..d97160f92 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/change-password-guard.service.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@angular/core'; +import { + ActivatedRouteSnapshot, + CanActivate, + CanActivateChild, + Router, + RouterStateSnapshot +} from '@angular/router'; + +import { AuthStorageService } from './auth-storage.service'; + +/** + * This service guard checks if a user must be redirected to a special + * page at '/login-change-password' to set a new password. + */ +@Injectable({ + providedIn: 'root' +}) +export class ChangePasswordGuardService implements CanActivate, CanActivateChild { + constructor(private router: Router, private authStorageService: AuthStorageService) {} + + canActivate(_route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { + // Redirect to '/login-change-password' when the following constraints + // are fulfilled: + // - The user must be logged in. + // - SSO must be disabled. + // - The flag 'User must change password at next logon' must be set. + if ( + this.authStorageService.isLoggedIn() && + !this.authStorageService.isSSO() && + this.authStorageService.getPwdUpdateRequired() + ) { + this.router.navigate(['/login-change-password'], { queryParams: { returnUrl: state.url } }); + return false; + } + return true; + } + + canActivateChild(childRoute: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean { + return this.canActivate(childRoute, state); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/device.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/device.service.spec.ts new file mode 100644 index 000000000..00524317e --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/device.service.spec.ts @@ -0,0 +1,92 @@ +import { TestBed } from '@angular/core/testing'; + +import moment from 'moment'; + +import { CdDevice } from '../models/devices'; +import { DeviceService } from './device.service'; + +describe('DeviceService', () => { + let service: DeviceService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(DeviceService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('should test getDevices pipe', () => { + let now: jasmine.Spy = null; + + const newDevice = (data: object): CdDevice => { + const device: CdDevice = { + devid: '', + location: [{ host: '', dev: '' }], + daemons: [] + }; + Object.assign(device, data); + return device; + }; + + beforeEach(() => { + // Mock 'moment.now()' to simplify testing by enabling testing with fixed dates. + now = spyOn(moment, 'now').and.returnValue( + moment('2019-10-01T00:00:00.00000+0100').valueOf() + ); + }); + + afterEach(() => { + expect(now).toHaveBeenCalled(); + }); + + it('should return status "good" for life expectancy > 6 weeks', () => { + const preparedDevice = service.calculateAdditionalData( + newDevice({ + life_expectancy_min: '2019-11-14T01:00:00.000000+0100', + life_expectancy_max: '0.000000', + life_expectancy_stamp: '2019-10-01T02:08:48.627312+0100' + }) + ); + expect(preparedDevice.life_expectancy_weeks).toEqual({ max: null, min: 6 }); + expect(preparedDevice.state).toBe('good'); + }); + + it('should return status "warning" for life expectancy <= 4 weeks', () => { + const preparedDevice = service.calculateAdditionalData( + newDevice({ + life_expectancy_min: '2019-10-14T01:00:00.000000+0100', + life_expectancy_max: '2019-11-14T01:00:00.000000+0100', + life_expectancy_stamp: '2019-10-01T00:00:00.00000+0100' + }) + ); + expect(preparedDevice.life_expectancy_weeks).toEqual({ max: 6, min: 2 }); + expect(preparedDevice.state).toBe('warning'); + }); + + it('should return status "bad" for life expectancy <= 2 weeks', () => { + const preparedDevice = service.calculateAdditionalData( + newDevice({ + life_expectancy_min: '0.000000', + life_expectancy_max: '2019-10-12T01:00:00.000000+0100', + life_expectancy_stamp: '2019-10-01T00:00:00.00000+0100' + }) + ); + expect(preparedDevice.life_expectancy_weeks).toEqual({ max: 2, min: null }); + expect(preparedDevice.state).toBe('bad'); + }); + + it('should return status "stale" for time stamp that is older than a week', () => { + const preparedDevice = service.calculateAdditionalData( + newDevice({ + life_expectancy_min: '0.000000', + life_expectancy_max: '0.000000', + life_expectancy_stamp: '2019-09-21T00:00:00.00000+0100' + }) + ); + expect(preparedDevice.life_expectancy_weeks).toEqual({ max: null, min: null }); + expect(preparedDevice.state).toBe('stale'); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/device.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/device.service.ts new file mode 100644 index 000000000..b433f235b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/device.service.ts @@ -0,0 +1,57 @@ +import { Injectable } from '@angular/core'; + +import moment from 'moment'; + +import { CdDevice } from '../models/devices'; + +@Injectable({ + providedIn: 'root' +}) +export class DeviceService { + /** + * Calculates additional data and appends them as new attributes to the given device. + */ + calculateAdditionalData(device: CdDevice): CdDevice { + if (!device.life_expectancy_min || !device.life_expectancy_max) { + device.state = 'unknown'; + return device; + } + const hasDate = (float: string): boolean => !!Number.parseFloat(float); + const weeks = (isoDate1: string, isoDate2: string): number => + !isoDate1 || !isoDate2 || !hasDate(isoDate1) || !hasDate(isoDate2) + ? null + : moment.duration(moment(isoDate1).diff(moment(isoDate2))).asWeeks(); + + const ageOfStamp = moment + .duration(moment(moment.now()).diff(moment(device.life_expectancy_stamp))) + .asWeeks(); + const max = weeks(device.life_expectancy_max, device.life_expectancy_stamp); + const min = weeks(device.life_expectancy_min, device.life_expectancy_stamp); + + if (ageOfStamp > 1) { + device.state = 'stale'; + } else if (max !== null && max <= 2) { + device.state = 'bad'; + } else if (min !== null && min <= 4) { + device.state = 'warning'; + } else { + device.state = 'good'; + } + + device.life_expectancy_weeks = { + max: max !== null ? Math.round(max) : null, + min: min !== null ? Math.round(min) : null + }; + + return device; + } + + readable(device: CdDevice): CdDevice { + device.readableDaemons = device.daemons.join(' '); + return device; + } + + prepareDevice(device: CdDevice): CdDevice { + return this.readable(this.calculateAdditionalData(device)); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/doc.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/doc.service.spec.ts new file mode 100644 index 000000000..7c3bf24dd --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/doc.service.spec.ts @@ -0,0 +1,75 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { Subscriber } from 'rxjs'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { SharedModule } from '../shared.module'; +import { DocService } from './doc.service'; + +describe('DocService', () => { + let service: DocService; + + configureTestBed({ imports: [HttpClientTestingModule, SharedModule] }); + + beforeEach(() => { + service = TestBed.inject(DocService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should return full URL', () => { + expect(service.urlGenerator('iscsi', 'foo')).toBe( + 'https://docs.ceph.com/en/foo/mgr/dashboard/#enabling-iscsi-management' + ); + }); + + it('should return latest version URL for master', () => { + expect(service.urlGenerator('orch', 'master')).toBe( + 'https://docs.ceph.com/en/latest/mgr/orchestrator' + ); + }); + + describe('Name of the group', () => { + let result: string; + let i: number; + + const nextSummary = (newData: any) => service['releaseDataSource'].next(newData); + + const callback = (response: string) => { + i++; + result = response; + }; + + beforeEach(() => { + i = 0; + result = undefined; + nextSummary(undefined); + }); + + it('should call subscribeOnce without releaseName', () => { + const subscriber = service.subscribeOnce('prometheus', callback); + + expect(subscriber).toEqual(jasmine.any(Subscriber)); + expect(i).toBe(0); + expect(result).toEqual(undefined); + }); + + it('should call subscribeOnce with releaseName', () => { + const subscriber = service.subscribeOnce('prometheus', callback); + + expect(subscriber).toEqual(jasmine.any(Subscriber)); + expect(i).toBe(0); + expect(result).toEqual(undefined); + + nextSummary('foo'); + expect(result).toEqual( + 'https://docs.ceph.com/en/foo/mgr/dashboard/#enabling-prometheus-alerting' + ); + expect(i).toBe(1); + expect(subscriber.closed).toBe(true); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/doc.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/doc.service.ts new file mode 100644 index 000000000..4cbb4cf18 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/doc.service.ts @@ -0,0 +1,65 @@ +import { Injectable } from '@angular/core'; + +import { BehaviorSubject, Subscription } from 'rxjs'; +import { filter, first, map } from 'rxjs/operators'; + +import { CephReleaseNamePipe } from '../pipes/ceph-release-name.pipe'; +import { SummaryService } from './summary.service'; + +@Injectable({ + providedIn: 'root' +}) +export class DocService { + private releaseDataSource = new BehaviorSubject<string>(null); + releaseData$ = this.releaseDataSource.asObservable(); + + constructor( + private summaryservice: SummaryService, + private cephReleaseNamePipe: CephReleaseNamePipe + ) { + this.summaryservice.subscribeOnce((summary) => { + const releaseName = this.cephReleaseNamePipe.transform(summary.version); + this.releaseDataSource.next(releaseName); + }); + } + + urlGenerator(section: string, release = 'master'): string { + const docVersion = release === 'master' ? 'latest' : release; + const domain = `https://docs.ceph.com/en/${docVersion}/`; + const domainCeph = `https://ceph.io/`; + + const sections = { + iscsi: `${domain}mgr/dashboard/#enabling-iscsi-management`, + prometheus: `${domain}mgr/dashboard/#enabling-prometheus-alerting`, + 'nfs-ganesha': `${domain}mgr/dashboard/#configuring-nfs-ganesha-in-the-dashboard`, + 'rgw-nfs': `${domain}radosgw/nfs`, + rgw: `${domain}mgr/dashboard/#enabling-the-object-gateway-management-frontend`, + dashboard: `${domain}mgr/dashboard`, + grafana: `${domain}mgr/dashboard/#enabling-the-embedding-of-grafana-dashboards`, + orch: `${domain}mgr/orchestrator`, + pgs: `${domainCeph}pgcalc`, + help: `${domainCeph}help/`, + security: `${domainCeph}security/`, + trademarks: `${domainCeph}legal-page/trademarks/`, + 'dashboard-landing-page-status': `${domain}mgr/dashboard/#dashboard-landing-page-status`, + 'dashboard-landing-page-performance': `${domain}mgr/dashboard/#dashboard-landing-page-performance`, + 'dashboard-landing-page-capacity': `${domain}mgr/dashboard/#dashboard-landing-page-capacity` + }; + + return sections[section]; + } + + subscribeOnce( + section: string, + next: (release: string) => void, + error?: (error: any) => void + ): Subscription { + return this.releaseData$ + .pipe( + filter((value) => !!value), + map((release) => this.urlGenerator(section, release)), + first() + ) + .subscribe(next, error); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/favicon.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/favicon.service.spec.ts new file mode 100644 index 000000000..0c9e619ea --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/favicon.service.spec.ts @@ -0,0 +1,23 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { CssHelper } from '~/app/shared/classes/css-helper'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { FaviconService } from './favicon.service'; + +describe('FaviconService', () => { + let service: FaviconService; + + configureTestBed({ + imports: [HttpClientTestingModule], + providers: [FaviconService, CssHelper] + }); + + beforeEach(() => { + service = TestBed.inject(FaviconService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/favicon.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/favicon.service.ts new file mode 100644 index 000000000..87ce8fcad --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/favicon.service.ts @@ -0,0 +1,79 @@ +import { DOCUMENT } from '@angular/common'; +import { Inject, Injectable, OnDestroy } from '@angular/core'; + +import { Subscription } from 'rxjs'; + +import { CssHelper } from '~/app/shared/classes/css-helper'; +import { HealthColor } from '~/app/shared/enum/health-color.enum'; +import { SummaryService } from './summary.service'; + +@Injectable() +export class FaviconService implements OnDestroy { + sub: Subscription; + oldStatus: string; + url: string; + + constructor( + @Inject(DOCUMENT) private document: HTMLDocument, + private summaryService: SummaryService, + private cssHelper: CssHelper + ) {} + + init() { + this.url = this.document.getElementById('cdFavicon')?.getAttribute('href'); + + this.sub = this.summaryService.subscribe((summary) => { + this.changeIcon(summary.health_status); + }); + } + + changeIcon(status?: string) { + if (status === this.oldStatus) { + return; + } + + this.oldStatus = status; + + const favicon = this.document.getElementById('cdFavicon'); + const faviconSize = 16; + const radius = faviconSize / 4; + + const canvas = this.document.createElement('canvas'); + canvas.width = faviconSize; + canvas.height = faviconSize; + + const context = canvas.getContext('2d'); + const img = this.document.createElement('img'); + img.src = this.url; + + img.onload = () => { + // Draw Original Favicon as Background + context.drawImage(img, 0, 0, faviconSize, faviconSize); + + if (Object.keys(HealthColor).includes(status as HealthColor)) { + // Cut notification circle area + context.save(); + context.globalCompositeOperation = 'destination-out'; + context.beginPath(); + context.arc(canvas.width - radius, radius, radius + 2, 0, 2 * Math.PI); + context.fill(); + context.restore(); + + // Draw Notification Circle + context.beginPath(); + context.arc(canvas.width - radius, radius, radius, 0, 2 * Math.PI); + + context.fillStyle = this.cssHelper.propertyValue(HealthColor[status]); + context.fill(); + } + + // Replace favicon + favicon.setAttribute('href', canvas.toDataURL('image/png')); + }; + } + + ngOnDestroy() { + this.changeIcon(); + this.sub?.unsubscribe(); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/feature-toggles-guard.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/feature-toggles-guard.service.spec.ts new file mode 100644 index 000000000..883139986 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/feature-toggles-guard.service.spec.ts @@ -0,0 +1,72 @@ +import { Component, NgZone } from '@angular/core'; +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { ActivatedRouteSnapshot, Router, Routes } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { of as observableOf } from 'rxjs'; + +import { DashboardNotFoundError } from '~/app/core/error/error'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { FeatureTogglesGuardService } from './feature-toggles-guard.service'; +import { FeatureTogglesService } from './feature-toggles.service'; + +describe('FeatureTogglesGuardService', () => { + let service: FeatureTogglesGuardService; + let fakeFeatureTogglesService: FeatureTogglesService; + let router: Router; + let ngZone: NgZone; + + @Component({ selector: 'cd-cephfs', template: '' }) + class CephfsComponent {} + + @Component({ selector: 'cd-404', template: '' }) + class NotFoundComponent {} + + const routes: Routes = [ + { path: 'cephfs', component: CephfsComponent }, + { path: '404', component: NotFoundComponent } + ]; + + configureTestBed({ + imports: [RouterTestingModule.withRoutes(routes)], + providers: [ + { provide: FeatureTogglesService, useValue: { get: null } }, + FeatureTogglesGuardService + ], + declarations: [CephfsComponent, NotFoundComponent] + }); + + beforeEach(() => { + service = TestBed.inject(FeatureTogglesGuardService); + fakeFeatureTogglesService = TestBed.inject(FeatureTogglesService); + ngZone = TestBed.inject(NgZone); + router = TestBed.inject(Router); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + function testCanActivate(path: string, feature_toggles_map: object) { + let result: boolean; + spyOn(fakeFeatureTogglesService, 'get').and.returnValue(observableOf(feature_toggles_map)); + + ngZone.run(() => { + service + .canActivate(<ActivatedRouteSnapshot>{ routeConfig: { path: path } }) + .subscribe((val) => (result = val)); + }); + tick(); + + return result; + } + + it('should allow the feature if enabled', fakeAsync(() => { + expect(testCanActivate('cephfs', { cephfs: true })).toBe(true); + expect(router.url).toBe('/'); + })); + + it('should throw error if disable', fakeAsync(() => { + expect(() => testCanActivate('cephfs', { cephfs: false })).toThrowError(DashboardNotFoundError); + })); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/feature-toggles-guard.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/feature-toggles-guard.service.ts new file mode 100644 index 000000000..ad94f2689 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/feature-toggles-guard.service.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, CanActivate, CanActivateChild } from '@angular/router'; + +import { map } from 'rxjs/operators'; + +import { DashboardNotFoundError } from '~/app/core/error/error'; +import { FeatureTogglesMap, FeatureTogglesService } from './feature-toggles.service'; + +@Injectable({ + providedIn: 'root' +}) +export class FeatureTogglesGuardService implements CanActivate, CanActivateChild { + constructor(private featureToggles: FeatureTogglesService) {} + + canActivate(route: ActivatedRouteSnapshot) { + return this.featureToggles.get().pipe( + map((enabledFeatures: FeatureTogglesMap) => { + if (enabledFeatures[route.routeConfig.path] === false) { + throw new DashboardNotFoundError(); + return false; + } + return true; + }) + ); + } + + canActivateChild(route: ActivatedRouteSnapshot) { + return this.canActivate(route.parent); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/feature-toggles.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/feature-toggles.service.spec.ts new file mode 100644 index 000000000..ddb888851 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/feature-toggles.service.spec.ts @@ -0,0 +1,54 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { discardPeriodicTasks, fakeAsync, TestBed, tick } from '@angular/core/testing'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { FeatureTogglesService } from './feature-toggles.service'; + +describe('FeatureTogglesService', () => { + let httpTesting: HttpTestingController; + let service: FeatureTogglesService; + + configureTestBed({ + providers: [FeatureTogglesService], + imports: [HttpClientTestingModule] + }); + + beforeEach(() => { + service = TestBed.inject(FeatureTogglesService); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should fetch HTTP endpoint once and only once', fakeAsync(() => { + const mockFeatureTogglesMap = [ + { + rbd: true, + mirroring: true, + iscsi: true, + cephfs: true, + rgw: true + } + ]; + + service + .get() + .subscribe((featureTogglesMap) => expect(featureTogglesMap).toEqual(mockFeatureTogglesMap)); + tick(); + + // Second subscription shouldn't trigger a new HTTP request + service + .get() + .subscribe((featureTogglesMap) => expect(featureTogglesMap).toEqual(mockFeatureTogglesMap)); + + const req = httpTesting.expectOne(service.API_URL); + req.flush(mockFeatureTogglesMap); + discardPeriodicTasks(); + })); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/feature-toggles.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/feature-toggles.service.ts new file mode 100644 index 000000000..bb7f2a0d6 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/feature-toggles.service.ts @@ -0,0 +1,37 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +import { Observable } from 'rxjs'; + +import { TimerService } from './timer.service'; + +export class FeatureTogglesMap { + rbd = true; + mirroring = true; + iscsi = true; + cephfs = true; + rgw = true; + nfs = true; +} +export type Features = keyof FeatureTogglesMap; +export type FeatureTogglesMap$ = Observable<FeatureTogglesMap>; + +@Injectable({ + providedIn: 'root' +}) +export class FeatureTogglesService { + readonly API_URL: string = 'api/feature_toggles'; + readonly REFRESH_INTERVAL: number = 30000; + private featureToggleMap$: FeatureTogglesMap$; + + constructor(private http: HttpClient, private timerService: TimerService) { + this.featureToggleMap$ = this.timerService.get( + () => this.http.get<FeatureTogglesMap>(this.API_URL), + this.REFRESH_INTERVAL + ); + } + + get(): FeatureTogglesMap$ { + return this.featureToggleMap$; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/formatter.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/formatter.service.spec.ts new file mode 100644 index 000000000..359c6028a --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/formatter.service.spec.ts @@ -0,0 +1,90 @@ +import { configureTestBed } from '~/testing/unit-test-helper'; +import { DimlessBinaryPipe } from '../pipes/dimless-binary.pipe'; +import { DimlessPipe } from '../pipes/dimless.pipe'; +import { FormatterService } from './formatter.service'; + +describe('FormatterService', () => { + let service: FormatterService; + let dimlessBinaryPipe: DimlessBinaryPipe; + let dimlessPipe: DimlessPipe; + + const convertToBytesAndBack = (value: string, newValue?: string) => { + expect(dimlessBinaryPipe.transform(service.toBytes(value))).toBe(newValue || value); + }; + + configureTestBed({ + providers: [FormatterService, DimlessBinaryPipe] + }); + + beforeEach(() => { + service = new FormatterService(); + dimlessBinaryPipe = new DimlessBinaryPipe(service); + dimlessPipe = new DimlessPipe(service); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('format_number', () => { + const formats = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']; + + it('should return minus for unsupported values', () => { + expect(service.format_number(service, 1024, formats)).toBe('-'); + expect(service.format_number(undefined, 1024, formats)).toBe('-'); + expect(service.format_number(null, 1024, formats)).toBe('-'); + }); + + it('should test some values', () => { + expect(service.format_number('0', 1024, formats)).toBe('0 B'); + expect(service.format_number('0.1', 1024, formats)).toBe('0.1 B'); + expect(service.format_number('1.2', 1024, formats)).toBe('1.2 B'); + expect(service.format_number('1', 1024, formats)).toBe('1 B'); + expect(service.format_number('1024', 1024, formats)).toBe('1 KiB'); + expect(service.format_number(23.45678 * Math.pow(1024, 3), 1024, formats)).toBe('23.5 GiB'); + expect(service.format_number(23.45678 * Math.pow(1024, 3), 1024, formats, 2)).toBe( + '23.46 GiB' + ); + }); + + it('should test some dimless values', () => { + expect(dimlessPipe.transform(0.6)).toBe('0.6'); + expect(dimlessPipe.transform(1000.608)).toBe('1 k'); + expect(dimlessPipe.transform(1e10)).toBe('10 G'); + expect(dimlessPipe.transform(2.37e16)).toBe('23.7 P'); + }); + }); + + describe('toBytes', () => { + it('should not convert wrong values', () => { + expect(service.toBytes('10xyz')).toBeNull(); + expect(service.toBytes('1.1.1KiB')).toBeNull(); + expect(service.toBytes('1.1 KiloByte')).toBeNull(); + expect(service.toBytes('1.1 kib')).toBeNull(); + expect(service.toBytes('1.kib')).toBeNull(); + expect(service.toBytes('1 ki')).toBeNull(); + expect(service.toBytes(undefined)).toBeNull(); + expect(service.toBytes('')).toBeNull(); + expect(service.toBytes('-')).toBeNull(); + expect(service.toBytes(null)).toBeNull(); + }); + + it('should convert values to bytes', () => { + expect(service.toBytes('4815162342')).toBe(4815162342); + expect(service.toBytes('100M')).toBe(104857600); + expect(service.toBytes('100 M')).toBe(104857600); + expect(service.toBytes('100 mIb')).toBe(104857600); + expect(service.toBytes('100 mb')).toBe(104857600); + expect(service.toBytes('100MIB')).toBe(104857600); + expect(service.toBytes('1.532KiB')).toBe(Math.round(1.532 * 1024)); + expect(service.toBytes('0.000000000001TiB')).toBe(1); + }); + + it('should convert values to human readable again', () => { + convertToBytesAndBack('1.1 MiB'); + convertToBytesAndBack('1.0MiB', '1 MiB'); + convertToBytesAndBack('8.9 GiB'); + convertToBytesAndBack('123.5 EiB'); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/formatter.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/formatter.service.ts new file mode 100644 index 000000000..a4b6d427b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/formatter.service.ts @@ -0,0 +1,77 @@ +import { Injectable } from '@angular/core'; + +import _ from 'lodash'; + +@Injectable({ + providedIn: 'root' +}) +export class FormatterService { + format_number(n: any, divisor: number, units: string[], decimals: number = 1): string { + if (_.isString(n)) { + n = Number(n); + } + if (!_.isNumber(n)) { + return '-'; + } + let unit = n < 1 ? 0 : Math.floor(Math.log(n) / Math.log(divisor)); + unit = unit >= units.length ? units.length - 1 : unit; + let result = _.round(n / Math.pow(divisor, unit), decimals).toString(); + if (result === '') { + return '-'; + } + if (units[unit] !== '') { + result = `${result} ${units[unit]}`; + } + return result; + } + + /** + * Convert the given value into bytes. + * @param {string} value The value to be converted, e.g. 1024B, 10M, 300KiB or 1ZB. + * @param error_value The value returned in case the regular expression did not match. Defaults to + * null. + * @returns Returns the given value in bytes without any unit appended or the defined error value + * in case xof an error. + */ + toBytes(value: string, error_value: number = null): number | null { + const base = 1024; + const units = ['b', 'k', 'm', 'g', 't', 'p', 'e', 'z', 'y']; + const m = RegExp('^(\\d+(.\\d+)?) ?([' + units.join('') + ']?(b|ib|B/s)?)?$', 'i').exec(value); + if (m === null) { + return error_value; + } + let bytes = parseFloat(m[1]); + if (_.isString(m[3])) { + bytes = bytes * Math.pow(base, units.indexOf(m[3].toLowerCase()[0])); + } + return Math.round(bytes); + } + + /** + * Converts `x ms` to `x` (currently) or `0` if the conversion fails + */ + toMilliseconds(value: string): number { + const pattern = /^\s*(\d+)\s*(ms)?\s*$/i; + const testResult = pattern.exec(value); + + if (testResult !== null) { + return +testResult[1]; + } + + return 0; + } + + /** + * Converts `x IOPS` to `x` (currently) or `0` if the conversion fails + */ + toIops(value: string): number { + const pattern = /^\s*(\d+)\s*(IOPS)?\s*$/i; + const testResult = pattern.exec(value); + + if (testResult !== null) { + return +testResult[1]; + } + + return 0; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/js-error-handler.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/js-error-handler.service.ts new file mode 100644 index 000000000..de42d005e --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/js-error-handler.service.ts @@ -0,0 +1,33 @@ +import { ErrorHandler, Injectable, Injector } from '@angular/core'; +import { Router } from '@angular/router'; + +import { DashboardError } from '~/app/core/error/error'; +import { LoggingService } from '../api/logging.service'; + +@Injectable() +export class JsErrorHandler implements ErrorHandler { + constructor(private injector: Injector, private router: Router) {} + + handleError(error: any) { + const loggingService = this.injector.get(LoggingService); + const url = window.location.href; + const message = error && error.message; + const stack = error && error.stack; + loggingService.jsError(url, message, stack).subscribe(); + if (error.rejection instanceof DashboardError) { + setTimeout( + () => + this.router.navigate(['error'], { + state: { + message: error.rejection.message, + header: error.rejection.header, + icon: error.rejection.icon + } + }), + 50 + ); + } else { + throw error; + } + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/language.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/language.service.spec.ts new file mode 100644 index 000000000..dacff44f0 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/language.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 { LanguageService } from './language.service'; + +describe('LanguageService', () => { + let service: LanguageService; + let httpTesting: HttpTestingController; + + configureTestBed({ + providers: [LanguageService], + imports: [HttpClientTestingModule] + }); + + beforeEach(() => { + service = TestBed.inject(LanguageService); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call create', () => { + service.getLanguages().subscribe(); + const req = httpTesting.expectOne('ui-api/langs'); + expect(req.request.method).toBe('GET'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/language.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/language.service.ts new file mode 100644 index 000000000..d2705ee36 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/language.service.ts @@ -0,0 +1,23 @@ +import { HttpClient } from '@angular/common/http'; +import { Inject, Injectable, LOCALE_ID } from '@angular/core'; + +import { environment } from '~/environments/environment'; + +@Injectable({ + providedIn: 'root' +}) +export class LanguageService { + constructor(private http: HttpClient, @Inject(LOCALE_ID) protected localeId: string) {} + + getLocale(): string { + return this.localeId || environment.default_lang; + } + + setLocale(lang: string) { + document.cookie = `cd-lang=${lang}`; + } + + getLanguages() { + return this.http.get<string[]>('ui-api/langs'); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/modal.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/modal.service.spec.ts new file mode 100644 index 000000000..4e5ed061d --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/modal.service.spec.ts @@ -0,0 +1,59 @@ +import { Component } from '@angular/core'; +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; + +import { NgbActiveModal, NgbModal, NgbModalModule } from '@ng-bootstrap/ng-bootstrap'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { ModalService } from './modal.service'; + +@Component({ + template: `` +}) +class MockComponent { + foo = ''; + + constructor(public activeModal: NgbActiveModal) {} +} + +describe('ModalService', () => { + let service: ModalService; + let ngbModal: NgbModal; + + configureTestBed({ declarations: [MockComponent], imports: [NgbModalModule] }, [MockComponent]); + + beforeEach(() => { + service = TestBed.inject(ModalService); + ngbModal = TestBed.inject(NgbModal); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call NgbModal.open when show is called', () => { + spyOn(ngbModal, 'open').and.callThrough(); + + const modaRef = service.show(MockComponent, { foo: 'bar' }); + + expect(ngbModal.open).toBeCalled(); + expect(modaRef.componentInstance.foo).toBe('bar'); + expect(modaRef.componentInstance.activeModal).toBeTruthy(); + }); + + it('should call dismissAll and hasOpenModals', fakeAsync(() => { + spyOn(ngbModal, 'dismissAll').and.callThrough(); + spyOn(ngbModal, 'hasOpenModals').and.callThrough(); + + expect(ngbModal.hasOpenModals()).toBeFalsy(); + + service.show(MockComponent, { foo: 'bar' }); + expect(service.hasOpenModals()).toBeTruthy(); + + service.dismissAll(); + tick(); + expect(service.hasOpenModals()).toBeFalsy(); + + expect(ngbModal.dismissAll).toBeCalled(); + expect(ngbModal.hasOpenModals).toBeCalled(); + })); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/modal.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/modal.service.ts new file mode 100644 index 000000000..33ce8bd4d --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/modal.service.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@angular/core'; + +import { NgbModal, NgbModalOptions, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; + +@Injectable({ + providedIn: 'root' +}) +export class ModalService { + constructor(private modal: NgbModal) {} + + show(component: any, initialState?: any, options?: NgbModalOptions): NgbModalRef { + const modalRef = this.modal.open(component, options); + + if (initialState) { + Object.assign(modalRef.componentInstance, initialState); + } + + return modalRef; + } + + dismissAll() { + this.modal.dismissAll(); + } + + hasOpenModals() { + return this.modal.hasOpenModals(); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/module-status-guard.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/module-status-guard.service.spec.ts new file mode 100644 index 000000000..532aa6c65 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/module-status-guard.service.spec.ts @@ -0,0 +1,102 @@ +import { HttpClient } from '@angular/common/http'; +import { Component, NgZone } from '@angular/core'; +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { ActivatedRouteSnapshot, Router, Routes } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { of as observableOf, throwError } from 'rxjs'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { MgrModuleService } from '../api/mgr-module.service'; +import { ModuleStatusGuardService } from './module-status-guard.service'; + +describe('ModuleStatusGuardService', () => { + let service: ModuleStatusGuardService; + let httpClient: HttpClient; + let router: Router; + let route: ActivatedRouteSnapshot; + let ngZone: NgZone; + let mgrModuleService: MgrModuleService; + + @Component({ selector: 'cd-foo', template: '' }) + class FooComponent {} + + const fakeService = { + get: () => true + }; + + const routes: Routes = [{ path: '**', component: FooComponent }]; + + const testCanActivate = ( + getResult: {}, + activateResult: boolean, + urlResult: string, + backend = 'cephadm', + configOptPermission = true + ) => { + let result: boolean; + spyOn(httpClient, 'get').and.returnValue(observableOf(getResult)); + const orchBackend = { orchestrator: backend }; + const getConfigSpy = spyOn(mgrModuleService, 'getConfig'); + configOptPermission + ? getConfigSpy.and.returnValue(observableOf(orchBackend)) + : getConfigSpy.and.returnValue(throwError({})); + ngZone.run(() => { + service.canActivateChild(route).subscribe((resp) => { + result = resp; + }); + }); + + tick(); + expect(result).toBe(activateResult); + expect(router.url).toBe(urlResult); + }; + + configureTestBed({ + imports: [RouterTestingModule.withRoutes(routes)], + providers: [ModuleStatusGuardService, { provide: HttpClient, useValue: fakeService }], + declarations: [FooComponent] + }); + + beforeEach(() => { + service = TestBed.inject(ModuleStatusGuardService); + httpClient = TestBed.inject(HttpClient); + mgrModuleService = TestBed.inject(MgrModuleService); + router = TestBed.inject(Router); + route = new ActivatedRouteSnapshot(); + route.url = []; + route.data = { + moduleStatusGuardConfig: { + uiApiPath: 'bar', + redirectTo: '/foo', + backend: 'rook' + } + }; + ngZone = TestBed.inject(NgZone); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should test canActivate with status available', fakeAsync(() => { + route.data.moduleStatusGuardConfig.redirectTo = 'foo'; + testCanActivate({ available: true, message: 'foo' }, true, '/'); + })); + + it('should test canActivateChild with status unavailable', fakeAsync(() => { + testCanActivate({ available: false, message: null }, false, '/foo'); + })); + + it('should test canActivateChild with status unavailable', fakeAsync(() => { + testCanActivate(null, false, '/foo'); + })); + + it('should redirect normally if the backend provided matches the current backend', fakeAsync(() => { + testCanActivate({ available: true, message: 'foo' }, true, '/', 'rook'); + })); + + it('should redirect to the "redirectTo" link for user without sufficient permission', fakeAsync(() => { + testCanActivate({ available: true, message: 'foo' }, true, '/foo', 'rook', false); + })); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/module-status-guard.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/module-status-guard.service.ts new file mode 100644 index 000000000..df6f4854e --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/module-status-guard.service.ts @@ -0,0 +1,101 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, CanActivate, CanActivateChild, Router } from '@angular/router'; + +import { of as observableOf } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; + +import { MgrModuleService } from '~/app/shared/api/mgr-module.service'; +import { Icons } from '~/app/shared/enum/icons.enum'; + +/** + * This service checks if a route can be activated by executing a + * REST API call to '/ui-api/<uiApiPath>/status'. If the returned response + * states that the module is not available, then the user is redirected + * to the specified <redirectTo> URL path. + * + * A controller implementing this endpoint should return an object of + * the following form: + * {'available': true|false, 'message': null|string}. + * + * The configuration of this guard should look like this: + * const routes: Routes = [ + * { + * path: 'rgw/bucket', + * component: RgwBucketListComponent, + * canActivate: [AuthGuardService, ModuleStatusGuardService], + * data: { + * moduleStatusGuardConfig: { + * uiApiPath: 'rgw', + * redirectTo: 'rgw/501' + * } + * } + * }, + * ... + */ +@Injectable({ + providedIn: 'root' +}) +export class ModuleStatusGuardService implements CanActivate, CanActivateChild { + // TODO: Hotfix - remove ALLOWLIST'ing when a generic ErrorComponent is implemented + static readonly ALLOWLIST: string[] = ['501']; + + constructor( + private http: HttpClient, + private router: Router, + private mgrModuleService: MgrModuleService + ) {} + + canActivate(route: ActivatedRouteSnapshot) { + return this.doCheck(route); + } + + canActivateChild(childRoute: ActivatedRouteSnapshot) { + return this.doCheck(childRoute); + } + + private doCheck(route: ActivatedRouteSnapshot) { + if (route.url.length > 0 && ModuleStatusGuardService.ALLOWLIST.includes(route.url[0].path)) { + return observableOf(true); + } + const config = route.data['moduleStatusGuardConfig']; + let backendCheck = false; + if (config.backend) { + this.mgrModuleService.getConfig('orchestrator').subscribe( + (resp) => { + backendCheck = config.backend === resp['orchestrator']; + }, + () => { + this.router.navigate([config.redirectTo]); + return observableOf(false); + } + ); + } + return this.http.get(`ui-api/${config.uiApiPath}/status`).pipe( + map((resp: any) => { + if (!resp.available && !backendCheck) { + this.router.navigate([config.redirectTo || ''], { + state: { + header: config.header, + message: resp.message, + section: config.section, + section_info: config.section_info, + button_name: config.button_name, + button_route: config.button_route, + button_title: config.button_title, + uiConfig: config.uiConfig, + uiApiPath: config.uiApiPath, + icon: Icons.wrench, + component: config.component + } + }); + } + return resp.available; + }), + catchError(() => { + this.router.navigate([config.redirectTo]); + return observableOf(false); + }) + ); + } +} 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 000000000..267e6aa57 --- /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 000000000..d2ee89f9c --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/motd-notification.service.ts @@ -0,0 +1,84 @@ +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'; +import { whenPageVisible } from '../rxjs/operators/page-visibilty.operator'; + +@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(), + whenPageVisible() + ) + .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/frontend/src/app/shared/services/ngzone-scheduler.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/ngzone-scheduler.service.ts new file mode 100644 index 000000000..a2c6b6c95 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/ngzone-scheduler.service.ts @@ -0,0 +1,48 @@ +import { Injectable, NgZone } from '@angular/core'; + +import { asyncScheduler, SchedulerLike, Subscription } from 'rxjs'; + +abstract class NgZoneScheduler implements SchedulerLike { + protected scheduler = asyncScheduler; + + constructor(protected zone: NgZone) {} + + abstract schedule(...args: any[]): Subscription; + + now(): number { + return this.scheduler.now(); + } +} + +@Injectable({ + providedIn: 'root' +}) +export class LeaveNgZoneScheduler extends NgZoneScheduler { + constructor(zone: NgZone) { + super(zone); + } + + schedule(...args: any[]): Subscription { + return this.zone.runOutsideAngular(() => this.scheduler.schedule.apply(this.scheduler, args)); + } +} + +@Injectable({ + providedIn: 'root' +}) +export class EnterNgZoneScheduler extends NgZoneScheduler { + constructor(zone: NgZone) { + super(zone); + } + + schedule(...args: any[]): Subscription { + return this.zone.run(() => this.scheduler.schedule.apply(this.scheduler, args)); + } +} + +@Injectable({ + providedIn: 'root' +}) +export class NgZoneSchedulerService { + constructor(public leave: LeaveNgZoneScheduler, public enter: EnterNgZoneScheduler) {} +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/no-sso-guard.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/no-sso-guard.service.spec.ts new file mode 100644 index 000000000..9a330cdc8 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/no-sso-guard.service.spec.ts @@ -0,0 +1,49 @@ +import { Component, NgZone } from '@angular/core'; +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { Routes } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { DashboardUserDeniedError } from '~/app/core/error/error'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { AuthStorageService } from './auth-storage.service'; +import { NoSsoGuardService } from './no-sso-guard.service'; + +describe('NoSsoGuardService', () => { + let service: NoSsoGuardService; + let authStorageService: AuthStorageService; + let ngZone: NgZone; + + @Component({ selector: 'cd-404', template: '' }) + class NotFoundComponent {} + + const routes: Routes = [{ path: '404', component: NotFoundComponent }]; + + configureTestBed({ + imports: [RouterTestingModule.withRoutes(routes)], + providers: [NoSsoGuardService, AuthStorageService], + declarations: [NotFoundComponent] + }); + + beforeEach(() => { + service = TestBed.inject(NoSsoGuardService); + authStorageService = TestBed.inject(AuthStorageService); + ngZone = TestBed.inject(NgZone); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should allow if not logged in via SSO', () => { + spyOn(authStorageService, 'isSSO').and.returnValue(false); + expect(service.canActivate()).toBe(true); + }); + + it('should prevent if logged in via SSO', fakeAsync(() => { + spyOn(authStorageService, 'isSSO').and.returnValue(true); + ngZone.run(() => { + expect(() => service.canActivate()).toThrowError(DashboardUserDeniedError); + }); + tick(); + })); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/no-sso-guard.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/no-sso-guard.service.ts new file mode 100644 index 000000000..d4abcde0d --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/no-sso-guard.service.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@angular/core'; +import { CanActivate, CanActivateChild } from '@angular/router'; + +import { DashboardUserDeniedError } from '~/app/core/error/error'; +import { AuthStorageService } from './auth-storage.service'; + +/** + * This service checks if a route can be activated if the user has not + * been logged in via SSO. + */ +@Injectable({ + providedIn: 'root' +}) +export class NoSsoGuardService implements CanActivate, CanActivateChild { + constructor(private authStorageService: AuthStorageService) {} + + canActivate() { + if (!this.authStorageService.isSSO()) { + return true; + } + throw new DashboardUserDeniedError(); + return false; + } + + canActivateChild(): boolean { + return this.canActivate(); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.spec.ts new file mode 100644 index 000000000..028dd90ea --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.spec.ts @@ -0,0 +1,285 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; + +import _ from 'lodash'; +import { ToastrService } from 'ngx-toastr'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { RbdService } from '../api/rbd.service'; +import { NotificationType } from '../enum/notification-type.enum'; +import { CdNotificationConfig } from '../models/cd-notification'; +import { FinishedTask } from '../models/finished-task'; +import { CdDatePipe } from '../pipes/cd-date.pipe'; +import { NotificationService } from './notification.service'; +import { TaskMessageService } from './task-message.service'; + +describe('NotificationService', () => { + let service: NotificationService; + const toastFakeService = { + error: () => true, + info: () => true, + success: () => true + }; + + configureTestBed({ + providers: [ + NotificationService, + TaskMessageService, + { provide: ToastrService, useValue: toastFakeService }, + { provide: CdDatePipe, useValue: { transform: (d: any) => d } }, + RbdService + ], + imports: [HttpClientTestingModule] + }); + + beforeEach(() => { + service = TestBed.inject(NotificationService); + service.removeAll(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should read empty notification list', () => { + localStorage.setItem('cdNotifications', '[]'); + expect(service['dataSource'].getValue()).toEqual([]); + }); + + it('should read old notifications', fakeAsync(() => { + localStorage.setItem( + 'cdNotifications', + '[{"type":2,"message":"foobar","timestamp":"2018-05-24T09:41:32.726Z"}]' + ); + service = new NotificationService(null, null, null); + expect(service['dataSource'].getValue().length).toBe(1); + })); + + it('should cancel a notification', fakeAsync(() => { + const timeoutId = service.show(NotificationType.error, 'Simple test'); + service.cancel(timeoutId); + tick(5000); + expect(service['dataSource'].getValue().length).toBe(0); + })); + + describe('Saved notifications', () => { + const expectSavedNotificationToHave = (expected: object) => { + tick(510); + expect(service['dataSource'].getValue().length).toBe(1); + const notification = service['dataSource'].getValue()[0]; + Object.keys(expected).forEach((key) => { + expect(notification[key]).toBe(expected[key]); + }); + }; + + const addNotifications = (quantity: number) => { + for (let index = 0; index < quantity; index++) { + service.show(NotificationType.info, `${index}`); + tick(510); + } + }; + + beforeEach(() => { + spyOn(service, 'show').and.callThrough(); + service.cancel((<any>service)['justShownTimeoutId']); + }); + + it('should create a success notification and save it', fakeAsync(() => { + service.show(new CdNotificationConfig(NotificationType.success, 'Simple test')); + expectSavedNotificationToHave({ type: NotificationType.success }); + })); + + it('should create an error notification and save it', fakeAsync(() => { + service.show(NotificationType.error, 'Simple test'); + expectSavedNotificationToHave({ type: NotificationType.error }); + })); + + it('should create an info notification and save it', fakeAsync(() => { + service.show(new CdNotificationConfig(NotificationType.info, 'Simple test')); + expectSavedNotificationToHave({ + type: NotificationType.info, + title: 'Simple test', + message: undefined + }); + })); + + it('should never have more then 10 notifications', fakeAsync(() => { + addNotifications(15); + expect(service['dataSource'].getValue().length).toBe(10); + })); + + it('should show a success task notification, but not save it', fakeAsync(() => { + const task = _.assign(new FinishedTask(), { + success: true + }); + + service.notifyTask(task, true); + tick(1500); + + expect(service.show).toHaveBeenCalled(); + const notifications = service['dataSource'].getValue(); + expect(notifications.length).toBe(0); + })); + + it('should be able to stop notifyTask from notifying', fakeAsync(() => { + const task = _.assign(new FinishedTask(), { + success: true + }); + const timeoutId = service.notifyTask(task, true); + service.cancel(timeoutId); + tick(100); + expect(service['dataSource'].getValue().length).toBe(0); + })); + + it('should show a error task notification', fakeAsync(() => { + const task = _.assign( + new FinishedTask('rbd/create', { + pool_name: 'somePool', + image_name: 'someImage' + }), + { + success: false, + exception: { + code: 17 + } + } + ); + service.notifyTask(task); + + tick(1500); + + expect(service.show).toHaveBeenCalled(); + const notifications = service['dataSource'].getValue(); + expect(notifications.length).toBe(0); + })); + + it('combines different notifications with the same title', fakeAsync(() => { + service.show(NotificationType.error, '502 - Bad Gateway', 'Error occurred in path a'); + tick(60); + service.show(NotificationType.error, '502 - Bad Gateway', 'Error occurred in path b'); + expectSavedNotificationToHave({ + type: NotificationType.error, + title: '502 - Bad Gateway', + message: '<ul><li>Error occurred in path a</li><li>Error occurred in path b</li></ul>' + }); + })); + + it('should remove a single notification', fakeAsync(() => { + addNotifications(5); + let messages = service['dataSource'].getValue().map((notification) => notification.title); + expect(messages).toEqual(['4', '3', '2', '1', '0']); + service.remove(2); + messages = service['dataSource'].getValue().map((notification) => notification.title); + expect(messages).toEqual(['4', '3', '1', '0']); + })); + + it('should remove all notifications', fakeAsync(() => { + addNotifications(5); + expect(service['dataSource'].getValue().length).toBe(5); + service.removeAll(); + expect(service['dataSource'].getValue().length).toBe(0); + })); + }); + + describe('notification queue', () => { + const n1 = new CdNotificationConfig(NotificationType.success, 'Some success'); + const n2 = new CdNotificationConfig(NotificationType.info, 'Some info'); + + const showArray = (arr: any[]) => arr.forEach((n) => service.show(n)); + + beforeEach(() => { + spyOn(service, 'save').and.stub(); + }); + + it('filters out duplicated notifications on single call', fakeAsync(() => { + showArray([n1, n1, n2, n2]); + tick(510); + expect(service.save).toHaveBeenCalledTimes(2); + })); + + it('filters out duplicated notifications presented in different calls', fakeAsync(() => { + showArray([n1, n2]); + showArray([n1, n2]); + tick(1000); + expect(service.save).toHaveBeenCalledTimes(2); + })); + + it('will reset the timeout on every call', fakeAsync(() => { + showArray([n1, n2]); + tick(490); + showArray([n1, n2]); + tick(450); + expect(service.save).toHaveBeenCalledTimes(0); + tick(60); + expect(service.save).toHaveBeenCalledTimes(2); + })); + + it('wont filter out duplicated notifications if timeout was reached before', fakeAsync(() => { + showArray([n1, n2]); + tick(510); + showArray([n1, n2]); + tick(510); + expect(service.save).toHaveBeenCalledTimes(4); + })); + }); + + describe('showToasty', () => { + let toastr: ToastrService; + const time = '2022-02-22T00:00:00.000Z'; + + beforeEach(() => { + const baseTime = new Date(time); + spyOn(global, 'Date').and.returnValue(baseTime); + spyOn(window, 'setTimeout').and.callFake((fn) => fn()); + + toastr = TestBed.inject(ToastrService); + // spyOn needs to know the methods before spying and can't read the array for clarification + ['error', 'info', 'success'].forEach((method: 'error' | 'info' | 'success') => + spyOn(toastr, method).and.stub() + ); + }); + + it('should show with only title defined', () => { + service.show(NotificationType.info, 'Some info'); + expect(toastr.info).toHaveBeenCalledWith( + `<small class="date">${time}</small>` + + '<i class="float-right custom-icon ceph-icon" title="Ceph"></i>', + 'Some info', + undefined + ); + }); + + it('should show with title and message defined', () => { + service.show( + () => + new CdNotificationConfig(NotificationType.error, 'Some error', 'Some operation failed') + ); + expect(toastr.error).toHaveBeenCalledWith( + 'Some operation failed<br>' + + `<small class="date">${time}</small>` + + '<i class="float-right custom-icon ceph-icon" title="Ceph"></i>', + 'Some error', + undefined + ); + }); + + it('should show with title, message and application defined', () => { + service.show( + new CdNotificationConfig( + NotificationType.success, + 'Alert resolved', + 'Some alert resolved', + undefined, + 'Prometheus' + ) + ); + expect(toastr.success).toHaveBeenCalledWith( + 'Some alert resolved<br>' + + `<small class="date">${time}</small>` + + '<i class="float-right custom-icon prometheus-icon" title="Prometheus"></i>', + 'Alert resolved', + undefined + ); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.ts new file mode 100644 index 000000000..c05dbce0f --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.ts @@ -0,0 +1,237 @@ +import { Injectable } from '@angular/core'; + +import _ from 'lodash'; +import { IndividualConfig, ToastrService } from 'ngx-toastr'; +import { BehaviorSubject, Subject } from 'rxjs'; + +import { NotificationType } from '../enum/notification-type.enum'; +import { CdNotification, CdNotificationConfig } from '../models/cd-notification'; +import { FinishedTask } from '../models/finished-task'; +import { CdDatePipe } from '../pipes/cd-date.pipe'; +import { TaskMessageService } from './task-message.service'; + +@Injectable({ + providedIn: 'root' +}) +export class NotificationService { + private hideToasties = false; + + // Data observable + private dataSource = new BehaviorSubject<CdNotification[]>([]); + data$ = this.dataSource.asObservable(); + + // Sidebar observable + sidebarSubject = new Subject(); + + private queued: CdNotificationConfig[] = []; + private queuedTimeoutId: number; + KEY = 'cdNotifications'; + + constructor( + public toastr: ToastrService, + private taskMessageService: TaskMessageService, + private cdDatePipe: CdDatePipe + ) { + const stringNotifications = localStorage.getItem(this.KEY); + let notifications: CdNotification[] = []; + + if (_.isString(stringNotifications)) { + notifications = JSON.parse(stringNotifications, (_key, value) => { + if (_.isPlainObject(value)) { + return _.assign(new CdNotification(), value); + } + return value; + }); + } + + this.dataSource.next(notifications); + } + + /** + * Removes all current saved notifications + */ + removeAll() { + localStorage.removeItem(this.KEY); + this.dataSource.next([]); + } + + /** + * Removes a single saved notifications + */ + remove(index: number) { + const recent = this.dataSource.getValue(); + recent.splice(index, 1); + this.dataSource.next(recent); + localStorage.setItem(this.KEY, JSON.stringify(recent)); + } + + /** + * Method used for saving a shown notification (check show() method). + */ + save(notification: CdNotification) { + const recent = this.dataSource.getValue(); + recent.push(notification); + recent.sort((a, b) => (a.timestamp > b.timestamp ? -1 : 1)); + while (recent.length > 10) { + recent.pop(); + } + this.dataSource.next(recent); + localStorage.setItem(this.KEY, JSON.stringify(recent)); + } + + /** + * Method for showing a notification. + * @param {NotificationType} type toastr type + * @param {string} title + * @param {string} [message] The message to be displayed. Note, use this field + * for error notifications only. + * @param {*} [options] toastr compatible options, used when creating a toastr + * @param {string} [application] Only needed if notification comes from an external application + * @returns The timeout ID that is set to be able to cancel the notification. + */ + show( + type: NotificationType, + title: string, + message?: string, + options?: any | IndividualConfig, + application?: string + ): number; + show(config: CdNotificationConfig | (() => CdNotificationConfig)): number; + show( + arg: NotificationType | CdNotificationConfig | (() => CdNotificationConfig), + title?: string, + message?: string, + options?: any | IndividualConfig, + application?: string + ): number { + return window.setTimeout(() => { + let config: CdNotificationConfig; + if (_.isFunction(arg)) { + config = arg() as CdNotificationConfig; + } else if (_.isObject(arg)) { + config = arg as CdNotificationConfig; + } else { + config = new CdNotificationConfig( + arg as NotificationType, + title, + message, + options, + application + ); + } + this.queueToShow(config); + }, 10); + } + + private queueToShow(config: CdNotificationConfig) { + this.cancel(this.queuedTimeoutId); + if (!this.queued.find((c) => _.isEqual(c, config))) { + this.queued.push(config); + } + this.queuedTimeoutId = window.setTimeout(() => { + this.showQueued(); + }, 500); + } + + private showQueued() { + this.getUnifiedTitleQueue().forEach((config) => { + const notification = new CdNotification(config); + + if (!notification.isFinishedTask) { + this.save(notification); + } + this.showToasty(notification); + }); + } + + private getUnifiedTitleQueue(): CdNotificationConfig[] { + return Object.values(this.queueShiftByTitle()).map((configs) => { + const config = configs[0]; + if (configs.length > 1) { + config.message = '<ul>' + configs.map((c) => `<li>${c.message}</li>`).join('') + '</ul>'; + } + return config; + }); + } + + private queueShiftByTitle(): { [key: string]: CdNotificationConfig[] } { + const byTitle: { [key: string]: CdNotificationConfig[] } = {}; + let config: CdNotificationConfig; + while ((config = this.queued.shift())) { + if (!byTitle[config.title]) { + byTitle[config.title] = []; + } + byTitle[config.title].push(config); + } + return byTitle; + } + + private showToasty(notification: CdNotification) { + // Exit immediately if no toasty should be displayed. + if (this.hideToasties) { + return; + } + this.toastr[['error', 'info', 'success'][notification.type]]( + (notification.message ? notification.message + '<br>' : '') + + this.renderTimeAndApplicationHtml(notification), + notification.title, + notification.options + ); + } + + renderTimeAndApplicationHtml(notification: CdNotification): string { + return `<small class="date">${this.cdDatePipe.transform( + notification.timestamp + )}</small><i class="float-right custom-icon ${notification.applicationClass}" title="${ + notification.application + }"></i>`; + } + + notifyTask(finishedTask: FinishedTask, success: boolean = true): number { + const notification = this.finishedTaskToNotification(finishedTask, success); + notification.isFinishedTask = true; + return this.show(notification); + } + + finishedTaskToNotification( + finishedTask: FinishedTask, + success: boolean = true + ): CdNotificationConfig { + let notification: CdNotificationConfig; + if (finishedTask.success && success) { + notification = new CdNotificationConfig( + NotificationType.success, + this.taskMessageService.getSuccessTitle(finishedTask) + ); + } else { + notification = new CdNotificationConfig( + NotificationType.error, + this.taskMessageService.getErrorTitle(finishedTask), + this.taskMessageService.getErrorMessage(finishedTask) + ); + } + notification.isFinishedTask = true; + + return notification; + } + + /** + * Prevent the notification from being shown. + * @param {number} timeoutId A number representing the ID of the timeout to be canceled. + */ + cancel(timeoutId: number) { + window.clearTimeout(timeoutId); + } + + /** + * Suspend showing the notification toasties. + * @param {boolean} suspend Set to ``true`` to disable/hide toasties. + */ + suspendToasties(suspend: boolean) { + this.hideToasties = suspend; + } + + toggleSidebar(forceClose = false) { + this.sidebarSubject.next(forceClose); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/password-policy.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/password-policy.service.spec.ts new file mode 100644 index 000000000..2925b152b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/password-policy.service.spec.ts @@ -0,0 +1,208 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { of as observableOf } from 'rxjs'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { SettingsService } from '../api/settings.service'; +import { SharedModule } from '../shared.module'; +import { PasswordPolicyService } from './password-policy.service'; + +describe('PasswordPolicyService', () => { + let service: PasswordPolicyService; + let settingsService: SettingsService; + + const helpTextHelper = { + get: (chk: string) => { + const chkTexts: { [key: string]: string } = { + chk_length: 'Must contain at least 10 characters', + chk_oldpwd: 'Must not be the same as the previous one', + chk_username: 'Cannot contain the username', + chk_exclusion_list: 'Cannot contain any configured keyword', + chk_repetitive: 'Cannot contain any repetitive characters e.g. "aaa"', + chk_sequential: 'Cannot contain any sequential characters e.g. "abc"', + chk_complexity: + 'Must consist of characters from the following groups:\n' + + ' * Alphabetic a-z, A-Z\n' + + ' * Numbers 0-9\n' + + ' * Special chars: !"#$%& \'()*+,-./:;<=>?@[\\]^_`{{|}}~\n' + + ' * Any other characters (signs)' + }; + return ['Required rules for passwords:', '- ' + chkTexts[chk]].join('\n'); + } + }; + + configureTestBed({ + imports: [HttpClientTestingModule, SharedModule] + }); + + beforeEach(() => { + service = TestBed.inject(PasswordPolicyService); + settingsService = TestBed.inject(SettingsService); + settingsService['settings'] = {}; + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should not get help text', () => { + let helpText = ''; + spyOn(settingsService, 'getStandardSettings').and.returnValue( + observableOf({ + pwd_policy_enabled: false + }) + ); + service.getHelpText().subscribe((text) => (helpText = text)); + expect(helpText).toBe(''); + }); + + it('should get help text chk_length', () => { + let helpText = ''; + const expectedHelpText = helpTextHelper.get('chk_length'); + spyOn(settingsService, 'getStandardSettings').and.returnValue( + observableOf({ + user_pwd_expiration_warning_1: 10, + user_pwd_expiration_warning_2: 5, + user_pwd_expiration_span: 90, + pwd_policy_enabled: true, + pwd_policy_min_length: 10, + pwd_policy_check_length_enabled: true, + pwd_policy_check_oldpwd_enabled: false, + pwd_policy_check_sequential_chars_enabled: false, + pwd_policy_check_complexity_enabled: false + }) + ); + service.getHelpText().subscribe((text) => (helpText = text)); + expect(helpText).toBe(expectedHelpText); + }); + + it('should get help text chk_oldpwd', () => { + let helpText = ''; + const expectedHelpText = helpTextHelper.get('chk_oldpwd'); + spyOn(settingsService, 'getStandardSettings').and.returnValue( + observableOf({ + pwd_policy_enabled: true, + pwd_policy_check_oldpwd_enabled: true, + pwd_policy_check_username_enabled: false, + pwd_policy_check_exclusion_list_enabled: false, + pwd_policy_check_complexity_enabled: false + }) + ); + service.getHelpText().subscribe((text) => (helpText = text)); + expect(helpText).toBe(expectedHelpText); + }); + + it('should get help text chk_username', () => { + let helpText = ''; + const expectedHelpText = helpTextHelper.get('chk_username'); + spyOn(settingsService, 'getStandardSettings').and.returnValue( + observableOf({ + pwd_policy_enabled: true, + pwd_policy_check_oldpwd_enabled: false, + pwd_policy_check_username_enabled: true, + pwd_policy_check_exclusion_list_enabled: false + }) + ); + service.getHelpText().subscribe((text) => (helpText = text)); + expect(helpText).toBe(expectedHelpText); + }); + + it('should get help text chk_exclusion_list', () => { + let helpText = ''; + const expectedHelpText = helpTextHelper.get('chk_exclusion_list'); + spyOn(settingsService, 'getStandardSettings').and.returnValue( + observableOf({ + pwd_policy_enabled: true, + pwd_policy_check_username_enabled: false, + pwd_policy_check_exclusion_list_enabled: true, + pwd_policy_check_repetitive_chars_enabled: false + }) + ); + service.getHelpText().subscribe((text) => (helpText = text)); + expect(helpText).toBe(expectedHelpText); + }); + + it('should get help text chk_repetitive', () => { + let helpText = ''; + const expectedHelpText = helpTextHelper.get('chk_repetitive'); + spyOn(settingsService, 'getStandardSettings').and.returnValue( + observableOf({ + user_pwd_expiration_warning_1: 10, + pwd_policy_enabled: true, + pwd_policy_check_oldpwd_enabled: false, + pwd_policy_check_exclusion_list_enabled: false, + pwd_policy_check_repetitive_chars_enabled: true, + pwd_policy_check_sequential_chars_enabled: false, + pwd_policy_check_complexity_enabled: false + }) + ); + service.getHelpText().subscribe((text) => (helpText = text)); + expect(helpText).toBe(expectedHelpText); + }); + + it('should get help text chk_sequential', () => { + let helpText = ''; + const expectedHelpText = helpTextHelper.get('chk_sequential'); + spyOn(settingsService, 'getStandardSettings').and.returnValue( + observableOf({ + pwd_policy_enabled: true, + pwd_policy_min_length: 8, + pwd_policy_check_length_enabled: false, + pwd_policy_check_oldpwd_enabled: false, + pwd_policy_check_username_enabled: false, + pwd_policy_check_exclusion_list_enabled: false, + pwd_policy_check_repetitive_chars_enabled: false, + pwd_policy_check_sequential_chars_enabled: true, + pwd_policy_check_complexity_enabled: false + }) + ); + service.getHelpText().subscribe((text) => (helpText = text)); + expect(helpText).toBe(expectedHelpText); + }); + + it('should get help text chk_complexity', () => { + let helpText = ''; + const expectedHelpText = helpTextHelper.get('chk_complexity'); + spyOn(settingsService, 'getStandardSettings').and.returnValue( + observableOf({ + pwd_policy_enabled: true, + pwd_policy_min_length: 8, + pwd_policy_check_length_enabled: false, + pwd_policy_check_oldpwd_enabled: false, + pwd_policy_check_username_enabled: false, + pwd_policy_check_exclusion_list_enabled: false, + pwd_policy_check_repetitive_chars_enabled: false, + pwd_policy_check_sequential_chars_enabled: false, + pwd_policy_check_complexity_enabled: true + }) + ); + service.getHelpText().subscribe((text) => (helpText = text)); + expect(helpText).toBe(expectedHelpText); + }); + + it('should get too-weak class', () => { + expect(service.mapCreditsToCssClass(0)).toBe('too-weak'); + expect(service.mapCreditsToCssClass(9)).toBe('too-weak'); + }); + + it('should get weak class', () => { + expect(service.mapCreditsToCssClass(10)).toBe('weak'); + expect(service.mapCreditsToCssClass(14)).toBe('weak'); + }); + + it('should get ok class', () => { + expect(service.mapCreditsToCssClass(15)).toBe('ok'); + expect(service.mapCreditsToCssClass(19)).toBe('ok'); + }); + + it('should get strong class', () => { + expect(service.mapCreditsToCssClass(20)).toBe('strong'); + expect(service.mapCreditsToCssClass(24)).toBe('strong'); + }); + + it('should get very-strong class', () => { + expect(service.mapCreditsToCssClass(25)).toBe('very-strong'); + expect(service.mapCreditsToCssClass(30)).toBe('very-strong'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/password-policy.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/password-policy.service.ts new file mode 100644 index 000000000..295420c27 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/password-policy.service.ts @@ -0,0 +1,65 @@ +import { Injectable } from '@angular/core'; + +import _ from 'lodash'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { SettingsService } from '../api/settings.service'; +import { CdPwdPolicySettings } from '../models/cd-pwd-policy-settings'; + +@Injectable({ + providedIn: 'root' +}) +export class PasswordPolicyService { + constructor(private settingsService: SettingsService) {} + + getHelpText(): Observable<string> { + return this.settingsService.getStandardSettings().pipe( + map((resp: { [key: string]: any }) => { + const settings = new CdPwdPolicySettings(resp); + let helpText: string[] = []; + if (settings.pwdPolicyEnabled) { + helpText.push($localize`Required rules for passwords:`); + const i18nHelp: { [key: string]: string } = { + pwdPolicyCheckLengthEnabled: $localize`Must contain at least ${settings.pwdPolicyMinLength} characters`, + pwdPolicyCheckOldpwdEnabled: $localize`Must not be the same as the previous one`, + pwdPolicyCheckUsernameEnabled: $localize`Cannot contain the username`, + pwdPolicyCheckExclusionListEnabled: $localize`Cannot contain any configured keyword`, + pwdPolicyCheckRepetitiveCharsEnabled: $localize`Cannot contain any repetitive characters e.g. "aaa"`, + pwdPolicyCheckSequentialCharsEnabled: $localize`Cannot contain any sequential characters e.g. "abc"`, + pwdPolicyCheckComplexityEnabled: $localize`Must consist of characters from the following groups: + * Alphabetic a-z, A-Z + * Numbers 0-9 + * Special chars: !"#$%& '()*+,-./:;<=>?@[\\]^_\`{{|}}~ + * Any other characters (signs)` + }; + helpText = helpText.concat( + _.keys(i18nHelp) + .filter((key) => _.get(settings, key)) + .map((key) => '- ' + _.get(i18nHelp, key)) + ); + } + return helpText.join('\n'); + }) + ); + } + + /** + * Helper function to map password policy credits to a CSS class. + * @param credits The password policy credits. + * @return The name of the CSS class. + */ + mapCreditsToCssClass(credits: number): string { + let result = 'very-strong'; + if (credits < 10) { + result = 'too-weak'; + } else if (credits < 15) { + result = 'weak'; + } else if (credits < 20) { + result = 'ok'; + } else if (credits < 25) { + result = 'strong'; + } + return result; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert-formatter.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert-formatter.spec.ts new file mode 100644 index 000000000..1384637bb --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert-formatter.spec.ts @@ -0,0 +1,95 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { ToastrModule } from 'ngx-toastr'; + +import { configureTestBed, PrometheusHelper } from '~/testing/unit-test-helper'; +import { NotificationType } from '../enum/notification-type.enum'; +import { CdNotificationConfig } from '../models/cd-notification'; +import { PrometheusCustomAlert } from '../models/prometheus-alerts'; +import { SharedModule } from '../shared.module'; +import { NotificationService } from './notification.service'; +import { PrometheusAlertFormatter } from './prometheus-alert-formatter'; + +describe('PrometheusAlertFormatter', () => { + let service: PrometheusAlertFormatter; + let notificationService: NotificationService; + let prometheus: PrometheusHelper; + + configureTestBed({ + imports: [ToastrModule.forRoot(), SharedModule, HttpClientTestingModule], + providers: [PrometheusAlertFormatter] + }); + + beforeEach(() => { + prometheus = new PrometheusHelper(); + service = TestBed.inject(PrometheusAlertFormatter); + notificationService = TestBed.inject(NotificationService); + spyOn(notificationService, 'show').and.stub(); + }); + + it('should create', () => { + expect(service).toBeTruthy(); + }); + + describe('sendNotifications', () => { + it('should not call queue notifications with no notification', () => { + service.sendNotifications([]); + expect(notificationService.show).not.toHaveBeenCalled(); + }); + + it('should call queue notifications with notifications', () => { + const notifications = [new CdNotificationConfig(NotificationType.success, 'test')]; + service.sendNotifications(notifications); + expect(notificationService.show).toHaveBeenCalledWith(notifications[0]); + }); + }); + + describe('convertToCustomAlert', () => { + it('converts PrometheusAlert', () => { + expect(service.convertToCustomAlerts([prometheus.createAlert('Something')])).toEqual([ + { + status: 'active', + name: 'Something', + description: 'Something is active', + url: 'http://Something', + fingerprint: 'Something' + } as PrometheusCustomAlert + ]); + }); + + it('converts PrometheusNotificationAlert', () => { + expect( + service.convertToCustomAlerts([prometheus.createNotificationAlert('Something')]) + ).toEqual([ + { + fingerprint: false, + status: 'active', + name: 'Something', + description: 'Something is firing', + url: 'http://Something' + } as PrometheusCustomAlert + ]); + }); + }); + + it('converts custom alert into notification', () => { + const alert: PrometheusCustomAlert = { + status: 'active', + name: 'Some alert', + description: 'Some alert is active', + url: 'http://some-alert', + fingerprint: '42' + }; + expect(service.convertAlertToNotification(alert)).toEqual( + new CdNotificationConfig( + NotificationType.error, + 'Some alert (active)', + 'Some alert is active <a href="http://some-alert" target="_blank">' + + '<i class="fa fa-line-chart"></i></a>', + undefined, + 'Prometheus' + ) + ); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert-formatter.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert-formatter.ts new file mode 100644 index 000000000..96ad5f96f --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert-formatter.ts @@ -0,0 +1,74 @@ +import { Injectable } from '@angular/core'; + +import _ from 'lodash'; + +import { Icons } from '../enum/icons.enum'; +import { NotificationType } from '../enum/notification-type.enum'; +import { CdNotificationConfig } from '../models/cd-notification'; +import { + AlertmanagerAlert, + AlertmanagerNotificationAlert, + PrometheusCustomAlert +} from '../models/prometheus-alerts'; +import { NotificationService } from './notification.service'; + +@Injectable({ + providedIn: 'root' +}) +export class PrometheusAlertFormatter { + constructor(private notificationService: NotificationService) {} + + sendNotifications(notifications: CdNotificationConfig[]) { + notifications.forEach((n) => this.notificationService.show(n)); + } + + convertToCustomAlerts( + alerts: (AlertmanagerNotificationAlert | AlertmanagerAlert)[] + ): PrometheusCustomAlert[] { + return _.uniqWith( + alerts.map((alert) => { + return { + status: _.isObject(alert.status) + ? (alert as AlertmanagerAlert).status.state + : this.getPrometheusNotificationStatus(alert as AlertmanagerNotificationAlert), + name: alert.labels.alertname, + url: alert.generatorURL, + description: alert.annotations.description, + fingerprint: _.isObject(alert.status) && (alert as AlertmanagerAlert).fingerprint + }; + }), + _.isEqual + ) as PrometheusCustomAlert[]; + } + + /* + * This is needed because NotificationAlerts don't use 'active' + */ + private getPrometheusNotificationStatus(alert: AlertmanagerNotificationAlert): string { + const state = alert.status; + return state === 'firing' ? 'active' : state; + } + + convertAlertToNotification(alert: PrometheusCustomAlert): CdNotificationConfig { + return new CdNotificationConfig( + this.formatType(alert.status), + `${alert.name} (${alert.status})`, + this.appendSourceLink(alert, alert.description), + undefined, + 'Prometheus' + ); + } + + private formatType(status: string): NotificationType { + const types = { + error: ['firing', 'active'], + info: ['suppressed', 'unprocessed'], + success: ['resolved'] + }; + return NotificationType[_.findKey(types, (type) => type.includes(status))]; + } + + private appendSourceLink(alert: PrometheusCustomAlert, message: string): string { + return `${message} <a href="${alert.url}" target="_blank"><i class="${Icons.lineChart}"></i></a>`; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.spec.ts new file mode 100644 index 000000000..aa3160b30 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.spec.ts @@ -0,0 +1,214 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { ToastrModule } from 'ngx-toastr'; +import { Observable, of } from 'rxjs'; + +import { configureTestBed, PrometheusHelper } from '~/testing/unit-test-helper'; +import { PrometheusService } from '../api/prometheus.service'; +import { NotificationType } from '../enum/notification-type.enum'; +import { CdNotificationConfig } from '../models/cd-notification'; +import { AlertmanagerAlert } from '../models/prometheus-alerts'; +import { SharedModule } from '../shared.module'; +import { NotificationService } from './notification.service'; +import { PrometheusAlertFormatter } from './prometheus-alert-formatter'; +import { PrometheusAlertService } from './prometheus-alert.service'; + +describe('PrometheusAlertService', () => { + let service: PrometheusAlertService; + let notificationService: NotificationService; + let alerts: AlertmanagerAlert[]; + let prometheusService: PrometheusService; + let prometheus: PrometheusHelper; + + configureTestBed({ + imports: [ToastrModule.forRoot(), SharedModule, HttpClientTestingModule], + providers: [PrometheusAlertService, PrometheusAlertFormatter] + }); + + beforeEach(() => { + prometheus = new PrometheusHelper(); + }); + + it('should create', () => { + expect(TestBed.inject(PrometheusAlertService)).toBeTruthy(); + }); + + describe('test failing status codes and verify disabling of the alertmanager', () => { + const isDisabledByStatusCode = (statusCode: number, expectedStatus: boolean, done: any) => { + service = TestBed.inject(PrometheusAlertService); + prometheusService = TestBed.inject(PrometheusService); + spyOn(prometheusService, 'ifAlertmanagerConfigured').and.callFake((fn) => fn()); + spyOn(prometheusService, 'getAlerts').and.returnValue( + new Observable((observer: any) => observer.error({ status: statusCode, error: {} })) + ); + const disableFn = spyOn(prometheusService, 'disableAlertmanagerConfig').and.callFake(() => { + expect(expectedStatus).toBe(true); + done(); + }); + + if (!expectedStatus) { + expect(disableFn).not.toHaveBeenCalled(); + done(); + } + + service.getAlerts(); + }; + + it('disables on 504 error which is thrown if the mgr failed', (done) => { + isDisabledByStatusCode(504, true, done); + }); + + it('disables on 404 error which is thrown if the external api cannot be reached', (done) => { + isDisabledByStatusCode(404, true, done); + }); + + it('does not disable on 400 error which is thrown if the external api receives unexpected data', (done) => { + isDisabledByStatusCode(400, false, done); + }); + }); + + it('should flatten the response of getRules()', () => { + service = TestBed.inject(PrometheusAlertService); + prometheusService = TestBed.inject(PrometheusService); + + spyOn(service['prometheusService'], 'ifPrometheusConfigured').and.callFake((fn) => fn()); + spyOn(prometheusService, 'getRules').and.returnValue( + of({ + groups: [ + { + name: 'group1', + rules: [{ name: 'nearly_full', type: 'alerting' }] + }, + { + name: 'test', + rules: [ + { name: 'load_0', type: 'alerting' }, + { name: 'load_1', type: 'alerting' }, + { name: 'load_2', type: 'alerting' } + ] + } + ] + }) + ); + + service.getRules(); + + expect(service.rules as any).toEqual([ + { name: 'nearly_full', type: 'alerting', group: 'group1' }, + { name: 'load_0', type: 'alerting', group: 'test' }, + { name: 'load_1', type: 'alerting', group: 'test' }, + { name: 'load_2', type: 'alerting', group: 'test' } + ]); + }); + + describe('refresh', () => { + beforeEach(() => { + service = TestBed.inject(PrometheusAlertService); + service['alerts'] = []; + service['canAlertsBeNotified'] = false; + + spyOn(window, 'setTimeout').and.callFake((fn: Function) => fn()); + + notificationService = TestBed.inject(NotificationService); + spyOn(notificationService, 'show').and.stub(); + + prometheusService = TestBed.inject(PrometheusService); + spyOn(prometheusService, 'ifAlertmanagerConfigured').and.callFake((fn) => fn()); + spyOn(prometheusService, 'getAlerts').and.callFake(() => of(alerts)); + + alerts = [prometheus.createAlert('alert0')]; + service.refresh(); + }); + + it('should not notify on first call', () => { + expect(notificationService.show).not.toHaveBeenCalled(); + }); + + it('should not notify with no change', () => { + service.refresh(); + expect(notificationService.show).not.toHaveBeenCalled(); + }); + + it('should notify on alert change', () => { + alerts = [prometheus.createAlert('alert0', 'resolved')]; + service.refresh(); + expect(notificationService.show).toHaveBeenCalledWith( + new CdNotificationConfig( + NotificationType.success, + 'alert0 (resolved)', + 'alert0 is resolved ' + prometheus.createLink('http://alert0'), + undefined, + 'Prometheus' + ) + ); + }); + + it('should not notify on change to suppressed', () => { + alerts = [prometheus.createAlert('alert0', 'suppressed')]; + service.refresh(); + expect(notificationService.show).not.toHaveBeenCalled(); + }); + + it('should notify on a new alert', () => { + alerts = [prometheus.createAlert('alert1'), prometheus.createAlert('alert0')]; + service.refresh(); + expect(notificationService.show).toHaveBeenCalledTimes(1); + expect(notificationService.show).toHaveBeenCalledWith( + new CdNotificationConfig( + NotificationType.error, + 'alert1 (active)', + 'alert1 is active ' + prometheus.createLink('http://alert1'), + undefined, + 'Prometheus' + ) + ); + }); + + it('should notify a resolved alert if it is not there anymore', () => { + alerts = []; + service.refresh(); + expect(notificationService.show).toHaveBeenCalledTimes(1); + expect(notificationService.show).toHaveBeenCalledWith( + new CdNotificationConfig( + NotificationType.success, + 'alert0 (resolved)', + 'alert0 is active ' + prometheus.createLink('http://alert0'), + undefined, + 'Prometheus' + ) + ); + }); + + it('should call multiple times for multiple changes', () => { + const alert1 = prometheus.createAlert('alert1'); + alerts.push(alert1); + service.refresh(); + alerts = [alert1, prometheus.createAlert('alert2')]; + service.refresh(); + expect(notificationService.show).toHaveBeenCalledTimes(2); + }); + }); + + describe('alert badge', () => { + beforeEach(() => { + service = TestBed.inject(PrometheusAlertService); + + prometheusService = TestBed.inject(PrometheusService); + spyOn(prometheusService, 'ifAlertmanagerConfigured').and.callFake((fn) => fn()); + spyOn(prometheusService, 'getAlerts').and.callFake(() => of(alerts)); + + alerts = [ + prometheus.createAlert('alert0', 'active'), + prometheus.createAlert('alert1', 'suppressed'), + prometheus.createAlert('alert2', 'suppressed') + ]; + service.refresh(); + }); + + it('should count active alerts', () => { + service.refresh(); + expect(service.activeAlerts).toBe(1); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.ts new file mode 100644 index 000000000..6223808fb --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.ts @@ -0,0 +1,100 @@ +import { Injectable } from '@angular/core'; + +import _ from 'lodash'; + +import { PrometheusService } from '../api/prometheus.service'; +import { + AlertmanagerAlert, + PrometheusCustomAlert, + PrometheusRule +} from '../models/prometheus-alerts'; +import { PrometheusAlertFormatter } from './prometheus-alert-formatter'; + +@Injectable({ + providedIn: 'root' +}) +export class PrometheusAlertService { + private canAlertsBeNotified = false; + alerts: AlertmanagerAlert[] = []; + rules: PrometheusRule[] = []; + activeAlerts: number; + + constructor( + private alertFormatter: PrometheusAlertFormatter, + private prometheusService: PrometheusService + ) {} + + getAlerts() { + this.prometheusService.ifAlertmanagerConfigured(() => { + this.prometheusService.getAlerts().subscribe( + (alerts) => this.handleAlerts(alerts), + (resp) => { + if ([404, 504].includes(resp.status)) { + this.prometheusService.disableAlertmanagerConfig(); + } + } + ); + }); + } + + getRules() { + this.prometheusService.ifPrometheusConfigured(() => { + this.prometheusService.getRules('alerting').subscribe((groups) => { + this.rules = groups['groups'].reduce((acc, group) => { + return acc.concat( + group.rules.map((rule) => { + rule.group = group.name; + return rule; + }) + ); + }, []); + }); + }); + } + + refresh() { + this.getAlerts(); + this.getRules(); + } + + private handleAlerts(alerts: AlertmanagerAlert[]) { + if (this.canAlertsBeNotified) { + this.notifyOnAlertChanges(alerts, this.alerts); + } + this.activeAlerts = _.reduce<AlertmanagerAlert, number>( + this.alerts, + (result, alert) => (alert.status.state === 'active' ? ++result : result), + 0 + ); + this.alerts = alerts; + this.canAlertsBeNotified = true; + } + + private notifyOnAlertChanges(alerts: AlertmanagerAlert[], oldAlerts: AlertmanagerAlert[]) { + const changedAlerts = this.getChangedAlerts( + this.alertFormatter.convertToCustomAlerts(alerts), + this.alertFormatter.convertToCustomAlerts(oldAlerts) + ); + const suppressedFiltered = _.filter(changedAlerts, (alert) => { + return alert.status !== 'suppressed'; + }); + const notifications = suppressedFiltered.map((alert) => + this.alertFormatter.convertAlertToNotification(alert) + ); + this.alertFormatter.sendNotifications(notifications); + } + + private getChangedAlerts(alerts: PrometheusCustomAlert[], oldAlerts: PrometheusCustomAlert[]) { + const updatedAndNew = _.differenceWith(alerts, oldAlerts, _.isEqual); + return updatedAndNew.concat(this.getVanishedAlerts(alerts, oldAlerts)); + } + + private getVanishedAlerts(alerts: PrometheusCustomAlert[], oldAlerts: PrometheusCustomAlert[]) { + return _.differenceWith(oldAlerts, alerts, (a, b) => a.fingerprint === b.fingerprint).map( + (alert) => { + alert.status = 'resolved'; + return alert; + } + ); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-notification.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-notification.service.spec.ts new file mode 100644 index 000000000..4fb2bbbb9 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-notification.service.spec.ts @@ -0,0 +1,227 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; + +import { ToastrModule, ToastrService } from 'ngx-toastr'; +import { of, throwError } from 'rxjs'; + +import { configureTestBed, PrometheusHelper } from '~/testing/unit-test-helper'; +import { PrometheusService } from '../api/prometheus.service'; +import { NotificationType } from '../enum/notification-type.enum'; +import { CdNotificationConfig } from '../models/cd-notification'; +import { AlertmanagerNotification } from '../models/prometheus-alerts'; +import { SharedModule } from '../shared.module'; +import { NotificationService } from './notification.service'; +import { PrometheusAlertFormatter } from './prometheus-alert-formatter'; +import { PrometheusNotificationService } from './prometheus-notification.service'; + +describe('PrometheusNotificationService', () => { + let service: PrometheusNotificationService; + let notificationService: NotificationService; + let notifications: AlertmanagerNotification[]; + let prometheusService: PrometheusService; + let prometheus: PrometheusHelper; + let shown: CdNotificationConfig[]; + let getNotificationSinceMock: Function; + + const toastFakeService = { + error: () => true, + info: () => true, + success: () => true + }; + + configureTestBed({ + imports: [ToastrModule.forRoot(), SharedModule, HttpClientTestingModule], + providers: [ + PrometheusNotificationService, + PrometheusAlertFormatter, + { provide: ToastrService, useValue: toastFakeService } + ] + }); + + beforeEach(() => { + prometheus = new PrometheusHelper(); + + service = TestBed.inject(PrometheusNotificationService); + service['notifications'] = []; + + notificationService = TestBed.inject(NotificationService); + shown = []; + spyOn(notificationService, 'show').and.callThrough(); + spyOn(notificationService, 'save').and.callFake((n) => shown.push(n)); + + spyOn(window, 'setTimeout').and.callFake((fn: Function) => fn()); + + prometheusService = TestBed.inject(PrometheusService); + getNotificationSinceMock = () => of(notifications); + spyOn(prometheusService, 'getNotifications').and.callFake(() => getNotificationSinceMock()); + + notifications = [prometheus.createNotification()]; + }); + + it('should create', () => { + expect(service).toBeTruthy(); + }); + + describe('getLastNotification', () => { + it('returns an empty object on the first call', () => { + service.refresh(); + expect(prometheusService.getNotifications).toHaveBeenCalledWith(undefined); + expect(service['notifications'].length).toBe(1); + }); + + it('returns last notification on any other call', () => { + service.refresh(); + notifications = [prometheus.createNotification(1, 'resolved')]; + service.refresh(); + expect(prometheusService.getNotifications).toHaveBeenCalledWith(service['notifications'][0]); + expect(service['notifications'].length).toBe(2); + + notifications = [prometheus.createNotification(2)]; + service.refresh(); + notifications = [prometheus.createNotification(3, 'resolved')]; + service.refresh(); + expect(prometheusService.getNotifications).toHaveBeenCalledWith(service['notifications'][2]); + expect(service['notifications'].length).toBe(4); + }); + }); + + it('notifies not on the first call', () => { + service.refresh(); + expect(notificationService.save).not.toHaveBeenCalled(); + }); + + it('notifies should not call the api again if it failed once', () => { + getNotificationSinceMock = () => throwError(new Error('Test error')); + service.refresh(); + expect(prometheusService.getNotifications).toHaveBeenCalledTimes(1); + expect(service['backendFailure']).toBe(true); + service.refresh(); + expect(prometheusService.getNotifications).toHaveBeenCalledTimes(1); + service['backendFailure'] = false; + }); + + describe('looks of fired notifications', () => { + const asyncRefresh = () => { + service.refresh(); + tick(20); + }; + + const expectShown = (expected: object[]) => { + tick(500); + expect(shown.length).toBe(expected.length); + expected.forEach((e, i) => + Object.keys(e).forEach((key) => expect(shown[i][key]).toEqual(expected[i][key])) + ); + }; + + beforeEach(() => { + service.refresh(); + }); + + it('notifies on the second call', () => { + service.refresh(); + expect(notificationService.show).toHaveBeenCalledTimes(1); + }); + + it('notify looks on single notification with single alert like', fakeAsync(() => { + asyncRefresh(); + expectShown([ + new CdNotificationConfig( + NotificationType.error, + 'alert0 (active)', + 'alert0 is firing ' + prometheus.createLink('http://alert0'), + undefined, + 'Prometheus' + ) + ]); + })); + + it('raises multiple pop overs for a single notification with multiple alerts', fakeAsync(() => { + asyncRefresh(); + notifications[0].alerts.push(prometheus.createNotificationAlert('alert1', 'resolved')); + asyncRefresh(); + expectShown([ + new CdNotificationConfig( + NotificationType.error, + 'alert0 (active)', + 'alert0 is firing ' + prometheus.createLink('http://alert0'), + undefined, + 'Prometheus' + ), + new CdNotificationConfig( + NotificationType.success, + 'alert1 (resolved)', + 'alert1 is resolved ' + prometheus.createLink('http://alert1'), + undefined, + 'Prometheus' + ) + ]); + })); + + it('should raise multiple notifications if they do not look like each other', fakeAsync(() => { + notifications[0].alerts.push(prometheus.createNotificationAlert('alert1')); + notifications.push(prometheus.createNotification()); + notifications[1].alerts.push(prometheus.createNotificationAlert('alert2')); + asyncRefresh(); + expectShown([ + new CdNotificationConfig( + NotificationType.error, + 'alert0 (active)', + 'alert0 is firing ' + prometheus.createLink('http://alert0'), + undefined, + 'Prometheus' + ), + new CdNotificationConfig( + NotificationType.error, + 'alert1 (active)', + 'alert1 is firing ' + prometheus.createLink('http://alert1'), + undefined, + 'Prometheus' + ), + new CdNotificationConfig( + NotificationType.error, + 'alert2 (active)', + 'alert2 is firing ' + prometheus.createLink('http://alert2'), + undefined, + 'Prometheus' + ) + ]); + })); + + it('only shows toasties if it got new data', () => { + service.refresh(); + expect(notificationService.save).toHaveBeenCalledTimes(1); + notifications = []; + service.refresh(); + service.refresh(); + expect(notificationService.save).toHaveBeenCalledTimes(1); + notifications = [prometheus.createNotification()]; + service.refresh(); + expect(notificationService.save).toHaveBeenCalledTimes(2); + service.refresh(); + expect(notificationService.save).toHaveBeenCalledTimes(3); + }); + + it('filters out duplicated and non user visible changes in notifications', fakeAsync(() => { + asyncRefresh(); + // Return 2 notifications with 3 duplicated alerts and 1 non visible changed alert + const secondAlert = prometheus.createNotificationAlert('alert0'); + secondAlert.endsAt = new Date().toString(); // Should be ignored as it's not visible + notifications[0].alerts.push(secondAlert); + notifications.push(prometheus.createNotification()); + notifications[1].alerts.push(prometheus.createNotificationAlert('alert0')); + notifications[1].notified = 'by somebody else'; + asyncRefresh(); + + expectShown([ + new CdNotificationConfig( + NotificationType.error, + 'alert0 (active)', + 'alert0 is firing ' + prometheus.createLink('http://alert0'), + undefined, + 'Prometheus' + ) + ]); + })); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-notification.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-notification.service.ts new file mode 100644 index 000000000..ab94c686e --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-notification.service.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@angular/core'; + +import _ from 'lodash'; + +import { PrometheusService } from '../api/prometheus.service'; +import { CdNotificationConfig } from '../models/cd-notification'; +import { AlertmanagerNotification } from '../models/prometheus-alerts'; +import { PrometheusAlertFormatter } from './prometheus-alert-formatter'; + +@Injectable({ + providedIn: 'root' +}) +export class PrometheusNotificationService { + private notifications: AlertmanagerNotification[]; + private backendFailure = false; + + constructor( + private alertFormatter: PrometheusAlertFormatter, + private prometheusService: PrometheusService + ) { + this.notifications = []; + } + + refresh() { + if (this.backendFailure) { + return; + } + this.prometheusService.getNotifications(_.last(this.notifications)).subscribe( + (notifications) => this.handleNotifications(notifications), + () => (this.backendFailure = true) + ); + } + + private handleNotifications(notifications: AlertmanagerNotification[]) { + if (notifications.length === 0) { + return; + } + if (this.notifications.length > 0) { + this.alertFormatter.sendNotifications( + _.flatten(notifications.map((notification) => this.formatNotification(notification))) + ); + } + this.notifications = this.notifications.concat(notifications); + } + + private formatNotification(notification: AlertmanagerNotification): CdNotificationConfig[] { + return this.alertFormatter + .convertToCustomAlerts(notification.alerts) + .map((alert) => this.alertFormatter.convertAlertToNotification(alert)); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-silence-matcher.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-silence-matcher.service.spec.ts new file mode 100644 index 000000000..92ff6baa7 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-silence-matcher.service.spec.ts @@ -0,0 +1,133 @@ +import { TestBed } from '@angular/core/testing'; + +import { configureTestBed, PrometheusHelper } from '~/testing/unit-test-helper'; +import { PrometheusRule } from '../models/prometheus-alerts'; +import { SharedModule } from '../shared.module'; +import { PrometheusSilenceMatcherService } from './prometheus-silence-matcher.service'; + +describe('PrometheusSilenceMatcherService', () => { + let service: PrometheusSilenceMatcherService; + let prometheus: PrometheusHelper; + let rules: PrometheusRule[]; + + configureTestBed({ + imports: [SharedModule] + }); + + const addMatcher = (name: string, value: any) => ({ + name: name, + value: value, + isRegex: false + }); + + beforeEach(() => { + prometheus = new PrometheusHelper(); + service = TestBed.inject(PrometheusSilenceMatcherService); + rules = [ + prometheus.createRule('alert0', 'someSeverity', [prometheus.createAlert('alert0')]), + prometheus.createRule('alert1', 'someSeverity', []), + prometheus.createRule('alert2', 'someOtherSeverity', [prometheus.createAlert('alert2')]) + ]; + }); + + it('should create', () => { + expect(service).toBeTruthy(); + }); + + describe('test rule matching with one matcher', () => { + const expectSingleMatch = ( + name: string, + value: any, + helpText: string, + successClass: boolean + ) => { + const match = service.singleMatch(addMatcher(name, value), rules); + expect(match.status).toBe(helpText); + expect(match.cssClass).toBe(successClass ? 'has-success' : 'has-warning'); + }; + + it('should match no rule and no alert', () => { + expectSingleMatch( + 'alertname', + 'alert', + 'Your matcher seems to match no currently defined rule or active alert.', + false + ); + }); + + it('should match a rule with no alert', () => { + expectSingleMatch('alertname', 'alert1', 'Matches 1 rule with no active alerts.', false); + }); + + it('should match a rule and an alert', () => { + expectSingleMatch('alertname', 'alert0', 'Matches 1 rule with 1 active alert.', true); + }); + + it('should match multiple rules and an alert', () => { + expectSingleMatch('severity', 'someSeverity', 'Matches 2 rules with 1 active alert.', true); + }); + + it('should match multiple rules and multiple alerts', () => { + expectSingleMatch('job', 'someJob', 'Matches 2 rules with 2 active alerts.', true); + }); + + it('should return any match if regex is checked', () => { + const match = service.singleMatch( + { + name: 'severity', + value: 'someSeverity', + isRegex: true + }, + rules + ); + expect(match).toBeFalsy(); + }); + }); + + describe('test rule matching with multiple matcher', () => { + const expectMultiMatch = (matchers: any[], helpText: string, successClass: boolean) => { + const match = service.multiMatch(matchers, rules); + expect(match.status).toBe(helpText); + expect(match.cssClass).toBe(successClass ? 'has-success' : 'has-warning'); + }; + + it('should match no rule and no alert', () => { + expectMultiMatch( + [addMatcher('alertname', 'alert0'), addMatcher('job', 'ceph')], + 'Your matcher seems to match no currently defined rule or active alert.', + false + ); + }); + + it('should match a rule with no alert', () => { + expectMultiMatch( + [addMatcher('severity', 'someSeverity'), addMatcher('alertname', 'alert1')], + 'Matches 1 rule with no active alerts.', + false + ); + }); + + it('should match a rule and an alert', () => { + expectMultiMatch( + [addMatcher('instance', 'someInstance'), addMatcher('alertname', 'alert0')], + 'Matches 1 rule with 1 active alert.', + true + ); + }); + + it('should return any match if regex is checked', () => { + const match = service.multiMatch( + [ + addMatcher('instance', 'someInstance'), + { + name: 'severity', + value: 'someSeverity', + isRegex: true + } + ], + rules + ); + expect(match).toBeFalsy(); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-silence-matcher.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-silence-matcher.service.ts new file mode 100644 index 000000000..7aec6d1d3 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-silence-matcher.service.ts @@ -0,0 +1,78 @@ +import { Injectable } from '@angular/core'; + +import _ from 'lodash'; + +import { + AlertmanagerSilenceMatcher, + AlertmanagerSilenceMatcherMatch +} from '../models/alertmanager-silence'; +import { PrometheusRule } from '../models/prometheus-alerts'; + +@Injectable({ + providedIn: 'root' +}) +export class PrometheusSilenceMatcherService { + private valueAttributePath = { + alertname: 'name', + instance: 'alerts.0.labels.instance', + job: 'alerts.0.labels.job', + severity: 'labels.severity' + }; + + singleMatch( + matcher: AlertmanagerSilenceMatcher, + rules: PrometheusRule[] + ): AlertmanagerSilenceMatcherMatch { + return this.multiMatch([matcher], rules); + } + + multiMatch( + matchers: AlertmanagerSilenceMatcher[], + rules: PrometheusRule[] + ): AlertmanagerSilenceMatcherMatch { + if (matchers.some((matcher) => matcher.isRegex)) { + return undefined; + } + matchers.forEach((matcher) => { + rules = this.getMatchedRules(matcher, rules); + }); + return this.describeMatch(rules); + } + + private getMatchedRules( + matcher: AlertmanagerSilenceMatcher, + rules: PrometheusRule[] + ): PrometheusRule[] { + const attributePath = this.getAttributePath(matcher.name); + return rules.filter((r) => _.get(r, attributePath) === matcher.value); + } + + private describeMatch(rules: PrometheusRule[]): AlertmanagerSilenceMatcherMatch { + let alerts = 0; + rules.forEach((r) => (alerts += r.alerts.length)); + return { + status: this.getMatchText(rules.length, alerts), + cssClass: alerts ? 'has-success' : 'has-warning' + }; + } + + getAttributePath(name: string): string { + return this.valueAttributePath[name]; + } + + private getMatchText(rules: number, alerts: number): string { + const msg = { + noRule: $localize`Your matcher seems to match no currently defined rule or active alert.`, + noAlerts: $localize`no active alerts`, + alert: $localize`1 active alert`, + alerts: $localize`${alerts} active alerts`, + rule: $localize`Matches 1 rule`, + rules: $localize`Matches ${rules} rules` + }; + + const rule = rules > 1 ? msg.rules : msg.rule; + const alert = alerts ? (alerts > 1 ? msg.alerts : msg.alert) : msg.noAlerts; + + return rules ? $localize`${rule} with ${alert}.` : msg.noRule; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/rbd-configuration.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/rbd-configuration.service.spec.ts new file mode 100644 index 000000000..b119f5d63 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/rbd-configuration.service.spec.ts @@ -0,0 +1,45 @@ +import { TestBed } from '@angular/core/testing'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { RbdConfigurationType } from '../models/configuration'; +import { RbdConfigurationService } from './rbd-configuration.service'; + +describe('RbdConfigurationService', () => { + let service: RbdConfigurationService; + + configureTestBed({ + providers: [RbdConfigurationService] + }); + + beforeEach(() => { + service = TestBed.inject(RbdConfigurationService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should filter config options', () => { + const result = service.getOptionByName('rbd_qos_write_iops_burst'); + expect(result).toEqual({ + name: 'rbd_qos_write_iops_burst', + displayName: 'Write IOPS Burst', + description: 'The desired burst limit of write operations.', + type: RbdConfigurationType.iops + }); + }); + + it('should return the display name', () => { + const displayName = service.getDisplayName('rbd_qos_write_iops_burst'); + expect(displayName).toBe('Write IOPS Burst'); + }); + + it('should return the description', () => { + const description = service.getDescription('rbd_qos_write_iops_burst'); + expect(description).toBe('The desired burst limit of write operations.'); + }); + + it('should have a class for each section', () => { + service.sections.forEach((section) => expect(section.class).toBeTruthy()); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/rbd-configuration.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/rbd-configuration.service.ts new file mode 100644 index 000000000..4499718e1 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/rbd-configuration.service.ts @@ -0,0 +1,144 @@ +import { Injectable } from '@angular/core'; + +import { + RbdConfigurationExtraField, + RbdConfigurationSection, + RbdConfigurationType +} from '../models/configuration'; + +/** + * Define here which options should be made available under which section heading. + * The display name and description needs to be added manually as long as Ceph does not provide + * this information. + */ +@Injectable({ + providedIn: 'root' +}) +export class RbdConfigurationService { + readonly sections: RbdConfigurationSection[]; + + constructor() { + this.sections = [ + { + heading: $localize`Quality of Service`, + class: 'quality-of-service', + options: [ + { + name: 'rbd_qos_bps_limit', + displayName: $localize`BPS Limit`, + description: $localize`The desired limit of IO bytes per second.`, + type: RbdConfigurationType.bps + }, + { + name: 'rbd_qos_iops_limit', + displayName: $localize`IOPS Limit`, + description: $localize`The desired limit of IO operations per second.`, + type: RbdConfigurationType.iops + }, + { + name: 'rbd_qos_read_bps_limit', + displayName: $localize`Read BPS Limit`, + description: $localize`The desired limit of read bytes per second.`, + type: RbdConfigurationType.bps + }, + { + name: 'rbd_qos_read_iops_limit', + displayName: $localize`Read IOPS Limit`, + description: $localize`The desired limit of read operations per second.`, + type: RbdConfigurationType.iops + }, + { + name: 'rbd_qos_write_bps_limit', + displayName: $localize`Write BPS Limit`, + description: $localize`The desired limit of write bytes per second.`, + type: RbdConfigurationType.bps + }, + { + name: 'rbd_qos_write_iops_limit', + displayName: $localize`Write IOPS Limit`, + description: $localize`The desired limit of write operations per second.`, + type: RbdConfigurationType.iops + }, + { + name: 'rbd_qos_bps_burst', + displayName: $localize`BPS Burst`, + description: $localize`The desired burst limit of IO bytes.`, + type: RbdConfigurationType.bps + }, + { + name: 'rbd_qos_iops_burst', + displayName: $localize`IOPS Burst`, + description: $localize`The desired burst limit of IO operations.`, + type: RbdConfigurationType.iops + }, + { + name: 'rbd_qos_read_bps_burst', + displayName: $localize`Read BPS Burst`, + description: $localize`The desired burst limit of read bytes.`, + type: RbdConfigurationType.bps + }, + { + name: 'rbd_qos_read_iops_burst', + displayName: $localize`Read IOPS Burst`, + description: $localize`The desired burst limit of read operations.`, + type: RbdConfigurationType.iops + }, + { + name: 'rbd_qos_write_bps_burst', + displayName: $localize`Write BPS Burst`, + description: $localize`The desired burst limit of write bytes.`, + type: RbdConfigurationType.bps + }, + { + name: 'rbd_qos_write_iops_burst', + displayName: $localize`Write IOPS Burst`, + description: $localize`The desired burst limit of write operations.`, + type: RbdConfigurationType.iops + } + ] as RbdConfigurationExtraField[] + } + ]; + } + + private static getOptionsFromSections(sections: RbdConfigurationSection[]) { + return sections.map((section) => section.options).reduce((a, b) => a.concat(b)); + } + + private filterConfigOptionsByName(configName: string) { + return RbdConfigurationService.getOptionsFromSections(this.sections).filter( + (option) => option.name === configName + ); + } + + private getOptionValueByName(configName: string, fieldName: string, defaultValue = '') { + const configOptions = this.filterConfigOptionsByName(configName); + return configOptions.length === 1 ? configOptions.pop()[fieldName] : defaultValue; + } + + getWritableSections() { + return this.sections.map((section) => { + section.options = section.options.filter((o) => !o.readOnly); + return section; + }); + } + + getOptionFields() { + return RbdConfigurationService.getOptionsFromSections(this.sections); + } + + getWritableOptionFields() { + return RbdConfigurationService.getOptionsFromSections(this.getWritableSections()); + } + + getOptionByName(optionName: string): RbdConfigurationExtraField { + return this.filterConfigOptionsByName(optionName).pop(); + } + + getDisplayName(configName: string): string { + return this.getOptionValueByName(configName, 'displayName'); + } + + getDescription(configName: string): string { + return this.getOptionValueByName(configName, 'description'); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/refresh-interval.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/refresh-interval.service.spec.ts new file mode 100644 index 000000000..c26d6389b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/refresh-interval.service.spec.ts @@ -0,0 +1,52 @@ +import { NgZone } from '@angular/core'; +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { RefreshIntervalService } from './refresh-interval.service'; + +describe('RefreshIntervalService', () => { + let service: RefreshIntervalService; + + configureTestBed({ + providers: [RefreshIntervalService] + }); + + beforeEach(() => { + service = TestBed.inject(RefreshIntervalService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should initial private interval time right', () => { + sessionStorage.setItem('dashboard_interval', '10000'); + const ngZone = TestBed.inject(NgZone); + service = new RefreshIntervalService(ngZone); + expect(service.getRefreshInterval()).toBe(10000); + }); + + describe('setRefreshInterval', () => { + let notifyCount: number; + + it('should send notification to component at correct interval time when interval changed', fakeAsync(() => { + service.intervalData$.subscribe(() => { + notifyCount++; + }); + + notifyCount = 0; + service.setRefreshInterval(10000); + tick(10000); + expect(service.getRefreshInterval()).toBe(10000); + expect(notifyCount).toBe(1); + + notifyCount = 0; + service.setRefreshInterval(30000); + tick(30000); + expect(service.getRefreshInterval()).toBe(30000); + expect(notifyCount).toBe(1); + + service.ngOnDestroy(); + })); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/refresh-interval.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/refresh-interval.service.ts new file mode 100644 index 000000000..03aa3b8a5 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/refresh-interval.service.ts @@ -0,0 +1,46 @@ +import { Injectable, NgZone, OnDestroy } from '@angular/core'; + +import { BehaviorSubject, interval, Subscription } from 'rxjs'; + +@Injectable({ + providedIn: 'root' +}) +export class RefreshIntervalService implements OnDestroy { + private intervalTime: number; + // Observable sources + private intervalDataSource = new BehaviorSubject(null); + private intervalSubscription: Subscription; + // Observable streams + intervalData$ = this.intervalDataSource.asObservable(); + + constructor(private ngZone: NgZone) { + const initialInterval = parseInt(sessionStorage.getItem('dashboard_interval'), 10) || 5000; + this.setRefreshInterval(initialInterval); + } + + setRefreshInterval(newInterval: number) { + this.intervalTime = newInterval; + sessionStorage.setItem('dashboard_interval', newInterval.toString()); + + if (this.intervalSubscription) { + this.intervalSubscription.unsubscribe(); + } + this.ngZone.runOutsideAngular(() => { + this.intervalSubscription = interval(this.intervalTime).subscribe(() => + this.ngZone.run(() => { + this.intervalDataSource.next(this.intervalTime); + }) + ); + }); + } + + getRefreshInterval() { + return this.intervalTime; + } + + ngOnDestroy() { + if (this.intervalSubscription) { + this.intervalSubscription.unsubscribe(); + } + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/summary.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/summary.service.spec.ts new file mode 100644 index 000000000..5369a578d --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/summary.service.spec.ts @@ -0,0 +1,179 @@ +import { HttpClient } from '@angular/common/http'; +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { of as observableOf, Subscriber, Subscription } from 'rxjs'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { ExecutingTask } from '../models/executing-task'; +import { Summary } from '../models/summary.model'; +import { AuthStorageService } from './auth-storage.service'; +import { SummaryService } from './summary.service'; + +describe('SummaryService', () => { + let summaryService: SummaryService; + let authStorageService: AuthStorageService; + let subs: Subscription; + + const summary: Summary = { + executing_tasks: [], + health_status: 'HEALTH_OK', + mgr_id: 'x', + rbd_mirroring: { errors: 0, warnings: 0 }, + rbd_pools: [], + have_mon_connection: true, + finished_tasks: [], + filesystems: [{ id: 1, name: 'cephfs_a' }] + }; + + const httpClientSpy = { + get: () => observableOf(summary) + }; + + const nextSummary = (newData: any) => summaryService['summaryDataSource'].next(newData); + + configureTestBed({ + imports: [RouterTestingModule], + providers: [ + SummaryService, + AuthStorageService, + { provide: HttpClient, useValue: httpClientSpy } + ] + }); + + beforeEach(() => { + summaryService = TestBed.inject(SummaryService); + authStorageService = TestBed.inject(AuthStorageService); + }); + + it('should be created', () => { + expect(summaryService).toBeTruthy(); + }); + + it('should call refresh', fakeAsync(() => { + authStorageService.set('foobar', undefined, undefined); + const calledWith: any[] = []; + subs = new Subscription(); + subs.add(summaryService.startPolling()); + tick(); + subs.add( + summaryService.subscribe((data) => { + calledWith.push(data); + }) + ); + expect(calledWith).toEqual([summary]); + subs.add(summaryService.refresh()); + expect(calledWith).toEqual([summary, summary]); + tick(summaryService.REFRESH_INTERVAL * 2); + expect(calledWith.length).toEqual(4); + subs.unsubscribe(); + })); + + describe('Should test subscribe without initial value', () => { + let result: Summary; + let i: number; + + const callback = (response: Summary) => { + i++; + result = response; + }; + + beforeEach(() => { + i = 0; + result = undefined; + nextSummary(undefined); + }); + + it('should call subscribeOnce', () => { + const subscriber = summaryService.subscribeOnce(callback); + + expect(subscriber).toEqual(jasmine.any(Subscriber)); + expect(i).toBe(0); + expect(result).toEqual(undefined); + + nextSummary(undefined); + expect(i).toBe(0); + expect(result).toEqual(undefined); + expect(subscriber.closed).toBe(false); + + nextSummary(summary); + expect(result).toEqual(summary); + expect(i).toBe(1); + expect(subscriber.closed).toBe(true); + + nextSummary(summary); + expect(result).toEqual(summary); + expect(i).toBe(1); + }); + + it('should call subscribe', () => { + const subscriber = summaryService.subscribe(callback); + + expect(subscriber).toEqual(jasmine.any(Subscriber)); + expect(i).toBe(0); + expect(result).toEqual(undefined); + + nextSummary(undefined); + expect(i).toBe(0); + expect(result).toEqual(undefined); + expect(subscriber.closed).toBe(false); + + nextSummary(summary); + expect(result).toEqual(summary); + expect(i).toBe(1); + expect(subscriber.closed).toBe(false); + + nextSummary(summary); + expect(result).toEqual(summary); + expect(i).toBe(2); + expect(subscriber.closed).toBe(false); + }); + }); + + describe('Should test methods after first refresh', () => { + beforeEach(() => { + authStorageService.set('foobar', undefined, undefined); + summaryService.refresh(); + }); + + it('should call addRunningTask', () => { + summaryService.addRunningTask( + new ExecutingTask('rbd/delete', { + pool_name: 'somePool', + image_name: 'someImage' + }) + ); + let result: any; + summaryService.subscribeOnce((response) => { + result = response; + }); + + expect(result.executing_tasks.length).toBe(1); + expect(result.executing_tasks[0]).toEqual({ + metadata: { image_name: 'someImage', pool_name: 'somePool' }, + name: 'rbd/delete' + }); + }); + + it('should call addRunningTask with duplicate task', () => { + let result: any; + summaryService.subscribe((response) => { + result = response; + }); + + const exec_task = new ExecutingTask('rbd/delete', { + pool_name: 'somePool', + image_name: 'someImage' + }); + + result.executing_tasks = [exec_task]; + nextSummary(result); + + expect(result.executing_tasks.length).toBe(1); + + summaryService.addRunningTask(exec_task); + + expect(result.executing_tasks.length).toBe(1); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/summary.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/summary.service.ts new file mode 100644 index 000000000..f8282ae97 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/summary.service.ts @@ -0,0 +1,89 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +import _ from 'lodash'; +import { BehaviorSubject, Observable, Subscription } from 'rxjs'; +import { filter, first } from 'rxjs/operators'; + +import { ExecutingTask } from '../models/executing-task'; +import { Summary } from '../models/summary.model'; +import { TimerService } from './timer.service'; + +@Injectable({ + providedIn: 'root' +}) +export class SummaryService { + readonly REFRESH_INTERVAL = 5000; + // Observable sources + private summaryDataSource = new BehaviorSubject<Summary>(null); + // Observable streams + summaryData$ = this.summaryDataSource.asObservable(); + + constructor(private http: HttpClient, private timerService: TimerService) {} + + startPolling(): Subscription { + return this.timerService + .get(() => this.retrieveSummaryObservable(), this.REFRESH_INTERVAL) + .subscribe(this.retrieveSummaryObserver()); + } + + refresh(): Subscription { + return this.retrieveSummaryObservable().subscribe(this.retrieveSummaryObserver()); + } + + private retrieveSummaryObservable(): Observable<Summary> { + return this.http.get<Summary>('api/summary'); + } + + private retrieveSummaryObserver(): (data: Summary) => void { + return (data: Summary) => { + this.summaryDataSource.next(data); + }; + } + + /** + * Subscribes to the summaryData and receive only the first, non undefined, value. + */ + subscribeOnce(next: (summary: Summary) => void, error?: (error: any) => void): Subscription { + return this.summaryData$ + .pipe( + filter((value) => !!value), + first() + ) + .subscribe(next, error); + } + + /** + * Subscribes to the summaryData, + * which is updated periodically or when a new task is created. + * Will receive only non undefined values. + */ + subscribe(next: (summary: Summary) => void, error?: (error: any) => void): Subscription { + return this.summaryData$.pipe(filter((value) => !!value)).subscribe(next, error); + } + + /** + * Inserts a newly created task to the local list of executing tasks. + * After that, it will automatically push that new information + * to all subscribers. + */ + addRunningTask(task: ExecutingTask) { + const current = this.summaryDataSource.getValue(); + if (!current) { + return; + } + + if (_.isArray(current.executing_tasks)) { + const exists = current.executing_tasks.find((element: any) => { + return element.name === task.name && _.isEqual(element.metadata, task.metadata); + }); + if (!exists) { + current.executing_tasks.push(task); + } + } else { + current.executing_tasks = [task]; + } + + this.summaryDataSource.next(current); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-list.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-list.service.spec.ts new file mode 100644 index 000000000..66aad3cff --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-list.service.spec.ts @@ -0,0 +1,133 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { of } from 'rxjs'; + +import { configureTestBed, expectItemTasks } from '~/testing/unit-test-helper'; +import { RbdService } from '../api/rbd.service'; +import { ExecutingTask } from '../models/executing-task'; +import { SummaryService } from './summary.service'; +import { TaskListService } from './task-list.service'; +import { TaskMessageService } from './task-message.service'; + +describe('TaskListService', () => { + let service: TaskListService; + let summaryService: SummaryService; + let taskMessageService: TaskMessageService; + + let list: any[]; + let apiResp: any; + let tasks: any[]; + + const addItem = (name: string) => { + apiResp.push({ name: name }); + }; + + configureTestBed({ + providers: [TaskListService, TaskMessageService, SummaryService, RbdService], + imports: [HttpClientTestingModule, RouterTestingModule] + }); + + beforeEach(() => { + service = TestBed.inject(TaskListService); + summaryService = TestBed.inject(SummaryService); + taskMessageService = TestBed.inject(TaskMessageService); + summaryService['summaryDataSource'].next({ executing_tasks: [] }); + + taskMessageService.messages['test/create'] = taskMessageService.messages['rbd/create']; + taskMessageService.messages['test/edit'] = taskMessageService.messages['rbd/edit']; + taskMessageService.messages['test/delete'] = taskMessageService.messages['rbd/delete']; + + tasks = []; + apiResp = []; + list = []; + addItem('a'); + addItem('b'); + addItem('c'); + + service.init( + () => of(apiResp), + undefined, + (updatedList) => (list = updatedList), + () => true, + (task) => task.name.startsWith('test'), + (item, task) => item.name === task.metadata['name'], + { + default: (metadata: object) => ({ name: metadata['name'] }) + } + ); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + const addTask = (name: string, itemName: string, progress?: number) => { + const task = new ExecutingTask(); + task.name = name; + task.progress = progress; + task.metadata = { name: itemName }; + tasks.push(task); + summaryService.addRunningTask(task); + }; + + it('gets all items without any executing items', () => { + expect(list.length).toBe(3); + expect(list.every((item) => !item.cdExecuting)).toBeTruthy(); + }); + + it('gets an item from a task during creation', () => { + addTask('test/create', 'd'); + expect(list.length).toBe(4); + expectItemTasks(list[3], 'Creating'); + }); + + it('shows progress of current task if any above 0', () => { + addTask('test/edit', 'd', 97); + addTask('test/edit', 'e', 0); + expect(list.length).toBe(5); + expectItemTasks(list[3], 'Updating', 97); + expectItemTasks(list[4], 'Updating'); + }); + + it('gets all items with one executing items', () => { + addTask('test/create', 'a'); + expect(list.length).toBe(3); + expectItemTasks(list[0], 'Creating'); + expectItemTasks(list[1], undefined); + expectItemTasks(list[2], undefined); + }); + + it('gets all items with multiple executing items', () => { + addTask('test/create', 'a'); + addTask('test/edit', 'a'); + addTask('test/delete', 'a'); + addTask('test/edit', 'b'); + addTask('test/delete', 'b'); + addTask('test/delete', 'c'); + expect(list.length).toBe(3); + expectItemTasks(list[0], 'Creating..., Updating..., Deleting'); + expectItemTasks(list[1], 'Updating..., Deleting'); + expectItemTasks(list[2], 'Deleting'); + }); + + it('gets all items with multiple executing tasks (not only item tasks', () => { + addTask('rbd/create', 'a'); + addTask('rbd/edit', 'a'); + addTask('test/delete', 'a'); + addTask('test/edit', 'b'); + addTask('rbd/delete', 'b'); + addTask('rbd/delete', 'c'); + expect(list.length).toBe(3); + expectItemTasks(list[0], 'Deleting'); + expectItemTasks(list[1], 'Updating'); + expectItemTasks(list[2], undefined); + }); + + it('should call ngOnDestroy', () => { + expect(service.summaryDataSubscription.closed).toBeFalsy(); + service.ngOnDestroy(); + expect(service.summaryDataSubscription.closed).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-list.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-list.service.ts new file mode 100644 index 000000000..321454753 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-list.service.ts @@ -0,0 +1,111 @@ +import { Injectable, OnDestroy } from '@angular/core'; + +import { Observable, Subscription } from 'rxjs'; + +import { ExecutingTask } from '../models/executing-task'; +import { Summary } from '../models/summary.model'; +import { SummaryService } from './summary.service'; +import { TaskMessageService } from './task-message.service'; + +@Injectable() +export class TaskListService implements OnDestroy { + summaryDataSubscription: Subscription; + + getUpdate: (context?: any) => Observable<object>; + preProcessing: (_: any) => any[]; + setList: (_: any[]) => void; + onFetchError: (error: any) => void; + taskFilter: (task: ExecutingTask) => boolean; + itemFilter: (item: any, task: ExecutingTask) => boolean; + builders: object; + summary: Summary; + + constructor( + private taskMessageService: TaskMessageService, + private summaryService: SummaryService + ) {} + + /** + * @param {() => Observable<object>} getUpdate Method that calls the api and + * returns that without subscribing. + * @param {(_: any) => any[]} preProcessing Method executed before merging + * Tasks with Items + * @param {(_: any[]) => void} setList Method used to update array of item in the component. + * @param {(error: any) => void} onFetchError Method called when there were + * problems while fetching data. + * @param {(task: ExecutingTask) => boolean} taskFilter callback used in tasks_array.filter() + * @param {(item, task: ExecutingTask) => boolean} itemFilter callback used in + * items_array.filter() + * @param {object} builders + * object with builders for each type of task. + * You can also use a 'default' one. + * @memberof TaskListService + */ + init( + getUpdate: (context?: any) => Observable<object>, + preProcessing: (_: any) => any[], + setList: (_: any[]) => void, + onFetchError: (error: any) => void, + taskFilter: (task: ExecutingTask) => boolean, + itemFilter: (item: any, task: ExecutingTask) => boolean, + builders: object + ) { + this.getUpdate = getUpdate; + this.preProcessing = preProcessing; + this.setList = setList; + this.onFetchError = onFetchError; + this.taskFilter = taskFilter; + this.itemFilter = itemFilter; + this.builders = builders || {}; + + this.summaryDataSubscription = this.summaryService.subscribe((summary) => { + this.summary = summary; + this.fetch(); + }, this.onFetchError); + } + + fetch(context: any = null) { + this.getUpdate(context).subscribe((resp: any) => { + this.updateData(resp, this.summary?.['executing_tasks'].filter(this.taskFilter)); + }, this.onFetchError); + } + + private updateData(resp: any, tasks: ExecutingTask[]) { + const data: any[] = this.preProcessing ? this.preProcessing(resp) : resp; + this.addMissing(data, tasks); + data.forEach((item) => { + const executingTasks = tasks.filter((task) => this.itemFilter(item, task)); + item.cdExecuting = this.getTaskAction(executingTasks); + }); + this.setList(data); + } + + private addMissing(data: any[], tasks: ExecutingTask[]) { + const defaultBuilder = this.builders['default']; + tasks?.forEach((task) => { + const existing = data.find((item) => this.itemFilter(item, task)); + const builder = this.builders[task.name]; + if (!existing && (builder || defaultBuilder)) { + data.push(builder ? builder(task.metadata) : defaultBuilder(task.metadata)); + } + }); + } + + private getTaskAction(tasks: ExecutingTask[]): string { + if (tasks.length === 0) { + return undefined; + } + return tasks + .map((task) => { + const progress = task.progress ? ` ${task.progress}%` : ''; + return this.taskMessageService.getRunningText(task) + '...' + progress; + }) + .join(', '); + } + + ngOnDestroy() { + if (this.summaryDataSubscription) { + this.summaryDataSubscription.unsubscribe(); + } + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-manager.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-manager.service.spec.ts new file mode 100644 index 000000000..117b60c7e --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-manager.service.spec.ts @@ -0,0 +1,72 @@ +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; + +import _ from 'lodash'; +import { BehaviorSubject } from 'rxjs'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { SummaryService } from './summary.service'; +import { TaskManagerService } from './task-manager.service'; + +const summary: Record<string, any> = { + executing_tasks: [], + health_status: 'HEALTH_OK', + mgr_id: 'x', + rbd_mirroring: { errors: 0, warnings: 0 }, + rbd_pools: [], + have_mon_connection: true, + finished_tasks: [{ name: 'foo', metadata: {} }], + filesystems: [{ id: 1, name: 'cephfs_a' }] +}; + +export class SummaryServiceMock { + summaryDataSource = new BehaviorSubject(summary); + summaryData$ = this.summaryDataSource.asObservable(); + + refresh() { + this.summaryDataSource.next(summary); + } + subscribe(call: any) { + return this.summaryData$.subscribe(call); + } +} + +describe('TaskManagerService', () => { + let taskManagerService: TaskManagerService; + let summaryService: any; + let called: boolean; + + configureTestBed({ + providers: [TaskManagerService, { provide: SummaryService, useClass: SummaryServiceMock }] + }); + + beforeEach(() => { + taskManagerService = TestBed.inject(TaskManagerService); + summaryService = TestBed.inject(SummaryService); + called = false; + taskManagerService.subscribe('foo', {}, () => (called = true)); + }); + + it('should be created', () => { + expect(taskManagerService).toBeTruthy(); + }); + + it('should subscribe and be notified when task is finished', fakeAsync(() => { + expect(taskManagerService.subscriptions.length).toBe(1); + summaryService.refresh(); + tick(); + taskManagerService.init(summaryService); + expect(called).toEqual(true); + expect(taskManagerService.subscriptions).toEqual([]); + })); + + it('should subscribe and process executing taks', fakeAsync(() => { + const original_subscriptions = _.cloneDeep(taskManagerService.subscriptions); + _.assign(summary, { + executing_tasks: [{ name: 'foo', metadata: {} }], + finished_tasks: [] + }); + summaryService.refresh(); + tick(); + expect(taskManagerService.subscriptions).toEqual(original_subscriptions); + })); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-manager.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-manager.service.ts new file mode 100644 index 000000000..0310a7826 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-manager.service.ts @@ -0,0 +1,59 @@ +import { Injectable } from '@angular/core'; + +import _ from 'lodash'; + +import { ExecutingTask } from '../models/executing-task'; +import { FinishedTask } from '../models/finished-task'; +import { Task } from '../models/task'; +import { SummaryService } from './summary.service'; + +class TaskSubscription { + name: string; + metadata: object; + onTaskFinished: (finishedTask: FinishedTask) => any; + + constructor(name: string, metadata: object, onTaskFinished: any) { + this.name = name; + this.metadata = metadata; + this.onTaskFinished = onTaskFinished; + } +} + +@Injectable({ + providedIn: 'root' +}) +export class TaskManagerService { + subscriptions: Array<TaskSubscription> = []; + + init(summaryService: SummaryService) { + return summaryService.subscribe((summary) => { + const executingTasks = summary.executing_tasks; + const finishedTasks = summary.finished_tasks; + const newSubscriptions: Array<TaskSubscription> = []; + for (const subscription of this.subscriptions) { + const finishedTask = <FinishedTask>this._getTask(subscription, finishedTasks); + const executingTask = <ExecutingTask>this._getTask(subscription, executingTasks); + if (finishedTask !== null && executingTask === null) { + subscription.onTaskFinished(finishedTask); + } + if (executingTask !== null) { + newSubscriptions.push(subscription); + } + this.subscriptions = newSubscriptions; + } + }); + } + + subscribe(name: string, metadata: object, onTaskFinished: (finishedTask: FinishedTask) => any) { + this.subscriptions.push(new TaskSubscription(name, metadata, onTaskFinished)); + } + + private _getTask(subscription: TaskSubscription, tasks: Array<Task>): Task { + for (const task of tasks) { + if (task.name === subscription.name && _.isEqual(task.metadata, subscription.metadata)) { + return task; + } + } + return null; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.spec.ts new file mode 100644 index 000000000..a529656a0 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.spec.ts @@ -0,0 +1,312 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import _ from 'lodash'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { RbdService } from '../api/rbd.service'; +import { FinishedTask } from '../models/finished-task'; +import { TaskException } from '../models/task-exception'; +import { TaskMessageOperation, TaskMessageService } from './task-message.service'; + +describe('TaskManagerMessageService', () => { + let service: TaskMessageService; + let finishedTask: FinishedTask; + + configureTestBed({ + providers: [TaskMessageService, RbdService], + imports: [HttpClientTestingModule] + }); + + beforeEach(() => { + service = TestBed.inject(TaskMessageService); + finishedTask = new FinishedTask(); + finishedTask.duration = 30; + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should get default description', () => { + expect(service.getErrorTitle(finishedTask)).toBe('Failed to execute unknown task'); + }); + + it('should get default running message', () => { + expect(service.getRunningTitle(finishedTask)).toBe('Executing unknown task'); + }); + + it('should get default running message with a set component', () => { + finishedTask.metadata = { component: 'rbd' }; + expect(service.getRunningTitle(finishedTask)).toBe('Executing RBD'); + }); + + it('should getSuccessMessage', () => { + expect(service.getSuccessTitle(finishedTask)).toBe('Executed unknown task'); + }); + + describe('defined tasks messages', () => { + let defaultMsg: string; + const testMessages = (operation: TaskMessageOperation, involves: string) => { + expect(service.getRunningTitle(finishedTask)).toBe(operation.running + ' ' + involves); + expect(service.getErrorTitle(finishedTask)).toBe( + 'Failed to ' + operation.failure + ' ' + involves + ); + expect(service.getSuccessTitle(finishedTask)).toBe(`${operation.success} ${involves}`); + }; + + const testCreate = (involves: string) => { + testMessages(new TaskMessageOperation('Creating', 'create', 'Created'), involves); + }; + + const testUpdate = (involves: string) => { + testMessages(new TaskMessageOperation('Updating', 'update', 'Updated'), involves); + }; + + const testDelete = (involves: string) => { + testMessages(new TaskMessageOperation('Deleting', 'delete', 'Deleted'), involves); + }; + + const testImport = (involves: string) => { + testMessages(new TaskMessageOperation('Importing', 'import', 'Imported'), involves); + }; + + const testErrorCode = (code: number, msg: string) => { + finishedTask.exception = _.assign(new TaskException(), { + code: code + }); + expect(service.getErrorMessage(finishedTask)).toBe(msg); + }; + + describe('pool tasks', () => { + beforeEach(() => { + const metadata = { + pool_name: 'somePool' + }; + defaultMsg = `pool '${metadata.pool_name}'`; + finishedTask.metadata = metadata; + }); + + it('tests pool/create messages', () => { + finishedTask.name = 'pool/create'; + testCreate(defaultMsg); + testErrorCode(17, `Name is already used by ${defaultMsg}.`); + }); + + it('tests pool/edit messages', () => { + finishedTask.name = 'pool/edit'; + testUpdate(defaultMsg); + testErrorCode(17, `Name is already used by ${defaultMsg}.`); + }); + + it('tests pool/delete messages', () => { + finishedTask.name = 'pool/delete'; + testDelete(defaultMsg); + }); + }); + + describe('erasure code profile tasks', () => { + beforeEach(() => { + const metadata = { + name: 'someEcpName' + }; + defaultMsg = `erasure code profile '${metadata.name}'`; + finishedTask.metadata = metadata; + }); + + it('tests ecp/create messages', () => { + finishedTask.name = 'ecp/create'; + testCreate(defaultMsg); + testErrorCode(17, `Name is already used by ${defaultMsg}.`); + }); + + it('tests ecp/delete messages', () => { + finishedTask.name = 'ecp/delete'; + testDelete(defaultMsg); + }); + }); + + describe('crush rule tasks', () => { + beforeEach(() => { + const metadata = { + name: 'someRuleName' + }; + defaultMsg = `crush rule '${metadata.name}'`; + finishedTask.metadata = metadata; + }); + + it('tests crushRule/create messages', () => { + finishedTask.name = 'crushRule/create'; + testCreate(defaultMsg); + testErrorCode(17, `Name is already used by ${defaultMsg}.`); + }); + + it('tests crushRule/delete messages', () => { + finishedTask.name = 'crushRule/delete'; + testDelete(defaultMsg); + }); + }); + + describe('rbd tasks', () => { + let metadata: Record<string, any>; + let childMsg: string; + let destinationMsg: string; + let snapMsg: string; + + beforeEach(() => { + metadata = { + pool_name: 'somePool', + image_name: 'someImage', + image_id: '12345', + image_spec: 'somePool/someImage', + image_id_spec: 'somePool/12345', + snapshot_name: 'someSnapShot', + dest_pool_name: 'someDestinationPool', + dest_image_name: 'someDestinationImage', + child_pool_name: 'someChildPool', + child_image_name: 'someChildImage', + new_image_name: 'someImage2' + }; + defaultMsg = `RBD '${metadata.pool_name}/${metadata.image_name}'`; + childMsg = `RBD '${metadata.child_pool_name}/${metadata.child_image_name}'`; + destinationMsg = `RBD '${metadata.dest_pool_name}/${metadata.dest_image_name}'`; + snapMsg = `RBD snapshot '${metadata.pool_name}/${metadata.image_name}@${metadata.snapshot_name}'`; + finishedTask.metadata = metadata; + }); + + it('tests rbd/create messages', () => { + finishedTask.name = 'rbd/create'; + testCreate(defaultMsg); + testErrorCode(17, `Name is already used by ${defaultMsg}.`); + }); + + it('tests rbd/edit messages', () => { + finishedTask.name = 'rbd/edit'; + testUpdate(defaultMsg); + testErrorCode(17, `Name is already used by ${defaultMsg}.`); + }); + + it('tests rbd/delete messages', () => { + finishedTask.name = 'rbd/delete'; + testDelete(defaultMsg); + testErrorCode(16, `${defaultMsg} is busy.`); + testErrorCode(39, `${defaultMsg} contains snapshots.`); + }); + + it('tests rbd/clone messages', () => { + finishedTask.name = 'rbd/clone'; + testMessages(new TaskMessageOperation('Cloning', 'clone', 'Cloned'), childMsg); + testErrorCode(17, `Name is already used by ${childMsg}.`); + testErrorCode(22, `Snapshot of ${childMsg} must be protected.`); + }); + + it('tests rbd/copy messages', () => { + finishedTask.name = 'rbd/copy'; + testMessages(new TaskMessageOperation('Copying', 'copy', 'Copied'), destinationMsg); + testErrorCode(17, `Name is already used by ${destinationMsg}.`); + }); + + it('tests rbd/flatten messages', () => { + finishedTask.name = 'rbd/flatten'; + testMessages(new TaskMessageOperation('Flattening', 'flatten', 'Flattened'), defaultMsg); + }); + + it('tests rbd/snap/create messages', () => { + finishedTask.name = 'rbd/snap/create'; + testCreate(snapMsg); + testErrorCode(17, `Name is already used by ${snapMsg}.`); + }); + + it('tests rbd/snap/edit messages', () => { + finishedTask.name = 'rbd/snap/edit'; + testUpdate(snapMsg); + testErrorCode(16, `Cannot unprotect ${snapMsg} because it contains child images.`); + }); + + it('tests rbd/snap/delete messages', () => { + finishedTask.name = 'rbd/snap/delete'; + testDelete(snapMsg); + testErrorCode(16, `Cannot delete ${snapMsg} because it's protected.`); + }); + + it('tests rbd/snap/rollback messages', () => { + finishedTask.name = 'rbd/snap/rollback'; + testMessages(new TaskMessageOperation('Rolling back', 'rollback', 'Rolled back'), snapMsg); + }); + + it('tests rbd/trash/move messages', () => { + finishedTask.name = 'rbd/trash/move'; + testMessages( + new TaskMessageOperation('Moving', 'move', 'Moved'), + `image '${metadata.image_spec}' to trash` + ); + testErrorCode(2, `Could not find image.`); + }); + + it('tests rbd/trash/restore messages', () => { + finishedTask.name = 'rbd/trash/restore'; + testMessages( + new TaskMessageOperation('Restoring', 'restore', 'Restored'), + `image '${metadata.image_id_spec}' into '${metadata.new_image_name}'` + ); + testErrorCode(17, `Image name '${metadata.new_image_name}' is already in use.`); + }); + + it('tests rbd/trash/remove messages', () => { + finishedTask.name = 'rbd/trash/remove'; + testDelete(`image '${metadata.image_id_spec}'`); + }); + + it('tests rbd/trash/purge messages', () => { + finishedTask.name = 'rbd/trash/purge'; + testMessages( + new TaskMessageOperation('Purging', 'purge', 'Purged'), + `images from '${metadata.pool_name}'` + ); + }); + }); + describe('rbd tasks', () => { + let metadata; + let modeMsg: string; + let peerMsg: string; + + beforeEach(() => { + metadata = { + pool_name: 'somePool' + }; + modeMsg = `mirror mode for pool '${metadata.pool_name}'`; + peerMsg = `mirror peer for pool '${metadata.pool_name}'`; + finishedTask.metadata = metadata; + }); + it('tests rbd/mirroring/site_name/edit messages', () => { + finishedTask.name = 'rbd/mirroring/site_name/edit'; + testUpdate('mirroring site name'); + }); + it('tests rbd/mirroring/bootstrap/create messages', () => { + finishedTask.name = 'rbd/mirroring/bootstrap/create'; + testCreate('bootstrap token'); + }); + it('tests rbd/mirroring/bootstrap/import messages', () => { + finishedTask.name = 'rbd/mirroring/bootstrap/import'; + testImport('bootstrap token'); + }); + it('tests rbd/mirroring/pool/edit messages', () => { + finishedTask.name = 'rbd/mirroring/pool/edit'; + testUpdate(modeMsg); + testErrorCode(16, 'Cannot disable mirroring because it contains a peer.'); + }); + it('tests rbd/mirroring/peer/edit messages', () => { + finishedTask.name = 'rbd/mirroring/peer/edit'; + testUpdate(peerMsg); + }); + it('tests rbd/mirroring/peer/add messages', () => { + finishedTask.name = 'rbd/mirroring/peer/add'; + testCreate(peerMsg); + }); + it('tests rbd/mirroring/peer/delete messages', () => { + finishedTask.name = 'rbd/mirroring/peer/delete'; + testDelete(peerMsg); + }); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts new file mode 100644 index 000000000..5adabe211 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts @@ -0,0 +1,424 @@ +import { Injectable } from '@angular/core'; + +import { Components } from '../enum/components.enum'; +import { FinishedTask } from '../models/finished-task'; +import { ImageSpec } from '../models/image-spec'; +import { Task } from '../models/task'; + +export class TaskMessageOperation { + running: string; + failure: string; + success: string; + + constructor(running: string, failure: string, success: string) { + this.running = running; + this.failure = failure; + this.success = success; + } +} + +class TaskMessage { + operation: TaskMessageOperation; + involves: (object: any) => string; + errors: (metadata: any) => object; + + failure(metadata: any): string { + return $localize`Failed to ${this.operation.failure} ${this.involves(metadata)}`; + } + + running(metadata: any): string { + return `${this.operation.running} ${this.involves(metadata)}`; + } + + success(metadata: any): string { + return `${this.operation.success} ${this.involves(metadata)}`; + } + + constructor( + operation: TaskMessageOperation, + involves: (metadata: any) => string, + errors?: (metadata: any) => object + ) { + this.operation = operation; + this.involves = involves; + this.errors = errors || (() => ({})); + } +} + +@Injectable({ + providedIn: 'root' +}) +export class TaskMessageService { + defaultMessage = this.newTaskMessage( + new TaskMessageOperation($localize`Executing`, $localize`execute`, $localize`Executed`), + (metadata) => { + return ( + (metadata && (Components[metadata.component] || metadata.component)) || + $localize`unknown task` + ); + }, + () => { + return {}; + } + ); + + commonOperations = { + create: new TaskMessageOperation($localize`Creating`, $localize`create`, $localize`Created`), + update: new TaskMessageOperation($localize`Updating`, $localize`update`, $localize`Updated`), + delete: new TaskMessageOperation($localize`Deleting`, $localize`delete`, $localize`Deleted`), + add: new TaskMessageOperation($localize`Adding`, $localize`add`, $localize`Added`), + remove: new TaskMessageOperation($localize`Removing`, $localize`remove`, $localize`Removed`), + import: new TaskMessageOperation($localize`Importing`, $localize`import`, $localize`Imported`) + }; + + rbd = { + default: (metadata: any) => $localize`RBD '${metadata.image_spec}'`, + create: (metadata: any) => { + const id = new ImageSpec( + metadata.pool_name, + metadata.namespace, + metadata.image_name + ).toString(); + return $localize`RBD '${id}'`; + }, + child: (metadata: any) => { + const id = new ImageSpec( + metadata.child_pool_name, + metadata.child_namespace, + metadata.child_image_name + ).toString(); + return $localize`RBD '${id}'`; + }, + destination: (metadata: any) => { + const id = new ImageSpec( + metadata.dest_pool_name, + metadata.dest_namespace, + metadata.dest_image_name + ).toString(); + return $localize`RBD '${id}'`; + }, + snapshot: (metadata: any) => + $localize`RBD snapshot '${metadata.image_spec}@${metadata.snapshot_name}'` + }; + + rbd_mirroring = { + site_name: () => $localize`mirroring site name`, + bootstrap: () => $localize`bootstrap token`, + pool: (metadata: any) => $localize`mirror mode for pool '${metadata.pool_name}'`, + pool_peer: (metadata: any) => $localize`mirror peer for pool '${metadata.pool_name}'` + }; + + grafana = { + update_dashboards: () => $localize`all dashboards` + }; + + messages = { + // Host tasks + 'host/add': this.newTaskMessage(this.commonOperations.add, (metadata) => this.host(metadata)), + 'host/remove': this.newTaskMessage(this.commonOperations.remove, (metadata) => + this.host(metadata) + ), + 'host/identify_device': this.newTaskMessage( + new TaskMessageOperation($localize`Identifying`, $localize`identify`, $localize`Identified`), + (metadata) => $localize`device '${metadata.device}' on host '${metadata.hostname}'` + ), + // OSD tasks + 'osd/create': this.newTaskMessage( + this.commonOperations.create, + (metadata) => $localize`OSDs (DriveGroups: ${metadata.tracking_id})` + ), + 'osd/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) => + this.osd(metadata) + ), + // Pool tasks + 'pool/create': this.newTaskMessage( + this.commonOperations.create, + (metadata) => this.pool(metadata), + (metadata) => ({ + '17': $localize`Name is already used by ${this.pool(metadata)}.` + }) + ), + 'pool/edit': this.newTaskMessage( + this.commonOperations.update, + (metadata) => this.pool(metadata), + (metadata) => ({ + '17': $localize`Name is already used by ${this.pool(metadata)}.` + }) + ), + 'pool/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) => + this.pool(metadata) + ), + // Erasure code profile tasks + 'ecp/create': this.newTaskMessage( + this.commonOperations.create, + (metadata) => this.ecp(metadata), + (metadata) => ({ + '17': $localize`Name is already used by ${this.ecp(metadata)}.` + }) + ), + 'ecp/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) => + this.ecp(metadata) + ), + // Crush rule tasks + 'crushRule/create': this.newTaskMessage( + this.commonOperations.create, + (metadata) => this.crushRule(metadata), + (metadata) => ({ + '17': $localize`Name is already used by ${this.crushRule(metadata)}.` + }) + ), + 'crushRule/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) => + this.crushRule(metadata) + ), + // RBD tasks + 'rbd/create': this.newTaskMessage( + this.commonOperations.create, + this.rbd.create, + (metadata) => ({ + '17': $localize`Name is already used by ${this.rbd.create(metadata)}.` + }) + ), + 'rbd/edit': this.newTaskMessage(this.commonOperations.update, this.rbd.default, (metadata) => ({ + '17': $localize`Name is already used by ${this.rbd.default(metadata)}.` + })), + 'rbd/delete': this.newTaskMessage( + this.commonOperations.delete, + this.rbd.default, + (metadata) => ({ + '16': $localize`${this.rbd.default(metadata)} is busy.`, + '39': $localize`${this.rbd.default(metadata)} contains snapshots.` + }) + ), + 'rbd/clone': this.newTaskMessage( + new TaskMessageOperation($localize`Cloning`, $localize`clone`, $localize`Cloned`), + this.rbd.child, + (metadata) => ({ + '17': $localize`Name is already used by ${this.rbd.child(metadata)}.`, + '22': $localize`Snapshot of ${this.rbd.child(metadata)} must be protected.` + }) + ), + 'rbd/copy': this.newTaskMessage( + new TaskMessageOperation($localize`Copying`, $localize`copy`, $localize`Copied`), + this.rbd.destination, + (metadata) => ({ + '17': $localize`Name is already used by ${this.rbd.destination(metadata)}.` + }) + ), + 'rbd/flatten': this.newTaskMessage( + new TaskMessageOperation($localize`Flattening`, $localize`flatten`, $localize`Flattened`), + this.rbd.default + ), + // RBD snapshot tasks + 'rbd/snap/create': this.newTaskMessage( + this.commonOperations.create, + this.rbd.snapshot, + (metadata) => ({ + '17': $localize`Name is already used by ${this.rbd.snapshot(metadata)}.` + }) + ), + 'rbd/snap/edit': this.newTaskMessage( + this.commonOperations.update, + this.rbd.snapshot, + (metadata) => ({ + '16': $localize`Cannot unprotect ${this.rbd.snapshot( + metadata + )} because it contains child images.` + }) + ), + 'rbd/snap/delete': this.newTaskMessage( + this.commonOperations.delete, + this.rbd.snapshot, + (metadata) => ({ + '16': $localize`Cannot delete ${this.rbd.snapshot(metadata)} because it's protected.` + }) + ), + 'rbd/snap/rollback': this.newTaskMessage( + new TaskMessageOperation( + $localize`Rolling back`, + $localize`rollback`, + $localize`Rolled back` + ), + this.rbd.snapshot + ), + // RBD trash tasks + 'rbd/trash/move': this.newTaskMessage( + new TaskMessageOperation($localize`Moving`, $localize`move`, $localize`Moved`), + (metadata) => $localize`image '${metadata.image_spec}' to trash`, + () => ({ + 2: $localize`Could not find image.` + }) + ), + 'rbd/trash/restore': this.newTaskMessage( + new TaskMessageOperation($localize`Restoring`, $localize`restore`, $localize`Restored`), + (metadata) => $localize`image '${metadata.image_id_spec}' into '${metadata.new_image_name}'`, + (metadata) => ({ + 17: $localize`Image name '${metadata.new_image_name}' is already in use.` + }) + ), + 'rbd/trash/remove': this.newTaskMessage( + new TaskMessageOperation($localize`Deleting`, $localize`delete`, $localize`Deleted`), + (metadata) => $localize`image '${metadata.image_id_spec}'` + ), + 'rbd/trash/purge': this.newTaskMessage( + new TaskMessageOperation($localize`Purging`, $localize`purge`, $localize`Purged`), + (metadata) => { + let message = $localize`all pools`; + if (metadata.pool_name) { + message = `'${metadata.pool_name}'`; + } + return $localize`images from ${message}`; + } + ), + // RBD mirroring tasks + 'rbd/mirroring/site_name/edit': this.newTaskMessage( + this.commonOperations.update, + this.rbd_mirroring.site_name, + () => ({}) + ), + 'rbd/mirroring/bootstrap/create': this.newTaskMessage( + this.commonOperations.create, + this.rbd_mirroring.bootstrap, + () => ({}) + ), + 'rbd/mirroring/bootstrap/import': this.newTaskMessage( + this.commonOperations.import, + this.rbd_mirroring.bootstrap, + () => ({}) + ), + 'rbd/mirroring/pool/edit': this.newTaskMessage( + this.commonOperations.update, + this.rbd_mirroring.pool, + () => ({ + 16: $localize`Cannot disable mirroring because it contains a peer.` + }) + ), + 'rbd/mirroring/peer/add': this.newTaskMessage( + this.commonOperations.create, + this.rbd_mirroring.pool_peer, + () => ({}) + ), + 'rbd/mirroring/peer/edit': this.newTaskMessage( + this.commonOperations.update, + this.rbd_mirroring.pool_peer, + () => ({}) + ), + 'rbd/mirroring/peer/delete': this.newTaskMessage( + this.commonOperations.delete, + this.rbd_mirroring.pool_peer, + () => ({}) + ), + // iSCSI target tasks + 'iscsi/target/create': this.newTaskMessage(this.commonOperations.create, (metadata) => + this.iscsiTarget(metadata) + ), + 'iscsi/target/edit': this.newTaskMessage(this.commonOperations.update, (metadata) => + this.iscsiTarget(metadata) + ), + 'iscsi/target/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) => + this.iscsiTarget(metadata) + ), + 'nfs/create': this.newTaskMessage(this.commonOperations.create, (metadata) => + this.nfs(metadata) + ), + 'nfs/edit': this.newTaskMessage(this.commonOperations.update, (metadata) => this.nfs(metadata)), + 'nfs/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) => + this.nfs(metadata) + ), + // Grafana tasks + 'grafana/dashboards/update': this.newTaskMessage( + this.commonOperations.update, + this.grafana.update_dashboards, + () => ({}) + ), + // Service tasks + 'service/create': this.newTaskMessage(this.commonOperations.create, (metadata) => + this.service(metadata) + ), + 'service/edit': this.newTaskMessage(this.commonOperations.update, (metadata) => + this.service(metadata) + ), + 'service/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) => + this.service(metadata) + ) + }; + + newTaskMessage( + operation: TaskMessageOperation, + involves: (metadata: any) => string, + errors?: (metadata: any) => object + ) { + return new TaskMessage(operation, involves, errors); + } + + host(metadata: any) { + return $localize`host '${metadata.hostname}'`; + } + + osd(metadata: any) { + return $localize`OSD '${metadata.svc_id}'`; + } + + pool(metadata: any) { + return $localize`pool '${metadata.pool_name}'`; + } + + ecp(metadata: any) { + return $localize`erasure code profile '${metadata.name}'`; + } + + crushRule(metadata: any) { + return $localize`crush rule '${metadata.name}'`; + } + + iscsiTarget(metadata: any) { + return $localize`target '${metadata.target_iqn}'`; + } + + nfs(metadata: any) { + return $localize`NFS '${metadata.cluster_id}\:${ + metadata.export_id ? metadata.export_id : metadata.path + }'`; + } + + service(metadata: any) { + return $localize`Service '${metadata.service_name}'`; + } + + _getTaskTitle(task: Task) { + if (task.name && task.name.startsWith('progress/')) { + // we don't fill the failure string because, at least for now, all + // progress module tasks will be considered successful + return this.newTaskMessage( + new TaskMessageOperation( + task.name.replace('progress/', ''), + '', + task.name.replace('progress/', '') + ), + (_metadata) => '' + ); + } + return this.messages[task.name] || this.defaultMessage; + } + + getSuccessTitle(task: FinishedTask) { + return this._getTaskTitle(task).success(task.metadata); + } + + getErrorMessage(task: FinishedTask) { + return ( + this._getTaskTitle(task).errors(task.metadata)[task.exception.code] || task.exception.detail + ); + } + + getErrorTitle(task: Task) { + return this._getTaskTitle(task).failure(task.metadata); + } + + getRunningTitle(task: Task) { + return this._getTaskTitle(task).running(task.metadata); + } + + getRunningText(task: Task) { + return this._getTaskTitle(task).operation.running; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-wrapper.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-wrapper.service.spec.ts new file mode 100644 index 000000000..e81962211 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-wrapper.service.spec.ts @@ -0,0 +1,98 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { inject, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { ToastrModule } from 'ngx-toastr'; +import { Observable } from 'rxjs'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { FinishedTask } from '../models/finished-task'; +import { SharedModule } from '../shared.module'; +import { NotificationService } from './notification.service'; +import { SummaryService } from './summary.service'; +import { TaskManagerService } from './task-manager.service'; +import { TaskWrapperService } from './task-wrapper.service'; + +describe('TaskWrapperService', () => { + let service: TaskWrapperService; + + configureTestBed({ + imports: [HttpClientTestingModule, ToastrModule.forRoot(), SharedModule, RouterTestingModule], + providers: [TaskWrapperService] + }); + + beforeEach(inject([TaskWrapperService], (wrapper: TaskWrapperService) => { + service = wrapper; + })); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('wrapTaskAroundCall', () => { + let notify: NotificationService; + let passed: boolean; + let summaryService: SummaryService; + + const fakeCall = (status?: number) => + new Observable((observer) => { + if (!status) { + observer.error({ error: 'failed' }); + } + observer.next({ status: status }); + observer.complete(); + }); + + const callWrapTaskAroundCall = (status: number, name: string) => { + return service.wrapTaskAroundCall({ + task: new FinishedTask(name, { sth: 'else' }), + call: fakeCall(status) + }); + }; + + beforeEach(() => { + passed = false; + notify = TestBed.inject(NotificationService); + summaryService = TestBed.inject(SummaryService); + spyOn(notify, 'show'); + spyOn(notify, 'notifyTask').and.stub(); + spyOn(service, '_handleExecutingTasks').and.callThrough(); + spyOn(summaryService, 'addRunningTask').and.callThrough(); + }); + + it('should simulate a synchronous task', () => { + callWrapTaskAroundCall(200, 'sync').subscribe({ complete: () => (passed = true) }); + expect(service._handleExecutingTasks).not.toHaveBeenCalled(); + expect(passed).toBeTruthy(); + expect(summaryService.addRunningTask).not.toHaveBeenCalled(); + }); + + it('should simulate a asynchronous task', () => { + callWrapTaskAroundCall(202, 'async').subscribe({ complete: () => (passed = true) }); + expect(service._handleExecutingTasks).toHaveBeenCalled(); + expect(passed).toBeTruthy(); + expect(summaryService.addRunningTask).toHaveBeenCalledTimes(1); + }); + + it('should call notifyTask if asynchronous task would have been finished', () => { + const taskManager = TestBed.inject(TaskManagerService); + spyOn(taskManager, 'subscribe').and.callFake((_name, _metadata, onTaskFinished) => { + onTaskFinished(); + }); + callWrapTaskAroundCall(202, 'async').subscribe({ complete: () => (passed = true) }); + expect(notify.notifyTask).toHaveBeenCalled(); + }); + + it('should simulate a task failure', () => { + callWrapTaskAroundCall(null, 'async').subscribe({ error: () => (passed = true) }); + expect(service._handleExecutingTasks).not.toHaveBeenCalled(); + expect(passed).toBeTruthy(); + expect(summaryService.addRunningTask).not.toHaveBeenCalled(); + /** + * A notification will be raised by the API interceptor. + * This resolves this bug https://tracker.ceph.com/issues/25139 + */ + expect(notify.notifyTask).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-wrapper.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-wrapper.service.ts new file mode 100644 index 000000000..721e1edcd --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-wrapper.service.ts @@ -0,0 +1,68 @@ +import { Injectable } from '@angular/core'; + +import { Observable, Subscriber } from 'rxjs'; + +import { NotificationType } from '../enum/notification-type.enum'; +import { CdNotificationConfig } from '../models/cd-notification'; +import { ExecutingTask } from '../models/executing-task'; +import { FinishedTask } from '../models/finished-task'; +import { NotificationService } from './notification.service'; +import { SummaryService } from './summary.service'; +import { TaskManagerService } from './task-manager.service'; +import { TaskMessageService } from './task-message.service'; + +@Injectable({ + providedIn: 'root' +}) +export class TaskWrapperService { + constructor( + private notificationService: NotificationService, + private summaryService: SummaryService, + private taskMessageService: TaskMessageService, + private taskManagerService: TaskManagerService + ) {} + + wrapTaskAroundCall({ task, call }: { task: FinishedTask; call: Observable<any> }) { + return new Observable((observer: Subscriber<any>) => { + call.subscribe( + (resp) => { + if (resp.status === 202) { + this._handleExecutingTasks(task); + } else { + this.summaryService.refresh(); + task.success = true; + this.notificationService.notifyTask(task); + } + }, + (resp) => { + task.success = false; + task.exception = resp.error; + observer.error(resp); + }, + () => { + observer.complete(); + } + ); + }); + } + + _handleExecutingTasks(task: FinishedTask) { + const notification = new CdNotificationConfig( + NotificationType.info, + this.taskMessageService.getRunningTitle(task) + ); + notification.isFinishedTask = true; + this.notificationService.show(notification); + + const executingTask = new ExecutingTask(task.name, task.metadata); + this.summaryService.addRunningTask(executingTask); + + this.taskManagerService.subscribe( + executingTask.name, + executingTask.metadata, + (asyncTask: FinishedTask) => { + this.notificationService.notifyTask(asyncTask); + } + ); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/telemetry-notification.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/telemetry-notification.service.spec.ts new file mode 100644 index 000000000..ea1f910e1 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/telemetry-notification.service.spec.ts @@ -0,0 +1,33 @@ +import { TestBed } from '@angular/core/testing'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { TelemetryNotificationService } from './telemetry-notification.service'; + +describe('TelemetryNotificationService', () => { + let service: TelemetryNotificationService; + + configureTestBed({ + providers: [TelemetryNotificationService] + }); + + beforeEach(() => { + service = TestBed.inject(TelemetryNotificationService); + spyOn(service.update, 'emit'); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should set notification visibility to true', () => { + service.setVisibility(true); + expect(service.visible).toBe(true); + expect(service.update.emit).toHaveBeenCalledWith(true); + }); + + it('should set notification visibility to false', () => { + service.setVisibility(false); + expect(service.visible).toBe(false); + expect(service.update.emit).toHaveBeenCalledWith(false); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/telemetry-notification.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/telemetry-notification.service.ts new file mode 100644 index 000000000..fcb2e0264 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/telemetry-notification.service.ts @@ -0,0 +1,16 @@ +import { EventEmitter, Injectable, Output } from '@angular/core'; + +@Injectable({ + providedIn: 'root' +}) +export class TelemetryNotificationService { + visible = false; + + @Output() + update: EventEmitter<boolean> = new EventEmitter<boolean>(); + + setVisibility(visible: boolean) { + this.visible = visible; + this.update.emit(visible); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/text-to-download.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/text-to-download.service.spec.ts new file mode 100644 index 000000000..f9ff4d29d --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/text-to-download.service.spec.ts @@ -0,0 +1,20 @@ +import { TestBed } from '@angular/core/testing'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { TextToDownloadService } from './text-to-download.service'; + +describe('TextToDownloadService', () => { + let service: TextToDownloadService; + + configureTestBed({ + providers: [TextToDownloadService] + }); + + beforeEach(() => { + service = TestBed.inject(TextToDownloadService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/text-to-download.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/text-to-download.service.ts new file mode 100644 index 000000000..6e63287ea --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/text-to-download.service.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@angular/core'; + +import { saveAs } from 'file-saver'; + +@Injectable({ + providedIn: 'root' +}) +export class TextToDownloadService { + download(downloadText: string, filename?: string) { + saveAs(new Blob([downloadText]), filename); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/time-diff.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/time-diff.service.spec.ts new file mode 100644 index 000000000..52be82b09 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/time-diff.service.spec.ts @@ -0,0 +1,71 @@ +import { TestBed } from '@angular/core/testing'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { TimeDiffService } from './time-diff.service'; + +describe('TimeDiffService', () => { + let service: TimeDiffService; + const baseTime = new Date('2022-02-22T00:00:00'); + + configureTestBed({ + providers: [TimeDiffService] + }); + + beforeEach(() => { + service = TestBed.inject(TimeDiffService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('calculates a new date that happens after the given date', () => { + expect(service.calculateDate(new Date('2022-02-28T04:05:00'), '2h')).toEqual( + new Date('2022-02-28T06:05:00') + ); + expect(service.calculateDate(baseTime, '15m')).toEqual(new Date('2022-02-22T00:15')); + expect(service.calculateDate(baseTime, '5d 23h')).toEqual(new Date('2022-02-27T23:00')); + }); + + it('calculates a new date that happens before the given date', () => { + expect(service.calculateDate(new Date('2022-02-22T02:00:00'), '2h', true)).toEqual(baseTime); + }); + + it('calculates the difference of two dates', () => { + expect( + service.calculateDuration(new Date('2022-02-22T00:45:00'), new Date('2022-02-22T02:00:00')) + ).toBe('1h 15m'); + expect(service.calculateDuration(baseTime, new Date('2022-02-28T04:05:00'))).toBe('6d 4h 5m'); + }); + + it('should return an empty string if time diff is less then a minute', () => { + const ts = 1568361327000; + expect(service.calculateDuration(new Date(ts), new Date(ts + 120))).toBe(''); + }); + + describe('testing duration calculation in detail', () => { + const minutes = 60 * 1000; + const hours = 60 * minutes; + const days = 24 * hours; + + it('should allow different writings', () => { + const expectDurationToBeMs = (duration: string, ms: number) => + expect(service['getDurationMs'](duration)).toBe(ms); + expectDurationToBeMs('2h', 2 * hours); + expectDurationToBeMs('4 Days', 4 * days); + expectDurationToBeMs('3 minutes', 3 * minutes); + expectDurationToBeMs('4 Days 2h 3 minutes', 4 * days + 2 * hours + 3 * minutes); + expectDurationToBeMs('5d3h120m', 5 * days + 5 * hours); + }); + + it('should create duration string from ms', () => { + const expectMsToBeDuration = (ms: number, duration: string) => + expect(service['getDuration'](ms)).toBe(duration); + expectMsToBeDuration(2 * hours, '2h'); + expectMsToBeDuration(4 * days, '4d'); + expectMsToBeDuration(3 * minutes, '3m'); + expectMsToBeDuration(4 * days + 2 * hours + 3 * minutes, '4d 2h 3m'); + expectMsToBeDuration(service['getDurationMs']('5d3h120m'), '5d 5h'); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/time-diff.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/time-diff.service.ts new file mode 100644 index 000000000..37477658c --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/time-diff.service.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@angular/core'; + +import _ from 'lodash'; + +@Injectable({ + providedIn: 'root' +}) +export class TimeDiffService { + calculateDuration(startDate: Date, endDate: Date): string { + const startTime = +startDate; + const endTime = +endDate; + const duration = this.getDuration(Math.abs(startTime - endTime)); + if (startTime > endTime) { + return '-' + duration; + } + return duration; + } + + /** + * Get the duration in the format '[Nd] [Nh] [Nm]', e.g. '2d 1h 15m'. + * @param ms The time in milliseconds. + * @return The duration. An empty string is returned if the duration is + * less than a minute. + */ + private getDuration(ms: number): string { + const date = new Date(ms); + const h = date.getUTCHours(); + const m = date.getUTCMinutes(); + const d = Math.floor(ms / (24 * 3600 * 1000)); + + const format = (n: number, s: string) => (n ? n + s : n); + return [format(d, 'd'), format(h, 'h'), format(m, 'm')].filter((x) => x).join(' '); + } + + calculateDate(date: Date, duration: string, reverse?: boolean): Date { + const time = +date; + if (_.isNaN(time)) { + return undefined; + } + const diff = this.getDurationMs(duration) * (reverse ? -1 : 1); + return new Date(time + diff); + } + + private getDurationMs(duration: string): number { + const d = this.getNumbersFromString(duration, 'd'); + const h = this.getNumbersFromString(duration, 'h'); + const m = this.getNumbersFromString(duration, 'm'); + return ((d * 24 + h) * 60 + m) * 60000; + } + + private getNumbersFromString(duration: string, prefix: string): number { + const match = duration.match(new RegExp(`[0-9 ]+${prefix}`, 'i')); + return match ? parseInt(match[0], 10) : 0; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/timer.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/timer.service.spec.ts new file mode 100644 index 000000000..10b528e3a --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/timer.service.spec.ts @@ -0,0 +1,68 @@ +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; + +import { of, Subscription } from 'rxjs'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { TimerService } from './timer.service'; + +describe('TimerService', () => { + let service: TimerService; + let subs: Subscription; + let receivedData: any[]; + const next = () => of(true); + const observer = (data: boolean) => { + receivedData.push(data); + }; + + configureTestBed({ + providers: [TimerService] + }); + + beforeEach(() => { + service = TestBed.inject(TimerService); + receivedData = []; + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should not emit any value when no subscribers', fakeAsync(() => { + subs = service.get(next).subscribe(observer); + tick(service.DEFAULT_REFRESH_INTERVAL); + expect(receivedData.length).toEqual(2); + + subs.unsubscribe(); + + tick(service.DEFAULT_REFRESH_INTERVAL); + expect(receivedData.length).toEqual(2); + })); + + it('should emit value with no dueTime and no refresh interval', fakeAsync(() => { + subs = service.get(next, null, null).subscribe(observer); + tick(service.DEFAULT_REFRESH_INTERVAL); + expect(receivedData.length).toEqual(1); + expect(receivedData).toEqual([true]); + + subs.unsubscribe(); + })); + + it('should emit expected values when refresh interval + no dueTime', fakeAsync(() => { + subs = service.get(next).subscribe(observer); + tick(service.DEFAULT_REFRESH_INTERVAL * 2); + expect(receivedData.length).toEqual(3); + expect(receivedData).toEqual([true, true, true]); + + subs.unsubscribe(); + })); + + it('should emit expected values when dueTime equal to refresh interval', fakeAsync(() => { + const dueTime = 1000; + subs = service.get(next, service.DEFAULT_REFRESH_INTERVAL, dueTime).subscribe(observer); + tick(service.DEFAULT_REFRESH_INTERVAL * 2); + expect(receivedData.length).toEqual(2); + expect(receivedData).toEqual([true, true]); + + subs.unsubscribe(); + })); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/timer.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/timer.service.ts new file mode 100644 index 000000000..716b71096 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/timer.service.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@angular/core'; + +import { Observable, timer } from 'rxjs'; +import { observeOn, shareReplay, switchMap } from 'rxjs/operators'; + +import { whenPageVisible } from '../rxjs/operators/page-visibilty.operator'; +import { NgZoneSchedulerService } from './ngzone-scheduler.service'; + +@Injectable({ + providedIn: 'root' +}) +export class TimerService { + readonly DEFAULT_REFRESH_INTERVAL = 5000; + readonly DEFAULT_DUE_TIME = 0; + constructor(private ngZone: NgZoneSchedulerService) {} + + get( + next: () => Observable<any>, + refreshInterval: number = this.DEFAULT_REFRESH_INTERVAL, + dueTime: number = this.DEFAULT_DUE_TIME + ): Observable<any> { + return timer(dueTime, refreshInterval, this.ngZone.leave).pipe( + observeOn(this.ngZone.enter), + switchMap(next), + shareReplay({ refCount: true, bufferSize: 1 }), + whenPageVisible() + ); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/url-builder.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/url-builder.service.spec.ts new file mode 100644 index 000000000..bc8b54ca3 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/url-builder.service.spec.ts @@ -0,0 +1,37 @@ +import { URLVerbs } from '../constants/app.constants'; +import { URLBuilderService } from './url-builder.service'; + +describe('URLBuilderService', () => { + const BASE = 'pool'; + const urlBuilder = new URLBuilderService(BASE); + + it('get base', () => { + expect(urlBuilder.base).toBe(BASE); + }); + + it('build absolute URL', () => { + expect(URLBuilderService.buildURL(true, urlBuilder.base, URLVerbs.CREATE)).toBe( + `/${urlBuilder.base}/${URLVerbs.CREATE}` + ); + }); + + it('build relative URL', () => { + expect(URLBuilderService.buildURL(false, urlBuilder.base, URLVerbs.CREATE)).toBe( + `${urlBuilder.base}/${URLVerbs.CREATE}` + ); + }); + + it('get Create URL', () => { + expect(urlBuilder.getCreate()).toBe(`/${urlBuilder.base}/${URLVerbs.CREATE}`); + }); + + it('get Create From URL', () => { + const id = 'someId'; + expect(urlBuilder.getCreateFrom(id)).toBe(`/${urlBuilder.base}/${URLVerbs.CREATE}/${id}`); + }); + + it('get Edit URL with item', () => { + const item = 'test_pool'; + expect(urlBuilder.getEdit(item)).toBe(`/${urlBuilder.base}/${URLVerbs.EDIT}/${item}`); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/url-builder.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/url-builder.service.ts new file mode 100644 index 000000000..b06f307ad --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/url-builder.service.ts @@ -0,0 +1,50 @@ +import { Location } from '@angular/common'; + +import { URLVerbs } from '../constants/app.constants'; + +export class URLBuilderService { + constructor(readonly base: string) {} + + private static concatURLSegments(segments: string[]): string { + return segments.reduce(Location.joinWithSlash); + } + + static buildURL(absolute: boolean, ...segments: string[]): string { + return URLBuilderService.concatURLSegments([...(absolute ? ['/'] : []), ...segments]); + } + + private getURL(verb: URLVerbs, absolute = true, ...segments: string[]): string { + return URLBuilderService.buildURL(absolute, this.base, verb, ...segments); + } + + getCreate(absolute = true): string { + return this.getURL(URLVerbs.CREATE, absolute); + } + + getCreateFrom(item: string, absolute = true): string { + return this.getURL(URLVerbs.CREATE, absolute, item); + } + + getDelete(absolute = true): string { + return this.getURL(URLVerbs.DELETE, absolute); + } + + getEdit(item: string, absolute = true): string { + return this.getURL(URLVerbs.EDIT, absolute, item); + } + getUpdate(item: string, absolute = true): string { + return this.getURL(URLVerbs.UPDATE, absolute, item); + } + + getAdd(absolute = true): string { + return this.getURL(URLVerbs.ADD, absolute); + } + getRemove(absolute = true): string { + return this.getURL(URLVerbs.REMOVE, absolute); + } + + // Prometheus wording + getRecreate(item: string, absolute = true): string { + return this.getURL(URLVerbs.RECREATE, absolute, item); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/wizard-steps.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/wizard-steps.service.spec.ts new file mode 100644 index 000000000..47c214975 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/wizard-steps.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { WizardStepsService } from './wizard-steps.service'; + +describe('WizardStepsService', () => { + let service: WizardStepsService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(WizardStepsService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/wizard-steps.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/wizard-steps.service.ts new file mode 100644 index 000000000..e0fb2be94 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/wizard-steps.service.ts @@ -0,0 +1,58 @@ +import { Injectable } from '@angular/core'; + +import { BehaviorSubject, Observable } from 'rxjs'; + +import { WizardStepModel } from '~/app/shared/models/wizard-steps'; + +const initialStep = [{ stepIndex: 1, isComplete: false }]; + +@Injectable({ + providedIn: 'root' +}) +export class WizardStepsService { + steps$: BehaviorSubject<WizardStepModel[]>; + currentStep$: BehaviorSubject<WizardStepModel> = new BehaviorSubject<WizardStepModel>(null); + + constructor() { + this.steps$ = new BehaviorSubject<WizardStepModel[]>(initialStep); + this.currentStep$.next(this.steps$.value[0]); + } + + setTotalSteps(step: number) { + const steps: WizardStepModel[] = []; + for (let i = 1; i <= step; i++) { + steps.push({ stepIndex: i, isComplete: false }); + } + this.steps$ = new BehaviorSubject<WizardStepModel[]>(steps); + } + + setCurrentStep(step: WizardStepModel): void { + this.currentStep$.next(step); + } + + getCurrentStep(): Observable<WizardStepModel> { + return this.currentStep$.asObservable(); + } + + getSteps(): Observable<WizardStepModel[]> { + return this.steps$.asObservable(); + } + + moveToNextStep(): void { + const index = this.currentStep$.value.stepIndex; + this.currentStep$.next(this.steps$.value[index]); + } + + moveToPreviousStep(): void { + const index = this.currentStep$.value.stepIndex - 1; + this.currentStep$.next(this.steps$.value[index - 1]); + } + + isLastStep(): boolean { + return this.currentStep$.value.stepIndex === this.steps$.value.length; + } + + isFirstStep(): boolean { + return this.currentStep$.value?.stepIndex - 1 === 0; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/shared.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/shared.module.ts new file mode 100644 index 000000000..905721fa4 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/shared.module.ts @@ -0,0 +1,19 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; + +import { CssHelper } from '~/app/shared/classes/css-helper'; +import { ComponentsModule } from './components/components.module'; +import { DataTableModule } from './datatable/datatable.module'; +import { DirectivesModule } from './directives/directives.module'; +import { PipesModule } from './pipes/pipes.module'; +import { AuthGuardService } from './services/auth-guard.service'; +import { AuthStorageService } from './services/auth-storage.service'; +import { FormatterService } from './services/formatter.service'; + +@NgModule({ + imports: [CommonModule, PipesModule, ComponentsModule, DataTableModule, DirectivesModule], + declarations: [], + exports: [ComponentsModule, PipesModule, DataTableModule, DirectivesModule], + providers: [AuthStorageService, AuthGuardService, FormatterService, CssHelper] +}) +export class SharedModule {} |