summaryrefslogtreecommitdiffstats
path: root/src/pybind/mgr/dashboard/frontend/src/app/shared/api
diff options
context:
space:
mode:
Diffstat (limited to 'src/pybind/mgr/dashboard/frontend/src/app/shared/api')
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/api-client.spec.ts11
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/api-client.ts5
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/auth.service.spec.ts57
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/auth.service.ts53
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/ceph-service.service.ts74
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/ceph-user.service.ts13
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs-subvolume-group.service.spec.ts23
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs-subvolume-group.service.ts79
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs-subvolume.service.spec.ts43
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs-subvolume.service.ts96
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.spec.ts114
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.ts106
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/cluster.service.spec.ts42
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/cluster.service.ts27
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/configuration.service.spec.ts99
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/configuration.service.ts59
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/crush-rule.service.spec.ts47
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/crush-rule.service.ts32
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/custom-login-banner.service.spec.ts35
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/custom-login-banner.service.ts15
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/daemon.service.spec.ts39
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/daemon.service.ts36
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/erasure-code-profile.service.spec.ts55
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/erasure-code-profile.service.ts110
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/feedback.service.spec.ts47
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/feedback.service.ts38
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/health.service.spec.ts40
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/health.service.ts29
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.spec.ts94
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.ts165
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/iscsi.service.spec.ts97
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/iscsi.service.ts60
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/logging.service.spec.ts39
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/logging.service.ts18
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/logs.service.spec.ts34
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/logs.service.ts17
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.spec.ts66
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.ts65
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/monitor.service.spec.ts34
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/monitor.service.ts13
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/motd.service.spec.ts34
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/motd.service.ts25
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/nfs.service.spec.ts74
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/nfs.service.ts108
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.spec.ts35
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/orchestrator.service.ts50
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/osd.service.spec.ts183
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/osd.service.ts190
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/paginate.model.ts16
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/performance-counter.service.spec.ts45
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/performance-counter.service.ts29
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/pool.service.spec.ts123
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/pool.service.ts74
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.spec.ts247
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.ts192
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd-mirroring.service.spec.ts164
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd-mirroring.service.ts118
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd.model.ts30
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd.service.spec.ts186
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/rbd.service.ts203
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-bucket.service.spec.ts126
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-bucket.service.ts199
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-daemon.service.spec.ts90
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-daemon.service.ts93
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-multisite.service.ts32
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-realm.service.spec.ts22
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-realm.service.ts84
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-site.service.spec.ts43
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-site.service.ts38
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-user.service.spec.ts170
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-user.service.ts179
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-zone.service.spec.ts22
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-zone.service.ts168
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-zonegroup.service.spec.ts22
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-zonegroup.service.ts93
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/role.service.spec.ts75
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/role.service.ts49
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/scope.service.spec.ts34
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/scope.service.ts13
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/settings.service.spec.ts154
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/settings.service.ts77
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/telemetry.service.spec.ts58
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/telemetry.service.ts23
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/upgrade.service.spec.ts67
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/upgrade.service.ts78
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/user.service.spec.ts104
-rw-r--r--src/pybind/mgr/dashboard/frontend/src/app/shared/api/user.service.ts62
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
+ });
+ }
+}