From: Syed Ali Ul Hasan Date: Wed, 22 Jul 2026 14:22:20 +0000 (+0530) Subject: mgr/dashboard: migrated user table tabs to resource pages X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=78a2d82cb16d92a153fa0e5cbdfcc6f7c25516de;p=ceph.git mgr/dashboard: migrated user table tabs to resource pages - Fixes: https://tracker.ceph.com/issues/77475 Signed-off-by: Syed Ali Ul Hasan --- diff --git a/src/pybind/mgr/dashboard/frontend/cypress/e2e/cluster/logs.po.ts b/src/pybind/mgr/dashboard/frontend/cypress/e2e/cluster/logs.po.ts index 6de2a0627fc7..bf20098fa183 100644 --- a/src/pybind/mgr/dashboard/frontend/cypress/e2e/cluster/logs.po.ts +++ b/src/pybind/mgr/dashboard/frontend/cypress/e2e/cluster/logs.po.ts @@ -5,6 +5,17 @@ export class LogsPageHelper extends PageHelper { index: { url: '#/logs', id: 'cd-logs' } }; + private setTimepickerValue(index: number, value: number) { + cy.get('.ngb-tp-input') + .eq(index) + .then(($input) => { + const input = $input[0] as HTMLInputElement; + input.value = String(value).padStart(2, '0'); + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); + }); + } + checkAuditForPoolFunction(poolname: string, poolfunction: string, hour: number, minute: number) { this.navigateTo(); @@ -16,24 +27,12 @@ export class LogsPageHelper extends PageHelper { cy.contains('.nav-link', 'Audit Logs').click(); // Enter an earliest time so that no old messages with the same pool name show up - cy.get('.ngb-tp-input') - .its(0) - .then((input) => { - cy.wrap(input).clear(); - - if (hour < 10) cy.wrap(input).type(`${hour}`); - }); - - cy.get('.ngb-tp-input') - .its(1) - .then((input) => { - cy.wrap(input).clear(); - - if (minute < 10) cy.wrap(input).type(`${minute}`); - }); + this.setTimepickerValue(0, hour); + this.setTimepickerValue(1, minute); // Enter the pool name into the filter box - cy.get('input.form-control.ng-valid').first().clear().type(poolname); + cy.get('#logs-keyword').clear(); + cy.get('#logs-keyword').type(poolname); cy.get('.tab-pane.active') .get('.log-viewer') @@ -49,24 +48,12 @@ export class LogsPageHelper extends PageHelper { cy.contains('.nav-link', 'Audit Logs').click(); // Enter an earliest time so that no old messages with the same config name show up - cy.get('.ngb-tp-input') - .its(0) - .then((input) => { - cy.wrap(input).clear(); - - if (hour < 10) cy.wrap(input).type(`${hour}`); - }); - - cy.get('.ngb-tp-input') - .its(1) - .then((input) => { - cy.wrap(input).clear(); - - if (minute < 10) cy.wrap(input).type(`${minute}`); - }); + this.setTimepickerValue(0, hour); + this.setTimepickerValue(1, minute); // Enter the config name into the filter box - cy.get('input.form-control.ng-valid').first().clear().type(configname); + cy.get('#logs-keyword').clear(); + cy.get('#logs-keyword').type(configname); cy.get('.tab-pane.active') .get('.log-viewer') diff --git a/src/pybind/mgr/dashboard/frontend/cypress/e2e/page-helper.po.ts b/src/pybind/mgr/dashboard/frontend/cypress/e2e/page-helper.po.ts index 4a26f780ee0a..729e5014a053 100644 --- a/src/pybind/mgr/dashboard/frontend/cypress/e2e/page-helper.po.ts +++ b/src/pybind/mgr/dashboard/frontend/cypress/e2e/page-helper.po.ts @@ -273,12 +273,16 @@ export abstract class PageHelper { getResourcePage(content?: string) { this.waitDataTableToLoad(); if (content) { + const escapedContent = content.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return cy .contains('[cdstablerow] [cdstabledata]', content) .parent('[cdstablerow]') - .contains('[cdstabledata] a', new RegExp(`^${content}$`)); + .contains( + '[cdstabledata] a, [cdstabledata] [cdslink]', + new RegExp(`^\\s*${escapedContent}\\s*$`) + ); } - return cy.get('[cdstablerow] [cdstabledata] a').first(); + return cy.get('[cdstablerow] [cdstabledata] a, [cdstablerow] [cdstabledata] [cdslink]').first(); } /** diff --git a/src/pybind/mgr/dashboard/frontend/cypress/e2e/rgw/users.po.ts b/src/pybind/mgr/dashboard/frontend/cypress/e2e/rgw/users.po.ts index e31dee68f9a1..16a79f8ec97e 100644 --- a/src/pybind/mgr/dashboard/frontend/cypress/e2e/rgw/users.po.ts +++ b/src/pybind/mgr/dashboard/frontend/cypress/e2e/rgw/users.po.ts @@ -48,12 +48,10 @@ export class UsersPageHelper extends PageHelper { cy.contains('button', 'Edit User').click(); - // Click the user and check its details table for updated content - this.getExpandCollapseElement(name).click(); - cy.get('[data-testid="datatable-row-detail"]') - .should('contain.text', new_fullname) - .and('contain.text', new_email) - .and('contain.text', new_maxbuckets); + // Check that the new values are reflected in the table + this.getTableCell(4, new_fullname, true).should('exist'); + this.getTableCell(5, new_email, true).should('exist'); + this.getTableCell(7, new_maxbuckets, true).should('exist'); } invalidCreate() { @@ -157,10 +155,10 @@ export class UsersPageHelper extends PageHelper { } checkUserKeys(user_name: string) { - this.getExpandCollapseElement(user_name).should('be.visible').click(); - cy.get('cd-table').contains('td', user_name).click(); - cy.get('cd-rgw-user-details cd-table [cdstablerow]').first().click(); - cy.get("[aria-label='Show']").should('exist').click({ force: true }); + this.searchTable(user_name); + this.getResourcePage(user_name).click(); + cy.get('cd-table').contains('td', user_name).should('exist'); + cy.contains('a', 'Show').should('exist').click({ force: true }); cy.get('input#user').should('exist'); cy.get('input#access_key').should('exist'); cy.get('input#secret_key').should('exist'); @@ -184,45 +182,14 @@ export class UsersPageHelper extends PageHelper { cy.contains('button', 'Edit User').click(); this.getTableRow(tenant + '$' + user_id).as('AccountUser'); - cy.get('@AccountUser').find('td').eq(3).should('contain.text', `${account_name}`); - - // check table details if we have all the details there - this.getExpandCollapseElement(username).should('be.visible').click(); - // check the Account Details section - cy.get('legend').should('contain.text', 'Account Details'); - cy.get('table#accountsDetails').scrollIntoView(); - cy.wait(500); - cy.get('table#accountsDetails').find('tbody tr').should('have.length', 4); - cy.get('table#accountsDetails').within(() => { - cy.get('tr') - .eq(0) - .within(() => { - cy.wait(500); - cy.get('td').eq(0).should('contain.text', 'Account ID'); - cy.get('td').eq(1).should('contain.text', account_id); - }); - cy.get('tr') - .eq(1) - .within(() => { - cy.wait(500); - cy.get('td').eq(0).should('contain.text', 'Name'); - cy.get('td').eq(1).should('contain.text', account_name); - }); - cy.get('tr') - .eq(2) - .within(() => { - cy.wait(500); - cy.get('td').eq(0).should('contain.text', 'Tenant'); - cy.get('td').eq(1).should('contain.text', tenant); - }); - cy.get('tr') - .eq(3) - .within(() => { - cy.wait(500); - cy.get('td').eq(0).should('contain.text', 'User type'); - cy.get('td').eq(1).should('contain.text', 'rgw user'); - }); - }); + cy.get('@AccountUser').find('td').eq(2).should('contain.text', `${account_name}`); + + // Check account details rendered in the resource overview card. + this.getResourcePage(username).should('be.visible').click(); + this.assertOverviewFieldValue('Account ID', account_id); + this.assertOverviewFieldValue('Name', account_name); + this.assertOverviewFieldValue('Tenant', tenant); + this.assertOverviewFieldValue('User type', 'rgw user'); } makeRootAccount(account_name: string, user_id: string, tenant: string) { @@ -238,21 +205,16 @@ export class UsersPageHelper extends PageHelper { cy.contains('button', 'Edit User').click(); - // check table details if we have all the details there - this.getExpandCollapseElement(username).should('be.visible').click(); - // check the Account Details section - cy.get('legend').should('contain.text', 'Account Details'); - cy.get('table#accountsDetails').scrollIntoView(); - cy.wait(500); - cy.get('table#accountsDetails').find('tbody tr').should('have.length', 4); - cy.get('table#accountsDetails').within(() => { - cy.get('tr') - .eq(3) - .within(() => { - cy.wait(500); - cy.get('td').eq(0).should('contain.text', 'User type'); - cy.get('td').eq(1).should('contain.text', 'Account root user'); - }); - }); + // Check account details rendered in the resource overview card. + this.getResourcePage(username).should('be.visible').click(); + this.assertOverviewFieldValue('User type', 'Account root user'); + } + + private assertOverviewFieldValue(label: string, value: string) { + cy.contains('cd-resource-overview-card h3', 'User details').should('be.visible'); + cy.contains('cd-resource-overview-card .cd-overview-label', label) + .parent('.cd-overview-item') + .find('.cd-overview-value') + .should('contain.text', value); } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-user.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-user.ts index 573dd5bb5255..6219c76a6853 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-user.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-user.ts @@ -1,11 +1,11 @@ -interface Key { +export interface Key { access_key: string; active: boolean; secret_key: string; user: string; } -interface SwiftKey { +export interface SwiftKey { active: boolean; secret_key: string; user: string; @@ -21,7 +21,7 @@ interface Subuser { permissions: string; } -interface BucketQuota { +export interface BucketQuota { check_on_raw: boolean; enabled: boolean; max_objects: number; @@ -29,7 +29,7 @@ interface BucketQuota { max_size_kb: number; } -interface UserQuota { +export interface UserQuota { check_on_raw: boolean; enabled: boolean; max_objects: number; @@ -78,3 +78,20 @@ export interface RgwUser { user_id: string; user_quota: UserQuota; } + +export interface KeyRow { + id: number; + type: 'S3' | 'Swift'; + username: string; + ref: Key | SwiftKey; +} + +export type ExtendedRgwUser = RgwUser & { + account?: { id?: string; name?: string; tenant?: string }; + managed_user_policies?: string[]; +}; + +export const RGW_MAX_BUCKETS_MAP: Record = { + '-1': $localize`Disabled`, + '0': $localize`Unlimited` +}; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-rate-limit-details/rgw-rate-limit-details.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-rate-limit-details/rgw-rate-limit-details.component.html index 6a0e316e8198..8b13dce81f00 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-rate-limit-details/rgw-rate-limit-details.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-rate-limit-details/rgw-rate-limit-details.component.html @@ -1,4 +1,8 @@ -{{ type === 'user' ? 'User Rate Limit' : 'Bucket Rate Limit' }} +@if (showHeading) { + + {{ type === 'user' ? 'User Rate Limit' : 'Bucket Rate Limit' }} + +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-rate-limit-details/rgw-rate-limit-details.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-rate-limit-details/rgw-rate-limit-details.component.ts index 140d77fc1d6d..79ec01107ce3 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-rate-limit-details/rgw-rate-limit-details.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-rate-limit-details/rgw-rate-limit-details.component.ts @@ -9,5 +9,6 @@ import { RgwRateLimitConfig } from '../models/rgw-rate-limit'; }) export class RgwRateLimitDetailsComponent { @Input() rateLimitConfig: RgwRateLimitConfig; - @Input() type: string; + @Input() type!: string; + @Input() showHeading = true; } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.html deleted file mode 100644 index 9f1be3001b30..000000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.html +++ /dev/null @@ -1,317 +0,0 @@ - -
-
- Keys - - - -
- - Details -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @if (user.type === 'rgw' && selection.account?.id) { - - - - - } - - - - - - - - - - - - - -
- Tenant - {{ user.tenant }}
- User ID - {{ user.uid }}
- Username - {{ user.uid }}
- Full name - {{ user.display_name }}
- Email address - {{ user.email }}
- Suspended - {{ user.suspended | booleanText }}
- System user - {{ user.system | booleanText }}
- Maximum buckets - {{ user.max_buckets | map: maxBucketsMap }}
- Managed policies - {{ extractPolicyNamesFromArns(user.managed_user_policies) }}
- Subusers - -
- {{ subuser.id }} ({{ subuser.permissions }}) -
-
- Capabilities - -
{{ cap.type }} ({{ cap.perm }})
-
- MFAs(Id) - {{ user.mfa_ids | join }}
- - - Account Details - - - - - - - - - - - - - - - - - - - -
- Account ID - {{ selection.account?.id }}
- Name - {{ selection.account?.name }}
- Tenant - {{ selection.account?.tenant || '-' }}
- User type - - {{ user?.type === 'root' ? 'Account root user' : 'rgw user' }} -
-
- - -
- User quota - - - - - - - - - - - - - - - - - - - -
- Enabled - {{ user.user_quota.enabled | booleanText }}
- Maximum size - - - Unlimited - - {{ user.user_quota.max_size | dimlessBinary }} -
- Maximum objects - - - Unlimited - - {{ user.user_quota.max_objects }} -
-
- - -
- Bucket quota - - - - - - - - - - - - - - - - - - - -
- Enabled - {{ user.bucket_quota.enabled | booleanText }}
- Maximum size - - - Unlimited - - {{ user.bucket_quota.max_size | dimlessBinary }} -
- Maximum objects - - - Unlimited - - {{ user.bucket_quota.max_objects }} -
-
- -
- -
- - diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.scss deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.spec.ts deleted file mode 100644 index d26a67e42a5d..000000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.spec.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { HttpClientTestingModule } from '@angular/common/http/testing'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; - -import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'; - -import { SharedModule } from '~/app/shared/shared.module'; -import { configureTestBed } from '~/testing/unit-test-helper'; -import { RgwUserDetailsComponent } from './rgw-user-details.component'; -import { ModalService } from 'carbon-components-angular'; - -describe('RgwUserDetailsComponent', () => { - let component: RgwUserDetailsComponent; - let fixture: ComponentFixture; - let modalRef: any; - configureTestBed({ - declarations: [RgwUserDetailsComponent], - imports: [BrowserAnimationsModule, HttpClientTestingModule, SharedModule, NgbNavModule], - provider: [ModalService] - }); - - beforeEach(() => { - fixture = TestBed.createComponent(RgwUserDetailsComponent); - component = fixture.componentInstance; - component.selection = {}; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should show correct "System" info', () => { - component.selection = { uid: '', email: '', system: true, keys: [], swift_keys: [] }; - - component.ngOnChanges(); - fixture.detectChanges(); - - const detailsTab = fixture.debugElement.nativeElement.querySelectorAll( - '.cds--data-table--sort.cds--data-table--no-border tr td' - ); - expect(detailsTab[10].textContent.trim()).toEqual('System user'); - expect(detailsTab[11].textContent.trim()).toEqual('Yes'); - - component.selection.system = false; - component.ngOnChanges(); - fixture.detectChanges(); - - expect(detailsTab[11].textContent.trim()).toEqual('No'); - }); - - it('should show mfa ids only if length > 0', () => { - component.selection = { - uid: 'dashboard', - email: '', - system: 'true', - keys: [], - swift_keys: [], - mfa_ids: ['testMFA1', 'testMFA2'], - type: 'rgw', - account: { id: 'RGW12345678901234567' } - }; - - component.ngOnChanges(); - fixture.detectChanges(); - - const detailsTab = fixture.debugElement.nativeElement.querySelectorAll( - '.cds--data-table--sort.cds--data-table--no-border tr td' - ); - expect(detailsTab[16].textContent.trim()).toEqual('MFAs(Id)'); - expect(detailsTab[17].textContent.trim()).toEqual('testMFA1, testMFA2'); - }); - it('should test updateKeysSelection', () => { - component.selection = { - hasMultiSelection: false, - hasSelection: false, - hasSingleSelection: false, - _selected: [] - }; - component.updateKeysSelection(component.selection); - expect(component.keysSelection).toEqual(component.selection); - }); - it('should call showKeyModal when key selection is of type S3', () => { - component.keysSelection.first = () => { - return { type: 'S3', ref: { user: '', access_key: '', secret_key: '' } }; - }; - const modalShowSpy = spyOn(component['cdsModalService'], 'show').and.callFake(() => { - modalRef = { - setValues: jest.fn(), - setViewing: jest.fn() - }; - return modalRef; - }); - component.showKeyModal(); - expect(modalShowSpy).toHaveBeenCalled(); - // expect(s).toHaveBeenCalledWith( modalRef.componentInstance.setViewing); - }); - it('should call showKeyModal when key selection is of type Swift', () => { - component.keysSelection.first = () => { - return { type: 'Swift', ref: { user: '', access_key: '', secret_key: '' } }; - }; - const modalShowSpy = spyOn(component['cdsModalService'], 'show').and.callFake(() => { - modalRef = { - setValues: jest.fn(), - setViewing: jest.fn() - }; - return modalRef; - }); - component.showKeyModal(); - expect(modalShowSpy).toHaveBeenCalled(); - // expect(s).toHaveBeenCalledWith( modalRef.componentInstance.setViewing); - }); -}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.ts deleted file mode 100644 index 4932911ca614..000000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { Component, Input, OnChanges, OnInit, TemplateRef, ViewChild } from '@angular/core'; - -import _ from 'lodash'; - -import { RgwUserService } from '~/app/shared/api/rgw-user.service'; -import { Icons } from '~/app/shared/enum/icons.enum'; -import { CdTableColumn } from '~/app/shared/models/cd-table-column'; -import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; -import { RgwUserS3Key } from '../models/rgw-user-s3-key'; -import { RgwUserSwiftKey } from '../models/rgw-user-swift-key'; -import { RgwUserS3KeyModalComponent } from '../rgw-user-s3-key-modal/rgw-user-s3-key-modal.component'; -import { RgwUserSwiftKeyModalComponent } from '../rgw-user-swift-key-modal/rgw-user-swift-key-modal.component'; -import { CdTableAction } from '~/app/shared/models/cd-table-action'; -import { Permissions } from '~/app/shared/models/permissions'; -import { RgwRateLimitConfig } from '../models/rgw-rate-limit'; -import { ModalCdsService } from '~/app/shared/services/modal-cds.service'; -import { USER } from '~/app/shared/constants/app.constants'; - -@Component({ - selector: 'cd-rgw-user-details', - templateUrl: './rgw-user-details.component.html', - styleUrls: ['./rgw-user-details.component.scss'], - standalone: false -}) -export class RgwUserDetailsComponent implements OnChanges, OnInit { - @ViewChild('accessKeyTpl') - public accessKeyTpl: TemplateRef; - @ViewChild('secretKeyTpl') - public secretKeyTpl: TemplateRef; - - @Input() - selection: any; - - // Details tab - user: any; - maxBucketsMap: {}; - - // Keys tab - keys: any = []; - keysColumns: CdTableColumn[] = []; - keysSelection: CdTableSelection = new CdTableSelection(); - tableAction: CdTableAction[] = []; - permissions: Permissions; - - icons = Icons; - - constructor( - private rgwUserService: RgwUserService, - private cdsModalService: ModalCdsService - ) {} - - ngOnInit() { - this.keysColumns = [ - { - name: $localize`Username`, - prop: 'username', - flexGrow: 1 - }, - { - name: $localize`Type`, - prop: 'type', - flexGrow: 1 - } - ]; - this.maxBucketsMap = { - '-1': $localize`Disabled`, - 0: $localize`Unlimited` - }; - } - - ngOnChanges() { - this.tableAction = [ - { - name: $localize`Show`, - permission: 'read', - click: () => this.showKeyModal(), - icon: Icons.show - } - ]; - - if (this.selection) { - this.user = this.selection; - - // Sort subusers and capabilities. - this.user.subusers = _.sortBy(this.user.subusers, 'id'); - this.user.caps = _.sortBy(this.user.caps, 'type'); - - // Load the user/bucket quota of the selected user. - this.rgwUserService.getQuota(this.user.uid).subscribe((resp: object) => { - _.extend(this.user, resp); - }); - - // Load the user rate limit of the selected user. - this.rgwUserService.getUserRateLimit(this.user.uid).subscribe((resp: RgwRateLimitConfig) => { - _.extend(this.user, resp); - }); - - // Process the keys. - this.keys = []; - if (this.user.keys) { - this.user.keys.forEach((key: RgwUserS3Key) => { - this.keys.push({ - id: this.keys.length + 1, // Create an unique identifier - type: 'S3', - username: key.user, - ref: key - }); - }); - } - if (this.user.swift_keys) { - this.user.swift_keys.forEach((key: RgwUserSwiftKey) => { - this.keys.push({ - id: this.keys.length + 1, // Create an unique identifier - type: 'Swift', - username: key.user, - ref: key - }); - }); - } - - this.keys = _.sortBy(this.keys, USER); - } - } - - updateKeysSelection(selection: CdTableSelection) { - this.keysSelection = selection; - } - - showKeyModal() { - const key = this.keysSelection.first(); - const modalRef = this.cdsModalService.show( - key.type === 'S3' ? RgwUserS3KeyModalComponent : RgwUserSwiftKeyModalComponent - ); - switch (key.type) { - case 'S3': - modalRef.setViewing(); - modalRef.setValues(key.ref.user, key.ref.access_key, key.ref.secret_key); - break; - case 'Swift': - modalRef.setValues(key.ref.user, key.ref.secret_key); - break; - } - } - - extractPolicyNamesFromArns(arnList: string[]) { - if (!arnList || arnList.length === 0) { - return '-'; - } - return arnList - .map((arn) => arn.trim().split('/').pop()) - .filter(Boolean) - .join(', '); - } -} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.html index 87abe6acce65..172a91c4133b 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.html @@ -7,8 +7,6 @@ [columns]="columns" columnMode="flex" selectionType="single" - [hasDetails]="true" - (setExpandedRow)="setExpandedRow($event)" (updateSelection)="updateSelection($event)" identifier="uid" (fetchData)="getUserList($event)" @@ -21,11 +19,6 @@ [tableActions]="tableActions" > - - -
- {{ row.uid }} +
+ + {{ row.uid }} + @if (row.type === 'root') { = new Subject(); declare staleTimeout: number; icons = Icons; + viewUrl = '/rgw/user'; constructor( private authStorageService: AuthStorageService, @@ -65,6 +66,8 @@ export class RgwUserListComponent extends ListWithDetails implements OnInit { ngOnInit() { this.permission = this.authStorageService.getPermissions().rgw; + this.userAccounts = []; + this.tableActions = []; this.columns = [ { name: $localize`Username`, @@ -104,10 +107,7 @@ export class RgwUserListComponent extends ListWithDetails implements OnInit { prop: 'max_buckets', flexGrow: 1, cellTransformation: CellTemplate.map, - customTemplateConfig: { - '-1': $localize`Disabled`, - 0: $localize`Unlimited` - } + customTemplateConfig: RGW_MAX_BUCKETS_MAP }, { name: $localize`Capacity Limit %`, @@ -177,7 +177,9 @@ export class RgwUserListComponent extends ListWithDetails implements OnInit { mapUsersWithAccount(users: RgwUser[]): RgwUser[] { return users.map((user: RgwUser) => { - const account: Account = this.userAccounts.find((acc: Account) => acc.id === user.account_id); + const account: Account | undefined = this.userAccounts.find( + (acc: Account) => acc.id === user.account_id + ); return { account: account ? account : { name: '' }, // adding {name: ''} for sorting account name in user list to work ...user diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-resource-page/rgw-user-details.resolver.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-resource-page/rgw-user-details.resolver.ts new file mode 100644 index 000000000000..8a94739ec274 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-resource-page/rgw-user-details.resolver.ts @@ -0,0 +1,71 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Resolve } from '@angular/router'; +import { Observable, of } from 'rxjs'; +import { catchError, map, switchMap } from 'rxjs/operators'; + +import _ from 'lodash'; +import { ExtendedRgwUser, RgwUser } from '~/app/ceph/rgw/models/rgw-user'; + +import { RgwUserAccountsService } from '~/app/shared/api/rgw-user-accounts.service'; +import { RgwUserService } from '~/app/shared/api/rgw-user.service'; +import { Account } from '../models/rgw-user-accounts'; + +@Injectable({ + providedIn: 'root' +}) +export class RgwUserDetailsResolver implements Resolve { + constructor( + private rgwUserService: RgwUserService, + private rgwUserAccountsService: RgwUserAccountsService + ) {} + + resolve(route: ActivatedRouteSnapshot): Observable { + const uid = route.paramMap.get('uid') ?? ''; + if (!uid) { + return of(null); + } + + return this.rgwUserService.get(uid).pipe( + switchMap((user: RgwUser) => + this.rgwUserService.getQuota(uid).pipe( + catchError(() => of({})), + map((quotaResp: Partial) => ({ user, quotaResp })) + ) + ), + switchMap(({ user, quotaResp }) => + this.rgwUserService.getUserRateLimit(uid).pipe( + catchError(() => of({})), + map((rateLimitResp: Record) => ({ + user, + quotaResp, + rateLimitResp + })) + ) + ), + switchMap(({ user, quotaResp, rateLimitResp }) => { + if (!user?.account_id) { + return of({ + ...user, + ...quotaResp, + ...rateLimitResp, + subusers: _.sortBy(user?.subusers, 'id'), + caps: _.sortBy(user?.caps, 'type') + }); + } + + return this.rgwUserAccountsService.get(user.account_id).pipe( + catchError(() => of(null)), + map((account: Account | null) => ({ + ...user, + ...quotaResp, + ...rateLimitResp, + account: account ?? undefined, + subusers: _.sortBy(user?.subusers, 'id'), + caps: _.sortBy(user?.caps, 'type') + })) + ); + }), + catchError(() => of(null)) + ); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-resource-page/rgw-user-resource-breadcrumb.resolver.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-resource-page/rgw-user-resource-breadcrumb.resolver.ts new file mode 100644 index 000000000000..4579b8415a03 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-resource-page/rgw-user-resource-breadcrumb.resolver.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot } from '@angular/router'; + +import { BreadcrumbsResolver, IBreadcrumb } from '~/app/shared/models/breadcrumbs'; + +@Injectable({ + providedIn: 'root' +}) +export class RgwUserResourceBreadcrumbResolver extends BreadcrumbsResolver { + resolve(route: ActivatedRouteSnapshot): IBreadcrumb[] { + const uid = route.params?.uid || route.parent?.params?.uid || ''; + const section = + route.firstChild?.url?.[0]?.path || + route.params?.section || + route.queryParams?.section || + 'overview'; + const sectionLabel = section + .split('-') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); + return [ + { text: uid, path: `/rgw/user/${uid}/overview` }, + { text: sectionLabel, path: this.getFullPath(route) } + ]; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-resource-page/rgw-user-resource-page.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-resource-page/rgw-user-resource-page.component.html new file mode 100644 index 000000000000..2ba3d7a5f0c6 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-resource-page/rgw-user-resource-page.component.html @@ -0,0 +1,115 @@ +@if (user) { + @switch (section) { + @case ('overview') { + + + + @if (keys.length) { +
+

+ Keys +

+

+ View the user key details, including access and secret keys. +

+ + +
+ } + + @if (user.user_quota) { +
+

+ User Quota +

+

+ View the user quota details, including maximum object and size. +

+ +
+ } + + @if (user.bucket_quota) { +
+

+ Bucket Quota +

+

+ View the bucket quota details, including maximum object and size. +

+ +
+ } + + @if (user.user_ratelimit) { +

+ User Rate Limit +

+

+ View the user rate limit details. +

+ + + } + } + } +} @else if (notFound) { + No user found. +} + + + + Show + + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-resource-page/rgw-user-resource-page.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-resource-page/rgw-user-resource-page.component.scss new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-resource-page/rgw-user-resource-page.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-resource-page/rgw-user-resource-page.component.spec.ts new file mode 100644 index 000000000000..cfa3dffe1485 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-resource-page/rgw-user-resource-page.component.spec.ts @@ -0,0 +1,209 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { of } from 'rxjs'; + +import { RgwUserResourcePageComponent } from './rgw-user-resource-page.component'; +import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe'; +import { ModalCdsService } from '~/app/shared/services/modal-cds.service'; +import { RgwUserS3KeyModalComponent } from '../rgw-user-s3-key-modal/rgw-user-s3-key-modal.component'; +import { RgwUserSwiftKeyModalComponent } from '../rgw-user-swift-key-modal/rgw-user-swift-key-modal.component'; +import { RgwUser } from '~/app/ceph/rgw/models/rgw-user'; + +describe('RgwUserResourcePageComponent', () => { + let component: RgwUserResourcePageComponent; + let fixture: ComponentFixture; + let modalCdsServiceMock: any; + + const mockUser: Partial & { account?: any; managed_user_policies?: any } = { + uid: 'test-user', + tenant: 'test-tenant', + display_name: 'Test User', + email: 'test@example.com', + suspended: 0, + system: false, + max_buckets: 0, + caps: [{ type: 'users', perm: '*' }], + subusers: [{ id: 'sub1', permissions: 'read' }], + mfa_ids: ['mfa1', 'mfa2'], + managed_user_policies: ['arn:aws:iam::123:policy/Pol1'], + user_quota: { + enabled: true, + max_size: 1024, + max_objects: 100, + max_size_kb: 1, + check_on_raw: false + }, + bucket_quota: { + enabled: false, + max_size: -1, + max_objects: -1, + max_size_kb: -1, + check_on_raw: false + }, + stats: { + size_actual: 512, + num_objects: 25, + size: 0, + size_utilized: 0, + size_kb: 0, + size_kb_actual: 0, + size_kb_utilized: 0 + }, + keys: [{ user: 'test-user', access_key: 'A1', secret_key: 'S1', active: true }], + swift_keys: [{ user: 'test-user:swift', secret_key: 'S2', active: true }], + account: { id: 'acc1', name: 'acc-name', tenant: 'acc-tenant' }, + type: 'rgw' + }; + + const activatedRouteMock = { + snapshot: { data: { section: 'overview' } }, + parent: { + data: of({ user: mockUser }) + } + }; + + class MockDimlessBinaryPipe { + transform(value: any): string { + return `${value} B`; + } + } + + beforeEach(async () => { + // Create a Jest mock object instead of a Jasmine SpyObj + modalCdsServiceMock = { + show: jest.fn() + }; + + await TestBed.configureTestingModule({ + declarations: [RgwUserResourcePageComponent], + providers: [ + { provide: ActivatedRoute, useValue: activatedRouteMock }, + { provide: ModalCdsService, useValue: modalCdsServiceMock }, + { provide: DimlessBinaryPipe, useClass: MockDimlessBinaryPipe } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(RgwUserResourcePageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should set section from route snapshot on init', () => { + expect(component.section).toBe('overview'); + }); + + it('should set notFound to true if user is null', () => { + component['applyUser'](null); + expect(component.notFound).toBe(true); + expect(component.user).toBeUndefined(); + expect(component.overviewFields).toEqual([]); + expect(component.keys).toEqual([]); + }); + + it('should populate overviewFields correctly based on user data', () => { + expect(component.overviewFields).toBeDefined(); + expect(component.overviewFields.length).toBeGreaterThan(0); + + const mfaField = component.overviewFields.find((f) => f.label === 'MFAs (Id)'); + expect(mfaField?.value).toBe('mfa1, mfa2'); + + const subuserField = component.overviewFields.find((f) => f.label === 'Subusers'); + expect(subuserField?.value).toBe('sub1 (read)'); + + const capsField = component.overviewFields.find((f) => f.label === 'Capabilities'); + expect(capsField?.value).toBe('users (*)'); + + const maxBucketsField = component.overviewFields.find((f) => f.label === 'Max buckets'); + expect(maxBucketsField?.value).toBe('Unlimited'); + }); + + it('should calculate quota usage text correctly', () => { + const sizeLimitField = component.overviewFields.find((f) => f.label === 'Capacity limit'); + expect(sizeLimitField?.value).toBe('50.0%'); + + const objectLimitField = component.overviewFields.find((f) => f.label === 'Object limit'); + expect(objectLimitField?.value).toBe('25.0%'); + }); + + it('should return null for quota usage if disabled', () => { + const quotaText = component['getQuotaUsageText'].call( + { user: { user_quota: mockUser.bucket_quota, stats: mockUser.stats } }, + 'size' + ); + expect(quotaText).toBeNull(); + }); + + it('should build user and bucket quota display values properly', () => { + expect(component.userQuota['Enabled']).toBe('Yes'); + expect(component.userQuota['Maximum size']).toBe('1024 B'); + expect(component.userQuota['Maximum objects']).toBe(100); + + expect(component.bucketQuota['Enabled']).toBe('No'); + expect(component.bucketQuota['Maximum size']).toBe('-'); + expect(component.bucketQuota['Maximum objects']).toBe('-'); + }); + + it('should process S3 and Swift keys correctly', () => { + expect(component.keys.length).toBe(2); + + expect(component.keys[0].type).toBe('S3'); + expect(component.keys[0].username).toBe('test-user'); + + expect(component.keys[1].type).toBe('Swift'); + expect(component.keys[1].username).toBe('test-user:swift'); + }); + + it('should show Key Modal for S3 keys', () => { + const s3KeyRow: any = { type: 'S3', ref: { user: 'u1', access_key: 'a1', secret_key: 's1' } }; + + // Create Jest mock functions for the modal reference + const modalRefMock = { + setViewing: jest.fn(), + setValues: jest.fn() + }; + modalCdsServiceMock.show.mockReturnValue(modalRefMock); + + component.showKeyModal(s3KeyRow); + + expect(modalCdsServiceMock.show).toHaveBeenCalledWith(RgwUserS3KeyModalComponent); + expect(modalRefMock.setViewing).toHaveBeenCalled(); + expect(modalRefMock.setValues).toHaveBeenCalledWith('u1', 'a1', 's1'); + }); + + it('should show Key Modal for Swift keys', () => { + const swiftKeyRow: any = { type: 'Swift', ref: { user: 'u2', secret_key: 's2' } }; + + // Create Jest mock functions for the modal reference + const modalRefMock = { + setViewing: jest.fn(), + setValues: jest.fn() + }; + modalCdsServiceMock.show.mockReturnValue(modalRefMock); + + component.showKeyModal(swiftKeyRow); + + expect(modalCdsServiceMock.show).toHaveBeenCalledWith(RgwUserSwiftKeyModalComponent); + expect(modalRefMock.setViewing).not.toHaveBeenCalled(); + expect(modalRefMock.setValues).toHaveBeenCalledWith('u2', 's2'); + }); + + it('should not throw error if showKeyModal is called without a key', () => { + expect(() => component.showKeyModal(undefined as any)).not.toThrow(); + expect(modalCdsServiceMock.show).not.toHaveBeenCalled(); + }); + + it('should unsubscribe on destroy', () => { + // Use jest.spyOn instead of spyOn + const subSpy = jest.spyOn(component['sub'], 'unsubscribe'); + component.ngOnDestroy(); + expect(subSpy).toHaveBeenCalled(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-resource-page/rgw-user-resource-page.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-resource-page/rgw-user-resource-page.component.ts new file mode 100644 index 000000000000..22c40a0faf36 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-resource-page/rgw-user-resource-page.component.ts @@ -0,0 +1,289 @@ +import { + Component, + OnDestroy, + OnInit, + TemplateRef, + ViewChild, + ViewEncapsulation +} from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { Subscription } from 'rxjs'; + +import _ from 'lodash'; +import { + KeyRow, + ExtendedRgwUser, + UserQuota, + BucketQuota, + Key, + SwiftKey, + RGW_MAX_BUCKETS_MAP +} from '~/app/ceph/rgw/models/rgw-user'; +import { RgwUserS3KeyModalComponent } from '../rgw-user-s3-key-modal/rgw-user-s3-key-modal.component'; +import { RgwUserSwiftKeyModalComponent } from '../rgw-user-swift-key-modal/rgw-user-swift-key-modal.component'; +import { USER } from '~/app/shared/constants/app.constants'; +import { CdTableColumn } from '~/app/shared/models/cd-table-column'; +import { OverviewField } from '~/app/shared/components/resource-overview-card/resource-overview-card.component'; +import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe'; +import { ModalCdsService } from '~/app/shared/services/modal-cds.service'; +@Component({ + selector: 'cd-rgw-user-resource-page', + templateUrl: './rgw-user-resource-page.component.html', + styleUrls: ['./rgw-user-resource-page.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false +}) +export class RgwUserResourcePageComponent implements OnInit, OnDestroy { + private sub = new Subscription(); + + @ViewChild('viewKeyTpl', { static: true }) + declare viewKeyTpl: TemplateRef; + + section = 'overview'; + user?: ExtendedRgwUser; + selection?: ExtendedRgwUser; + notFound = false; + keys: KeyRow[] = []; + keysColumns: CdTableColumn[] = []; + userQuota: Record = {}; + bucketQuota: Record = {}; + overviewFields: OverviewField[] = []; + + constructor( + private route: ActivatedRoute, + private cdsModalService: ModalCdsService, + private dimlessBinary: DimlessBinaryPipe + ) {} + + ngOnInit(): void { + this.section = this.route.snapshot.data['section'] ?? 'overview'; + this.keysColumns = [ + { + name: $localize`Username`, + prop: 'username', + flexGrow: 1 + }, + { + name: $localize`Type`, + prop: 'type', + flexGrow: 1 + }, + { + name: $localize`View`, + prop: 'view', + flexGrow: 1, + cellTemplate: this.viewKeyTpl + } + ]; + + this.sub.add( + this.route.parent?.data.subscribe((data) => { + this.applyUser(data?.user ?? null); + }) + ); + } + + ngOnDestroy(): void { + this.sub.unsubscribe(); + } + + private applyUser(user: ExtendedRgwUser | null): void { + this.notFound = !user; + + if (!user) { + this.user = undefined; + this.selection = undefined; + this.overviewFields = []; + this.userQuota = {}; + this.bucketQuota = {}; + this.keys = []; + return; + } + + this.user = user; + this.selection = user; + this.overviewFields = this.buildOverviewFields(this.user, this.selection); + this.userQuota = this.createDisplayValues(this.user?.user_quota); + this.bucketQuota = this.createDisplayValues(this.user?.bucket_quota); + this.processKeys(); + } + + private buildOverviewFields(user: ExtendedRgwUser, selection: ExtendedRgwUser): OverviewField[] { + const fields: OverviewField[] = [ + { + label: $localize`Username`, + value: user?.uid + }, + { + label: $localize`Tenant`, + value: user?.tenant + }, + { + label: $localize`Account name`, + value: selection?.account?.name + }, + { + label: $localize`Full name`, + value: user?.display_name + }, + { + label: $localize`Email`, + value: user?.email + }, + { + label: $localize`Suspended`, + value: user?.suspended ? $localize`Yes` : $localize`No` + }, + { + label: $localize`System user`, + value: user?.system ? $localize`Yes` : $localize`No` + }, + { + label: $localize`Max buckets`, + value: RGW_MAX_BUCKETS_MAP[`${user.max_buckets}`] || `${user.max_buckets}` + }, + { + label: $localize`Capacity limit`, + value: this.getQuotaUsageText('size'), + emptyText: $localize`No Limit` + }, + { + label: $localize`Object limit`, + value: this.getQuotaUsageText('object'), + emptyText: $localize`No Limit` + }, + { + label: $localize`Managed policies`, + value: user?.managed_user_policies + ?.map((arn) => arn?.trim()?.split('/').pop()) + .filter(Boolean) + .join(', ') + }, + { + label: $localize`Subusers`, + value: user?.subusers?.map((subuser) => `${subuser.id} (${subuser.permissions})`).join(', ') + }, + { + label: $localize`Capabilities`, + value: user?.caps?.map((cap) => `${cap.type} (${cap.perm})`).join(', ') + }, + { + label: $localize`MFAs (Id)`, + value: user?.mfa_ids?.join(', ') + } + ]; + + if (selection?.account?.id) { + fields.push( + ...[ + { + label: $localize`Account ID`, + value: selection?.account?.id + }, + { + label: $localize`Name`, + value: selection?.account?.name + }, + { + label: $localize`Tenant`, + value: selection?.account?.tenant + }, + { + label: $localize`User type`, + value: user?.type === 'root' ? $localize`Account root user` : $localize`rgw user` + } + ] + ); + } + + return fields; + } + + private getQuotaUsageText(kind: 'size' | 'object'): string | null { + const quota = this.user?.user_quota; + const stats = this.user?.stats; + + if (!quota?.enabled) return null; + + if (kind === 'size' && quota.max_size > 0) { + const used = Number(stats?.size_actual ?? 0); + return `${((used / quota.max_size) * 100).toFixed(1)}%`; + } + + if (kind === 'object' && quota.max_objects > 0) { + const used = Number(stats?.num_objects ?? 0); + return `${((used / quota.max_objects) * 100).toFixed(1)}%`; + } + + return null; + } + + private createDisplayValues(quota?: UserQuota | BucketQuota): Record { + if (!quota) { + return {}; + } + + return { + [$localize`Enabled`]: quota.enabled ? $localize`Yes` : $localize`No`, + [$localize`Maximum size`]: quota.enabled + ? quota.max_size <= -1 + ? $localize`Unlimited` + : this.dimlessBinary.transform(quota.max_size) + : '-', + [$localize`Maximum objects`]: quota.enabled + ? quota.max_objects <= -1 + ? $localize`Unlimited` + : quota.max_objects + : '-' + }; + } + + private processKeys(): void { + this.keys = []; + if (this.user?.keys) { + this.user.keys.forEach((key: Key) => { + this.keys.push({ + id: this.keys.length + 1, + type: 'S3', + username: key.user, + ref: key + }); + }); + } + + if (this.user?.swift_keys) { + this.user.swift_keys.forEach((key: SwiftKey) => { + this.keys.push({ + id: this.keys.length + 1, + type: 'Swift', + username: key.user, + ref: key + }); + }); + } + + this.keys = _.sortBy(this.keys, USER); + } + + showKeyModal(key: KeyRow): void { + if (!key) { + return; + } + + const modalRef = this.cdsModalService.show( + key.type === 'S3' ? RgwUserS3KeyModalComponent : RgwUserSwiftKeyModalComponent + ); + + switch (key.type) { + case 'S3': + const s3Ref = key.ref as Key; + modalRef.setViewing(); + modalRef.setValues(s3Ref.user, s3Ref.access_key, s3Ref.secret_key); + break; + case 'Swift': + const swiftRef = key.ref as SwiftKey; + modalRef.setValues(swiftRef.user, swiftRef.secret_key); + break; + } + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-resource-sidebar/rgw-user-resource-sidebar.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-resource-sidebar/rgw-user-resource-sidebar.component.html new file mode 100644 index 000000000000..e1eae038b510 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-resource-sidebar/rgw-user-resource-sidebar.component.html @@ -0,0 +1,7 @@ + + + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-resource-sidebar/rgw-user-resource-sidebar.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-resource-sidebar/rgw-user-resource-sidebar.component.scss new file mode 100644 index 000000000000..3596fc201a4d --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-resource-sidebar/rgw-user-resource-sidebar.component.scss @@ -0,0 +1,8 @@ +.rgw-user-details-layout .sidebar-layout-container { + min-height: auto; + padding-right: 0; +} + +.rgw-user-details-layout .sidebar-layout-main { + padding: var(--cds-spacing-05); +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-resource-sidebar/rgw-user-resource-sidebar.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-resource-sidebar/rgw-user-resource-sidebar.component.spec.ts new file mode 100644 index 000000000000..ef1a15c17147 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-resource-sidebar/rgw-user-resource-sidebar.component.spec.ts @@ -0,0 +1,44 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { of } from 'rxjs'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { RgwUserResourceSidebarComponent } from './rgw-user-resource-sidebar.component'; +import { RgwUser } from '../models/rgw-user'; + +describe('RgwUserResourceSidebarComponent', () => { + let component: RgwUserResourceSidebarComponent; + let fixture: ComponentFixture; + + const mockActivatedRoute = { + paramMap: of({ get: (key: string) => (key === 'uid' ? 'test-user-id' : null) }), + data: of({ user: { uid: 'test-user-id' } as RgwUser }) + }; + + configureTestBed({ + declarations: [RgwUserResourceSidebarComponent], + providers: [{ provide: ActivatedRoute, useValue: mockActivatedRoute }] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(RgwUserResourceSidebarComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should set uid and build sidebar items on init', () => { + expect(component.uid).toBe('test-user-id'); + expect(component.sidebarItems.length).toBe(1); + expect(component.sidebarItems[0].label).toBe('Overview'); + expect(component.sidebarItems[0].route).toEqual(['/rgw/user', 'test-user-id', 'overview']); + }); + + it('should set user from route data on init', () => { + expect(component.user).toBeDefined(); + expect(component.user?.uid).toBe('test-user-id'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-resource-sidebar/rgw-user-resource-sidebar.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-resource-sidebar/rgw-user-resource-sidebar.component.ts new file mode 100644 index 000000000000..25f5f5a617d5 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-resource-sidebar/rgw-user-resource-sidebar.component.ts @@ -0,0 +1,53 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { ActivatedRoute, ParamMap } from '@angular/router'; + +import { Subscription } from 'rxjs'; + +import { SidebarItem } from '~/app/shared/components/sidebar-layout/sidebar-layout.component'; +import { RgwUser } from '../models/rgw-user'; + +@Component({ + selector: 'cd-rgw-user-resource-sidebar', + templateUrl: './rgw-user-resource-sidebar.component.html', + styleUrls: ['./rgw-user-resource-sidebar.component.scss'], + standalone: false +}) +export class RgwUserResourceSidebarComponent implements OnInit, OnDestroy { + private sub = new Subscription(); + + uid = ''; + user: RgwUser; + sidebarItems: SidebarItem[] = []; + readonly basePath = '/rgw/user'; + + constructor(private route: ActivatedRoute) {} + + ngOnInit() { + this.sub.add( + this.route.paramMap.subscribe((pm: ParamMap) => { + this.uid = pm.get('uid') ?? ''; + this.buildSidebarItems(); + }) + ); + + this.sub.add( + this.route.data.subscribe((data) => { + this.user = data?.user ?? null; + }) + ); + } + + ngOnDestroy(): void { + this.sub.unsubscribe(); + } + + private buildSidebarItems(): void { + this.sidebarItems = [ + { + label: $localize`Overview`, + route: [this.basePath, this.uid, 'overview'], + routerLinkActiveOptions: { exact: true } + } + ]; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts index 7b74bb573806..835703ac6bb4 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts @@ -25,7 +25,10 @@ import { RgwConfigModalComponent } from './rgw-config-modal/rgw-config-modal.com import { RgwDaemonDetailsComponent } from './rgw-daemon-details/rgw-daemon-details.component'; import { RgwDaemonListComponent } from './rgw-daemon-list/rgw-daemon-list.component'; import { RgwUserCapabilityModalComponent } from './rgw-user-capability-modal/rgw-user-capability-modal.component'; -import { RgwUserDetailsComponent } from './rgw-user-details/rgw-user-details.component'; +import { RgwUserResourceSidebarComponent } from './rgw-user-resource-sidebar/rgw-user-resource-sidebar.component'; +import { RgwUserResourcePageComponent } from './rgw-user-resource-page/rgw-user-resource-page.component'; +import { RgwUserResourceBreadcrumbResolver } from './rgw-user-resource-page/rgw-user-resource-breadcrumb.resolver'; +import { RgwUserDetailsResolver } from './rgw-user-resource-page/rgw-user-details.resolver'; import { RgwUserFormComponent } from './rgw-user-form/rgw-user-form.component'; import { RgwUserListComponent } from './rgw-user-list/rgw-user-list.component'; import { RgwUserS3KeyModalComponent } from './rgw-user-s3-key-modal/rgw-user-s3-key-modal.component'; @@ -182,7 +185,8 @@ import { RgwAccountRoleFormComponent } from './rgw-account-role-form/rgw-account RgwBucketListComponent, RgwBucketDetailsComponent, RgwUserListComponent, - RgwUserDetailsComponent, + RgwUserResourceSidebarComponent, + RgwUserResourcePageComponent, RgwStorageClassListComponent ], declarations: [ @@ -193,7 +197,8 @@ import { RgwAccountRoleFormComponent } from './rgw-account-role-form/rgw-account RgwBucketListComponent, RgwBucketDetailsComponent, RgwUserListComponent, - RgwUserDetailsComponent, + RgwUserResourceSidebarComponent, + RgwUserResourcePageComponent, RgwUserFormComponent, RgwUserSwiftKeyModalComponent, RgwUserS3KeyModalComponent, @@ -288,6 +293,22 @@ const routes: Routes = [ path: `${URLVerbs.EDIT}/:uid`, component: RgwUserFormComponent, data: { breadcrumbs: ActionLabels.EDIT } + }, + { + path: ':uid', + component: RgwUserResourceSidebarComponent, + data: { breadcrumbs: RgwUserResourceBreadcrumbResolver }, + resolve: { + user: RgwUserDetailsResolver + }, + children: [ + { path: '', redirectTo: 'overview', pathMatch: 'full' }, + { + path: 'overview', + component: RgwUserResourcePageComponent, + data: { breadcrumbs: 'Overview', section: 'overview' } + } + ] } ] }, diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sidebar-layout/sidebar-layout.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sidebar-layout/sidebar-layout.component.scss index 744f95ac0953..ad8e5409a3ce 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sidebar-layout/sidebar-layout.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sidebar-layout/sidebar-layout.component.scss @@ -10,19 +10,24 @@ } .sidebar-layout-container { - min-height: calc(100vh - (vv.$navbar-height + layout.rem(55px))); padding-right: var(--cds-spacing-07); background-color: var(--cds-background); } .sidebar-layout-shell { - transform: translate(0); position: relative; - height: 100vh; + min-height: 100vh; } .sidebar-layout-nav { background-color: var(--cds-layer-03); + position: sticky !important; + top: 0; + height: calc(100vh - vv.$navbar-height); + width: layout.rem(272px); + float: left; + z-index: 10; + overflow-y: auto; .cds--side-nav__icon:not(.cds--side-nav__submenu-chevron) { display: none;