--- /dev/null
+<div cds-mb="lg">
+ <div class="cds--type-heading-03"
+ i18n>Select filesystem</div>
+</div>
+<div class="cds-mt-5">
+ <cd-alert-panel type="info">
+ <div [cdsStack]="'vertical'"
+ [gap]="2">
+ <div class="cds--type-heading-compact-01"
+ i18n>Selection requirements</div>
+ <ul class="cds--type-body-compact-01 requirements-list">
+ <li i18n>Only one filesystem can be selected as the mirroring target</li>
+ <li i18n>The selected filesystem must have an active MDS (Metadata Server)</li>
+ <li i18n>Ensure sufficient storage capacity for incoming mirrored snapshots</li>
+ </ul>
+ </div>
+ </cd-alert-panel>
+</div>
+
+<div class="cds-mt-5">
+ <cd-table [data]="(filesystems$ | async) ?? []"
+ [columns]="columns"
+ columnMode="flex"
+ selectionType="singleRadio"
+ headerTitle="Select target filesystem"
+ headerDescription="Choose the filesystem that will receive mirrored data from the local cluster."
+ (updateSelection)="updateSelection($event)">
+ <ng-template #mdsStatus
+ let-value="data.value"
+ let-row="data.row">
+ <div [cdsStack]="'horizontal'"
+ gap="2">
+ @if (value === 'Active') {
+ <cd-icon type="success"></cd-icon>
+ }
+ @if (value === 'Warning') {
+ <cd-icon type="warning"></cd-icon>
+ }
+ @if (value === 'Inactive') {
+ <cd-icon type="error"></cd-icon>
+ }
+ <span class="cds--type-body-compact-01 cds-pt-2px">{{ mdsStatusLabels[value] }}</span>
+ </div>
+ </ng-template>
+
+ <ng-template #mirroringStatus
+ let-value="data.value"
+ let-row="data.row">
+ <div [cdsStack]="'horizontal'"
+ gap="2">
+ @if (value === 'Enabled') {
+ <cd-icon type="success"></cd-icon>
+ }
+ @if (value === 'Disabled') {
+ <cd-icon type="warning"></cd-icon>
+ }
+ <span class="cds--type-body-compact-01 cds-pt-2px">{{ mirroringStatusLabels[value] }}</span>
+ </div>
+ </ng-template>
+
+ <ng-template let-row="row"
+ let-col="col">
+ <ng-container [ngSwitch]="col.prop">
+ <ng-container *ngSwitchCase="'pools'">
+ @for (pool of row.pools; track $index; let last = $last) {
+ <cd-badge>{{ pool }}</cd-badge>
+ @if (!last) {
+ <span>, </span>
+ }
+ }
+ </ng-container>
+ <ng-container *ngSwitchDefault>
+ {{ row[col.prop] }}
+ </ng-container>
+ </ng-container>
+ </ng-template>
+ </cd-table>
+</div>
--- /dev/null
+@use '@carbon/layout';
+
+.requirements-list {
+ list-style-type: disc;
+ padding-left: var(--cds-spacing-05);
+ margin-left: layout.$spacing-02;
+}
--- /dev/null
+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<string, unknown> = { peer: {} }
+) => ({
+ cephfs: {
+ id,
+ name,
+ pools,
+ flags: { enabled },
+ mirror_info: { peers }
+ }
+});
+
+describe('CephfsFilesystemSelectorComponent', () => {
+ let component: CephfsFilesystemSelectorComponent;
+ let fixture: ComponentFixture<CephfsFilesystemSelectorComponent>;
+ let cephfsServiceMock: jest.Mocked<Pick<CephfsService, 'list' | 'getCephfs'>>;
+
+ 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);
+ });
+});
--- /dev/null
+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<any>;
+ @ViewChild('mirroringStatus', { static: true })
+ mirroringStatus: TemplateRef<any>;
+ columns: CdTableColumn[] = [];
+ filesystems$: Observable<FilesystemRow[]> = of([]);
+ selection = new CdTableSelection();
+ icons = Icons;
+ mdsStatusLabels: Record<MdsStatus, string> = {
+ Active: $localize`Active`,
+ Warning: $localize`Warning`,
+ Inactive: $localize`Inactive`
+ };
+ mirroringStatusLabels: Record<MirroringStatus, string> = {
+ 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<CephfsDetail>) => {
+ if (!listResponse?.length) {
+ return of([]);
+ }
+ const detailRequests = listResponse.map(
+ (fs): Observable<CephfsDetail | null> =>
+ this.cephfsService.getCephfs(fs.id).pipe(catchError(() => of(null)))
+ );
+ return forkJoin(detailRequests).pipe(
+ map((details: Array<CephfsDetail | null>) =>
+ 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;
+ }
+}
-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,
type="info"
spacingClass="mb-3 mt-3"
dismissible="true"
- (dismissed)="showMessage = false"
- class="mirroring-alert">
+ (dismissed)="showMessage = false">
<div>
<div class="cds--type-heading-compact-01 cds-mb-2"
i18n>About Remote Peer Setup</div>
<!-- Step 1 -->
<cd-tearsheet-step>
- <div>Test 1</div>
+ <cd-cephfs-filesystem-selector>
+ </cd-cephfs-filesystem-selector>
</cd-tearsheet-step>
<!-- Step 2 -->
-:host ::ng-deep cd-alert-panel.mirroring-alert cds-actionable-notification {
- max-width: 77% !important;
+form {
+ max-width: 77%;
}
.list-disc {
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: [
CephfsMountDetailsComponent,
CephfsAuthModalComponent,
CephfsMirroringListComponent,
- CephfsMirroringWizardComponent
+ CephfsMirroringWizardComponent,
+ CephfsFilesystemSelectorComponent
],
providers: [provideCharts(withDefaultRegisterables())]
})
[expandable]="model.isRowExpandable(i)"
[expanded]="model.isRowExpanded(i)"
[showSelectionColumn]="showSelectionColumn"
+ [enableSingleSelect]="enableSingleSelect"
[skeleton]="loadingIndicator"
(selectRow)="onSelect(i)"
(deselectRow)="onDeselect(i)"
<ng-template #checkIconTpl
let-value="data.value">
-@if (value | boolean) {
+ @if (value | boolean) {
<cd-icon type="check"></cd-icon>
}
</ng-template>
// 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';
}
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<any> {
}
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);
onDeselect(deselectedRowIndex: number) {
this.model.selectRow(deselectedRowIndex, false);
- if (this.selectionType === 'single') {
+ if (this.selectionType === 'single' || this.selectionType === 'singleRadio') {
return;
}
this._toggleSelection(deselectedRowIndex, false);
id?: string;
}
+export type CephfsPool = {
+ pool: string;
+ used: number;
+};
+
+export type CephfsDetail = {
+ id: number;
+ mdsmap: {
+ info: Record<string, any>;
+ fs_name: string;
+ enabled: boolean;
+ [key: string]: any;
+ };
+ mirror_info?: {
+ peers?: Record<string, string>;
+ };
+ cephfs: {
+ id: number;
+ name: string;
+ pools: CephfsPool[];
+ flags?: {
+ enabled?: boolean;
+ };
+ mirror_info?: {
+ peers?: Record<string, unknown>;
+ };
+ };
+};
+
+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<MdsStatus, MdsStatus> = {
+ Active: 'Active',
+ Warning: 'Warning',
+ Inactive: 'Inactive'
+} as const;
+
+export const MIRRORING_STATUS: Record<MirroringStatus, MirroringStatus> = {
+ Enabled: 'Enabled',
+ Disabled: 'Disabled'
+} as const;
+
+const MDS_STATE_TO_STATUS: Record<string, MdsStatus> = {
+ [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[];
padding: 0;
}
+.cds-pt-2px {
+ padding-top: 2px;
+}
+
.cds-pt-3 {
padding-top: layout.$spacing-03;
}