]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: add backend-driven UI tables
authorErnesto Puerta <epuertat@redhat.com>
Tue, 2 Nov 2021 12:03:19 +0000 (13:03 +0100)
committerPere Diaz Bou <pere-altea@hotmail.com>
Fri, 5 May 2023 16:43:11 +0000 (18:43 +0200)
As an example this PR displays the list of Ceph Auth users, keys and
caps.

It tries to minimize the amount of UI code required by feeding a generic
table-like component (RestTable) with backend-generated JSON data.

This is just a proof of concept and there's lot of room for improvement.

Fixes: https://tracker.ceph.com/issues/52701
Signed-off-by: Ernesto Puerta <epuertat@redhat.com>
Signed-off-by: Pere Diaz Bou <pdiazbou@redhat.com>
Signed-off-by: Nizamudeen A <nia@redhat.com>
(cherry picked from commit f6c88f4c30a8de8b03aaa96b2e8916943eab5f80)

21 files changed:
src/pybind/mgr/dashboard/controllers/__init__.py
src/pybind/mgr/dashboard/controllers/_crud.py [new file with mode: 0644]
src/pybind/mgr/dashboard/controllers/ceph_users.py [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/users.e2e-spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/users.po.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/cypress/integration/ui/navigation.po.ts
src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-table.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-table.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-table.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-table.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/datatable.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/crud-table-metadata.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/services/data-gateway.service.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/services/data-gateway.service.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/openapi.yaml
src/pybind/mgr/dashboard/tests/__init__.py
src/pybind/mgr/dashboard/tests/test_ceph_users.py [new file with mode: 0644]
src/pybind/mgr/dashboard/tests/test_crud.py [new file with mode: 0644]

index 7a5b090ee461e71d7b90ff890db5beaad610c088..af3f276ebfa2faf9c8306e52a2091e013a5a4586 100755 (executable)
@@ -1,6 +1,7 @@
 from ._api_router import APIRouter
 from ._auth import ControllerAuthMixin
 from ._base_controller import BaseController
+from ._crud import CRUDCollectionMethod, CRUDEndpoint, CRUDResourceMethod, SecretStr
 from ._docs import APIDoc, EndpointDoc
 from ._endpoint import Endpoint, Proxy
 from ._helpers import ENDPOINT_MAP, allow_empty_body, \
@@ -31,5 +32,9 @@ __all__ = [
     'CreatePermission',
     'ReadPermission',
     'UpdatePermission',
-    'DeletePermission'
+    'DeletePermission',
+    'CRUDEndpoint',
+    'CRUDCollectionMethod',
+    'CRUDResourceMethod',
+    'SecretStr',
 ]
diff --git a/src/pybind/mgr/dashboard/controllers/_crud.py b/src/pybind/mgr/dashboard/controllers/_crud.py
new file mode 100644 (file)
index 0000000..907759f
--- /dev/null
@@ -0,0 +1,117 @@
+from typing import Any, Callable, Dict, Generator, Iterable, Iterator, List, \
+    NamedTuple, Optional, get_type_hints
+
+from ._api_router import APIRouter
+from ._docs import APIDoc, EndpointDoc
+from ._rest_controller import RESTController
+from ._ui_router import UIRouter
+
+
+class SecretStr(str):
+    pass
+
+
+def isnamedtuple(o):
+    return isinstance(o, tuple) and hasattr(o, '_asdict') and hasattr(o, '_fields')
+
+
+def serialize(o, expected_type=None):
+    # pylint: disable=R1705,W1116
+    if isnamedtuple(o):
+        hints = get_type_hints(o)
+        return {k: serialize(v, hints[k]) for k, v in zip(o._fields, o)}
+    elif isinstance(o, (list, tuple, set)):
+        # json serializes list and tuples to arrays, hence we also serialize
+        # sets to lists.
+        # NOTE: we could add a metadata value in a list to indentify tuples and,
+        # sets if we wanted but for now let's go for lists.
+        return [serialize(i) for i in o]
+    elif isinstance(o, (Iterator, Generator)):
+        return [serialize(i) for i in o]
+    elif expected_type and issubclass(expected_type, SecretStr):
+        return "***********"
+    else:
+        return o
+
+
+class TableColumn(NamedTuple):
+    prop: str
+    cellTemplate: str = ''
+    isHidden: bool = False
+    filterable: bool = True
+
+
+class TableComponent(NamedTuple):
+    columns: List[TableColumn] = []
+    columnMode: str = 'flex'
+    toolHeader: bool = True
+
+
+class CRUDMeta(NamedTuple):
+    table: TableComponent = TableComponent()
+
+
+class CRUDCollectionMethod(NamedTuple):
+    func: Callable[..., Iterable[Any]]
+    doc: EndpointDoc
+
+
+class CRUDResourceMethod(NamedTuple):
+    func: Callable[..., Any]
+    doc: EndpointDoc
+
+
+class CRUDEndpoint(NamedTuple):
+    router: APIRouter
+    doc: APIDoc
+    set_column: Optional[Dict[str, Dict[str, str]]] = None
+    meta: CRUDMeta = CRUDMeta()
+    get_all: Optional[CRUDCollectionMethod] = None
+
+    # for testing purposes
+    CRUDClass: Optional[RESTController] = None
+    CRUDClassMetadata: Optional[RESTController] = None
+    # ---------------------
+
+    def __call__(self, cls: NamedTuple):
+        self.create_crud_class(cls)
+
+        self.meta.table.columns.extend(TableColumn(prop=field) for field in cls._fields)
+        self.create_meta_class(cls)
+        return cls
+
+    def create_crud_class(self, cls):
+        outer_self: CRUDEndpoint = self
+
+        @self.router
+        @self.doc
+        class CRUDClass(RESTController):
+
+            if self.get_all:
+                @self.get_all.doc
+                def list(self):
+                    return serialize(cls(**item)
+                                     for item in outer_self.get_all.func())  # type: ignore
+        cls.CRUDClass = CRUDClass
+
+    def create_meta_class(self, cls):
+        outer_self: CRUDEndpoint = self
+
+        @UIRouter(self.router.path, self.router.security_scope)
+        class CRUDClassMetadata(RESTController):
+            def list(self):
+                self.update_columns()
+                return serialize(outer_self.meta)
+
+            def update_columns(self):
+                if outer_self.set_column:
+                    for i, column in enumerate(outer_self.meta.table.columns):
+                        if column.prop in dict(outer_self.set_column):
+                            new_template = outer_self.set_column[column.prop]["cellTemplate"]
+                            new_column = TableColumn(column.prop,
+                                                     new_template,
+                                                     column.isHidden,
+                                                     column.filterable)
+                            outer_self.meta.table.columns[i] = new_column
+
+        cls.CRUDClassMetadata = CRUDClassMetadata
diff --git a/src/pybind/mgr/dashboard/controllers/ceph_users.py b/src/pybind/mgr/dashboard/controllers/ceph_users.py
new file mode 100644 (file)
index 0000000..65b8a02
--- /dev/null
@@ -0,0 +1,27 @@
+from typing import NamedTuple
+
+from ..security import Scope
+from ..services.ceph_service import CephService
+from . import APIDoc, APIRouter, CRUDCollectionMethod, CRUDEndpoint, EndpointDoc, SecretStr
+
+
+class CephUserCaps(NamedTuple):
+    mon: str
+    osd: str
+    mgr: str
+    mds: str
+
+
+@CRUDEndpoint(
+    router=APIRouter('/cluster/user', Scope.CONFIG_OPT),
+    doc=APIDoc("Get Ceph Users", "Cluster"),
+    set_column={"caps": {"cellTemplate": "badgeDict"}},
+    get_all=CRUDCollectionMethod(
+        func=lambda **_: CephService.send_command('mon', 'auth ls')["auth_dump"],
+        doc=EndpointDoc("Get Ceph Users")
+    )
+)
+class CephUser(NamedTuple):
+    entity: str
+    caps: CephUserCaps
+    key: SecretStr
diff --git a/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/users.e2e-spec.ts b/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/users.e2e-spec.ts
new file mode 100644 (file)
index 0000000..8374e7c
--- /dev/null
@@ -0,0 +1,27 @@
+import { UsersPageHelper } from './users.po';
+
+describe('Cluster Users', () => {
+  const users = new UsersPageHelper();
+
+  beforeEach(() => {
+    cy.login();
+    Cypress.Cookies.preserveOnce('token');
+    users.navigateTo();
+  });
+
+  describe('breadcrumb and tab tests', () => {
+    it('should open and show breadcrumb', () => {
+      users.expectBreadcrumbText('Users');
+    });
+  });
+
+  describe('Cluster users table', () => {
+    it('should verify the table is not empty', () => {
+      users.checkForUsers();
+    });
+
+    it('should verify the keys are hidden', () => {
+      users.verifyKeysAreHidden();
+    });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/users.po.ts b/src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/users.po.ts
new file mode 100644 (file)
index 0000000..8778384
--- /dev/null
@@ -0,0 +1,29 @@
+import { PageHelper } from '../page-helper.po';
+
+const pages = {
+  index: { url: '#/ceph-users', id: 'cd-crud-table' }
+};
+
+export class UsersPageHelper extends PageHelper {
+  pages = pages;
+
+  columnIndex = {
+    entity: 1,
+    capabilities: 2,
+    key: 3
+  };
+
+  checkForUsers() {
+    this.getTableCount('total').should('not.be.eq', 0);
+  }
+
+  verifyKeysAreHidden() {
+    this.getTableCell(this.columnIndex.entity, 'osd.0')
+      .parent()
+      .find(`datatable-body-cell:nth-child(${this.columnIndex.key}) span`)
+      .should(($ele) => {
+        const serviceInstances = $ele.toArray().map((v) => v.innerText);
+        expect(serviceInstances).not.contains(/^[a-z0-9]+$/i);
+      });
+  }
+}
index a7ecf3af0e8d5946bfa942edb1320d5cf8ece78e..3713bd772c1a5f6596ad0faded44c8a74de5a289 100644 (file)
@@ -27,6 +27,7 @@ export class NavigationPageHelper extends PageHelper {
         { menu: 'Configuration', component: 'cd-configuration' },
         { menu: 'CRUSH map', component: 'cd-crushmap' },
         { menu: 'Manager Modules', component: 'cd-mgr-module-list' },
+        { menu: 'Users', component: 'cd-crud-table' },
         { menu: 'Logs', component: 'cd-logs' },
         { menu: 'Monitoring', component: 'cd-prometheus-tabs' }
       ]
@@ -60,10 +61,18 @@ export class NavigationPageHelper extends PageHelper {
     navs.forEach((nav: any) => {
       cy.contains('.simplebar-content li.nav-item a', nav.menu).click();
       if (nav.submenus) {
-        this.checkNavigations(nav.submenus);
+        this.checkNavSubMenu(nav.menu, nav.submenus);
       } else {
         cy.get(nav.component).should('exist');
       }
     });
   }
+
+  checkNavSubMenu(menu: any, submenu: any) {
+    submenu.forEach((nav: any) => {
+      cy.contains('.simplebar-content li.nav-item', menu).within(() => {
+        cy.contains(`ul.list-unstyled li a`, nav.menu).click();
+      });
+    });
+  }
 }
index 4a490728b7cb41980d47e4ebe8e36b77c9996b88..333a8422ebebed788c7f2d5966177373d5518ff5 100644 (file)
@@ -37,6 +37,7 @@ import { LoginLayoutComponent } from './core/layouts/login-layout/login-layout.c
 import { WorkbenchLayoutComponent } from './core/layouts/workbench-layout/workbench-layout.component';
 import { ApiDocsComponent } from './core/navigation/api-docs/api-docs.component';
 import { ActionLabels, URLVerbs } from './shared/constants/app.constants';
+import { CRUDTableComponent } from './shared/datatable/crud-table/crud-table.component';
 import { BreadcrumbsResolver, IBreadcrumb } from './shared/models/breadcrumbs';
 import { AuthGuardService } from './shared/services/auth-guard.service';
 import { ChangePasswordGuardService } from './shared/services/change-password-guard.service';
@@ -115,6 +116,14 @@ const routes: Routes = [
           }
         ]
       },
+      {
+        path: 'ceph-users',
+        component: CRUDTableComponent,
+        data: {
+          breadcrumbs: 'Cluster/Users',
+          resource: 'api.cluster.user@1.0'
+        }
+      },
       {
         path: 'monitor',
         component: MonitorComponent,
index aa9b70cc49b2c69ed396c1e7bf10f3ab6ad76558..dd72a2493ce1ef167370db973cbd4769fb816a22 100644 (file)
             <a i18n
                routerLink="/mgr-modules">Manager Modules</a>
           </li>
+          <li routerLinkActive="active"
+              class="tc_submenuitem tc_submenuitem_users"
+              *ngIf="permissions.configOpt.read">
+            <a i18n
+               routerLink="/ceph-users">Users</a>
+          </li>
           <li routerLinkActive="active"
               class="tc_submenuitem tc_submenuitem_log"
               *ngIf="permissions.log.read">
index 2dbce116315f2e61f7d82c12978f82609193ac7c..64aaca65b5a4e568fc06b38fac89afef5015643c 100644 (file)
@@ -109,7 +109,10 @@ describe('NavigationComponent', () => {
       ],
       [['monitor'], ['.tc_submenuitem_cluster_monitor']],
       [['osd'], ['.tc_submenuitem_osds', '.tc_submenuitem_crush']],
-      [['configOpt'], ['.tc_submenuitem_configuration', '.tc_submenuitem_modules']],
+      [
+        ['configOpt'],
+        ['.tc_submenuitem_configuration', '.tc_submenuitem_modules', '.tc_submenuitem_users']
+      ],
       [['log'], ['.tc_submenuitem_log']],
       [['prometheus'], ['.tc_submenuitem_monitoring']],
       [['pool'], ['.tc_menuitem_pool']],
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-table.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-table.component.html
new file mode 100644 (file)
index 0000000..e8982a9
--- /dev/null
@@ -0,0 +1,16 @@
+<ng-container *ngIf="meta">
+  <cd-table
+    [data]="data$ | async"
+    [columns]="meta.table.columns"
+    [columnMode]="meta.table.columnMode"
+    [toolHeader]="meta.table.toolHeader"
+  ></cd-table>
+</ng-container>
+
+<ng-template #badgeDictTpl
+             let-value="value">
+  <span *ngFor="let instance of value | keyvalue; last as isLast">
+    <span class="badge badge-background-primary" >{{ instance.key }}: {{ instance.value }}</span>
+    <ng-container *ngIf="!isLast">&nbsp;</ng-container>
+  </span>
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-table.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-table.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-table.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-table.component.spec.ts
new file mode 100644 (file)
index 0000000..df10e2a
--- /dev/null
@@ -0,0 +1,45 @@
+/* tslint:disable:no-unused-variable */
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { FormsModule } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { NgbDropdownModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
+import { NgxDatatableModule } from '@swimlane/ngx-datatable';
+import { NgxPipeFunctionModule } from 'ngx-pipe-function';
+
+import { ComponentsModule } from '~/app/shared/components/components.module';
+import { PipesModule } from '~/app/shared/pipes/pipes.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { TableKeyValueComponent } from '../table-key-value/table-key-value.component';
+import { TableComponent } from '../table/table.component';
+import { CRUDTableComponent } from './crud-table.component';
+
+describe('CRUDTableComponent', () => {
+  let component: CRUDTableComponent;
+  let fixture: ComponentFixture<CRUDTableComponent>;
+
+  configureTestBed({
+    declarations: [CRUDTableComponent, TableComponent, TableKeyValueComponent],
+    imports: [
+      NgxDatatableModule,
+      FormsModule,
+      ComponentsModule,
+      NgbDropdownModule,
+      PipesModule,
+      NgbTooltipModule,
+      RouterTestingModule,
+      NgxPipeFunctionModule,
+      HttpClientTestingModule
+    ]
+  });
+  beforeEach(() => {
+    fixture = TestBed.createComponent(CRUDTableComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-table.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-table.component.ts
new file mode 100644 (file)
index 0000000..7878a06
--- /dev/null
@@ -0,0 +1,56 @@
+import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core';
+import { ActivatedRoute } from '@angular/router';
+
+import _ from 'lodash';
+import { Observable } from 'rxjs';
+
+import { CrudMetadata } from '~/app/shared/models/crud-table-metadata';
+import { DataGatewayService } from '~/app/shared/services/data-gateway.service';
+import { TimerService } from '~/app/shared/services/timer.service';
+
+@Component({
+  selector: 'cd-crud-table',
+  templateUrl: './crud-table.component.html',
+  styleUrls: ['./crud-table.component.scss']
+})
+export class CRUDTableComponent implements OnInit {
+  @ViewChild('badgeDictTpl')
+  public badgeDictTpl: TemplateRef<any>;
+
+  data$: Observable<any>;
+  meta$: Observable<CrudMetadata>;
+  meta: CrudMetadata;
+
+  constructor(
+    private timerService: TimerService,
+    private dataGatewayService: DataGatewayService,
+    private activatedRoute: ActivatedRoute
+  ) {}
+
+  ngOnInit() {
+    /* The following should be simplified with a wrapper that
+    converts .data to @Input args. For example:
+    https://medium.com/@andrewcherepovskiy/passing-route-params-into-angular-components-input-properties-fc85c34c9aca
+    */
+    this.activatedRoute.data.subscribe((data) => {
+      const resource: string = data.resource;
+      this.dataGatewayService
+        .list(`ui-${resource}`)
+        .subscribe((response) => this.processMeta(response));
+      this.data$ = this.timerService.get(() => this.dataGatewayService.list(resource));
+    });
+  }
+
+  processMeta(meta: CrudMetadata) {
+    this.meta = meta;
+    const templates = {
+      badgeDict: this.badgeDictTpl
+    };
+    this.meta.table.columns.forEach((element, index) => {
+      if (element['cellTemplate'] !== undefined) {
+        this.meta.table.columns[index]['cellTemplate'] =
+          templates[element['cellTemplate'] as string];
+      }
+    });
+  }
+}
index fa6de840757f124f8ce66b0cb9fdf49db0a39cbb..ed92e6b9d7d27d2eea690cc742124cabd2947654 100644 (file)
@@ -9,6 +9,7 @@ import { NgxPipeFunctionModule } from 'ngx-pipe-function';
 
 import { ComponentsModule } from '../components/components.module';
 import { PipesModule } from '../pipes/pipes.module';
+import { CRUDTableComponent } from './crud-table/crud-table.component';
 import { TableActionsComponent } from './table-actions/table-actions.component';
 import { TableKeyValueComponent } from './table-key-value/table-key-value.component';
 import { TablePaginationComponent } from './table-pagination/table-pagination.component';
@@ -26,18 +27,14 @@ import { TableComponent } from './table/table.component';
     ComponentsModule,
     RouterModule
   ],
-  declarations: [
-    TableComponent,
-    TableKeyValueComponent,
-    TableActionsComponent,
-    TablePaginationComponent
-  ],
+  declarations: [TableComponent, TableKeyValueComponent, TableActionsComponent, CRUDTableComponent],
   exports: [
     TableComponent,
     NgxDatatableModule,
     TableKeyValueComponent,
     TableActionsComponent,
-    TablePaginationComponent
+    TablePaginationComponent,
+    CRUDTableComponent
   ]
 })
 export class DataTableModule {}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/crud-table-metadata.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/crud-table-metadata.ts
new file mode 100644 (file)
index 0000000..ba3e176
--- /dev/null
@@ -0,0 +1,11 @@
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+
+class Table {
+  columns: CdTableColumn[];
+  columnMode: string;
+  toolHeader: boolean;
+}
+
+export class CrudMetadata {
+  table: Table;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/data-gateway.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/data-gateway.service.spec.ts
new file mode 100644 (file)
index 0000000..1eb7ccb
--- /dev/null
@@ -0,0 +1,19 @@
+/* tslint:disable:no-unused-variable */
+
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { inject, TestBed } from '@angular/core/testing';
+
+import { DataGatewayService } from './data-gateway.service';
+
+describe('Service: DataGateway', () => {
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      imports: [HttpClientTestingModule],
+      providers: [DataGatewayService]
+    });
+  });
+
+  it('should ...', inject([DataGatewayService], (service: DataGatewayService) => {
+    expect(service).toBeTruthy();
+  }));
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/data-gateway.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/data-gateway.service.ts
new file mode 100644 (file)
index 0000000..283d37b
--- /dev/null
@@ -0,0 +1,27 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+
+import { Observable } from 'rxjs';
+
+@Injectable({
+  providedIn: 'root'
+})
+export class DataGatewayService {
+  cache: { [keys: string]: Observable<any> } = {};
+
+  constructor(private http: HttpClient) {}
+
+  list(dataPath: string): Observable<any> {
+    if (this.cache[dataPath] === undefined) {
+      const match = dataPath.match(/(?<url>[^@]+)(?:@(?<version>.+))?/);
+      const url = match.groups.url.split('.').join('/');
+      const version = match.groups.version || '1.0';
+
+      this.cache[dataPath] = this.http.get<any>(url, {
+        headers: { Accept: `application/vnd.ceph.api.v${version}+json` }
+      });
+    }
+
+    return this.cache[dataPath];
+  }
+}
index 13f2fb4d32b1df1a7025dc67c14f132583c37918..99be5131d444eb66e68f7a8bd3c18f121166f7e5 100644 (file)
@@ -2143,7 +2143,7 @@ paths:
       summary: Update the cluster status
       tags:
       - Cluster
-  /api/cluster/capacity:
+  /api/cluster/user:
     get:
       parameters: []
       responses:
@@ -2163,6 +2163,7 @@ paths:
             trace.
       security:
       - jwt: []
+      summary: Get Ceph Users
       tags:
       - Cluster
   /api/cluster_conf:
index d262d27d7fc58f951aceeca621ee44afe2b7ee32..51d233208b94cb9d3e8888b64664b4aaec605e70 100644 (file)
@@ -139,6 +139,18 @@ class ControllerTestCase(helper.CPWebCase):
         cherrypy.tree.mount(None, config={
             base_url: {'request.dispatch': mapper}})
 
+    @classmethod
+    def setup_crud_controllers(cls, crud_ctrl_classes, base_url='',
+                               cp_config: Dict[str, Any] = None):
+        if crud_ctrl_classes and not isinstance(crud_ctrl_classes, list):
+            crud_ctrl_classes = [crud_ctrl_classes]
+        ctrl_classes = []
+        for ctrl in crud_ctrl_classes:
+            ctrl_classes.append(ctrl.CRUDClass)
+            ctrl_classes.append(ctrl.CRUDClassMetadata)
+
+        cls.setup_controllers(ctrl_classes, base_url=base_url, cp_config=cp_config)
+
     _request_logging = False
 
     @classmethod
diff --git a/src/pybind/mgr/dashboard/tests/test_ceph_users.py b/src/pybind/mgr/dashboard/tests/test_ceph_users.py
new file mode 100644 (file)
index 0000000..04a03ca
--- /dev/null
@@ -0,0 +1,43 @@
+import unittest.mock as mock
+
+from ..controllers.ceph_users import CephUser
+from ..tests import ControllerTestCase
+
+auth_dump_mock = {"auth_dump": [
+    {"entity": "client.admin",
+     "key": "RANDOMFi7NwMARAA7RdGqdav+BEEFDEAD0x00g==",
+     "caps": {"mds": "allow *",
+              "mgr": "allow *",
+              "mon": "allow *",
+              "osd": "allow *"}},
+    {"entity": "client.bootstrap-mds",
+     "key": "2RANDOMi7NwMARAA7RdGqdav+BEEFDEAD0x00g==",
+     "caps": {"mds": "allow *",
+              "osd": "allow *"}}
+]}
+
+
+class CephUsersControllerTestCase(ControllerTestCase):
+    @classmethod
+    def setup_server(cls):
+        cls.setup_crud_controllers(CephUser)
+
+    @mock.patch('dashboard.services.ceph_service.CephService.send_command')
+    def test_get_all(self, send_command):
+        send_command.return_value = auth_dump_mock
+        self._get('/api/cluster/user')
+        self.assertStatus(200)
+        self.assertJsonBody([
+            {"entity": "client.admin",
+             "caps": {"mds": "allow *",
+                      "mgr": "allow *",
+                      "mon": "allow *",
+                      "osd": "allow *"},
+             "key": "***********"
+             },
+            {"entity": "client.bootstrap-mds",
+             "caps": {"mds": "allow *",
+                      "osd": "allow *"},
+             "key": "***********"
+             }
+        ])
diff --git a/src/pybind/mgr/dashboard/tests/test_crud.py b/src/pybind/mgr/dashboard/tests/test_crud.py
new file mode 100644 (file)
index 0000000..db8aa29
--- /dev/null
@@ -0,0 +1,35 @@
+# pylint: disable=C0102
+
+import json
+from typing import NamedTuple
+
+import pytest
+
+from ..controllers._crud import SecretStr, serialize
+
+
+def assertObjectEquals(a, b):
+    assert json.dumps(a) == json.dumps(b)
+
+
+class NamedTupleMock(NamedTuple):
+    foo: int
+    var: str
+
+
+class NamedTupleSecretMock(NamedTuple):
+    foo: int
+    var: str
+    key: SecretStr
+
+
+@pytest.mark.parametrize("inp,out", [
+    (["foo", "var"], ["foo", "var"]),
+    (NamedTupleMock(1, "test"), {"foo": 1, "var": "test"}),
+    (NamedTupleSecretMock(1, "test", "supposethisisakey"), {"foo": 1, "var": "test",
+                                                            "key": "***********"}),
+    ((1, 2, 3), [1, 2, 3]),
+    (set((1, 2, 3)), [1, 2, 3]),
+])
+def test_serialize(inp, out):
+    assertObjectEquals(serialize(inp), out)