]> git.apps.os.sepia.ceph.com Git - ceph-ci.git/commitdiff
mgr/dashboard: add bucket tiering option to create lifecycle policy
authorNaman Munet <naman.munet@ibm.com>
Fri, 7 Feb 2025 06:53:07 +0000 (12:23 +0530)
committerNaman Munet <naman.munet@ibm.com>
Tue, 18 Feb 2025 16:43:41 +0000 (22:13 +0530)
Fixes: https://tracker.ceph.com/issues/69649
Signed-off-by: Naman Munet <naman.munet@ibm.com>
23 files changed:
src/pybind/mgr/dashboard/controllers/rgw.py
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-snapshotschedule-form/cephfs-snapshotschedule-form.component.scss
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-lifecycle-list/rgw-bucket-lifecycle-list.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-lifecycle-list/rgw-bucket-lifecycle-list.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-lifecycle-list/rgw-bucket-lifecycle-list.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-lifecycle-list/rgw-bucket-lifecycle-list.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-list/rgw-bucket-list.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-list/rgw-bucket-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-tiering-form/rgw-bucket-tiering-form.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-tiering-form/rgw-bucket-tiering-form.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-tiering-form/rgw-bucket-tiering-form.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-tiering-form/rgw-bucket-tiering-form.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-storage-class-list/rgw-storage-class-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/utils/rgw-bucket-tiering.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-bucket.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts
src/pybind/mgr/dashboard/frontend/src/styles/_carbon-defaults.scss
src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_forms.scss
src/pybind/mgr/dashboard/openapi.yaml
src/pybind/mgr/dashboard/services/rgw_client.py

index ef8903afb082ec3081e2f9f7f720cf913f55c8a0..1a694b4734146085308136cd93b6b751b4584095 100755 (executable)
@@ -465,6 +465,10 @@ class RgwBucket(RgwRESTController):
         rgw_client = RgwClient.instance(owner, daemon_name)
         return rgw_client.set_tags(bucket_name, tags)
 
+    def _get_lifecycle_progress(self):
+        rgw_client = RgwClient.admin_instance()
+        return rgw_client.get_lifecycle_progress()
+
     def _get_lifecycle(self, bucket_name: str, daemon_name, owner):
         rgw_client = RgwClient.instance(owner, daemon_name)
         return rgw_client.get_lifecycle(bucket_name)
@@ -561,7 +565,7 @@ class RgwBucket(RgwRESTController):
         result['acl'] = self._get_acl(bucket_name, daemon_name, owner)
         result['replication'] = self._get_replication(bucket_name, owner, daemon_name)
         result['lifecycle'] = self._get_lifecycle(bucket_name, daemon_name, owner)
-
+        result['lifecycle_progress'] = self._get_lifecycle_progress()
         # Append the locking configuration.
         locking = self._get_locking(owner, daemon_name, bucket_name)
         result.update(locking)
@@ -706,6 +710,18 @@ class RgwBucket(RgwRESTController):
     def get_encryption_config(self, daemon_name=None, owner=None):
         return CephService.get_encryption_config(daemon_name)
 
+    @RESTController.Collection(method='PUT', path='/lifecycle')
+    @allow_empty_body
+    def set_lifecycle_policy(self, bucket_name: str = '', lifecycle: str = '', daemon_name=None,
+                             owner=None):
+        if lifecycle == '{}':
+            return self._delete_lifecycle(bucket_name, daemon_name, owner)
+        return self._set_lifecycle(bucket_name, lifecycle, daemon_name, owner)
+
+    @RESTController.Collection(method='GET', path='/lifecycle')
+    def get_lifecycle_policy(self, bucket_name: str = '', daemon_name=None, owner=None):
+        return self._get_lifecycle(bucket_name, daemon_name, owner)
+
 
 @UIRouter('/rgw/bucket', Scope.RGW)
 class RgwBucketUi(RgwBucket):
index 463eac88b1e99652c08f94a2bff5454fff860002..1a422e5396f5c66e1d74702af8ea8f51ab27c149 100644 (file)
                   </cds-code-snippet>
                 </td>
               </tr>
+              <tr *ngIf="selection.lifecycle_progress?.length > 0">
+                <td i18n
+                    class="bold w-25">Lifecycle Progress</td>
+                <td>
+                  <cds-tooltip [description]="lifecycleProgressMap.get(lifecycleProgress)?.description"
+                               [align]="'top'">
+                    <cds-tag size="md"
+                             [type]="lifecycleProgressMap.get(lifecycleProgress)?.color">
+                      {{ lifecycleProgress }}
+                    </cds-tag>
+                  </cds-tooltip>
+                </td>
+              </tr>
               <tr>
                 <td i18n
                     class="bold w-25">Replication policy</td>
         </div>
       </ng-template>
     </ng-container>
+
+    <ng-container ngbNavItem="tiering">
+      <a ngbNavLink
+         i18n>Tiering</a>
+      <ng-template ngbNavContent>
+        <cd-rgw-bucket-lifecycle-list [bucket]="selection"></cd-rgw-bucket-lifecycle-list>
+      </ng-template>
+    </ng-container>
   </nav>
 
   <div [ngbNavOutlet]="nav"></div>
index 15382c9fc31ac01ef793f4afb68837d52a005306..79e25808b93ea2888e6fb3d814743264e3fce942 100644 (file)
@@ -12,7 +12,12 @@ import * as xml2js from 'xml2js';
 export class RgwBucketDetailsComponent implements OnChanges {
   @Input()
   selection: any;
-
+  lifecycleProgress: string;
+  lifecycleProgressMap = new Map<string, { description: string; color: string }>([
+    ['UNINITIAL', { description: $localize`The process has not run yet`, color: 'cool-gray' }],
+    ['PROCESSING', { description: $localize`The process is currently running`, color: 'cyan' }],
+    ['COMPLETE', { description: $localize`The process has completed`, color: 'green' }]
+  ]);
   lifecycleFormat: 'json' | 'xml' = 'json';
   aclPermissions: Record<string, string[]> = {};
   replicationStatus = $localize`Disabled`;
@@ -31,6 +36,15 @@ export class RgwBucketDetailsComponent implements OnChanges {
         if (this.selection.replication?.['Rule']?.['Status']) {
           this.replicationStatus = this.selection.replication?.['Rule']?.['Status'];
         }
+        if (this.selection.lifecycle_progress?.length > 0) {
+          this.selection.lifecycle_progress.forEach(
+            (progress: { bucket: string; status: string; started: string }) => {
+              if (progress.bucket.includes(this.selection.bucket)) {
+                this.lifecycleProgress = progress.status;
+              }
+            }
+          );
+        }
       });
     }
   }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-lifecycle-list/rgw-bucket-lifecycle-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-lifecycle-list/rgw-bucket-lifecycle-list.component.html
