# -*- coding: utf-8 -*-
+import json
import logging
import os
from collections import defaultdict
except (cephfs.PermissionError, cephfs.ObjectNotFound): # pragma: no cover
paths = []
return paths
+
+
+@APIRouter('/cephfs/subvolume', Scope.CEPHFS)
+@APIDoc('CephFS Subvolume Management API', 'CephFSSubvolume')
+class CephFSSubvolume(RESTController):
+
+ def get(self, vol_name: str):
+ error_code, out, err = mgr.remote(
+ 'volumes', '_cmd_fs_subvolume_ls', None, {'vol_name': vol_name})
+ if error_code != 0:
+ raise RuntimeError(
+ f'Failed to list subvolumes for volume {vol_name}: {err}'
+ )
+ subvolumes = json.loads(out)
+ for subvolume in subvolumes:
+ error_code, out, err = mgr.remote('volumes', '_cmd_fs_subvolume_info', None, {
+ 'vol_name': vol_name, 'sub_name': subvolume['name']})
+ if error_code != 0:
+ raise RuntimeError(
+ f'Failed to get info for subvolume {subvolume["name"]}: {err}'
+ )
+ subvolume['info'] = json.loads(out)
+ return subvolumes
--- /dev/null
+<ng-container *ngIf="subVolumes$ | async as subVolumes">
+ <cd-table [data]="subVolumes"
+ columnMode="flex"
+ [columns]="columns"
+ selectionType="single"
+ [hasDetails]="false">
+ </cd-table>
+</ng-container>
+
+<ng-template #quotaUsageTpl
+ 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"
+ [title]="row.name"
+ [calculatePerc]="false"
+ customLegend="Quota"
+ [customLegendValue]="row.info.bytes_quota"
+ decimals="2"></cd-usage-bar>
+
+ <ng-template #noLimitTpl>
+ <span ngbTooltip="Quota limit is not set"
+ *ngIf="row.info.bytes_pcent === 'undefined'"
+ i18n-ngbTooltip>
+ {{row.info.bytes_used | dimlessBinary}}</span>
+ </ng-template>
+</ng-template>
+
+<ng-template #typeTpl
+ let-value="value">
+ <cd-label [value]="value"></cd-label>
+</ng-template>
+
+<ng-template #modeToHumanReadableTpl
+ let-value="value">
+ <span *ngFor="let result of (value | octalToHumanReadable)"
+ [ngClass]="result.class"
+ [ngbTooltip]="result.toolTip">
+ {{ result.content }}
+ </span>
+</ng-template>
+
+<ng-template #nameTpl
+ let-row="row">
+ <span class="fw-bold">{{row.name}}</span>
+
+ <span *ngIf="row.info.state === 'complete'; else snapshotRetainedTpl">
+ <i [ngClass]="[icons.success, icons.large]"
+ ngbTooltip="{{row.name}} is ready to use"
+ class="text-success"></i>
+ </span>
+
+ <ng-template #snapshotRetainedTpl>
+ <i [ngClass]="[icons.warning, icons.large]"
+ class="text-warning"
+ ngbTooltip="{{row.name}} is removed after retaining the snapshots"></i>
+ </ng-template>
+
+ <cd-label [value]="row.info.type"
+ *ngIf="row.info.type !== 'subvolume'"></cd-label>
+
+ <cd-label value="namespaced"
+ *ngIf="row.info.pool_namespace"
+ [tooltipText]="row.info.pool_namespace"></cd-label>
+</ng-template>
--- /dev/null
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { CephfsSubvolumeListComponent } from './cephfs-subvolume-list.component';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { SharedModule } from '~/app/shared/shared.module';
+
+describe('CephfsSubvolumeListComponent', () => {
+ let component: CephfsSubvolumeListComponent;
+ let fixture: ComponentFixture<CephfsSubvolumeListComponent>;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ declarations: [CephfsSubvolumeListComponent],
+ imports: [HttpClientTestingModule, SharedModule]
+ }).compileComponents();
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(CephfsSubvolumeListComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
--- /dev/null
+import { Component, Input, OnChanges, OnInit, ViewChild } from '@angular/core';
+import { Observable, of } from 'rxjs';
+import { catchError } from 'rxjs/operators';
+import { CephfsSubvolumeService } from '~/app/shared/api/cephfs-subvolume.service';
+import { CellTemplate } from '~/app/shared/enum/cell-template.enum';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { CephfsSubvolume } from '~/app/shared/models/cephfs-subvolume.model';
+
+@Component({
+ selector: 'cd-cephfs-subvolume-list',
+ templateUrl: './cephfs-subvolume-list.component.html',
+ styleUrls: ['./cephfs-subvolume-list.component.scss']
+})
+export class CephfsSubvolumeListComponent implements OnInit, OnChanges {
+ @ViewChild('quotaUsageTpl', { static: true })
+ quotaUsageTpl: any;
+
+ @ViewChild('typeTpl', { static: true })
+ typeTpl: any;
+
+ @ViewChild('modeToHumanReadableTpl', { static: true })
+ modeToHumanReadableTpl: any;
+
+ @ViewChild('nameTpl', { static: true })
+ nameTpl: any;
+
+ @ViewChild('quotaSizeTpl', { static: true })
+ quotaSizeTpl: any;
+
+ @Input() fsName: string;
+
+ columns: CdTableColumn[] = [];
+ context: CdTableFetchDataContext;
+ selection = new CdTableSelection();
+ icons = Icons;
+
+ subVolumes$: Observable<CephfsSubvolume[]>;
+
+ constructor(private cephfsSubVolume: CephfsSubvolumeService) {}
+
+ ngOnInit(): void {
+ this.columns = [
+ {
+ name: $localize`Name`,
+ prop: 'name',
+ flexGrow: 1,
+ cellTemplate: this.nameTpl
+ },
+ {
+ name: $localize`Data Pool`,
+ prop: 'info.data_pool',
+ flexGrow: 0.7,
+ cellTransformation: CellTemplate.badge,
+ customTemplateConfig: {
+ class: 'badge-background-primary'
+ }
+ },
+ {
+ name: $localize`Usage`,
+ prop: 'info.bytes_pcent',
+ flexGrow: 0.7,
+ cellTemplate: this.quotaUsageTpl,
+ cellClass: 'text-right'
+ },
+ {
+ name: $localize`Path`,
+ prop: 'info.path',
+ flexGrow: 1,
+ cellTransformation: CellTemplate.path
+ },
+ {
+ name: $localize`Mode`,
+ prop: 'info.mode',
+ flexGrow: 0.5,
+ cellTemplate: this.modeToHumanReadableTpl
+ },
+ {
+ name: $localize`Created`,
+ prop: 'info.created_at',
+ flexGrow: 0.5,
+ cellTransformation: CellTemplate.timeAgo
+ }
+ ];
+ }
+
+ ngOnChanges() {
+ this.subVolumes$ = this.cephfsSubVolume.get(this.fsName).pipe(
+ catchError(() => {
+ this.context.error();
+ return of(null);
+ })
+ );
+ }
+
+ updateSelection(selection: CdTableSelection) {
+ this.selection = selection;
+ }
+}
</cd-cephfs-detail>
</ng-template>
</ng-container>
+ <ng-container ngbNavItem="subvolumes">
+ <a ngbNavLink
+ i18n>Subvolumes</a>
+ <ng-template ngbNavContent>
+ <cd-cephfs-subvolume-list [fsName]="selection.mdsmap.fs_name"></cd-cephfs-subvolume-list>
+ </ng-template>
+ </ng-container>
<ng-container ngbNavItem="clients">
<a ngbNavLink>
<ng-container i18n>Clients</ng-container>
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { TreeModule } from '@circlon/angular-tree-component';
-import { NgbNavModule, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap';
+import { NgbNavModule, NgbTooltipModule, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap';
import { ChartsModule } from 'ng2-charts';
import { AppRoutingModule } from '~/app/app-routing.module';
import { CephfsVolumeFormComponent } from './cephfs-form/cephfs-form.component';
import { CephfsListComponent } from './cephfs-list/cephfs-list.component';
import { CephfsTabsComponent } from './cephfs-tabs/cephfs-tabs.component';
+import { CephfsSubvolumeListComponent } from './cephfs-subvolume-list/cephfs-subvolume-list.component';
@NgModule({
imports: [
NgbNavModule,
FormsModule,
ReactiveFormsModule,
- NgbTypeaheadModule
+ NgbTypeaheadModule,
+ NgbTooltipModule
],
declarations: [
CephfsDetailComponent,
CephfsListComponent,
CephfsTabsComponent,
CephfsVolumeFormComponent,
- CephfsDirectoriesComponent
+ CephfsDirectoriesComponent,
+ CephfsSubvolumeListComponent
]
})
export class CephfsModule {}
--- /dev/null
+import { TestBed } from '@angular/core/testing';
+
+import { CephfsSubvolumeService } from './cephfs-subvolume.service';
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { configureTestBed } from '~/testing/unit-test-helper';
+
+describe('CephfsSubvolumeService', () => {
+ let service: CephfsSubvolumeService;
+ let httpTesting: HttpTestingController;
+
+ configureTestBed({
+ imports: [HttpClientTestingModule],
+ providers: [CephfsSubvolumeService]
+ });
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({});
+ service = TestBed.inject(CephfsSubvolumeService);
+ httpTesting = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpTesting.verify();
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ it('should call get', () => {
+ service.get('testFS').subscribe();
+ const req = httpTesting.expectOne('api/cephfs/subvolume/testFS');
+ expect(req.request.method).toBe('GET');
+ });
+});
--- /dev/null
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+import { CephfsSubvolume } from '../models/cephfs-subvolume.model';
+import { Observable } from 'rxjs';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class CephfsSubvolumeService {
+ baseURL = 'api/cephfs/subvolume';
+
+ constructor(private http: HttpClient) {}
+
+ get(fsName: string): Observable<CephfsSubvolume[]> {
+ return this.http.get<CephfsSubvolume[]>(`${this.baseURL}/${fsName}`);
+ }
+}
<span *ngIf="!key; else key_value"
class="badge badge-{{value}}"
- ngClass="{{value | colorClassFromText}}">
+ ngClass="{{value | colorClassFromText}}"
+ [ngbTooltip]="tooltipText">
{{ value }}
</span>
export class CdLabelComponent {
@Input() key?: string;
@Input() value?: string;
+ @Input() tooltipText?: string;
}
-<button (click)="onClick()"
- type="button"
- class="btn btn-light"
- i18n-title
- title="Copy to Clipboard">
- <i [ngClass]="[icons.clipboard]"></i>
-</button>
+<i [ngClass]="[icons.clipboard, icons.large]"
+ (click)="onClick()"
+ class="text-primary ms-2"
+ title="Copy to Clipboard"
+ *ngIf="showIconOnly; else withButtonTpl"></i>
+
+<ng-template #withButtonTpl>
+ <button (click)="onClick()"
+ type="button"
+ class="btn btn-light"
+ i18n-title
+ title="Copy to Clipboard">
+ <i [ngClass]="[icons.clipboard]"></i>
+ </button>
+</ng-template>
@Input()
byId = true;
+ @Input()
+ showIconOnly = false;
+
icons = Icons;
constructor(private toastr: ToastrService) {}
<ng-template #usageTooltipTpl>
<table>
<tr>
- <td class="text-left">Used: </td>
+ <td class="text-left me-1">Used:</td>
<td class="text-right"><strong> {{ isBinary ? (used | dimlessBinary) : (used | dimless) }}</strong></td>
</tr>
<tr *ngIf="calculatePerc">
- <td class="text-left">Free: </td>
+ <td class="text-left me-1">Free:</td>
<td class="'text-right"><strong>{{ isBinary ? (total - used | dimlessBinary) : (total - used | dimless) }}</strong></td>
</tr>
+ <tr *ngIf="customLegend">
+ <td class="text-left me-1">{{ customLegend }}:</td>
+ <td class="text-right"><strong>{{ isBinary ? (customLegendValue | dimlessBinary) : (customLegend[1] | dimless) }}</strong></td>
+ </tr>
</table>
</ng-template>
calculatePerc = true;
@Input()
title = $localize`usage`;
+ @Input()
+ customLegend?: string;
+ @Input()
+ customLegendValue?: string;
usedPercentage: number;
freePercentage: number;
<span data-toggle="tooltip"
[title]="value | cdDate">{{ value | relativeDate }}</span>
</ng-template>
+
+<ng-template #pathTpl
+ let-value="value">
+ <span data-toggle="tooltip"
+ [title]="value"
+ class="font-monospace">{{ value | path }}
+ <cd-copy-2-clipboard-button [source]="value"
+ [byId]="false"
+ [showIconOnly]="true">
+ </cd-copy-2-clipboard-button>
+ </span>
+</ng-template>
rowDetailsTpl: TemplateRef<any>;
@ViewChild('rowSelectionTpl', { static: true })
rowSelectionTpl: TemplateRef<any>;
+ @ViewChild('pathTpl', { static: true })
+ pathTpl: TemplateRef<any>;
// This is the array with the items to be shown.
@Input()
this.cellTemplates.map = this.mapTpl;
this.cellTemplates.truncate = this.truncateTpl;
this.cellTemplates.timeAgo = this.timeAgoTpl;
+ this.cellTemplates.path = this.pathTpl;
}
useCustomClass(value: any): string {
This templace replaces a time, datetime or timestamp with a user-friendly "X {seconds,minutes,hours,days,...} ago",
but the tooltip still displays the absolute timestamp
*/
- timeAgo = 'timeAgo'
+ timeAgo = 'timeAgo',
+ /*
+ This template truncates a path to a shorter format and shows the whole path in a tooltip
+ eg: /var/lib/ceph/osd/ceph-0 -> /var/.../ceph-0
+ */
+ path = 'path'
}
--- /dev/null
+export interface CephfsSubvolume {
+ name: string;
+ info: CephfsSubvolumeInfo;
+}
+
+export interface CephfsSubvolumeInfo {
+ mode: number;
+ type: string;
+ bytes_pcent: number;
+ bytes_quota: number;
+ data_pool: string;
+ path: string;
+ state: string;
+ created_at: string;
+}
--- /dev/null
+import { OctalToHumanReadablePipe } from './octal-to-human-readable.pipe';
+
+describe('OctalToHumanReadablePipe', () => {
+ const testPipeResults = (value: any, expected: any) => {
+ // eslint-disable-next-line
+ for (let r in value) {
+ expect(value[r].content).toEqual(expected[r].content);
+ }
+ };
+
+ it('create an instance', () => {
+ const pipe = new OctalToHumanReadablePipe();
+ expect(pipe).toBeTruthy();
+ });
+
+ it('should transform decimal values to octal mode human readable', () => {
+ const values = [16877, 16868, 16804];
+
+ const expected = [
+ [{ content: 'owner: rwx' }, { content: 'group: r-x' }, { content: 'others: r-x' }],
+ [{ content: 'owner: rwx' }, { content: 'group: r--' }, { content: 'others: r--' }],
+ [{ content: 'owner: rw-' }, { content: 'group: r--' }, { content: 'others: r--' }]
+ ];
+
+ const pipe = new OctalToHumanReadablePipe();
+ // eslint-disable-next-line
+ for (let index in values) {
+ const summary = pipe.transform(values[index]);
+ testPipeResults(summary, expected[index]);
+ }
+ });
+});
--- /dev/null
+import { Pipe, PipeTransform } from '@angular/core';
+
+@Pipe({
+ name: 'octalToHumanReadable'
+})
+export class OctalToHumanReadablePipe implements PipeTransform {
+ transform(value: number): any {
+ if (!value) {
+ return [];
+ }
+ const permissionSummary = [];
+ const permissions = ['---', '--x', '-w-', '-wx', 'r--', 'r-x', 'rw-', 'rwx'];
+ const octal = value.toString(8).padStart(7, '0');
+ const digits = octal.split('');
+
+ const fileType = this.getFileTypeSymbol(parseInt(digits[1] + digits[2]));
+ const owner = permissions[parseInt(digits[4])];
+ const group = permissions[parseInt(digits[5])];
+ const others = permissions[parseInt(digits[6])];
+
+ if (fileType !== 'directory') {
+ permissionSummary.push({
+ content: fileType,
+ class: 'badge-primary me-1'
+ });
+ }
+
+ if (owner !== '---') {
+ permissionSummary.push({
+ content: `owner: ${owner}`,
+ class: 'badge-primary me-1'
+ });
+ }
+
+ if (group !== '---') {
+ permissionSummary.push({
+ content: `group: ${group}`,
+ class: 'badge-primary me-1'
+ });
+ }
+
+ if (others !== '---') {
+ permissionSummary.push({
+ content: `others: ${others}`,
+ class: 'badge-primary me-1'
+ });
+ }
+
+ if (permissionSummary.length === 0) {
+ return [
+ {
+ content: 'no permissions',
+ class: 'badge-warning me-1',
+ toolTip: `owner: ${owner}, group: ${group}, others: ${others}`
+ }
+ ];
+ }
+
+ return permissionSummary;
+ }
+
+ private getFileTypeSymbol(fileType: number): string {
+ switch (fileType) {
+ case 1:
+ return 'fifo';
+ case 2:
+ return 'character';
+ case 4:
+ return 'directory';
+ case 6:
+ return 'block';
+ case 10:
+ return 'regular';
+ case 12:
+ return 'symbolic-link';
+ default:
+ return '-';
+ }
+ }
+}
--- /dev/null
+import { PathPipe } from './path.pipe';
+
+describe('PathPipe', () => {
+ it('create an instance', () => {
+ const pipe = new PathPipe();
+ expect(pipe).toBeTruthy();
+ });
+
+ it('should transform the path', () => {
+ const pipe = new PathPipe();
+ expect(pipe.transform('/a/b/c/d')).toBe('/a/.../d');
+ });
+
+ it('should transform the path with no slash at beginning', () => {
+ const pipe = new PathPipe();
+ expect(pipe.transform('a/b/c/d')).toBe('a/.../d');
+ });
+});
--- /dev/null
+import { Pipe, PipeTransform } from '@angular/core';
+
+@Pipe({
+ name: 'path'
+})
+export class PathPipe implements PipeTransform {
+ transform(value: unknown): string {
+ const splittedPath = value.toString().split('/');
+
+ if (splittedPath[0] === '') {
+ splittedPath.shift();
+ return `/${splittedPath[0]}/.../${splittedPath[splittedPath.length - 1]}`;
+ }
+ return `${splittedPath[0]}/.../${splittedPath[splittedPath.length - 1]}`;
+ }
+}
import { SearchHighlightPipe } from './search-highlight.pipe';
import { TruncatePipe } from './truncate.pipe';
import { UpperFirstPipe } from './upper-first.pipe';
+import { OctalToHumanReadablePipe } from './octal-to-human-readable.pipe';
+import { PathPipe } from './path.pipe';
@NgModule({
imports: [CommonModule],
HealthIconPipe,
MgrSummaryPipe,
MdsSummaryPipe,
- OsdSummaryPipe
+ OsdSummaryPipe,
+ OctalToHumanReadablePipe,
+ PathPipe
],
exports: [
ArrayPipe,
HealthIconPipe,
MgrSummaryPipe,
MdsSummaryPipe,
- OsdSummaryPipe
+ OsdSummaryPipe,
+ OctalToHumanReadablePipe,
+ PathPipe
],
providers: [
ArrayPipe,
HealthIconPipe,
MgrSummaryPipe,
MdsSummaryPipe,
- OsdSummaryPipe
+ OsdSummaryPipe,
+ OctalToHumanReadablePipe
]
})
export class PipesModule {}
*/
transform(value: Date | string | number, upperFirst = true): string {
let date: moment.Moment;
+ const offset = moment().utcOffset();
if (_.isNumber(value)) {
- date = moment.unix(value);
+ date = moment.parseZone(moment.unix(value)).utc().utcOffset(offset).local();
} else {
- date = moment(value);
+ date = moment.parseZone(value).utc().utcOffset(offset).local();
}
if (!date.isValid()) {
return '';
- jwt: []
tags:
- Cephfs
+ /api/cephfs/subvolume/{vol_name}:
+ get:
+ parameters:
+ - in: path
+ name: vol_name
+ required: true
+ 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:
+ - CephFSSubvolume
/api/cephfs/{fs_id}:
get:
parameters:
tags:
- description: Initiate a session with Ceph
name: Auth
+- description: CephFS Subvolume Management API
+ name: CephFSSubvolume
- description: Cephfs Management API
name: Cephfs
- description: Get Cluster Details