From: Dnyaneshwari Talwekar Date: Wed, 7 Jan 2026 10:46:55 +0000 (+0530) Subject: mgr/dashboard: Cephfs Mirroring - Filesystem Selection X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=refs%2Fpull%2F66821%2Fhead;p=ceph.git mgr/dashboard: Cephfs Mirroring - Filesystem Selection Fixes: https://tracker.ceph.com/issues/74280 Signed-off-by: Dnyaneshwari Talwekar --- diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-filesystem-selector/cephfs-filesystem-selector.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-filesystem-selector/cephfs-filesystem-selector.component.html new file mode 100644 index 00000000000..0a2a647b9a2 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-filesystem-selector/cephfs-filesystem-selector.component.html @@ -0,0 +1,78 @@ +
+
Select filesystem
+
+
+ +
+
Selection requirements
+
    +
  • Only one filesystem can be selected as the mirroring target
  • +
  • The selected filesystem must have an active MDS (Metadata Server)
  • +
  • Ensure sufficient storage capacity for incoming mirrored snapshots
  • +
+
+
+
+ +
+ + +
+ @if (value === 'Active') { + + } + @if (value === 'Warning') { + + } + @if (value === 'Inactive') { + + } + {{ mdsStatusLabels[value] }} +
+
+ + +
+ @if (value === 'Enabled') { + + } + @if (value === 'Disabled') { + + } + {{ mirroringStatusLabels[value] }} +
+
+ + + + + @for (pool of row.pools; track $index; let last = $last) { + {{ pool }} + @if (!last) { + , + } + } + + + {{ row[col.prop] }} + + + +
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-filesystem-selector/cephfs-filesystem-selector.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-filesystem-selector/cephfs-filesystem-selector.component.scss new file mode 100644 index 00000000000..46d43981012 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-filesystem-selector/cephfs-filesystem-selector.component.scss @@ -0,0 +1,7 @@ +@use '@carbon/layout'; + +.requirements-list { + list-style-type: disc; + padding-left: var(--cds-spacing-05); + margin-left: layout.$spacing-02; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-filesystem-selector/cephfs-filesystem-selector.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-filesystem-selector/cephfs-filesystem-selector.component.spec.ts new file mode 100644 index 00000000000..47f8e054e97 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-filesystem-selector/cephfs-filesystem-selector.component.spec.ts @@ -0,0 +1,147 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; + +import { CephfsService } from '~/app/shared/api/cephfs.service'; +import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe'; +import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; +import { CephfsFilesystemSelectorComponent } from './cephfs-filesystem-selector.component'; + +const createDetail = ( + id: number, + name: string, + pools: Array<{ pool: string; used: number }>, + enabled = true, + peers: Record = { peer: {} } +) => ({ + cephfs: { + id, + name, + pools, + flags: { enabled }, + mirror_info: { peers } + } +}); + +describe('CephfsFilesystemSelectorComponent', () => { + let component: CephfsFilesystemSelectorComponent; + let fixture: ComponentFixture; + let cephfsServiceMock: jest.Mocked>; + + beforeEach(async () => { + cephfsServiceMock = { + list: jest.fn(), + getCephfs: jest.fn() + }; + + cephfsServiceMock.list.mockReturnValue(of([])); + cephfsServiceMock.getCephfs.mockReturnValue(of(null)); + + await TestBed.configureTestingModule({ + declarations: [CephfsFilesystemSelectorComponent], + providers: [{ provide: CephfsService, useValue: cephfsServiceMock }, DimlessBinaryPipe], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + + fixture = TestBed.createComponent(CephfsFilesystemSelectorComponent); + component = fixture.componentInstance; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should configure columns on init', () => { + fixture.detectChanges(); + + expect(component.columns.map((c) => c.prop)).toEqual([ + 'name', + 'used', + 'pools', + 'mdsStatus', + 'mirroringStatus' + ]); + }); + + it('should populate filesystems from service data', fakeAsync(() => { + cephfsServiceMock.list.mockReturnValue(of([{ id: 1 }])); + cephfsServiceMock.getCephfs.mockReturnValue( + of( + createDetail(1, 'fs1', [ + { pool: 'data', used: 100 }, + { pool: 'meta', used: 50 } + ]) + ) + ); + + fixture.detectChanges(); + + let filesystems: any[] = []; + component.filesystems$.subscribe((rows) => { + filesystems = rows; + }); + tick(); + + expect(filesystems).toEqual([ + { + id: 1, + name: 'fs1', + pools: ['data', 'meta'], + used: '150', + mdsStatus: 'Inactive', + mirroringStatus: 'Disabled' + } + ]); + })); + + it('should set mirroring status to Disabled when list response has no mirror info', fakeAsync(() => { + cephfsServiceMock.list.mockReturnValue(of([{ id: 2 }])); + cephfsServiceMock.getCephfs.mockReturnValue(of(createDetail(2, 'fs2', []))); + + fixture.detectChanges(); + + let filesystems: any[] = []; + component.filesystems$.subscribe((rows) => { + filesystems = rows; + }); + tick(); + + expect(filesystems[0].mirroringStatus).toBe('Disabled'); + })); + + it('should produce empty filesystems when list is empty', fakeAsync(() => { + cephfsServiceMock.list.mockReturnValue(of([])); + + fixture.detectChanges(); + + let filesystems: any[] = []; + component.filesystems$.subscribe((rows) => { + filesystems = rows; + }); + tick(); + + expect(filesystems).toEqual([]); + expect(cephfsServiceMock.getCephfs).not.toHaveBeenCalled(); + })); + + it('should skip null details when getCephfs errors', fakeAsync(() => { + cephfsServiceMock.list.mockReturnValue(of([{ id: 3 }])); + cephfsServiceMock.getCephfs.mockReturnValue(throwError(() => new Error('boom'))); + + fixture.detectChanges(); + + let filesystems: any[] = []; + component.filesystems$.subscribe((rows) => { + filesystems = rows; + }); + tick(); + + expect(filesystems).toEqual([]); + })); + + it('should update selection reference', () => { + const selection = new CdTableSelection([{ id: 1 }]); + component.updateSelection(selection); + expect(component.selection).toBe(selection); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-filesystem-selector/cephfs-filesystem-selector.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-filesystem-selector/cephfs-filesystem-selector.component.ts new file mode 100644 index 00000000000..56d3bbb204c --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-filesystem-selector/cephfs-filesystem-selector.component.ts @@ -0,0 +1,115 @@ +import { Component, OnInit, TemplateRef, ViewChild, inject } from '@angular/core'; + +import { CephfsService } from '~/app/shared/api/cephfs.service'; +import { CdTableColumn } from '~/app/shared/models/cd-table-column'; +import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; +import { Icons } from '~/app/shared/enum/icons.enum'; +import { catchError, map, switchMap } from 'rxjs/operators'; +import { forkJoin, of, Observable } from 'rxjs'; +import { CellTemplate } from '~/app/shared/enum/cell-template.enum'; +import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe'; +import { + CephfsDetail, + FilesystemRow, + MdsStatus, + MirroringStatus, + MIRRORING_STATUS, + mdsStateToStatus +} from '~/app/shared/models/cephfs.model'; + +@Component({ + selector: 'cd-cephfs-filesystem-selector', + templateUrl: './cephfs-filesystem-selector.component.html', + standalone: false, + styleUrls: ['./cephfs-filesystem-selector.component.scss'] +}) +export class CephfsFilesystemSelectorComponent implements OnInit { + @ViewChild('mdsStatus', { static: true }) + mdsStatus: TemplateRef; + @ViewChild('mirroringStatus', { static: true }) + mirroringStatus: TemplateRef; + columns: CdTableColumn[] = []; + filesystems$: Observable = of([]); + selection = new CdTableSelection(); + icons = Icons; + mdsStatusLabels: Record = { + Active: $localize`Active`, + Warning: $localize`Warning`, + Inactive: $localize`Inactive` + }; + mirroringStatusLabels: Record = { + Enabled: $localize`Enabled`, + Disabled: $localize`Disabled` + }; + + private cephfsService = inject(CephfsService); + private dimlessBinaryPipe = inject(DimlessBinaryPipe); + + ngOnInit(): void { + this.columns = [ + { name: $localize`Filesystem name`, prop: 'name', flexGrow: 2 }, + { name: $localize`Usage`, prop: 'used', flexGrow: 1, pipe: this.dimlessBinaryPipe }, + { + prop: $localize`pools`, + name: 'Pools used', + cellTransformation: CellTemplate.tag, + customTemplateConfig: { + class: 'tag-background-primary' + }, + flexGrow: 1.3 + }, + { name: $localize`Status`, prop: 'mdsStatus', flexGrow: 0.8, cellTemplate: this.mdsStatus }, + + { + name: $localize`Mirroring status`, + prop: 'mirroringStatus', + flexGrow: 0.8, + cellTemplate: this.mirroringStatus + } + ]; + + this.filesystems$ = this.cephfsService.list().pipe( + switchMap((listResponse: Array) => { + if (!listResponse?.length) { + return of([]); + } + const detailRequests = listResponse.map( + (fs): Observable => + this.cephfsService.getCephfs(fs.id).pipe(catchError(() => of(null))) + ); + return forkJoin(detailRequests).pipe( + map((details: Array) => + details + .map((detail, index) => { + if (!detail?.cephfs) { + return null; + } + const listItem = listResponse[index]; + const pools = detail.cephfs.pools || []; + const poolNames = pools.map((p) => p.pool); + const totalUsed = pools.reduce((sum, p) => sum + p.used, 0); + const mdsInfo = listItem?.mdsmap?.info ?? {}; + const firstMdsGid = Object.keys(mdsInfo)[0]; + const mdsState = firstMdsGid ? mdsInfo[firstMdsGid]?.state : undefined; + return { + id: detail.cephfs.id, + name: detail.cephfs.name, + pools: poolNames, + used: `${totalUsed}`, + mdsStatus: mdsStateToStatus(mdsState), + mirroringStatus: listItem?.mirror_info + ? MIRRORING_STATUS.Enabled + : MIRRORING_STATUS.Disabled + } as FilesystemRow; + }) + .filter((row): row is FilesystemRow => row !== null) + ) + ); + }) + ); + } + + updateSelection(selection: CdTableSelection) { + this.selection = selection; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard-step.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard-step.enum.ts index e8db5f9cf48..2b712d810ba 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard-step.enum.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard-step.enum.ts @@ -1,10 +1,10 @@ -export enum StepTitles { - ChooseMirrorPeerRole = 'Choose mirror peer role', - SelectFilesystem = 'Select filesystem', - CreateOrSelectEntity = 'Create or select entity', - GenerateBootstrapToken = 'Generate bootstrap token', - Review = 'Review' -} +export const StepTitles = { + ChooseMirrorPeerRole: $localize`Choose mirror peer role`, + SelectFilesystem: $localize`Select filesystem`, + CreateOrSelectEntity: $localize`Create or select entity`, + GenerateBootstrapToken: $localize`Generate bootstrap token`, + Review: $localize`Review` +} as const; export const STEP_TITLES_MIRRORING_CONFIGURED = [ StepTitles.ChooseMirrorPeerRole, diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard.component.html index 5ac776029b2..029bcc3f294 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard.component.html @@ -67,8 +67,7 @@ type="info" spacingClass="mb-3 mt-3" dismissible="true" - (dismissed)="showMessage = false" - class="mirroring-alert"> + (dismissed)="showMessage = false">
About Remote Peer Setup
@@ -94,7 +93,8 @@ -
Test 1
+ +
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard.component.scss index b7a07e3a90f..c327f0ffe19 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard.component.scss @@ -1,5 +1,5 @@ -:host ::ng-deep cd-alert-panel.mirroring-alert cds-actionable-notification { - max-width: 77% !important; +form { + max-width: 77%; } .list-disc { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs.module.ts index 548cb4c9821..47ec4f9b355 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs.module.ts @@ -58,6 +58,7 @@ import Close from '@carbon/icons/es/close/32'; import Trash from '@carbon/icons/es/trash-can/32'; import { CephfsMirroringListComponent } from './cephfs-mirroring-list/cephfs-mirroring-list.component'; import { CephfsMirroringWizardComponent } from './cephfs-mirroring-wizard/cephfs-mirroring-wizard.component'; +import { CephfsFilesystemSelectorComponent } from './cephfs-filesystem-selector/cephfs-filesystem-selector.component'; @NgModule({ imports: [ @@ -112,7 +113,8 @@ import { CephfsMirroringWizardComponent } from './cephfs-mirroring-wizard/cephfs CephfsMountDetailsComponent, CephfsAuthModalComponent, CephfsMirroringListComponent, - CephfsMirroringWizardComponent + CephfsMirroringWizardComponent, + CephfsFilesystemSelectorComponent ], providers: [provideCharts(withDefaultRegisterables())] }) diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html index 81b63d35d41..0065b624746 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html @@ -179,6 +179,7 @@ [expandable]="model.isRowExpandable(i)" [expanded]="model.isRowExpanded(i)" [showSelectionColumn]="showSelectionColumn" + [enableSingleSelect]="enableSingleSelect" [skeleton]="loadingIndicator" (selectRow)="onSelect(i)" (deselectRow)="onDeselect(i)" @@ -320,7 +321,7 @@ -@if (value | boolean) { + @if (value | boolean) { } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts index 30edd6a6ec4..2ce7a5182d4 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts @@ -169,7 +169,7 @@ export class TableComponent implements AfterViewInit, OnInit, OnChanges, OnDestr // Allows other components to specify which type of selection they want, // e.g. 'single' or 'multi'. @Input() - selectionType: string = undefined; + selectionType: 'single' | 'multiClick' | 'singleRadio' = undefined; // By default selected item details will be updated on table refresh, if data has changed @Input() updateSelectionOnRefresh: 'always' | 'never' | 'onChange' = 'onChange'; @@ -313,11 +313,11 @@ export class TableComponent implements AfterViewInit, OnInit, OnChanges, OnDestr } get showSelectionColumn() { - return this.selectionType === 'multiClick'; + return this.selectionType === 'multiClick' || this.selectionType === 'singleRadio'; } get enableSingleSelect() { - return this.selectionType === 'single'; + return this.selectionType === 'single' || this.selectionType === 'singleRadio'; } get headerTitle(): string | TemplateRef { @@ -1123,7 +1123,7 @@ export class TableComponent implements AfterViewInit, OnInit, OnChanges, OnDestr } onSelect(selectedRowIndex: number) { - if (this.selectionType === 'single') { + if (this.selectionType === 'single' || this.selectionType === 'singleRadio') { this.model.selectAll(false); this.selection.selected = [_.get(this.model.data?.[selectedRowIndex], [0, 'selected'])]; this.model.selectRow(selectedRowIndex, true); @@ -1146,7 +1146,7 @@ export class TableComponent implements AfterViewInit, OnInit, OnChanges, OnDestr onDeselect(deselectedRowIndex: number) { this.model.selectRow(deselectedRowIndex, false); - if (this.selectionType === 'single') { + if (this.selectionType === 'single' || this.selectionType === 'singleRadio') { return; } this._toggleSelection(deselectedRowIndex, false); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs.model.ts index 955b423b55a..e6db4222000 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs.model.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs.model.ts @@ -52,4 +52,81 @@ export interface MirroringRow { id?: string; } +export type CephfsPool = { + pool: string; + used: number; +}; + +export type CephfsDetail = { + id: number; + mdsmap: { + info: Record; + fs_name: string; + enabled: boolean; + [key: string]: any; + }; + mirror_info?: { + peers?: Record; + }; + cephfs: { + id: number; + name: string; + pools: CephfsPool[]; + flags?: { + enabled?: boolean; + }; + mirror_info?: { + peers?: Record; + }; + }; +}; + +export type FilesystemRow = { + id: number; + name: string; + pools: string[]; + used: string; + mdsStatus: MdsStatus; + mirroringStatus: MirroringStatus; +}; + +export type MdsStatus = 'Active' | 'Warning' | 'Inactive'; + +export type MirroringStatus = 'Enabled' | 'Disabled'; + +export const MDS_STATE = { + UP_ACTIVE: 'up:active', + UP_STARTING: 'up:starting', + UP_REJOIN: 'up:rejoin', + DOWN_FAILED: 'down:failed', + DOWN_STOPPED: 'down:stopped', + DOWN_CRASHED: 'down:crashed', + UNKNOWN: 'unknown' +} as const; + +export const MDS_STATUS: Record = { + Active: 'Active', + Warning: 'Warning', + Inactive: 'Inactive' +} as const; + +export const MIRRORING_STATUS: Record = { + Enabled: 'Enabled', + Disabled: 'Disabled' +} as const; + +const MDS_STATE_TO_STATUS: Record = { + [MDS_STATE.UP_ACTIVE]: MDS_STATUS.Active, + [MDS_STATE.UP_STARTING]: MDS_STATUS.Warning, + [MDS_STATE.UP_REJOIN]: MDS_STATUS.Warning, + [MDS_STATE.DOWN_FAILED]: MDS_STATUS.Inactive, + [MDS_STATE.DOWN_STOPPED]: MDS_STATUS.Inactive, + [MDS_STATE.DOWN_CRASHED]: MDS_STATUS.Inactive +}; + +export function mdsStateToStatus(state: string | undefined): MdsStatus { + const status = state ? MDS_STATE_TO_STATUS[state] : undefined; + return status ?? MDS_STATUS.Inactive; +} + export type DaemonResponse = Daemon[]; diff --git a/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_spacings.scss b/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_spacings.scss index b4eb9862906..dd87e614c70 100644 --- a/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_spacings.scss +++ b/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_spacings.scss @@ -4,6 +4,10 @@ padding: 0; } +.cds-pt-2px { + padding-top: 2px; +} + .cds-pt-3 { padding-top: layout.$spacing-03; }