new file mode 100644 (file)
index 0000000..7e7927f
--- /dev/null
@@ -0,0 +1,20 @@
+<legend i18n>
+  Tiering Configuration
+  <cd-help-text>
+    Configure a bucket tiering rule to automatically transition objects between storage classes after a specified number of days. Define the scope of the rule by applying it globally or to objects with specific prefixes and tags.
+  </cd-help-text>
+</legend>
+<cd-table #table
+          [data]="filteredLifecycleRules$ | async"
+          [columns]="columns"
+          columnMode="flex"
+          selectionType="multiClick"
+          (updateSelection)="updateSelection($event)"
+          identifier="ID"
+          (fetchData)="loadLifecyclePolicies($event)">
+  <cd-table-actions class="table-actions"
+                    [permission]="permission"
+                    [selection]="selection"
+                    [tableActions]="tableActions">
+  </cd-table-actions>
+</cd-table>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-lifecycle-list/rgw-bucket-lifecycle-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-lifecycle-list/rgw-bucket-lifecycle-list.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-lifecycle-list/rgw-bucket-lifecycle-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-lifecycle-list/rgw-bucket-lifecycle-list.component.spec.ts
new file mode 100644 (file)
index 0000000..4aabbed
--- /dev/null
@@ -0,0 +1,56 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { RgwBucketLifecycleListComponent } from './rgw-bucket-lifecycle-list.component';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { of } from 'rxjs';
+import { RgwBucketService } from '~/app/shared/api/rgw-bucket.service';
+import { ReactiveFormsModule } from '@angular/forms';
+import { ToastrModule } from 'ngx-toastr';
+import { ComponentsModule } from '~/app/shared/components/components.module';
+import {
+  InputModule,
+  ModalModule,
+  ModalService,
+  NumberModule,
+  RadioModule,
+  SelectModule
+} from 'carbon-components-angular';
+import { CdLabelComponent } from '~/app/shared/components/cd-label/cd-label.component';
+
+class MockRgwBucketService {
+  setLifecycle = jest.fn().mockReturnValue(of(null));
+  getLifecycle = jest.fn().mockReturnValue(of(null));
+}
+
+describe('RgwBucketLifecycleListComponent', () => {
+  let component: RgwBucketLifecycleListComponent;
+  let fixture: ComponentFixture<RgwBucketLifecycleListComponent>;
+
+  configureTestBed({
+    declarations: [RgwBucketLifecycleListComponent, CdLabelComponent],
+    imports: [
+      ReactiveFormsModule,
+      RadioModule,
+      SelectModule,
+      NumberModule,
+      InputModule,
+      ToastrModule.forRoot(),
+      ComponentsModule,
+      ModalModule
+    ],
+    providers: [
+      ModalService,
+      { provide: 'bucket', useValue: { bucket: 'bucket1', owner: 'dashboard' } },
+      { provide: RgwBucketService, useClass: MockRgwBucketService }
+    ]
+  });
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(RgwBucketLifecycleListComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-lifecycle-list/rgw-bucket-lifecycle-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-lifecycle-list/rgw-bucket-lifecycle-list.component.ts
new file mode 100644 (file)
index 0000000..759f425
--- /dev/null
@@ -0,0 +1,161 @@
+import { Component, Input, OnInit, ViewChild } from '@angular/core';
+import { Bucket } from '../models/rgw-bucket';
+import { RgwBucketService } from '~/app/shared/api/rgw-bucket.service';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { CdTableAction } from '~/app/shared/models/cd-table-action';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { Permission } from '~/app/shared/models/permissions';
+import { TableComponent } from '~/app/shared/datatable/table/table.component';
+import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
+import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
+import { RgwBucketTieringFormComponent } from '../rgw-bucket-tiering-form/rgw-bucket-tiering-form.component';
+import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component';
+import { Observable, of } from 'rxjs';
+import { catchError, map, tap } from 'rxjs/operators';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+
+@Component({
+  selector: 'cd-rgw-bucket-lifecycle-list',
+  templateUrl: './rgw-bucket-lifecycle-list.component.html',
+  styleUrls: ['./rgw-bucket-lifecycle-list.component.scss']
+})
+export class RgwBucketLifecycleListComponent implements OnInit {
+  @Input() bucket: Bucket;
+  @ViewChild(TableComponent, { static: true })
+  table: TableComponent;
+  permission: Permission;
+  tableActions: CdTableAction[];
+  columns: CdTableColumn[] = [];
+  selection: CdTableSelection = new CdTableSelection();
+  filteredLifecycleRules$: Observable<any[]>;
+  lifecycleRuleList: any = [];
+  modalRef: any;
+
+  constructor(
+    private rgwBucketService: RgwBucketService,
+    private authStorageService: AuthStorageService,
+    public actionLabels: ActionLabelsI18n,
+    private modalService: ModalCdsService,
+    private notificationService: NotificationService
+  ) {}
+
+  ngOnInit() {
+    this.permission = this.authStorageService.getPermissions().rgw;
+    this.columns = [
+      {
+        name: $localize`Name`,
+        prop: 'ID',
+        flexGrow: 2
+      },
+      {
+        name: $localize`Days`,
+        prop: 'Transition.Days',
+        flexGrow: 1
+      },
+      {
+        name: $localize`Storage class`,
+        prop: 'Transition.StorageClass',
+        flexGrow: 1
+      },
+      {
+        name: $localize`Status`,
+        prop: 'Status',
+        flexGrow: 1
+      }
+    ];
+    const createAction: CdTableAction = {
+      permission: 'create',
+      icon: Icons.add,
+      click: () => this.openTieringModal(this.actionLabels.CREATE),
+      name: this.actionLabels.CREATE
+    };
+    const editAction: CdTableAction = {
+      permission: 'update',
+      icon: Icons.edit,
+      disable: () => this.selection.hasMultiSelection,
+      click: () => this.openTieringModal(this.actionLabels.EDIT),
+      name: this.actionLabels.EDIT
+    };
+    const deleteAction: CdTableAction = {
+      permission: 'delete',
+      icon: Icons.destroy,
+      click: () => this.deleteAction(),
+      disable: () => !this.selection.hasSelection,
+      name: this.actionLabels.DELETE,
+      canBePrimary: (selection: CdTableSelection) => selection.hasMultiSelection
+    };
+    this.tableActions = [createAction, editAction, deleteAction];
+  }
+
+  loadLifecyclePolicies(context: CdTableFetchDataContext) {
+    const allLifecycleRules$ = this.rgwBucketService
+      .getLifecycle(this.bucket.bucket, this.bucket.owner)
+      .pipe(
+        tap((lifecycle) => {
+          this.lifecycleRuleList = lifecycle;
+        }),
+        catchError(() => {
+          context.error();
+          return of(null);
+        })
+      );
+
+    this.filteredLifecycleRules$ = allLifecycleRules$.pipe(
+      map(
+        (lifecycle: any) =>
+          lifecycle?.LifecycleConfiguration?.Rules?.filter((rule: object) =>
+            rule.hasOwnProperty('Transition')
+          ) || []
+      )
+    );
+  }
+
+  openTieringModal(type: string) {
+    this.modalService.show(RgwBucketTieringFormComponent, {
+      bucket: this.bucket,
+      selectedLifecycle: this.selection.first(),
+      editing: type === this.actionLabels.EDIT ? true : false
+    });
+  }
+
+  updateSelection(selection: CdTableSelection) {
+    this.selection = selection;
+  }
+
+  deleteAction() {
+    const ruleNames = this.selection.selected.map((rule) => rule.ID);
+    const filteredRules = this.lifecycleRuleList.LifecycleConfiguration.Rules.filter(
+      (rule: any) => !ruleNames.includes(rule.ID)
+    );
+    const rules = filteredRules.length > 0 ? { Rules: filteredRules } : {};
+    this.modalRef = this.modalService.show(DeleteConfirmationModalComponent, {
+      itemDescription: $localize`Rule`,
+      itemNames: ruleNames,
+      actionDescription: $localize`remove`,
+      submitAction: () => this.submitLifecycleConfig(rules)
+    });
+  }
+
+  submitLifecycleConfig(rules: any) {
+    this.rgwBucketService
+      .setLifecycle(this.bucket.bucket, JSON.stringify(rules), this.bucket.owner)
+      .subscribe({
+        next: () => {
+          this.notificationService.show(
+            NotificationType.success,
+            $localize`Lifecycle rule deleted successfully`
+          );
+        },
+        error: () => {
+          this.modalRef.componentInstance.stopLoadingSpinner();
+        },
+        complete: () => {
+          this.modalService.dismissAll();
+        }
+      });
+  }
+}
index f240ab7d53f5b66ef9e401007b9ef45a98047f03..a1dff9182a353d304726647aa84be8bc7a42259d 100644 (file)
@@ -55,7 +55,7 @@ describe('RgwBucketListComponent', () => {
 
     expect(tableActions).toEqual({
       'create,update,delete': {
-        actions: ['Create', 'Edit', 'Delete'],
+        actions: ['Create', 'Edit', 'Delete', 'Tiering'],
         primary: {
           multiple: 'Create',
           executing: 'Create',
@@ -64,7 +64,7 @@ describe('RgwBucketListComponent', () => {
         }
       },
       'create,update': {
-        actions: ['Create', 'Edit'],
+        actions: ['Create', 'Edit', 'Tiering'],
         primary: {
           multiple: 'Create',
           executing: 'Create',
@@ -91,7 +91,7 @@ describe('RgwBucketListComponent', () => {
         }
       },
       'update,delete': {
-        actions: ['Edit', 'Delete'],
+        actions: ['Edit', 'Delete', 'Tiering'],
         primary: {
           multiple: '',
           executing: '',
@@ -100,12 +100,12 @@ describe('RgwBucketListComponent', () => {
         }
       },
       update: {
-        actions: ['Edit'],
+        actions: ['Edit', 'Tiering'],
         primary: {
-          multiple: 'Edit',
-          executing: 'Edit',
-          single: 'Edit',
-          no: 'Edit'
+          multiple: '',
+          executing: '',
+          single: '',
+          no: ''
         }
       },
       delete: {
index 347ed3745829cac935b440aca008f0cae51f3a45..4761ff69d072751a6fc94323d460640ef4ce62f4 100644 (file)
@@ -24,6 +24,7 @@ import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
 import { URLBuilderService } from '~/app/shared/services/url-builder.service';
 import { Bucket } from '../models/rgw-bucket';
 import { DeletionImpact } from '~/app/shared/enum/delete-confirmation-modal-impact.enum';
+import { RgwBucketTieringFormComponent } from '../rgw-bucket-tiering-form/rgw-bucket-tiering-form.component';
 
 const BASE_URL = 'rgw/bucket';
 
@@ -129,7 +130,14 @@ export class RgwBucketListComponent extends ListWithDetails implements OnInit, O
       click: () => this.deleteAction(),
       name: this.actionLabels.DELETE
     };
-    this.tableActions = [addAction, editAction, deleteAction];
+    const tieringAction: CdTableAction = {
+      permission: 'update',
+      icon: Icons.edit,
+      click: () => this.openTieringModal(),
+      disable: () => !this.selection.hasSelection,
+      name: this.actionLabels.TIERING
+    };
+    this.tableActions = [addAction, editAction, deleteAction, tieringAction];
     this.setTableRefreshTimeout();
   }
 
@@ -152,6 +160,12 @@ export class RgwBucketListComponent extends ListWithDetails implements OnInit, O
     this.selection = selection;
   }
 
+  openTieringModal() {
+    this.modalService.show(RgwBucketTieringFormComponent, {
+      bucket: this.selection.first()
+    });
+  }
+
   deleteAction() {
     const itemNames = this.selection.selected.map((bucket: any) => bucket['bid']);
     this.modalService.show(DeleteConfirmationModalComponent, {
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-tiering-form/rgw-bucket-tiering-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-tiering-form/rgw-bucket-tiering-form.component.html
new file mode 100644 (file)
index 0000000..045896e
--- /dev/null
@@ -0,0 +1,227 @@
+<cds-modal size="md"
+           [open]="open"
+           (overlaySelected)="closeModal()">
+  <cds-modal-header (closeSelect)="closeModal()"
+                    i18n>{{editing ? 'Edit' : 'Create'}} Tiering configuration</cds-modal-header>
+
+<ng-container *cdFormLoading="loading">
+  <section cdsModalContent>
+    <legend>
+      <cd-help-text i18n>
+        All fields are required, except where marked optional.
+      </cd-help-text>
+    </legend>
+    <cd-alert-panel
+      *ngIf="(snapScheduleModuleStatus$ | async) === false"
+      type="info"
+      spacingClass="mb-3"
+      i18n
+      class="align-items-center"
+      actionName="Enable"
+      (action)="enableSnapshotSchedule()"
+>
+  In order to access the snapshot scheduler feature, the snap_scheduler module must be enabled
+    </cd-alert-panel>
+      <cd-alert-panel *ngIf="storageClassList?.length === 0 &&
+                      isStorageClassFetched"
+                      type="info"
+                      spacingClass="mb-3"
+                      class="align-items-center"
+                      actionName="Create"
+                      i18n
+                      (action)="goToCreateStorageClass()">
+      No storage class found. Consider creating it first to proceed.
+    </cd-alert-panel>
+    <form name="tieringForm"
+          #formDir="ngForm"
+          [formGroup]="tieringForm"
+          novalidate>
+      <div class="form-item">
+        <cds-text-label
+          labelInputID="rule_name"
+          [invalid]="!tieringForm.controls.name.valid && tieringForm.controls.name.dirty"
+          [invalidText]="ruleNameError"
+          [helperText]="ruleHelper"
+          i18n
+          >Rule Name
+          <input
+            cdsText
+            type="text"
+            id="rule_name"
+            maxlength="255"
+            formControlName="name"
+          />
+        </cds-text-label>
+        <ng-template #ruleHelper>
+          <span i18n>Unique identifier for the rule. The value cannot be longer than 255 characters.
+          </span>
+        </ng-template>
+        <ng-template #ruleNameError>
+          <span *ngIf="tieringForm.showError('name', formDir, 'required')"
+                class="invalid-feedback">
+            <ng-container i18n>This field is required.</ng-container>
+          </span>
+          <span *ngIf="tieringForm.showError('name', formDir, 'duplicate')"
+                class="invalid-feedback">
+            <ng-container i18n>Please enter a unique name.</ng-container>
+          </span>
+        </ng-template>
+      </div>
+      <div class="form-item">
+        <cds-select id="storageClass"
+                    formControlName="storageClass"
+                    label="Storage Class"
+                    [helperText]="storageClassHelper">
+          <option *ngIf="storageClassList === null"
+                  value="">Loading...</option>
+          <option *ngIf="storageClassList !== null && storageClassList.length === 0"
+                  value="">-- No storage class available --</option>
+          <option *ngIf="storageClassList !== null && storageClassList.length > 0"
+                  value="">-- Select the storage class --</option>
+          <option *ngFor="let tier of storageClassList"
+                  [value]="tier.storage_class">
+            {{ tier.storage_class }}
+          </option>
+        </cds-select>
+        <ng-template #storageClassHelper>
+          <span i18n>The storage class to which you want the object to transition.
+          </span>
+        </ng-template>
+      </div>
+      <legend class="cds--label">Choose a configuration scope</legend>
+      <div>
+        <cds-radio-group
+          formControlName="hasPrefix"
+        >
+        <cds-radio [value]="false"
+                   i18n>
+            {{ 'Apply to all objects in the bucket' }}
+          </cds-radio>
+          <cds-radio [value]="true"
+                     i18n>
+            {{ 'Limit the scope of this rule to selected filter criteria' }}
+          </cds-radio>
+        </cds-radio-group>
+      </div>
+      <div class="form-item"
+           *ngIf="tieringForm.controls.hasPrefix.value">
+        <cds-text-label labelInputID="prefix"
+                        [invalid]="!tieringForm.controls.prefix.valid && tieringForm.controls.prefix.dirty"
+                        [invalidText]="prefixError"
+                        [helperText]="prefixHelper"
+                        i18n>Prefix
+          <input cdsText
+                 type="text"
+                 id="prefix"
+                 formControlName="prefix"/>
+        </cds-text-label>
+        <ng-template #prefixHelper>
+          <span i18n>
+          Prefix identifying one or more objects to which the rule applies
+          </span>
+        </ng-template>
+        <ng-template #prefixError>
+          <span *ngIf="tieringForm.showError('prefix', formDir, 'required')"
+                class="invalid-feedback">
+            <ng-container i18n>This field is required.</ng-container>
+          </span>
+        </ng-template>
+      </div>
+
+      <!-- tags -->
+      <div *ngIf="tieringForm.controls.hasPrefix.value">
+        <div class="form-group-header">Tags</div>
+        <div>All the tags must exist in the object's tag set for the rule to apply.</div>
+        <ng-container formArrayName="tags"
+                      *ngFor="let tags of tags.controls; index as i">
+          <ng-container [formGroupName]="i">
+            <div cdsRow
+                 class="form-item form-item-append">
+              <div cdsCol>
+                <cds-text-label labelInputID="Key"
+                                i18n>Name of the object key
+                  <input cdsText
+                         type="text"
+                         placeholder="Enter name of the object key"
+                         id="Key"
+                         formControlName="Key"
+                         i18n-placeholder/>
+                </cds-text-label>
+              </div>
+              <div cdsCol>
+                <cds-text-label labelInputID="Value"
+                                i18n>Value of the tag
+                  <input cdsText
+                         type="text"
+                         placeholder="Enter value of the tag"
+                         id="Value"
+                         formControlName="Value"
+                         i18n-placeholder/>
+                </cds-text-label>
+              </div>
+              <div cdsCol
+                   [columnNumbers]="{ lg: 2, md: 2 }"
+                   class="item-action-btn">
+                <cds-icon-button kind="tertiary"
+                                 size="sm"
+                                 (click)="removeTags(i)">
+                  <svg cdsIcon="trash-can"
+                       size="32"
+                       class="cds--btn__icon"></svg>
+                </cds-icon-button>
+              </div>
+            </div>
+          </ng-container>
+        </ng-container>
+        <div class="form-item">
+          <button cdsButton="tertiary"
+                  type="button"
+                  (click)="addTags()"
+                  i18n>Add tags
+            <svg cdsIcon="add"
+                 size="32"
+                 class="cds--btn__icon"
+                 icon></svg>
+          </button>
+        </div>
+      </div>
+
+      <legend class="cds--label">Status</legend>
+      <div>
+        <cds-radio-group
+          formControlName="status">
+        <cds-radio [value]="'Enabled'"
+                   i18n>Enabled </cds-radio>
+          <cds-radio [value]="'Disabled'"
+                     i18n>Disabled </cds-radio>
+        </cds-radio-group>
+      </div>
+      <div class="form-item">
+        <cds-number formControlName="days"
+                    label="Number of days"
+                    [min]="1"
+                    [invalid]="!tieringForm.controls.days.valid && tieringForm.controls.days.dirty"
+                    [invalidText]="daysError"
+                    [helperText]="daysHelper"></cds-number>
+        <ng-template #daysHelper>
+          <span i18n>Select the number of days to transition the objects to the specified storage class. The value must be a positive integer.
+          </span>
+        </ng-template>
+        <ng-template #daysError>
+          <span *ngIf="tieringForm.showError('days', formDir, 'required')"
+                i18n>This field is required.</span>
+          <span *ngIf="tieringForm.showError('days', formDir, 'pattern')"
+                i18n>Enter a valid positive number</span>
+        </ng-template>
+      </div>
+    </form>
+  </section>
+</ng-container>
+  <cd-form-button-panel
+    (submitActionEvent)="submitTieringConfig()"
+    [form]="tieringForm"
+    [submitText]="editing ? actionLabels.EDIT : actionLabels.CREATE"
+    [modalForm]="true"
+    [disabled]="storageClassList?.length === 0 && isStorageClassFetched"
+  ></cd-form-button-panel>
+</cds-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-tiering-form/rgw-bucket-tiering-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-tiering-form/rgw-bucket-tiering-form.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-tiering-form/rgw-bucket-tiering-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-tiering-form/rgw-bucket-tiering-form.component.spec.ts
new file mode 100644 (file)
index 0000000..48c3686
--- /dev/null
@@ -0,0 +1,86 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { RgwBucketTieringFormComponent } from './rgw-bucket-tiering-form.component';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { of } from 'rxjs';
+import { RgwBucketService } from '~/app/shared/api/rgw-bucket.service';
+import { ReactiveFormsModule } from '@angular/forms';
+import { ToastrModule } from 'ngx-toastr';
+import { ComponentsModule } from '~/app/shared/components/components.module';
+import {
+  InputModule,
+  ModalModule,
+  ModalService,
+  NumberModule,
+  RadioModule,
+  SelectModule
+} from 'carbon-components-angular';
+import { CdLabelComponent } from '~/app/shared/components/cd-label/cd-label.component';
+import { RouterTestingModule } from '@angular/router/testing';
+
+class MockRgwBucketService {
+  setLifecycle = jest.fn().mockReturnValue(of(null));
+  getLifecycle = jest.fn().mockReturnValue(of(null));
+}
+
+describe('RgwBucketTieringFormComponent', () => {
+  let component: RgwBucketTieringFormComponent;
+  let fixture: ComponentFixture<RgwBucketTieringFormComponent>;
+  let rgwBucketService: MockRgwBucketService;
+
+  configureTestBed({
+    declarations: [RgwBucketTieringFormComponent, CdLabelComponent],
+    imports: [
+      ReactiveFormsModule,
+      HttpClientTestingModule,
+      RadioModule,
+      SelectModule,
+      NumberModule,
+      InputModule,
+      ToastrModule.forRoot(),
+      ComponentsModule,
+      ModalModule,
+      RouterTestingModule
+    ],
+    providers: [
+      ModalService,
+      { provide: 'bucket', useValue: { bucket: 'bucket1', owner: 'dashboard' } },
+      { provide: RgwBucketService, useClass: MockRgwBucketService }
+    ]
+  });
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(RgwBucketTieringFormComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+    rgwBucketService = (TestBed.inject(RgwBucketService) as unknown) as MockRgwBucketService;
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  it('should call setLifecyclePolicy function', () => {
+    component.ngOnInit();
+    component.tieringForm.setValue({
+      name: 'test',
+      storageClass: 'CLOUD',
+      hasPrefix: false,
+      prefix: '',
+      tags: [],
+      status: 'Enabled',
+      days: 60
+    });
+    const createTieringSpy = jest.spyOn(component, 'submitTieringConfig');
+    const setLifecycleSpy = jest.spyOn(rgwBucketService, 'setLifecycle').mockReturnValue(of(null));
+    component.submitTieringConfig();
+    expect(createTieringSpy).toHaveBeenCalled();
+    expect(component.tieringForm.valid).toBe(true);
+    expect(setLifecycleSpy).toHaveBeenCalled();
+    expect(setLifecycleSpy).toHaveBeenCalledWith(
+      'bucket1',
+      JSON.stringify(component.configuredLifecycle.LifecycleConfiguration),
+      'dashboard'
+    );
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-tiering-form/rgw-bucket-tiering-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-tiering-form/rgw-bucket-tiering-form.component.ts
new file mode 100644 (file)
index 0000000..00e4f00
--- /dev/null
@@ -0,0 +1,270 @@
+import { ChangeDetectorRef, Component, Inject, OnInit, Optional } from '@angular/core';
+import {
+  AbstractControl,
+  FormArray,
+  FormControl,
+  FormGroup,
+  ValidationErrors,
+  Validators
+} from '@angular/forms';
+import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { CdValidators } from '~/app/shared/forms/cd-validators';
+import { Bucket } from '../models/rgw-bucket';
+import { RgwBucketService } from '~/app/shared/api/rgw-bucket.service';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { RgwZonegroupService } from '~/app/shared/api/rgw-zonegroup.service';
+import { BucketTieringUtils } from '../utils/rgw-bucket-tiering';
+import { StorageClass, ZoneGroupDetails } from '../models/rgw-storage-class.model';
+import { CdForm } from '~/app/shared/forms/cd-form';
+import { Router } from '@angular/router';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+
+export interface Tags {
+  tagKey: number;
+  tagValue: string;
+}
+
+@Component({
+  selector: 'cd-rgw-bucket-tiering',
+  templateUrl: './rgw-bucket-tiering-form.component.html',
+  styleUrls: ['./rgw-bucket-tiering-form.component.scss']
+})
+export class RgwBucketTieringFormComponent extends CdForm implements OnInit {
+  tieringForm: CdFormGroup;
+  tagsToRemove: Tags[] = [];
+  storageClassList: StorageClass[] = null;
+  configuredLifecycle: any;
+  isStorageClassFetched = false;
+
+  constructor(
+    @Inject('bucket') public bucket: Bucket,
+    @Optional() @Inject('selectedLifecycle') public selectedLifecycle: any,
+    @Optional() @Inject('editing') public editing = false,
+    public actionLabels: ActionLabelsI18n,
+    private rgwBucketService: RgwBucketService,
+    private fb: CdFormBuilder,
+    private cd: ChangeDetectorRef,
+    private rgwZonegroupService: RgwZonegroupService,
+    private notificationService: NotificationService,
+    private router: Router
+  ) {
+    super();
+  }
+
+  ngOnInit() {
+    this.rgwBucketService
+      .getLifecycle(this.bucket.bucket, this.bucket.owner)
+      .subscribe((lifecycle) => {
+        this.configuredLifecycle = lifecycle || { LifecycleConfiguration: { Rules: [] } };
+        if (this.editing) {
+          const ruleToEdit = this.configuredLifecycle?.['LifecycleConfiguration']?.['Rules'].filter(
+            (rule: any) => rule?.['ID'] === this.selectedLifecycle?.['ID']
+          )[0];
+          this.tieringForm.patchValue({
+            name: ruleToEdit?.['ID'],
+            hasPrefix: this.checkIfRuleHasFilters(ruleToEdit),
+            prefix:
+              ruleToEdit?.['Prefix'] ||
+              ruleToEdit?.['Filter']?.['Prefix'] ||
+              ruleToEdit?.['Filter']?.['And']?.['Prefix'] ||
+              '',
+            status: ruleToEdit?.['Status'],
+            days: ruleToEdit?.['Transition']?.['Days']
+          });
+          this.setTags(ruleToEdit);
+          this.tieringForm.get('name').disable();
+        }
+      });
+    this.tieringForm = this.fb.group({
+      name: [null, [Validators.required, this.duplicateConfigName.bind(this)]],
+      storageClass: [null, Validators.required],
+      hasPrefix: [false, [Validators.required]],
+      prefix: [null, [CdValidators.composeIf({ hasPrefix: true }, [Validators.required])]],
+      tags: this.fb.array([]),
+      status: ['Enabled', [Validators.required]],
+      days: [60, [Validators.required, CdValidators.number(false)]]
+    });
+    this.loadStorageClass();
+  }
+
+  checkIfRuleHasFilters(rule: any) {
+    if (
+      this.isValidPrefix(rule?.['Prefix']) ||
+      this.isValidPrefix(rule?.['Filter']?.['Prefix']) ||
+      this.isValidArray(rule?.['Filter']?.['Tags']) ||
+      this.isValidPrefix(rule?.['Filter']?.['And']?.['Prefix']) ||
+      this.isValidArray(rule?.['Filter']?.['And']?.['Tags'])
+    ) {
+      return true;
+    }
+    return false;
+  }
+
+  isValidPrefix(value: string) {
+    return value !== undefined && value !== '';
+  }
+
+  isValidArray(value: object[]) {
+    return Array.isArray(value) && value.length > 0;
+  }
+
+  setTags(rule: any) {
+    if (rule?.['Filter']?.['Tags']?.length > 0) {
+      rule?.['Filter']?.['Tags']?.forEach((tag: { Key: string; Value: string }) =>
+        this.addTags(tag.Key, tag.Value)
+      );
+    }
+    if (rule?.['Filter']?.['And']?.['Tags']?.length > 0) {
+      rule?.['Filter']?.['And']?.['Tags']?.forEach((tag: { Key: string; Value: string }) =>
+        this.addTags(tag.Key, tag.Value)
+      );
+    }
+  }
+
+  get tags() {
+    return this.tieringForm.get('tags') as FormArray;
+  }
+
+  addTags(key?: string, value?: string) {
+    this.tags.push(
+      new FormGroup({
+        Key: new FormControl(key),
+        Value: new FormControl(value)
+      })
+    );
+    this.cd.detectChanges();
+  }
+
+  duplicateConfigName(control: AbstractControl): ValidationErrors | null {
+    if (this.configuredLifecycle?.LifecycleConfiguration?.Rules?.length > 0) {
+      const ruleIds = this.configuredLifecycle.LifecycleConfiguration.Rules.map(
+        (rule: any) => rule.ID
+      );
+      return ruleIds.includes(control.value) ? { duplicate: true } : null;
+    }
+    return null;
+  }
+
+  removeTags(idx: number) {
+    this.tags.removeAt(idx);
+    this.cd.detectChanges();
+  }
+
+  loadStorageClass(): Promise<void> {
+    return new Promise((resolve, reject) => {
+      this.rgwZonegroupService.getAllZonegroupsInfo().subscribe(
+        (data: ZoneGroupDetails) => {
+          this.storageClassList = [];
+          const tierObj = BucketTieringUtils.filterAndMapTierTargets(data);
+          this.isStorageClassFetched = true;
+          this.storageClassList.push(...tierObj);
+          if (this.editing) {
+            this.tieringForm
+              .get('storageClass')
+              .setValue(this.selectedLifecycle?.['Transition']?.['StorageClass']);
+          }
+          this.loadingReady();
+          resolve();
+        },
+        (error) => {
+          reject(error);
+        }
+      );
+    });
+  }
+
+  submitTieringConfig() {
+    const formValue = this.tieringForm.value;
+    if (!this.tieringForm.valid) {
+      return;
+    }
+
+    let lifecycle: any = {
+      ID: this.tieringForm.getRawValue().name,
+      Status: formValue.status,
+      Transition: [
+        {
+          Days: formValue.days,
+          StorageClass: formValue.storageClass
+        }
+      ]
+    };
+    if (formValue.hasPrefix) {
+      if (this.tags.length > 0) {
+        Object.assign(lifecycle, {
+          Filter: {
+            And: {
+              Prefix: formValue.prefix,
+              Tag: this.tags.value
+            }
+          }
+        });
+      } else {
+        Object.assign(lifecycle, {
+          Filter: {
+            Prefix: formValue.prefix
+          }
+        });
+      }
+    } else {
+      Object.assign(lifecycle, {
+        Filter: {}
+      });
+    }
+    if (!this.editing) {
+      this.configuredLifecycle.LifecycleConfiguration.Rules.push(lifecycle);
+      this.rgwBucketService
+        .setLifecycle(
+          this.bucket.bucket,
+          JSON.stringify(this.configuredLifecycle.LifecycleConfiguration),
+          this.bucket.owner
+        )
+        .subscribe({
+          next: () => {
+            this.notificationService.show(
+              NotificationType.success,
+              $localize`Bucket lifecycle created succesfully`
+            );
+          },
+          error: (error: any) => {
+            this.notificationService.show(NotificationType.error, error);
+            this.tieringForm.setErrors({ cdSubmitButton: true });
+          },
+          complete: () => {
+            this.closeModal();
+          }
+        });
+    } else {
+      const rules = this.configuredLifecycle.LifecycleConfiguration.Rules;
+      const index = rules.findIndex((rule: any) => rule?.['ID'] === this.selectedLifecycle?.['ID']);
+      rules.splice(index, 1, lifecycle);
+      this.rgwBucketService
+        .setLifecycle(
+          this.bucket.bucket,
+          JSON.stringify(this.configuredLifecycle.LifecycleConfiguration),
+          this.bucket.owner
+        )
+        .subscribe({
+          next: () => {
+            this.notificationService.show(
+              NotificationType.success,
+              $localize`Bucket lifecycle modified succesfully`
+            );
+          },
+          error: (error: any) => {
+            this.notificationService.show(NotificationType.error, error);
+            this.tieringForm.setErrors({ cdSubmitButton: true });
+          },
+          complete: () => {
+            this.closeModal();
+          }
+        });
+    }
+  }
+
+  goToCreateStorageClass() {
+    this.router.navigate(['rgw/tiering/create']);
+  }
+}
index 1f6fddf032dca67cb708830574f410e3ceb34b2b..445c372b3361fd5e4f6a2a3a32f13b2e3f060c8e 100644 (file)
@@ -4,14 +4,7 @@ import { CdTableColumn } from '~/app/shared/models/cd-table-column';
 import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
 
 import { ListWithDetails } from '~/app/shared/classes/list-with-details.class';
-import {
-  StorageClass,
-  CLOUD_TIER,
-  ZoneGroup,
-  TierTarget,
-  Target,
-  ZoneGroupDetails
-} from '../models/rgw-storage-class.model';
+import { StorageClass, ZoneGroupDetails } from '../models/rgw-storage-class.model';
 import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
 import { FinishedTask } from '~/app/shared/models/finished-task';
 import { Icons } from '~/app/shared/enum/icons.enum';
@@ -23,6 +16,7 @@ import { RgwStorageClassService } from '~/app/shared/api/rgw-storage-class.servi
 import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
 import { URLBuilderService } from '~/app/shared/services/url-builder.service';
 import { Permission } from '~/app/shared/models/permissions';
+import { BucketTieringUtils } from '../utils/rgw-bucket-tiering';
 
 import { Router } from '@angular/router';
 
@@ -105,18 +99,7 @@ export class RgwStorageClassListComponent extends ListWithDetails implements OnI
       this.rgwZonegroupService.getAllZonegroupsInfo().subscribe(
         (data: ZoneGroupDetails) => {
           this.storageClassList = [];
-
-          const tierObj = data.zonegroups.flatMap((zoneGroup: ZoneGroup) =>
-            zoneGroup.placement_targets
-              .filter((target: Target) => target.tier_targets)
-              .flatMap((target: Target) =>
-                target.tier_targets
-                  .filter((tierTarget: TierTarget) => tierTarget.val.tier_type === CLOUD_TIER)
-                  .map((tierTarget: TierTarget) => {
-                    return this.getTierTargets(tierTarget, zoneGroup.name, target.name);
-                  })
-              )
-          );
+          const tierObj = BucketTieringUtils.filterAndMapTierTargets(data);
           this.storageClassList.push(...tierObj);
           resolve();
         },
@@ -127,16 +110,6 @@ export class RgwStorageClassListComponent extends ListWithDetails implements OnI
     });
   }
 
-  getTierTargets(tierTarget: TierTarget, zoneGroup: string, targetName: string) {
-    if (tierTarget.val.tier_type !== CLOUD_TIER) return null;
-    return {
-      zonegroup_name: zoneGroup,
-      placement_target: targetName,
-      storage_class: tierTarget.val.storage_class,
-      ...tierTarget.val.s3
-    };
-  }
-
   removeStorageClassModal() {
     const storage_class = this.selection.first().storage_class;
     const placement_target = this.selection.first().placement_target;
index 6bb87d9ec363c816a0f1a4fc19afad509d915dd5..fad630b50dcc62fbfcfca6e2fb4682d550f4f82d 100644 (file)
@@ -76,10 +76,13 @@ import {
   InputModule,
   CheckboxModule,
   TreeviewModule,
+  RadioModule,
   SelectModule,
   NumberModule,
   TabsModule,
-  AccordionModule
+  AccordionModule,
+  TagModule,
+  TooltipModule
 } from 'carbon-components-angular';
 import { CephSharedModule } from '../shared/ceph-shared.module';
 import { RgwUserAccountsComponent } from './rgw-user-accounts/rgw-user-accounts.component';
@@ -87,6 +90,8 @@ import { RgwUserAccountsFormComponent } from './rgw-user-accounts-form/rgw-user-
 import { RgwUserAccountsDetailsComponent } from './rgw-user-accounts-details/rgw-user-accounts-details.component';
 import { RgwStorageClassDetailsComponent } from './rgw-storage-class-details/rgw-storage-class-details.component';
 import { RgwStorageClassFormComponent } from './rgw-storage-class-form/rgw-storage-class-form.component';
+import { RgwBucketTieringFormComponent } from './rgw-bucket-tiering-form/rgw-bucket-tiering-form.component';
+import { RgwBucketLifecycleListComponent } from './rgw-bucket-lifecycle-list/rgw-bucket-lifecycle-list.component';
 
 @NgModule({
   imports: [
@@ -120,7 +125,12 @@ import { RgwStorageClassFormComponent } from './rgw-storage-class-form/rgw-stora
     NumberModule,
     TabsModule,
     IconModule,
-    SelectModule
+    SelectModule,
+    RadioModule,
+    SelectModule,
+    NumberModule,
+    TagModule,
+    TooltipModule
   ],
   exports: [
     RgwDaemonListComponent,
@@ -178,7 +188,9 @@ import { RgwStorageClassFormComponent } from './rgw-storage-class-form/rgw-stora
     RgwUserAccountsDetailsComponent,
     RgwStorageClassListComponent,
     RgwStorageClassDetailsComponent,
-    RgwStorageClassFormComponent
+    RgwStorageClassFormComponent,
+    RgwBucketTieringFormComponent,
+    RgwBucketLifecycleListComponent
   ],
   providers: [TitleCasePipe]
 })
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/utils/rgw-bucket-tiering.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/utils/rgw-bucket-tiering.ts
new file mode 100644 (file)
index 0000000..7c33d89
--- /dev/null
@@ -0,0 +1,33 @@
+import {
+  CLOUD_TIER,
+  Target,
+  TierTarget,
+  ZoneGroup,
+  ZoneGroupDetails
+} from '../models/rgw-storage-class.model';
+
+export class BucketTieringUtils {
+  static filterAndMapTierTargets(zonegroupData: ZoneGroupDetails) {
+    return zonegroupData.zonegroups.flatMap((zoneGroup: ZoneGroup) =>
+      zoneGroup.placement_targets
+        .filter((target: Target) => target.tier_targets)
+        .flatMap((target: Target) =>
+          target.tier_targets
+            .filter((tierTarget: TierTarget) => tierTarget.val.tier_type === CLOUD_TIER)
+            .map((tierTarget: TierTarget) => {
+              return this.getTierTargets(tierTarget, zoneGroup.name, target.name);
+            })
+        )
+    );
+  }
+
+  private static getTierTargets(tierTarget: TierTarget, zoneGroup: string, targetName: string) {
+    if (tierTarget.val.tier_type !== CLOUD_TIER) return null;
+    return {
+      zonegroup_name: zoneGroup,
+      placement_target: targetName,
+      storage_class: tierTarget.val.storage_class,
+      ...tierTarget.val.s3
+    };
+  }
+}
index ed3134f5cae6b9c1d2ef6bde59b3c90604469fcd..621919da59c49768372c451a78fc20c27badb16d 100644 (file)
@@ -272,4 +272,25 @@ export class RgwBucketService extends ApiClient {
       return this.http.get(`${this.url}/getEncryptionConfig`, { params: params });
     });
   }
+
+  setLifecycle(bucket_name: string, lifecycle: string, owner: string) {
+    return this.rgwDaemonService.request((params: HttpParams) => {
+      params = params.appendAll({
+        bucket_name: bucket_name,
+        lifecycle: lifecycle,
+        owner: owner
+      });
+      return this.http.put(`${this.url}/lifecycle`, null, { params: params });
+    });
+  }
+
+  getLifecycle(bucket_name: string, owner: string) {
+    return this.rgwDaemonService.request((params: HttpParams) => {
+      params = params.appendAll({
+        bucket_name: bucket_name,
+        owner: owner
+      });
+      return this.http.get(`${this.url}/lifecycle`, { params: params });
+    });
+  }
 }
index bf7cb6b9567f3977f5324c30c5b91ed79d7ca458..d9f7854f192c14d65848164241ef25495d38f22c 100644 (file)
@@ -120,6 +120,7 @@ export class ActionLabelsI18n {
   SET: string;
   SUBMIT: string;
   SHOW: string;
+  TIERING: string;
   TRASH: string;
   UNPROTECT: string;
   UNSET: string;
@@ -206,6 +207,7 @@ export class ActionLabelsI18n {
     this.ROLLBACK = $localize`Rollback`;
     this.SCRUB = $localize`Scrub`;
     this.SHOW = $localize`Show`;
+    this.TIERING = $localize`Tiering`;
     this.TRASH = $localize`Move to Trash`;
     this.UNPROTECT = $localize`Unprotect`;
     this.CHANGE = $localize`Change`;
index 61ca421101e6d594321a6289ae4eb07838201cda..2d99c77bd8067e378ca675de2075cb2868a05a43 100644 (file)
@@ -149,3 +149,10 @@ Code snippet
 .cds--snippet {
   width: fit-content;
 }
+
+/******************************************
+Tooltip
+******************************************/
+.cds--tooltip-content {
+  background-color: theme.$layer-02;
+}
index 683abc7a237f613c52645ab703ea9aa35a3934c8..0801a6ff0d3a218d354af43cb3b1d5a29c8b781c 100644 (file)
@@ -1,5 +1,7 @@
 @use '../vendor/variables' as vv;
 @use '@carbon/colors';
+@use '@carbon/layout';
+@use '@carbon/type';
 
 /* Forms */
 .required::after {
@@ -133,3 +135,11 @@ Form Controls
 fieldset {
   @extend .cds--fieldset;
 }
+
+.item-action-btn {
+  margin-top: layout.$spacing-07;
+}
+
+.form-group-header {
+  @include type.type-style('heading-01');
+}
index c4fb7fc9465bdef28fcd254c6b20b759f78d27e1..dc2ccc370553d9b9ae75d610df10470967a97030 100644 (file)
@@ -11556,6 +11556,85 @@ paths:
       - jwt: []
       tags:
       - RgwBucket
+  /api/rgw/bucket/lifecycle:
+    get:
+      parameters:
+      - default: ''
+        in: query
+        name: bucket_name
+        schema:
+          type: string
+      - allowEmptyValue: true
+        in: query
+        name: daemon_name
+        schema:
+          type: string
+      - allowEmptyValue: true
+        in: query
+        name: owner
+        schema:
+          type: string
+      responses:
+        '200':
+          content:
+            application/vnd.ceph.api.v1.0+json:
+              type: object
+          description: OK
+        '400':
+          description: Operation exception. Please check the response body for details.
+        '401':
+          description: Unauthenticated access. Please login first.
+        '403':
+          description: Unauthorized access. Please check your permissions.
+        '500':
+          description: Unexpected error. Please check the response body for the stack
+            trace.
+      security:
+      - jwt: []
+      tags:
+      - RgwBucket
+    put:
+      parameters: []
+      requestBody:
+        content:
+          application/json:
+            schema:
+              properties:
+                bucket_name:
+                  default: ''
+                  type: string
+                daemon_name:
+                  type: string
+                lifecycle:
+                  default: ''
+                  type: string
+                owner:
+                  type: string
+              type: object
+      responses:
+        '200':
+          content:
+            application/vnd.ceph.api.v1.0+json:
+              type: object
+          description: Resource updated.
+        '202':
+          content:
+            application/vnd.ceph.api.v1.0+json:
+              type: object
+          description: Operation is still executing. Please check the task queue.
+        '400':
+          description: Operation exception. Please check the response body for details.
+        '401':
+          description: Unauthenticated access. Please login first.
+        '403':
+          description: Unauthorized access. Please check your permissions.
+        '500':
+          description: Unexpected error. Please check the response body for the stack
+            trace.
+      security:
+      - jwt: []
+      tags:
+      - RgwBucket
   /api/rgw/bucket/setEncryptionConfig:
     put:
       parameters: []
index 92c23f090e6b4c8dba6994647d87c1984183e5d5..1b0c05feed4489998c37fb4ad29a297c81c0bd99 100755 (executable)
@@ -948,6 +948,14 @@ class RgwClient(RestClient):
                    f' For more information about the format look at {link}')
             raise DashboardException(msg=msg, component='rgw')
 
+    def get_lifecycle_progress(self):
+        rgw_bucket_lc_progress_command = ['lc', 'list']
+        code, lifecycle_progress, _err = mgr.send_rgwadmin_command(rgw_bucket_lc_progress_command)
+        if code != 0:
+            raise DashboardException(msg=f'Error getting lifecycle status: {_err}',
+                                     component='rgw')
+        return lifecycle_progress
+
     def get_role(self, role_name: str):
         rgw_get_role_command = ['role', 'get', '--role-name', role_name]
         code, role, _err = mgr.send_rgwadmin_command(rgw_get_role_command)