raise DashboardException(
f'Failed to create subvolume group {group_name}: {err}'
)
+
+ def set(self, vol_name: str, subvol_name: str, size: str):
+ if size:
+ error_code, _, err = mgr.remote('volumes', '_cmd_fs_subvolume_resize', None, {
+ 'vol_name': vol_name, 'sub_name': subvol_name, 'new_size': size})
+ if error_code != 0:
+ raise DashboardException(
+ f'Failed to update subvolume {subvol_name}: {err}'
+ )
+
+ return f'Subvolume {subvol_name} updated successfully'
<ng-container i18n="form title"
class="modal-title">{{ action | titlecase }} {{ resource | upperFirst }}</ng-container>
- <ng-container class="modal-content">
+ <ng-container class="modal-content"
+ *cdFormLoading="loading">
<form name="subvolumeForm"
#formDir="ngForm"
[formGroup]="subvolumeForm"
<label class="cd-col-form-label"
for="size"
i18n>Size
- <cd-helper>The size of the subvolume is specified by setting a quota on it</cd-helper>
+ <cd-helper>The size of the subvolume is specified by setting a quota on it.
+ If left blank or put 0, then quota will be infinite</cd-helper>
</label>
<div class="cd-col-form-input">
<input class="form-control"
[form]="subvolumeForm"
inputField="mode"
[isTableForOctalMode]="true"
- [scopes]="scopes"></cd-checked-table-form>
+ [initialValue]="initialMode"
+ [scopes]="scopes"
+ [isDisabled]="isEdit"></cd-checked-table-form>
</div>
</div>
import { SharedModule } from '~/app/shared/shared.module';
import { RouterTestingModule } from '@angular/router/testing';
import { ReactiveFormsModule } from '@angular/forms';
+import { FormHelper } from '~/testing/unit-test-helper';
+import { CephfsSubvolumeService } from '~/app/shared/api/cephfs-subvolume.service';
describe('CephfsSubvolumeFormComponent', () => {
let component: CephfsSubvolumeFormComponent;
let fixture: ComponentFixture<CephfsSubvolumeFormComponent>;
+ let formHelper: FormHelper;
+ let createSubVolumeSpy: jasmine.Spy;
+ let editSubVolumeSpy: jasmine.Spy;
beforeEach(async () => {
await TestBed.configureTestingModule({
beforeEach(() => {
fixture = TestBed.createComponent(CephfsSubvolumeFormComponent);
component = fixture.componentInstance;
+ component.fsName = 'test_volume';
component.pools = [];
+ component.ngOnInit();
+ formHelper = new FormHelper(component.subvolumeForm);
+ createSubVolumeSpy = spyOn(TestBed.inject(CephfsSubvolumeService), 'create').and.stub();
+ editSubVolumeSpy = spyOn(TestBed.inject(CephfsSubvolumeService), 'update').and.stub();
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
+
+ it('should have a form open in modal', () => {
+ const nativeEl = fixture.debugElement.nativeElement;
+ expect(nativeEl.querySelector('cd-modal')).not.toBe(null);
+ });
+
+ it('should have the volume name prefilled', () => {
+ component.ngOnInit();
+ expect(component.subvolumeForm.get('volumeName').value).toBe('test_volume');
+ });
+
+ it('should submit the form', () => {
+ formHelper.setValue('subvolumeName', 'test_subvolume');
+ formHelper.setValue('size', 10);
+ component.submit();
+
+ expect(createSubVolumeSpy).toHaveBeenCalled();
+ expect(editSubVolumeSpy).not.toHaveBeenCalled();
+ });
+
+ it('should edit the subvolume', () => {
+ component.isEdit = true;
+ component.ngOnInit();
+ formHelper.setValue('subvolumeName', 'test_subvolume');
+ formHelper.setValue('size', 10);
+ component.submit();
+
+ expect(editSubVolumeSpy).toHaveBeenCalled();
+ expect(createSubVolumeSpy).not.toHaveBeenCalled();
+ });
});
import { CdTableColumn } from '~/app/shared/models/cd-table-column';
import _ from 'lodash';
import { CdValidators } from '~/app/shared/forms/cd-validators';
+import { CephfsSubvolumeInfo } from '~/app/shared/models/cephfs-subvolume.model';
+import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
+import { OctalToHumanReadablePipe } from '~/app/shared/pipes/octal-to-human-readable.pipe';
+import { CdForm } from '~/app/shared/forms/cd-form';
@Component({
selector: 'cd-cephfs-subvolume-form',
templateUrl: './cephfs-subvolume-form.component.html',
styleUrls: ['./cephfs-subvolume-form.component.scss']
})
-export class CephfsSubvolumeFormComponent implements OnInit {
+export class CephfsSubvolumeFormComponent extends CdForm implements OnInit {
fsName: string;
+ subVolumeName: string;
pools: Pool[];
+ isEdit = false;
subvolumeForm: CdFormGroup;
columns: CdTableColumn[];
scopePermissions: Array<any> = [];
+ initialMode = {
+ owner: ['read', 'write', 'execute'],
+ group: ['read', 'execute'],
+ others: ['read', 'execute']
+ };
scopes: string[] = ['owner', 'group', 'others'];
constructor(
private actionLabels: ActionLabelsI18n,
private taskWrapper: TaskWrapperService,
private cephFsSubvolumeService: CephfsSubvolumeService,
- private formatter: FormatterService
+ private formatter: FormatterService,
+ private dimlessBinary: DimlessBinaryPipe,
+ private octalToHumanReadable: OctalToHumanReadablePipe
) {
- this.action = this.actionLabels.CREATE;
+ super();
this.resource = $localize`Subvolume`;
}
ngOnInit(): void {
+ this.action = this.actionLabels.CREATE;
this.columns = [
{
prop: 'scope',
this.dataPools = this.pools.filter((pool) => pool.type === 'data');
this.createForm();
+
+ this.isEdit ? this.populateForm() : this.loadingReady();
}
createForm() {
});
}
+ populateForm() {
+ this.action = this.actionLabels.EDIT;
+ this.cephFsSubvolumeService
+ .info(this.fsName, this.subVolumeName)
+ .subscribe((resp: CephfsSubvolumeInfo) => {
+ // Disabled these fields since its not editable
+ this.subvolumeForm.get('subvolumeName').disable();
+ this.subvolumeForm.get('pool').disable();
+ this.subvolumeForm.get('uid').disable();
+ this.subvolumeForm.get('gid').disable();
+
+ this.subvolumeForm.get('isolatedNamespace').disable();
+ this.subvolumeForm.get('subvolumeName').setValue(this.subVolumeName);
+ if (resp.bytes_quota !== 'infinite') {
+ this.subvolumeForm.get('size').setValue(this.dimlessBinary.transform(resp.bytes_quota));
+ }
+ this.subvolumeForm.get('uid').setValue(resp.uid);
+ this.subvolumeForm.get('gid').setValue(resp.gid);
+ this.subvolumeForm.get('isolatedNamespace').setValue(resp.pool_namespace);
+ this.initialMode = this.octalToHumanReadable.transform(resp.mode, true);
+
+ this.loadingReady();
+ });
+ }
+
submit() {
const subVolumeName = this.subvolumeForm.getValue('subvolumeName');
const pool = this.subvolumeForm.getValue('pool');
- const size = this.formatter.toBytes(this.subvolumeForm.getValue('size'));
+ const size = this.formatter.toBytes(this.subvolumeForm.getValue('size')) || 0;
const uid = this.subvolumeForm.getValue('uid');
const gid = this.subvolumeForm.getValue('gid');
const mode = this.formatter.toOctalPermission(this.subvolumeForm.getValue('mode'));
const isolatedNamespace = this.subvolumeForm.getValue('isolatedNamespace');
- this.taskWrapper
- .wrapTaskAroundCall({
- task: new FinishedTask('cephfs/subvolume/' + URLVerbs.CREATE, {
- subVolumeName: subVolumeName
- }),
- call: this.cephFsSubvolumeService.create(
- this.fsName,
- subVolumeName,
- pool,
- size,
- uid,
- gid,
- mode,
- isolatedNamespace
- )
- })
- .subscribe({
- error: () => {
- this.subvolumeForm.setErrors({ cdSubmitButton: true });
- },
- complete: () => {
- this.activeModal.close();
- }
- });
+
+ if (this.isEdit) {
+ const editSize = size === 0 ? 'infinite' : size;
+ this.taskWrapper
+ .wrapTaskAroundCall({
+ task: new FinishedTask('cephfs/subvolume/' + URLVerbs.EDIT, {
+ subVolumeName: subVolumeName
+ }),
+ call: this.cephFsSubvolumeService.update(this.fsName, subVolumeName, String(editSize))
+ })
+ .subscribe({
+ error: () => {
+ this.subvolumeForm.setErrors({ cdSubmitButton: true });
+ },
+ complete: () => {
+ this.activeModal.close();
+ }
+ });
+ } else {
+ this.taskWrapper
+ .wrapTaskAroundCall({
+ task: new FinishedTask('cephfs/subvolume/' + URLVerbs.CREATE, {
+ subVolumeName: subVolumeName
+ }),
+ call: this.cephFsSubvolumeService.create(
+ this.fsName,
+ subVolumeName,
+ pool,
+ String(size),
+ uid,
+ gid,
+ mode,
+ isolatedNamespace
+ )
+ })
+ .subscribe({
+ error: () => {
+ this.subvolumeForm.setErrors({ cdSubmitButton: true });
+ },
+ complete: () => {
+ this.activeModal.close();
+ }
+ });
+ }
}
}
[columns]="columns"
selectionType="single"
[hasDetails]="false"
- (fetchData)="fetchData()">
+ (fetchData)="fetchData()"
+ (updateSelection)="updateSelection($event)">
<div class="table-actions btn-toolbar">
<cd-table-actions [permission]="permissions.cephfs"
let-row="row">
<cd-usage-bar *ngIf="row.info.bytes_pcent && row.info.bytes_pcent !== 'undefined'; else noLimitTpl"
[total]="row.info.bytes_quota"
- [used]="row.info.bytes_pcent"
+ [used]="row.info.bytes_used"
[title]="row.name"
- [calculatePerc]="false"
+ [showFreeToolTip]="false"
customLegend="Quota"
[customLegendValue]="row.info.bytes_quota"
decimals="2"></cd-usage-bar>
name: this.actionLabels.CREATE,
permission: 'create',
icon: Icons.add,
- click: () =>
- this.modalService.show(
- CephfsSubvolumeFormComponent,
- {
- fsName: this.fsName,
- pools: this.pools
- },
- { size: 'lg' }
- )
+ click: () => this.openModal(),
+ canBePrimary: (selection: CdTableSelection) => !selection.hasSelection
+ },
+ {
+ name: this.actionLabels.EDIT,
+ permission: 'update',
+ icon: Icons.edit,
+ click: () => this.openModal(true)
}
];
updateSelection(selection: CdTableSelection) {
this.selection = selection;
}
+
+ openModal(edit = false) {
+ this.modalService.show(
+ CephfsSubvolumeFormComponent,
+ {
+ fsName: this.fsName,
+ subVolumeName: this.selection?.first()?.name,
+ pools: this.pools,
+ isEdit: edit
+ },
+ { size: 'lg' }
+ );
+ }
}
fsName: string,
subVolumeName: string,
poolName: string,
- size: number,
+ size: string,
uid: number,
gid: number,
mode: string,
})
);
}
+
+ update(fsName: string, subVolumeName: string, size: string) {
+ return this.http.put(`${this.baseURL}/${fsName}`, {
+ subvol_name: subVolumeName,
+ size: size
+ });
+ }
}
<td class="text-left me-1">Used:</td>
<td class="text-right"><strong> {{ isBinary ? (used | dimlessBinary) : (used | dimless) }}</strong></td>
</tr>
- <tr *ngIf="calculatePerc">
+ <tr *ngIf="calculatePerc && showFreeToolTip">
<td class="text-left me-1">Free:</td>
<td class="'text-right"><strong>{{ isBinary ? (total - used | dimlessBinary) : (total - used | dimless) }}</strong></td>
</tr>
customLegend?: string;
@Input()
customLegendValue?: string;
+ @Input()
+ showFreeToolTip = true;
usedPercentage: number;
freePercentage: number;
id="scope_{{ row.scope }}"
type="checkbox"
[checked]="isRowChecked(row.scope)"
+ [disabled]="isDisabled"
(change)="onClickCellCheckbox(row.scope, column.prop, $event)">
<label class="datatable-permissions-scope-cell-label custom-control-label"
for="scope_{{ row.scope }}">{{ value }}</label>
<input class="custom-control-input"
type="checkbox"
[checked]="value"
+ [disabled]="isDisabled"
[id]="row.scope + '-' + column.prop"
(change)="onClickCellCheckbox(row.scope, column.prop, $event)">
<label class="custom-control-label"
<input class="custom-control-input"
id="header_{{ column.prop }}"
type="checkbox"
+ [disabled]="isDisabled"
[checked]="isHeaderChecked(column.prop)"
(change)="onClickHeaderCheckbox(column.prop, $event)">
<label class="datatable-permissions-header-cell-label custom-control-label"
@Input() inputField: string;
@Input() scopes: Array<string> = [];
@Input() isTableForOctalMode = false;
- @Input() initialValue: any = {
- owner: ['read', 'write', 'execute'],
- group: ['read', 'execute'],
- others: ['read', 'execute']
- };
+ @Input() initialValue = {};
+ @Input() isDisabled = false;
@ViewChild('headerPermissionCheckboxTpl', { static: true })
headerPermissionCheckboxTpl: TemplateRef<any>;
export interface CephfsSubvolumeInfo {
mode: number;
type: string;
- bytes_pcent: number;
- bytes_quota: number;
+ bytes_pcent: string;
+ bytes_quota: string;
data_pool: string;
path: string;
state: string;
created_at: string;
+ uid: number;
+ gid: number;
+ pool_namespace: string;
}
name: 'octalToHumanReadable'
})
export class OctalToHumanReadablePipe implements PipeTransform {
- transform(value: number): any {
+ transform(value: number, toTableArray = false): any {
if (!value) {
return [];
}
const group = permissions[parseInt(digits[5])];
const others = permissions[parseInt(digits[6])];
+ if (toTableArray) {
+ return {
+ owner: this.getItem(owner),
+ group: this.getItem(group),
+ others: this.getItem(others)
+ };
+ }
+
if (fileType !== 'directory') {
permissionSummary.push({
content: fileType,
return '-';
}
}
+
+ private getItem(item: string) {
+ const returnlist = [];
+ if (item.includes('r')) returnlist.push('read');
+ if (item.includes('w')) returnlist.push('write');
+ if (item.includes('x')) returnlist.push('execute');
+ return returnlist;
+ }
}
'cephfs/create': this.newTaskMessage(this.commonOperations.create, (metadata) =>
this.volume(metadata)
),
+ 'cephfs/remove': this.newTaskMessage(this.commonOperations.remove, (metadata) =>
+ this.volume(metadata)
+ ),
'cephfs/subvolume/create': this.newTaskMessage(this.commonOperations.create, (metadata) =>
this.subvolume(metadata)
),
+ 'cephfs/subvolume/edit': this.newTaskMessage(this.commonOperations.update, (metadata) =>
+ this.subvolume(metadata)
+ ),
'cephfs/subvolume/group/create': this.newTaskMessage(this.commonOperations.create, (metadata) =>
this.subvolumegroup(metadata)
- ),
- 'cephfs/remove': this.newTaskMessage(this.commonOperations.remove, (metadata) =>
- this.volume(metadata)
)
};
- jwt: []
tags:
- CephfsSubvolumeGroup
+ put:
+ parameters:
+ - in: path
+ name: vol_name
+ required: true
+ schema:
+ type: string
+ requestBody:
+ content:
+ application/json:
+ schema:
+ properties:
+ size:
+ type: integer
+ subvol_name:
+ type: string
+ required:
+ - subvol_name
+ - size
+ 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:
+ - CephfsSubvolumeGroup
/api/cephfs/subvolume/{vol_name}:
get:
parameters: