diff options
Diffstat (limited to 'src/pybind/mgr/dashboard/frontend/src/app/shared/api')
87 files changed, 6427 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..4c7e4cab3 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/ceph-service.service.ts @@ -0,0 +1,74 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +import { Observable } from 'rxjs'; + +import { ApiClient } from '~/app/shared/api/api-client'; +import { Daemon } from '../models/daemon.interface'; +import { CephServiceSpec } from '../models/service.interface'; +import { PaginateObservable } from './paginate.model'; + +@Injectable({ + providedIn: 'root' +}) +export class CephServiceService extends ApiClient { + private url = 'api/service'; + + constructor(private http: HttpClient) { + super(); + } + + list(httpParams: HttpParams, serviceName?: string): PaginateObservable<CephServiceSpec[]> { + const options = { + headers: { Accept: this.getVersionHeaderValue(2, 0) }, + params: httpParams + }; + options['observe'] = 'response'; + if (serviceName) { + options.params = options.params.append('service_name', serviceName); + } + return new PaginateObservable<CephServiceSpec[]>( + 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/ceph-user.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/ceph-user.service.ts new file mode 100644 index 000000000..c41c70dc7 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/ceph-user.service.ts @@ -0,0 +1,13 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root' +}) +export class CephUserService { + constructor(private http: HttpClient) {} + + export(entities: string[]) { + return this.http.post('api/cluster/user/export', { entities: entities }); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs-subvolume-group.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs-subvolume-group.service.spec.ts new file mode 100644 index 000000000..13dad1430 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs-subvolume-group.service.spec.ts @@ -0,0 +1,23 @@ +import { TestBed } from '@angular/core/testing'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { CephfsSubvolumeGroupService } from './cephfs-subvolume-group.service'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; + +describe('CephfsSubvolumeGroupService', () => { + let service: CephfsSubvolumeGroupService; + + configureTestBed({ + imports: [HttpClientTestingModule], + providers: [CephfsSubvolumeGroupService] + }); + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(CephfsSubvolumeGroupService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs-subvolume-group.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs-subvolume-group.service.ts new file mode 100644 index 000000000..db7fcfacd --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs-subvolume-group.service.ts @@ -0,0 +1,79 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable, of } from 'rxjs'; +import { CephfsSubvolumeGroup } from '../models/cephfs-subvolumegroup.model'; +import _ from 'lodash'; +import { mapTo, catchError } from 'rxjs/operators'; + +@Injectable({ + providedIn: 'root' +}) +export class CephfsSubvolumeGroupService { + baseURL = 'api/cephfs/subvolume/group'; + + constructor(private http: HttpClient) {} + + get(volName: string): Observable<CephfsSubvolumeGroup[]> { + return this.http.get<CephfsSubvolumeGroup[]>(`${this.baseURL}/${volName}`); + } + + create( + volName: string, + groupName: string, + poolName: string, + size: string, + uid: number, + gid: number, + mode: string + ) { + return this.http.post( + this.baseURL, + { + vol_name: volName, + group_name: groupName, + pool_layout: poolName, + size: size, + uid: uid, + gid: gid, + mode: mode + }, + { observe: 'response' } + ); + } + + info(volName: string, groupName: string) { + return this.http.get(`${this.baseURL}/${volName}/info`, { + params: { + group_name: groupName + } + }); + } + + exists(groupName: string, volName: string) { + return this.info(volName, groupName).pipe( + mapTo(true), + catchError((error: Event) => { + if (_.isFunction(error.preventDefault)) { + error.preventDefault(); + } + return of(false); + }) + ); + } + + update(volName: string, groupName: string, size: string) { + return this.http.put(`${this.baseURL}/${volName}`, { + group_name: groupName, + size: size + }); + } + + remove(volName: string, groupName: string) { + return this.http.delete(`${this.baseURL}/${volName}`, { + params: { + group_name: groupName + }, + observe: 'response' + }); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs-subvolume.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs-subvolume.service.spec.ts new file mode 100644 index 000000000..e40e9a52f --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs-subvolume.service.spec.ts @@ -0,0 +1,43 @@ +import { TestBed } from '@angular/core/testing'; + +import { CephfsSubvolumeService } from './cephfs-subvolume.service'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { configureTestBed } from '~/testing/unit-test-helper'; + +describe('CephfsSubvolumeService', () => { + let service: CephfsSubvolumeService; + let httpTesting: HttpTestingController; + + configureTestBed({ + imports: [HttpClientTestingModule], + providers: [CephfsSubvolumeService] + }); + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(CephfsSubvolumeService); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call get', () => { + service.get('testFS').subscribe(); + const req = httpTesting.expectOne('api/cephfs/subvolume/testFS?group_name='); + expect(req.request.method).toBe('GET'); + }); + + it('should call remove', () => { + service.remove('testFS', 'testSubvol').subscribe(); + const req = httpTesting.expectOne( + 'api/cephfs/subvolume/testFS?subvol_name=testSubvol&group_name=&retain_snapshots=false' + ); + expect(req.request.method).toBe('DELETE'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs-subvolume.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs-subvolume.service.ts new file mode 100644 index 000000000..4c1677250 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs-subvolume.service.ts @@ -0,0 +1,96 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { CephfsSubvolume } from '../models/cephfs-subvolume.model'; +import { Observable, of } from 'rxjs'; +import { catchError, mapTo } from 'rxjs/operators'; +import _ from 'lodash'; + +@Injectable({ + providedIn: 'root' +}) +export class CephfsSubvolumeService { + baseURL = 'api/cephfs/subvolume'; + + constructor(private http: HttpClient) {} + + get(fsName: string, subVolumeGroupName: string = ''): Observable<CephfsSubvolume[]> { + return this.http.get<CephfsSubvolume[]>(`${this.baseURL}/${fsName}`, { + params: { + group_name: subVolumeGroupName + } + }); + } + + create( + fsName: string, + subVolumeName: string, + subVolumeGroupName: string, + poolName: string, + size: string, + uid: number, + gid: number, + mode: string, + namespace: boolean + ) { + return this.http.post( + this.baseURL, + { + vol_name: fsName, + subvol_name: subVolumeName, + group_name: subVolumeGroupName, + pool_layout: poolName, + size: size, + uid: uid, + gid: gid, + mode: mode, + namespace_isolated: namespace + }, + { observe: 'response' } + ); + } + + info(fsName: string, subVolumeName: string, subVolumeGroupName: string = '') { + return this.http.get(`${this.baseURL}/${fsName}/info`, { + params: { + subvol_name: subVolumeName, + group_name: subVolumeGroupName + } + }); + } + + remove( + fsName: string, + subVolumeName: string, + subVolumeGroupName: string = '', + retainSnapshots: boolean = false + ) { + return this.http.delete(`${this.baseURL}/${fsName}`, { + params: { + subvol_name: subVolumeName, + group_name: subVolumeGroupName, + retain_snapshots: retainSnapshots + }, + observe: 'response' + }); + } + + exists(subVolumeName: string, fsName: string) { + return this.info(fsName, subVolumeName).pipe( + mapTo(true), + catchError((error: Event) => { + if (_.isFunction(error.preventDefault)) { + error.preventDefault(); + } + return of(false); + }) + ); + } + + update(fsName: string, subVolumeName: string, size: string, subVolumeGroupName: string = '') { + return this.http.put(`${this.baseURL}/${fsName}`, { + subvol_name: subVolumeName, + size: size, + group_name: subVolumeGroupName + }); + } +} 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..90fa98845 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.spec.ts @@ -0,0 +1,114 @@ +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 }); + }); + + it('should rename the cephfs volume', () => { + const volName = 'testvol'; + const newVolName = 'newtestvol'; + service.rename(volName, newVolName).subscribe(); + const req = httpTesting.expectOne('api/cephfs/rename'); + expect(req.request.method).toBe('PUT'); + expect(req.request.body).toEqual({ name: 'testvol', new_name: 'newtestvol' }); + }); + + it('should remove the cephfs volume', () => { + const volName = 'testvol'; + service.remove(volName).subscribe(); + const req = httpTesting.expectOne(`api/cephfs/remove/${volName}`); + expect(req.request.method).toBe('DELETE'); + }); +}); 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..6142d7359 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.ts @@ -0,0 +1,106 @@ +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 + }); + } + + create(name: string, serviceSpec: object) { + return this.http.post( + this.baseURL, + { name: name, service_spec: serviceSpec }, + { + observe: 'response' + } + ); + } + + isCephFsPool(pool: any) { + return _.indexOf(pool.application_metadata, 'cephfs') !== -1 && !pool.pool_name.includes('/'); + } + + remove(name: string) { + return this.http.delete(`${this.baseURL}/remove/${name}`, { + observe: 'response' + }); + } + + rename(vol_name: string, new_vol_name: string) { + let requestBody = { + name: vol_name, + new_name: new_vol_name + }; + return this.http.put(`${this.baseURL}/rename`, requestBody, { + observe: 'response' + }); + } +} 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..0912e6931 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/daemon.service.ts @@ -0,0 +1,36 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { cdEncode } from '~/app/shared/decorators/cd-encode'; +import { Daemon } from '../models/daemon.interface'; + +@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' + } + ); + } + + list(daemonTypes: string[]): Observable<Daemon[]> { + return this.http.get<Daemon[]>(this.url, { + params: { daemon_types: daemonTypes } + }); + } +} 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/feedback.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/feedback.service.spec.ts new file mode 100644 index 000000000..ee0becd10 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/feedback.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 { FeedbackService } from './feedback.service'; + +describe('FeedbackService', () => { + let service: FeedbackService; + let httpTesting: HttpTestingController; + + configureTestBed({ + imports: [HttpClientTestingModule], + providers: [FeedbackService] + }); + + beforeEach(() => { + service = TestBed.inject(FeedbackService); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call checkAPIKey', () => { + service.isKeyExist().subscribe(); + const req = httpTesting.expectOne('ui-api/feedback/api_key/exist'); + expect(req.request.method).toBe('GET'); + }); + + it('should call createIssue to create issue tracker', () => { + service.createIssue('dashboard', 'bug', 'test', 'test', '').subscribe(); + const req = httpTesting.expectOne('api/feedback'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ + api_key: '', + description: 'test', + project: 'dashboard', + subject: 'test', + tracker: 'bug' + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/feedback.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/feedback.service.ts new file mode 100644 index 000000000..c450bbe07 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/feedback.service.ts @@ -0,0 +1,38 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +import * as _ from 'lodash'; + +@Injectable({ + providedIn: 'root' +}) +export class FeedbackService { + constructor(private http: HttpClient) {} + baseUIURL = 'api/feedback'; + + isKeyExist() { + return this.http.get('ui-api/feedback/api_key/exist'); + } + + createIssue( + project: string, + tracker: string, + subject: string, + description: string, + apiKey: string + ) { + return this.http.post( + 'api/feedback', + { + project: project, + tracker: tracker, + subject: subject, + description: description, + api_key: apiKey + }, + { + headers: { Accept: 'application/vnd.ceph.api.v0.1+json' } + } + ); + } +} 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..42634a148 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/health.service.ts @@ -0,0 +1,29 @@ +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'); + } + + getClusterCapacity() { + return this.http.get('api/health/get_cluster_capacity'); + } + + getClusterFsid() { + return this.http.get('api/health/get_cluster_fsid'); + } + + getOrchestratorName() { + return this.http.get('api/health/get_orchestrator_name'); + } +} 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..49b48cd6c --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.spec.ts @@ -0,0 +1,94 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { CdTableFetchDataContext } from '../models/cd-table-fetch-data-context'; +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: any[] = [{}, {}]; + const hostContext = new CdTableFetchDataContext(() => undefined); + service.list(hostContext.toParams(), 'true').subscribe((resp) => (result = resp)); + const req = httpTesting.expectOne('api/host?offset=0&limit=10&search=&sort=%2Bname&facts=true'); + expect(req.request.method).toBe('GET'); + req.flush([{ foo: 1 }, { bar: 2 }]); + tick(); + expect(result[0].foo).toEqual(1); + expect(result[1].bar).toEqual(2); + })); + + 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..3bb569575 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.ts @@ -0,0 +1,165 @@ +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(params: any, facts: string): Observable<object[]> { + params = params.set('facts', facts); + return this.http + .get<object[]>(this.baseURL, { + headers: { Accept: this.getVersionHeaderValue(1, 2) }, + params: params, + observe: 'response' + }) + .pipe( + map((response: any) => { + return response['body'].map((host: any) => { + host['headers'] = response.headers; + return host; + }); + }) + ); + } + + 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..a036b3943 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.ts @@ -0,0 +1,50 @@ +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; + } + + getName() { + return this.http.get(`${this.url}/get_name`); + } +} 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/paginate.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/paginate.model.ts new file mode 100644 index 000000000..703792a75 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/paginate.model.ts @@ -0,0 +1,16 @@ +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +export class PaginateObservable<Type> { + observable: Observable<Type>; + count: number; + + constructor(obs: Observable<Type>) { + this.observable = obs.pipe( + map((response: any) => { + this.count = Number(response.headers?.get('X-Total-Count')); + return response['body']; + }) + ); + } +} 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..65fc174b9 --- /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('ui-api/prometheus/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('ui-api/prometheus/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..6917b3766 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.ts @@ -0,0 +1,192 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +import { Observable, Subscription, timer } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { AlertmanagerSilence } from '../models/alertmanager-silence'; +import { + AlertmanagerAlert, + AlertmanagerNotification, + PrometheusRuleGroup +} from '../models/prometheus-alerts'; +import moment from 'moment'; + +@Injectable({ + providedIn: 'root' +}) +export class PrometheusService { + timerGetPrometheusDataSub: Subscription; + timerTime = 30000; + readonly lastHourDateObject = { + start: moment().unix() - 3600, + end: moment().unix(), + step: 14 + }; + private baseURL = 'api/prometheus'; + private settingsKey = { + alertmanager: 'ui-api/prometheus/alertmanager-api-host', + prometheus: 'ui-api/prometheus/prometheus-api-host' + }; + private settings: { [url: string]: string } = {}; + + constructor(private http: HttpClient) {} + + unsubscribe() { + if (this.timerGetPrometheusDataSub) { + this.timerGetPrometheusDataSub.unsubscribe(); + } + } + + getPrometheusData(params: any): any { + return this.http.get<any>(`${this.baseURL}/data`, { params }); + } + + ifAlertmanagerConfigured(fn: (value?: string) => void, elseFn?: () => void): void { + this.ifSettingConfigured(this.settingsKey.alertmanager, fn, elseFn); + } + + disableAlertmanagerConfig(): void { + this.disableSetting(this.settingsKey.alertmanager); + } + + ifPrometheusConfigured(fn: (value?: string) => void, elseFn?: () => void): void { + this.ifSettingConfigured(this.settingsKey.prometheus, fn, elseFn); + } + + disablePrometheusConfig(): void { + this.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); + } + + 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 || ''; + } + + getPrometheusQueriesData( + selectedTime: any, + queries: any, + queriesResults: any, + checkNan?: boolean + ) { + this.ifPrometheusConfigured(() => { + if (this.timerGetPrometheusDataSub) { + this.timerGetPrometheusDataSub.unsubscribe(); + } + this.timerGetPrometheusDataSub = timer(0, this.timerTime).subscribe(() => { + selectedTime = this.updateTimeStamp(selectedTime); + + for (const queryName in queries) { + if (queries.hasOwnProperty(queryName)) { + const query = queries[queryName]; + this.getPrometheusData({ + params: encodeURIComponent(query), + start: selectedTime['start'], + end: selectedTime['end'], + step: selectedTime['step'] + }).subscribe((data: any) => { + if (data.result.length) { + queriesResults[queryName] = data.result[0].values; + } + if ( + queriesResults[queryName] !== undefined && + queriesResults[queryName] !== '' && + checkNan + ) { + queriesResults[queryName].forEach((valueArray: string[]) => { + if (valueArray.includes('NaN')) { + const index = valueArray.indexOf('NaN'); + if (index !== -1) { + valueArray[index] = '0'; + } + } + }); + } + }); + } + } + }); + }); + return queriesResults; + } + + private updateTimeStamp(selectedTime: any): any { + let formattedDate = {}; + let secondsAgo = selectedTime['end'] - selectedTime['start']; + const date: number = moment().unix() - secondsAgo; + const dateNow: number = moment().unix(); + formattedDate = { + start: date, + end: dateNow, + step: selectedTime['step'] + }; + return formattedDate; + } +} 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..9dc574e48 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd-mirroring.service.ts @@ -0,0 +1,118 @@ +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}`); + } + + getPeerForPool(poolName: string) { + return this.http.get(`api/block/mirroring/pool/${poolName}/peer`); + } + + 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..25b8733d0 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd.service.spec.ts @@ -0,0 +1,186 @@ +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((req) => { + return 'api/block/image?offset=0&limit=-1&search=&sort=+name' && req.method === 'GET'; + }); + 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', false) + .subscribe(); + const req = httpTesting.expectOne('api/block/image/poolName%2FrbdName/snap'); + expect(req.request.body).toEqual({ + snapshot_name: 'snapshotName', + mirrorImageSnapshot: false + }); + 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..0ffa8fcff --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd.service.ts @@ -0,0 +1,203 @@ +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, + mirrorImageSnapshot: boolean + ) { + const request = { + snapshot_name: snapshotName, + mirrorImageSnapshot: mirrorImageSnapshot + }; + 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..2c42d8b42 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-bucket.service.spec.ts @@ -0,0 +1,126 @@ +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', + true, + 'aws:kms', + 'qwerty1' + ) + .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&encryption_state=true&encryption_type=aws%253Akms&key_id=qwerty1&${RgwHelper.DAEMON_QUERY_PARAM}` + ); + expect(req.request.method).toBe('POST'); + }); + + it('should call update', () => { + service + .update( + 'foo', + 'bar', + 'baz', + 'Enabled', + true, + 'aws:kms', + 'qwerty1', + '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&encryption_state=true&encryption_type=aws%253Akms&key_id=qwerty1&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..7207d0b5c --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-bucket.service.ts @@ -0,0 +1,199 @@ +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 }); + }); + } + + getTotalBucketsAndUsersLength() { + return this.rgwDaemonService.request((params: HttpParams) => { + return this.http.get(`ui-${this.url}/buckets_and_users_count`, { params: params }); + }); + } + + create( + bucket: string, + uid: string, + zonegroup: string, + placementTarget: string, + lockEnabled: boolean, + lock_mode: 'GOVERNANCE' | 'COMPLIANCE', + lock_retention_period_days: string, + encryption_state: boolean, + encryption_type: string, + key_id: 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, + encryption_state: String(encryption_state), + encryption_type, + key_id, + daemon_name: params.get('daemon_name') + } + }) + }); + }); + } + + update( + bucket: string, + bucketId: string, + uid: string, + versioningState: string, + encryptionState: boolean, + encryptionType: string, + keyId: string, + mfaDelete: string, + mfaTokenSerial: string, + mfaTokenPin: string, + lockMode: 'GOVERNANCE' | 'COMPLIANCE', + lockRetentionPeriodDays: string + ) { + return this.rgwDaemonService.request((params: HttpParams) => { + params = params.appendAll({ + bucket_id: bucketId, + uid: uid, + versioning_state: versioningState, + encryption_state: String(encryptionState), + encryption_type: encryptionType, + key_id: keyId, + mfa_delete: mfaDelete, + mfa_token_serial: mfaTokenSerial, + mfa_token_pin: mfaTokenPin, + lock_mode: lockMode, + 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; + } + + setEncryptionConfig( + encryption_type: string, + kms_provider: string, + auth_method: string, + secret_engine: string, + secret_path: string, + namespace: string, + address: string, + token: string, + owner: string, + ssl_cert: string, + client_cert: string, + client_key: string + ) { + return this.rgwDaemonService.request((params: HttpParams) => { + params = params.appendAll({ + encryption_type: encryption_type, + kms_provider: kms_provider, + auth_method: auth_method, + secret_engine: secret_engine, + secret_path: secret_path, + namespace: namespace, + address: address, + token: token, + owner: owner, + ssl_cert: ssl_cert, + client_cert: client_cert, + client_key: client_key + }); + return this.http.put(`${this.url}/setEncryptionConfig`, null, { params: params }); + }); + } + + getEncryption(bucket: string) { + return this.rgwDaemonService.request((params: HttpParams) => { + return this.http.get(`${this.url}/${bucket}/getEncryption`, { params: params }); + }); + } + + deleteEncryption(bucket: string) { + return this.rgwDaemonService.request((params: HttpParams) => { + return this.http.get(`${this.url}/${bucket}/deleteEncryption`, { params: params }); + }); + } + + getEncryptionConfig() { + return this.rgwDaemonService.request((params: HttpParams) => { + return this.http.get(`${this.url}/getEncryptionConfig`, { params: params }); + }); + } +} 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..a60074046 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-daemon.service.ts @@ -0,0 +1,93 @@ +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); + }) + ); + } + + setMultisiteConfig(realm_name: string, zonegroup_name: string, zone_name: string) { + return this.request((params: HttpParams) => { + params = params.appendAll({ + realm_name: realm_name, + zonegroup_name: zonegroup_name, + zone_name: zone_name + }); + return this.http.put(`${this.url}/set_multisite_config`, null, { params: params }); + }); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-multisite.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-multisite.service.ts new file mode 100644 index 000000000..d36c3a29e --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-multisite.service.ts @@ -0,0 +1,32 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { RgwRealm, RgwZone, RgwZonegroup } from '~/app/ceph/rgw/models/rgw-multisite'; +import { RgwDaemonService } from './rgw-daemon.service'; + +@Injectable({ + providedIn: 'root' +}) +export class RgwMultisiteService { + private url = 'ui-api/rgw/multisite'; + + constructor(private http: HttpClient, public rgwDaemonService: RgwDaemonService) {} + + migrate(realm: RgwRealm, zonegroup: RgwZonegroup, zone: RgwZone) { + return this.rgwDaemonService.request((params: HttpParams) => { + params = params.appendAll({ + realm_name: realm.name, + zonegroup_name: zonegroup.name, + zone_name: zone.name, + zonegroup_endpoints: zonegroup.endpoints, + zone_endpoints: zone.endpoints, + access_key: zone.system_key.access_key, + secret_key: zone.system_key.secret_key + }); + return this.http.put(`${this.url}/migrate`, null, { params: params }); + }); + } + + getSyncStatus() { + return this.http.get(`${this.url}/sync_status`); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-realm.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-realm.service.spec.ts new file mode 100644 index 000000000..359551436 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-realm.service.spec.ts @@ -0,0 +1,22 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { configureTestBed } from '~/testing/unit-test-helper'; + +import { RgwRealmService } from './rgw-realm.service'; + +describe('RgwRealmService', () => { + let service: RgwRealmService; + + configureTestBed({ + imports: [HttpClientTestingModule] + }); + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(RgwRealmService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-realm.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-realm.service.ts new file mode 100644 index 000000000..e81731cd5 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-realm.service.ts @@ -0,0 +1,84 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { RgwRealm } from '~/app/ceph/rgw/models/rgw-multisite'; +import { Icons } from '../enum/icons.enum'; +import { RgwDaemonService } from './rgw-daemon.service'; + +@Injectable({ + providedIn: 'root' +}) +export class RgwRealmService { + private url = 'api/rgw/realm'; + + constructor(private http: HttpClient, public rgwDaemonService: RgwDaemonService) {} + + create(realm: RgwRealm, defaultRealm: boolean) { + let requestBody = { + realm_name: realm.name, + default: defaultRealm + }; + return this.http.post(`${this.url}`, requestBody); + } + + update(realm: RgwRealm, defaultRealm: boolean, newRealmName: string) { + let requestBody = { + realm_name: realm.name, + default: defaultRealm, + new_realm_name: newRealmName + }; + return this.http.put(`${this.url}/${realm.name}`, requestBody); + } + + list(): Observable<object> { + return this.http.get<object>(`${this.url}`); + } + + get(realm: RgwRealm): Observable<object> { + return this.http.get(`${this.url}/${realm.name}`); + } + + getAllRealmsInfo(): Observable<object> { + return this.http.get(`${this.url}/get_all_realms_info`); + } + + delete(realmName: string): Observable<any> { + let params = new HttpParams(); + params = params.appendAll({ + realm_name: realmName + }); + return this.http.delete(`${this.url}/${realmName}`, { params: params }); + } + + getRealmTree(realm: RgwRealm, defaultRealmId: string) { + let nodes = {}; + let realmIds = []; + nodes['id'] = realm.id; + realmIds.push(realm.id); + nodes['name'] = realm.name; + nodes['info'] = realm; + nodes['is_default'] = realm.id === defaultRealmId ? true : false; + nodes['icon'] = Icons.reweight; + nodes['type'] = 'realm'; + return { + nodes: nodes, + realmIds: realmIds + }; + } + + importRealmToken(realm_token: string, zone_name: string, port: number, placementSpec: object) { + let requestBody = { + realm_token: realm_token, + zone_name: zone_name, + port: port, + placement_spec: placementSpec + }; + return this.http.post(`${this.url}/import_realm_token`, requestBody); + } + + getRealmTokens() { + return this.rgwDaemonService.request(() => { + return this.http.get(`${this.url}/get_realm_tokens`); + }); + } +} 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/rgw-zone.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-zone.service.spec.ts new file mode 100644 index 000000000..24cbcc515 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-zone.service.spec.ts @@ -0,0 +1,22 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { configureTestBed } from '~/testing/unit-test-helper'; + +import { RgwZoneService } from './rgw-zone.service'; + +describe('RgwZoneService', () => { + let service: RgwZoneService; + + configureTestBed({ + imports: [HttpClientTestingModule] + }); + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(RgwZoneService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-zone.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-zone.service.ts new file mode 100644 index 000000000..028778161 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-zone.service.ts @@ -0,0 +1,168 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { RgwRealm, RgwZone, RgwZonegroup } from '~/app/ceph/rgw/models/rgw-multisite'; +import { Icons } from '../enum/icons.enum'; + +@Injectable({ + providedIn: 'root' +}) +export class RgwZoneService { + private url = 'api/rgw/zone'; + + constructor(private http: HttpClient) {} + + create( + zone: RgwZone, + zonegroup: RgwZonegroup, + defaultZone: boolean, + master: boolean, + endpoints: string + ) { + let params = new HttpParams(); + params = params.appendAll({ + zone_name: zone.name, + zonegroup_name: zonegroup.name, + default: defaultZone, + master: master, + zone_endpoints: endpoints, + access_key: zone.system_key.access_key, + secret_key: zone.system_key.secret_key + }); + return this.http.post(`${this.url}`, null, { params: params }); + } + + list(): Observable<object> { + return this.http.get<object>(`${this.url}`); + } + + get(zone: RgwZone): Observable<object> { + return this.http.get(`${this.url}/${zone.name}`); + } + + getAllZonesInfo(): Observable<object> { + return this.http.get(`${this.url}/get_all_zones_info`); + } + + delete( + zoneName: string, + deletePools: boolean, + pools: Set<string>, + zonegroupName: string + ): Observable<any> { + let params = new HttpParams(); + params = params.appendAll({ + zone_name: zoneName, + delete_pools: deletePools, + pools: Array.from(pools.values()), + zonegroup_name: zonegroupName + }); + return this.http.delete(`${this.url}/${zoneName}`, { params: params }); + } + + update( + zone: RgwZone, + zonegroup: RgwZonegroup, + newZoneName: string, + defaultZone?: boolean, + master?: boolean, + endpoints?: string, + placementTarget?: string, + dataPool?: string, + indexPool?: string, + dataExtraPool?: string, + storageClass?: string, + dataPoolClass?: string, + compression?: string + ) { + let requestBody = { + zone_name: zone.name, + zonegroup_name: zonegroup.name, + new_zone_name: newZoneName, + default: defaultZone, + master: master, + zone_endpoints: endpoints, + access_key: zone.system_key.access_key, + secret_key: zone.system_key.secret_key, + placement_target: placementTarget, + data_pool: dataPool, + index_pool: indexPool, + data_extra_pool: dataExtraPool, + storage_class: storageClass, + data_pool_class: dataPoolClass, + compression: compression + }; + return this.http.put(`${this.url}/${zone.name}`, requestBody); + } + + getZoneTree( + zone: RgwZone, + defaultZoneId: string, + zones: RgwZone[], + zonegroup?: RgwZonegroup, + realm?: RgwRealm + ) { + let nodes = {}; + let zoneIds = []; + nodes['id'] = zone.id; + zoneIds.push(zone.id); + nodes['name'] = zone.name; + nodes['type'] = 'zone'; + nodes['name'] = zone.name; + nodes['info'] = zone; + nodes['icon'] = Icons.deploy; + nodes['zone_zonegroup'] = zonegroup; + nodes['parent'] = zonegroup ? zonegroup.name : ''; + nodes['second_parent'] = realm ? realm.name : ''; + nodes['is_default'] = zone.id === defaultZoneId ? true : false; + nodes['endpoints'] = zone.endpoints; + nodes['is_master'] = zonegroup && zonegroup.master_zone === zone.id ? true : false; + nodes['type'] = 'zone'; + const zoneNames = zones.map((zone: RgwZone) => { + return zone['name']; + }); + nodes['secondary_zone'] = !zoneNames.includes(zone.name) ? true : false; + const zoneInfo = zones.filter((zoneInfo) => zoneInfo.name === zone.name); + if (zoneInfo && zoneInfo.length > 0) { + const access_key = zoneInfo[0].system_key['access_key']; + const secret_key = zoneInfo[0].system_key['secret_key']; + nodes['access_key'] = access_key ? access_key : ''; + nodes['secret_key'] = secret_key ? secret_key : ''; + nodes['user'] = access_key && access_key !== '' ? true : false; + } + if (nodes['access_key'] === '' || nodes['access_key'] === 'null') { + nodes['show_warning'] = true; + nodes['warning_message'] = 'Access/Secret keys not found'; + } else { + nodes['show_warning'] = false; + } + if (nodes['endpoints'] && nodes['endpoints'].length === 0) { + nodes['show_warning'] = true; + nodes['warning_message'] = nodes['warning_message'] + '\n' + 'Endpoints not configured'; + } + return { + nodes: nodes, + zoneIds: zoneIds + }; + } + + getPoolNames() { + return this.http.get(`${this.url}/get_pool_names`); + } + + createSystemUser(userName: string, zone: string) { + let requestBody = { + userName: userName, + zoneName: zone + }; + return this.http.put(`${this.url}/create_system_user`, requestBody); + } + + getUserList(zoneName: string) { + let params = new HttpParams(); + params = params.appendAll({ + zoneName: zoneName + }); + return this.http.get(`${this.url}/get_user_list`, { params: params }); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-zonegroup.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-zonegroup.service.spec.ts new file mode 100644 index 000000000..aec80e017 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-zonegroup.service.spec.ts @@ -0,0 +1,22 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { configureTestBed } from '~/testing/unit-test-helper'; + +import { RgwZonegroupService } from './rgw-zonegroup.service'; + +describe('RgwZonegroupService', () => { + let service: RgwZonegroupService; + + configureTestBed({ + imports: [HttpClientTestingModule] + }); + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(RgwZonegroupService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-zonegroup.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-zonegroup.service.ts new file mode 100644 index 000000000..7f795c1d1 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-zonegroup.service.ts @@ -0,0 +1,93 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { RgwRealm, RgwZonegroup } from '~/app/ceph/rgw/models/rgw-multisite'; +import { Icons } from '../enum/icons.enum'; + +@Injectable({ + providedIn: 'root' +}) +export class RgwZonegroupService { + private url = 'api/rgw/zonegroup'; + + constructor(private http: HttpClient) {} + + create(realm: RgwRealm, zonegroup: RgwZonegroup, defaultZonegroup: boolean, master: boolean) { + let params = new HttpParams(); + params = params.appendAll({ + realm_name: realm.name, + zonegroup_name: zonegroup.name, + default: defaultZonegroup, + master: master, + zonegroup_endpoints: zonegroup.endpoints + }); + return this.http.post(`${this.url}`, null, { params: params }); + } + + update( + realm: RgwRealm, + zonegroup: RgwZonegroup, + newZonegroupName: string, + defaultZonegroup?: boolean, + master?: boolean, + removedZones?: string[], + addedZones?: string[] + ) { + let requestBody = { + zonegroup_name: zonegroup.name, + realm_name: realm.name, + new_zonegroup_name: newZonegroupName, + default: defaultZonegroup, + master: master, + zonegroup_endpoints: zonegroup.endpoints, + placement_targets: zonegroup.placement_targets, + remove_zones: removedZones, + add_zones: addedZones + }; + return this.http.put(`${this.url}/${zonegroup.name}`, requestBody); + } + + list(): Observable<object> { + return this.http.get<object>(`${this.url}`); + } + + get(zonegroup: RgwZonegroup): Observable<object> { + return this.http.get(`${this.url}/${zonegroup.name}`); + } + + getAllZonegroupsInfo(): Observable<object> { + return this.http.get(`${this.url}/get_all_zonegroups_info`); + } + + delete(zonegroupName: string, deletePools: boolean, pools: Set<string>): Observable<any> { + let params = new HttpParams(); + params = params.appendAll({ + zonegroup_name: zonegroupName, + delete_pools: deletePools, + pools: Array.from(pools.values()) + }); + return this.http.delete(`${this.url}/${zonegroupName}`, { params: params }); + } + + getZonegroupTree(zonegroup: RgwZonegroup, defaultZonegroupId: string, realm?: RgwRealm) { + let nodes = {}; + nodes['id'] = zonegroup.id; + nodes['name'] = zonegroup.name; + nodes['info'] = zonegroup; + nodes['icon'] = Icons.cubes; + nodes['is_master'] = zonegroup.is_master; + nodes['parent'] = realm ? realm.name : ''; + nodes['is_default'] = zonegroup.id === defaultZonegroupId ? true : false; + nodes['type'] = 'zonegroup'; + nodes['endpoints'] = zonegroup.endpoints; + nodes['master_zone'] = zonegroup.master_zone; + nodes['zones'] = zonegroup.zones; + nodes['placement_targets'] = zonegroup.placement_targets; + nodes['default_placement'] = zonegroup.default_placement; + if (nodes['endpoints'].length === 0) { + nodes['show_warning'] = true; + nodes['warning_message'] = 'Endpoints not configured'; + } + return nodes; + } +} 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/upgrade.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/upgrade.service.spec.ts new file mode 100644 index 000000000..5acd490cf --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/upgrade.service.spec.ts @@ -0,0 +1,67 @@ +import { UpgradeService } from './upgrade.service'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { SummaryService } from '../services/summary.service'; +import { BehaviorSubject } from 'rxjs'; + +export class SummaryServiceMock { + summaryDataSource = new BehaviorSubject({ + version: + 'ceph version 18.1.3-12222-gcd0cd7cb ' + + '(b8193bb4cda16ccc5b028c3e1df62bc72350a15d) reef (dev)' + }); + summaryData$ = this.summaryDataSource.asObservable(); + + subscribe(call: any) { + return this.summaryData$.subscribe(call); + } +} + +describe('UpgradeService', () => { + let service: UpgradeService; + let httpTesting: HttpTestingController; + + configureTestBed({ + imports: [HttpClientTestingModule], + providers: [UpgradeService, { provide: SummaryService, useClass: SummaryServiceMock }] + }); + + beforeEach(() => { + service = TestBed.inject(UpgradeService); + httpTesting = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpTesting.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should call upgrade list', () => { + service.list().subscribe(); + const req = httpTesting.expectOne('api/cluster/upgrade'); + expect(req.request.method).toBe('GET'); + }); + + it('should not show any version if the registry versions are older than the cluster version', () => { + const upgradeInfoPayload = { + image: 'quay.io/ceph-test/ceph', + registry: 'quay.io', + versions: ['18.1.0', '18.1.1', '18.1.2'] + }; + const expectedVersions: string[] = []; + expect(service.versionAvailableForUpgrades(upgradeInfoPayload).versions).toEqual( + expectedVersions + ); + }); + + it('should start the upgrade', () => { + service.start('18.1.0').subscribe(); + const req = httpTesting.expectOne('api/cluster/upgrade/start'); + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual({ version: '18.1.0' }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/upgrade.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/upgrade.service.ts new file mode 100644 index 000000000..9aa25aa16 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/upgrade.service.ts @@ -0,0 +1,78 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { ApiClient } from './api-client'; +import { map } from 'rxjs/operators'; +import { SummaryService } from '../services/summary.service'; +import { UpgradeInfoInterface, UpgradeStatusInterface } from '../models/upgrade.interface'; +import { Observable } from 'rxjs'; + +@Injectable({ + providedIn: 'root' +}) +export class UpgradeService extends ApiClient { + baseURL = 'api/cluster/upgrade'; + + upgradableServiceTypes = [ + 'mgr', + 'mon', + 'crash', + 'osd', + 'mds', + 'rgw', + 'rbd-mirror', + 'cephfs-mirror', + 'iscsi', + 'nfs' + ]; + + constructor(private http: HttpClient, private summaryService: SummaryService) { + super(); + } + + list() { + return this.http.get(this.baseURL).pipe( + map((resp: UpgradeInfoInterface) => { + return this.versionAvailableForUpgrades(resp); + }) + ); + } + + // Filter out versions that are older than the current cluster version + // Only allow upgrades to the same major version + versionAvailableForUpgrades(upgradeInfo: UpgradeInfoInterface): UpgradeInfoInterface { + let version = ''; + this.summaryService.subscribe((summary) => { + version = summary.version.replace('ceph version ', '').split('-')[0]; + }); + + const upgradableVersions = upgradeInfo.versions.filter((targetVersion) => { + const cVersion = version.split('.'); + const tVersion = targetVersion.split('.'); + return ( + cVersion[0] === tVersion[0] && (cVersion[1] < tVersion[1] || cVersion[2] < tVersion[2]) + ); + }); + upgradeInfo.versions = upgradableVersions.sort(); + return upgradeInfo; + } + + start(version?: string, image?: string) { + return this.http.post(`${this.baseURL}/start`, { image: image, version: version }); + } + + pause() { + return this.http.put(`${this.baseURL}/pause`, null); + } + + resume() { + return this.http.put(`${this.baseURL}/resume`, null); + } + + stop() { + return this.http.put(`${this.baseURL}/stop`, null); + } + + status(): Observable<UpgradeStatusInterface> { + return this.http.get<UpgradeStatusInterface>(`${this.baseURL}/status`); + } +} 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 + }); + } +} |