]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: migrated user table tabs to resource pages 69790/head
authorSyed Ali Ul Hasan <syedaliulhasan19@gmail.com>
Wed, 22 Jul 2026 14:22:20 +0000 (19:52 +0530)
committerSyed Ali Ul Hasan <syedaliulhasan19@gmail.com>
Mon, 27 Jul 2026 16:05:31 +0000 (21:35 +0530)
- Fixes: https://tracker.ceph.com/issues/77475

Signed-off-by: Syed Ali Ul Hasan <syedaliulhasan19@gmail.com>
25 files changed:
src/pybind/mgr/dashboard/frontend/cypress/e2e/cluster/logs.po.ts
src/pybind/mgr/dashboard/frontend/cypress/e2e/page-helper.po.ts
src/pybind/mgr/dashboard/frontend/cypress/e2e/rgw/users.po.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-user.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-rate-limit-details/rgw-rate-limit-details.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-rate-limit-details/rgw-rate-limit-details.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.html [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.scss [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.spec.ts [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.ts [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.scss
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-list/rgw-user-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-resource-page/rgw-user-details.resolver.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-resource-page/rgw-user-resource-breadcrumb.resolver.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-resource-page/rgw-user-resource-page.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-resource-page/rgw-user-resource-page.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-resource-page/rgw-user-resource-page.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-resource-page/rgw-user-resource-page.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-resource-sidebar/rgw-user-resource-sidebar.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-resource-sidebar/rgw-user-resource-sidebar.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-resource-sidebar/rgw-user-resource-sidebar.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-resource-sidebar/rgw-user-resource-sidebar.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/sidebar-layout/sidebar-layout.component.scss

index 6de2a0627fc744eadab271274b59ad5e261410c7..bf20098fa18375a511872311d0c66f199cf9bd46 100644 (file)
@@ -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')
index 4a26f780ee0a81c43d99c27e42278032059cb1ff..729e5014a05326fd84d24eb926c737fdbfe05be2 100644 (file)
@@ -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();
   }
 
   /**
index e31dee68f9a1e960d100ec61433e373406a2394b..16a79f8ec97ea9a585fe8d2881773aa500279e5d 100644 (file)
@@ -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);
   }
 }
index 573dd5bb52553d9e0a8bbfdb1e19de744a8a69c4..6219c76a68533661f1527bbd8fb26f487d81c0ab 100644 (file)
@@ -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<string, string> = {
+  '-1': $localize`Disabled`,
+  '0': $localize`Unlimited`
+};
index 6a0e316e8198124f9a79cd9151d19a33855e71d1..8b13dce81f005d842e230a4f81dbdcd293847aab 100644 (file)
@@ -1,4 +1,8 @@
-<legend i18n>{{ type === 'user' ? 'User Rate Limit' : 'Bucket Rate Limit' }}</legend>
+@if (showHeading) {
+  <legend i18n>
+    {{ type === 'user' ? 'User Rate Limit' : 'Bucket Rate Limit' }}
+  </legend>
+}
 <table class="cds--data-table--sort cds--data-table--no-border cds--data-table cds--data-table--md">
   <tbody>
     <tr>
index 140d77fc1d6d93fe7fcf45b9d3262e08e17e288e..79ec01107ce3cee38e0b55d44aa4fb75f647fdbd 100644 (file)
@@ -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 (file)
index 9f1be30..0000000
+++ /dev/null
@@ -1,317 +0,0 @@
-<ng-container *ngIf="selection">
-  <div *ngIf="user">
-    <div *ngIf="keys.length">
-      <legend i18n>Keys</legend>
-      <cd-table
-        [data]="keys"
-        [columns]="keysColumns"
-        columnMode="flex"
-        selectionType="single"
-        forceIdentifier="true"
-        (updateSelection)="updateKeysSelection($event)"
-      >
-        <cd-table-actions
-          class="table-actions"
-          [permission]="{ read: true }"
-          [selection]="selection"
-          [tableActions]="tableAction"
-        ></cd-table-actions>
-      </cd-table>
-    </div>
-
-    <legend i18n>Details</legend>
-    <table
-      class="cds--data-table--sort cds--data-table--no-border cds--data-table cds--data-table--md"
-    >
-      <tbody>
-        <tr>
-          <td
-            i18n
-            class="bold w-25"
-          >
-            Tenant
-          </td>
-          <td class="w-75">{{ user.tenant }}</td>
-        </tr>
-        <tr>
-          <td
-            i18n
-            class="bold w-25"
-          >
-            User ID
-          </td>
-          <td class="w-75">{{ user.uid }}</td>
-        </tr>
-        <tr>
-          <td
-            i18n
-            class="bold w-25"
-          >
-            Username
-          </td>
-          <td class="w-75">{{ user.uid }}</td>
-        </tr>
-        <tr>
-          <td
-            i18n
-            class="bold"
-          >
-            Full name
-          </td>
-          <td>{{ user.display_name }}</td>
-        </tr>
-        <tr *ngIf="user.email?.length">
-          <td
-            i18n
-            class="bold"
-          >
-            Email address
-          </td>
-          <td>{{ user.email }}</td>
-        </tr>
-        <tr>
-          <td
-            i18n
-            class="bold"
-          >
-            Suspended
-          </td>
-          <td>{{ user.suspended | booleanText }}</td>
-        </tr>
-        <tr>
-          <td
-            i18n
-            class="bold"
-          >
-            System user
-          </td>
-          <td>{{ user.system | booleanText }}</td>
-        </tr>
-        <tr>
-          <td
-            i18n
-            class="bold"
-          >
-            Maximum buckets
-          </td>
-          <td>{{ user.max_buckets | map: maxBucketsMap }}</td>
-        </tr>
-        @if (user.type === 'rgw' && selection.account?.id) {
-          <tr>
-            <td
-              i18n
-              class="bold"
-            >
-              Managed policies
-            </td>
-            <td i18n>{{ extractPolicyNamesFromArns(user.managed_user_policies) }}</td>
-          </tr>
-        }
-        <tr *ngIf="user.subusers && user.subusers.length">
-          <td
-            i18n
-            class="bold"
-          >
-            Subusers
-          </td>
-          <td>
-            <div *ngFor="let subuser of user.subusers">
-              {{ subuser.id }} ({{ subuser.permissions }})
-            </div>
-          </td>
-        </tr>
-        <tr *ngIf="user.caps && user.caps.length">
-          <td
-            i18n
-            class="bold"
-          >
-            Capabilities
-          </td>
-          <td>
-            <div *ngFor="let cap of user.caps">{{ cap.type }} ({{ cap.perm }})</div>
-          </td>
-        </tr>
-        <tr *ngIf="user.mfa_ids?.length">
-          <td
-            i18n
-            class="bold"
-          >
-            MFAs(Id)
-          </td>
-          <td>{{ user.mfa_ids | join }}</td>
-        </tr>
-      </tbody>
-    </table>
-
-    <ng-container *ngIf="selection.account && selection.account?.id">
-      <legend i18n>Account Details</legend>
-      <table
-        class="cds--data-table--sort cds--data-table--no-border cds--data-table cds--data-table--md"
-        id="accountsDetails"
-      >
-        <tbody>
-          <tr>
-            <td
-              i18n
-              class="bold w-25"
-            >
-              Account ID
-            </td>
-            <td class="w-75">{{ selection.account?.id }}</td>
-          </tr>
-          <tr>
-            <td
-              i18n
-              class="bold w-25"
-            >
-              Name
-            </td>
-            <td class="w-75">{{ selection.account?.name }}</td>
-          </tr>
-          <tr>
-            <td
-              i18n
-              class="bold w-25"
-            >
-              Tenant
-            </td>
-            <td class="w-75">{{ selection.account?.tenant || '-' }}</td>
-          </tr>
-          <tr>
-            <td
-              i18n
-              class="bold w-25"
-            >
-              User type
-            </td>
-            <td
-              class="w-75"
-              i18n
-            >
-              {{ user?.type === 'root' ? 'Account root user' : 'rgw user' }}
-            </td>
-          </tr>
-        </tbody>
-      </table>
-    </ng-container>
-
-    <!-- User quota -->
-    <div *ngIf="user.user_quota">
-      <legend i18n>User quota</legend>
-      <table
-        class="cds--data-table--sort cds--data-table--no-border cds--data-table cds--data-table--md"
-      >
-        <tbody>
-          <tr>
-            <td
-              i18n
-              class="bold w-25"
-            >
-              Enabled
-            </td>
-            <td class="w-75">{{ user.user_quota.enabled | booleanText }}</td>
-          </tr>
-          <tr>
-            <td
-              i18n
-              class="bold"
-            >
-              Maximum size
-            </td>
-            <td *ngIf="!user.user_quota.enabled">-</td>
-            <td
-              *ngIf="user.user_quota.enabled && user.user_quota.max_size <= -1"
-              i18n
-            >
-              Unlimited
-            </td>
-            <td *ngIf="user.user_quota.enabled && user.user_quota.max_size > -1">
-              {{ user.user_quota.max_size | dimlessBinary }}
-            </td>
-          </tr>
-          <tr>
-            <td
-              i18n
-              class="bold"
-            >
-              Maximum objects
-            </td>
-            <td *ngIf="!user.user_quota.enabled">-</td>
-            <td
-              *ngIf="user.user_quota.enabled && user.user_quota.max_objects <= -1"
-              i18n
-            >
-              Unlimited
-            </td>
-            <td *ngIf="user.user_quota.enabled && user.user_quota.max_objects > -1">
-              {{ user.user_quota.max_objects }}
-            </td>
-          </tr>
-        </tbody>
-      </table>
-    </div>
-
-    <!-- Bucket quota -->
-    <div *ngIf="user.bucket_quota">
-      <legend i18n>Bucket quota</legend>
-      <table
-        class="cds--data-table--sort cds--data-table--no-border cds--data-table cds--data-table--md"
-      >
-        <tbody>
-          <tr>
-            <td
-              i18n
-              class="bold w-25"
-            >
-              Enabled
-            </td>
-            <td class="w-75">{{ user.bucket_quota.enabled | booleanText }}</td>
-          </tr>
-          <tr>
-            <td
-              i18n
-              class="bold"
-            >
-              Maximum size
-            </td>
-            <td *ngIf="!user.bucket_quota.enabled">-</td>
-            <td
-              *ngIf="user.bucket_quota.enabled && user.bucket_quota.max_size <= -1"
-              i18n
-            >
-              Unlimited
-            </td>
-            <td *ngIf="user.bucket_quota.enabled && user.bucket_quota.max_size > -1">
-              {{ user.bucket_quota.max_size | dimlessBinary }}
-            </td>
-          </tr>
-          <tr>
-            <td
-              i18n
-              class="bold"
-            >
-              Maximum objects
-            </td>
-            <td *ngIf="!user.bucket_quota.enabled">-</td>
-            <td
-              *ngIf="user.bucket_quota.enabled && user.bucket_quota.max_objects <= -1"
-              i18n
-            >
-              Unlimited
-            </td>
-            <td *ngIf="user.bucket_quota.enabled && user.bucket_quota.max_objects > -1">
-              {{ user.bucket_quota.max_objects }}
-            </td>
-          </tr>
-        </tbody>
-      </table>
-    </div>
-    <!-- User Rate Limit -->
-    <div *ngIf="user.user_ratelimit">
-      <cd-rgw-rate-limit-details
-        [rateLimitConfig]="user.user_ratelimit"
-        [type]="'user'"
-      ></cd-rgw-rate-limit-details>
-    </div>
-  </div>
-</ng-container>
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 (file)
index e69de29..0000000
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 (file)
index d26a67e..0000000
+++ /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<RgwUserDetailsComponent>;
-  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 (file)
index 4932911..0000000
+++ /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<any>;
-  @ViewChild('secretKeyTpl')
-  public secretKeyTpl: TemplateRef<any>;
-
-  @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(', ');
-  }
-}
index 87abe6acce658ef3d27187f8768f5d417d6deafb..172a91c4133ba7e3301df9bbf64ff53ad89aefc7 100644 (file)
@@ -7,8 +7,6 @@
   [columns]="columns"
   columnMode="flex"
   selectionType="single"
-  [hasDetails]="true"
-  (setExpandedRow)="setExpandedRow($event)"
   (updateSelection)="updateSelection($event)"
   identifier="uid"
   (fetchData)="getUserList($event)"
     [tableActions]="tableActions"
   >
   </cd-table-actions>
-  <cd-rgw-user-details
-    *cdTableDetail
-    [selection]="expandedRow"
-  >
-  </cd-rgw-user-details>
 </cd-table>
 
 <ng-template
   #usernameTpl
   let-row="data.row"
 >
-  <div cdsRow>
-    <span i18n>{{ row.uid }}</span>
+  <div class="username-cell">
+    <a
+      cdsLink
+      [routerLink]="[viewUrl, row.uid | encodeUri, 'overview']"
+      (click)="$event.stopPropagation()"
+    >
+      {{ row.uid }}
+    </a>
     @if (row.type === 'root') {
       <cds-tooltip
         [description]="'Account root user'"
index b13eb5c0ed345dc0902cf7856cddf2ac51a09651..b642532ae6fc39eb2fe25777dbfcc15eb900b10f 100644 (file)
@@ -1,5 +1,11 @@
 @use '@carbon/layout';
 
+.username-cell {
+  display: flex;
+  align-items: center;
+  min-height: layout.$spacing-07;
+}
+
 .account-root-icon {
   margin-left: layout.$spacing-03;
 }
index d8021d22837a17205f31e063108689ab9ffb1c7e..15dcafbdfcbd2dd2adfe7be099803d370ad78c3e 100644 (file)
@@ -21,7 +21,7 @@ import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
 import { URLBuilderService } from '~/app/shared/services/url-builder.service';
 import { Account } from '../models/rgw-user-accounts';
 import { catchError, map, switchMap } from 'rxjs/operators';
-import { RgwUser } from '../models/rgw-user';
+import { RgwUser, RGW_MAX_BUCKETS_MAP } from '../models/rgw-user';
 
 const BASE_URL = 'rgw/user';
 
@@ -47,9 +47,10 @@ export class RgwUserListComponent extends ListWithDetails implements OnInit {
   users: RgwUser[] = [];
   userAccounts: Account[];
   selection: CdTableSelection = new CdTableSelection();
-  userDataSubject = new Subject();
+  userDataSubject: Subject<RgwUser[]> = new Subject<RgwUser[]>();
   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 (file)
index 0000000..8a94739
--- /dev/null
@@ -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<ExtendedRgwUser | null> {
+  constructor(
+    private rgwUserService: RgwUserService,
+    private rgwUserAccountsService: RgwUserAccountsService
+  ) {}
+
+  resolve(route: ActivatedRouteSnapshot): Observable<ExtendedRgwUser | null> {
+    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<RgwUser>) => ({ user, quotaResp }))
+        )
+      ),
+      switchMap(({ user, quotaResp }) =>
+        this.rgwUserService.getUserRateLimit(uid).pipe(
+          catchError(() => of({})),
+          map((rateLimitResp: Record<string, number | boolean>) => ({
+            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 (file)
index 0000000..4579b84
--- /dev/null
@@ -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 (file)
index 0000000..2ba3d7a
--- /dev/null
@@ -0,0 +1,115 @@
+@if (user) {
+  @switch (section) {
+    @case ('overview') {
+      <cd-resource-overview-card
+        title="User details"
+        [columns]="4"
+        [fields]="overviewFields"
+        i18n-title
+      >
+      </cd-resource-overview-card>
+
+      @if (keys.length) {
+        <div class="cds-mt-6 cds-mb-6">
+          <h2
+            class="cds--type-heading-03"
+            i18n
+          >
+            Keys
+          </h2>
+          <p
+            class="cds--type-body-compact-01"
+            i18n
+          >
+            View the user key details, including access and secret keys.
+          </p>
+          <cd-table
+            [data]="keys"
+            [columns]="keysColumns"
+            columnMode="flex"
+            forceIdentifier="true"
+          >
+          </cd-table>
+        </div>
+      }
+
+      @if (user.user_quota) {
+        <section class="cds-mt-6 cds-mb-6">
+          <h2
+            class="cds--type-heading-03"
+            i18n
+          >
+            User Quota
+          </h2>
+          <p
+            class="cds--type-body-compact-01"
+            i18n
+          >
+            View the user quota details, including maximum object and size.
+          </p>
+          <cd-table-key-value [data]="userQuota"></cd-table-key-value>
+        </section>
+      }
+
+      @if (user.bucket_quota) {
+        <section class="cds-mb-6">
+          <h2
+            class="cds--type-heading-03"
+            i18n
+          >
+            Bucket Quota
+          </h2>
+          <p
+            class="cds--type-body-compact-01"
+            i18n
+          >
+            View the bucket quota details, including maximum object and size.
+          </p>
+          <cd-table-key-value [data]="bucketQuota"></cd-table-key-value>
+        </section>
+      }
+
+      @if (user.user_ratelimit) {
+        <h2
+          class="user-resource-section__title cds--type-heading-03"
+          i18n
+        >
+          User Rate Limit
+        </h2>
+        <p
+          class="user-resource-section__description cds--type-body-compact-01"
+          i18n
+        >
+          View the user rate limit details.
+        </p>
+        <cd-rgw-rate-limit-details
+          [rateLimitConfig]="user.user_ratelimit"
+          [type]="'user'"
+          [showHeading]="false"
+        >
+        </cd-rgw-rate-limit-details>
+      }
+    }
+  }
+} @else if (notFound) {
+  <cd-alert-panel
+    type="error"
+    i18n
+    >No user found.</cd-alert-panel
+  >
+}
+
+<ng-template
+  #viewKeyTpl
+  let-row="data.row"
+>
+  <a
+    cdsLink
+    tabindex="0"
+    (click)="showKeyModal(row); $event.stopPropagation()"
+    (keydown.enter)="showKeyModal(row); $event.stopPropagation()"
+    i18n
+  >
+    Show
+  </a>
+</ng-template>
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 (file)
index 0000000..e69de29
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 (file)
index 0000000..cfa3dff
--- /dev/null
@@ -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<RgwUserResourcePageComponent>;
+  let modalCdsServiceMock: any;
+
+  const mockUser: Partial<RgwUser> & { 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 (file)
index 0000000..22c40a0
--- /dev/null
@@ -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<any>;
+
+  section = 'overview';
+  user?: ExtendedRgwUser;
+  selection?: ExtendedRgwUser;
+  notFound = false;
+  keys: KeyRow[] = [];
+  keysColumns: CdTableColumn[] = [];
+  userQuota: Record<string, string | number> = {};
+  bucketQuota: Record<string, string | number> = {};
+  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<string, string | number> {
+    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 (file)
index 0000000..e1eae03
--- /dev/null
@@ -0,0 +1,7 @@
+<cd-sidebar-layout
+  class="rgw-user-details-layout"
+  [title]="user?.uid || uid"
+  [items]="sidebarItems"
+>
+  <router-outlet></router-outlet>
+</cd-sidebar-layout>
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 (file)
index 0000000..3596fc2
--- /dev/null
@@ -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 (file)
index 0000000..ef1a15c
--- /dev/null
@@ -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<RgwUserResourceSidebarComponent>;
+
+  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 (file)
index 0000000..25f5f5a
--- /dev/null
@@ -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 }
+      }
+    ];
+  }
+}
index 7b74bb573806b52e9af414fdf5498a522594a9dc..835703ac6bb4538ac10820232a19354242b38c7f 100644 (file)
@@ -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' }
+          }
+        ]
       }
     ]
   },
index 744f95ac09532d0b090f3a6c516e276befd39d0b..ad8e5409a3ce22de515ee90fd48b7c11509ac796 100644 (file)
 }
 
 .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;