pageHeader.navigateToCephfsMirroring();
});
- it('should display the page header on CephFS Mirroring page', () => {
+ it('should display the page header on Filesystem Mirroring page', () => {
pageHeader.getPageHeader().should('be.visible');
});
it('should show the expected title in the page header', () => {
pageHeader.getHeaderTitle().then((text) => {
- expect(text.trim()).to.equal('CephFS Mirroring');
+ expect(text.trim()).to.equal('Filesystem Mirroring');
});
});
it('should show the expected description in the page header', () => {
pageHeader.getHeaderDescription().then((text) => {
- expect(text.trim()).to.equal('Centralised view of all CephFS Mirroring relationships.');
+ expect(text.trim()).to.equal(
+ 'Configure mirroring between filesystems and monitor replication status.'
+ );
});
});
});
import { MultiClusterFormComponent } from './ceph/cluster/multi-cluster/multi-cluster-form/multi-cluster-form.component';
import { CephfsMirroringListComponent } from './ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component';
import { NotificationsPageComponent } from './core/navigation/notification-panel/notifications-page/notifications-page.component';
-import { CephfsMirroringWizardComponent } from './ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard.component';
import { CephfsMirroringErrorComponent } from './ceph/cephfs/cephfs-mirroring-error/cephfs-mirroring-error.component';
import { OverviewComponent } from './ceph/overview/overview.component';
pageHeader: CEPHFS_MIRRORING_PAGE_HEADER
}
},
- {
- path: `mirroring/${URLVerbs.CREATE}`,
- component: CephfsMirroringWizardComponent,
- data: { breadcrumbs: ActionLabels.CREATE }
- },
{
path: 'nfs',
canActivateChild: [FeatureTogglesGuardService, ModuleStatusGuardService],
+++ /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,
- Output,
- EventEmitter
-} 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;
- @Output() filesystemSelected = new EventEmitter<FilesystemRow | null>();
- 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;
- const selectedRow = typeof selection?.first === 'function' ? selection.first() : null;
- this.filesystemSelected.emit(selectedRow as FilesystemRow | null);
- }
-}
#formDir="ngForm"
[formGroup]="form"
novalidate>
- <div i18n="form title|Example: Create Volume@@formTitle"
+ <div i18n="form title|Example: Create Volume@@cephfsFormTitle"
class="form-header">{{ action | titlecase }} {{ resource | upperFirst }}</div>
<div class="form-item">
-@if (daemonStatus$ | async; as daemonStatus) {
+<div cdsStack="vertical"
+ gap="5"
+ class="cds-mt-6">
+ <h4 class="cds--type-heading-03"
+ i18n>Jump in</h4>
+ <div cdsGrid
+ [narrow]="true">
+ <div cdsRow
+ class="jump-in-row">
+ <div cdsCol
+ [columnNumbers]="{lg: 4, md: 4, sm: 4}">
+ <cds-clickable-tile>
+ <div cdsStack="vertical"
+ gap="5">
+ <p class="cds--type-heading-compact-01"
+ i18n>Set up mirroring</p>
+ <p class="cds--type-body-compact-01"
+ i18n>
+ Configure mirroring for a filesystem by importing a token from a peer cluster and adding paths to replicate.
+ </p>
+ <div [cdsStack]="'horizontal'">
+ <cd-icon type="replicate"
+ size="24"
+ class="cds-ml-1 cds-mr-12"></cd-icon>
+ <span class="cds--text-right">→</span>
+ </div>
+ </div>
+ </cds-clickable-tile>
+ </div>
+ <div cdsCol
+ [columnNumbers]="{lg: 4, md: 4, sm: 4}">
+ <cds-clickable-tile>
+ <div cdsStack="vertical"
+ gap="5">
+ <p class="cds--type-heading-compact-01"
+ i18n>Prepare to receive</p>
+ <p class="cds--type-body-compact-01"
+ i18n>
+ Generate a bootstrap token for a filesystem to allow a peer cluster to replicate data to it.
+ </p>
+ <div [cdsStack]="'horizontal'">
+ <cd-icon type="share"
+ size="24"
+ class="cds-ml-3 cds-mr-12"></cd-icon>
+ <span class="cds--text-right">→</span>
+ </div>
+ </div>
+ </cds-clickable-tile>
+ </div>
+ </div>
+ </div>
+</div>
+
+<!-- Mirrored filesystems table -->
+<section class="cds-mt-6">
+ <h3 class="cds--type-heading-03 cds-mb-3"
+ i18n>Mirrored filesystems</h3>
<cd-table
#table
- [data]="daemonStatus"
+ [data]="daemonStatus$ | async"
[columns]="columns"
columnMode="flex"
selectionType="single"
- (updateSelection)="updateSelection($event)"
- (fetchData)="loadDaemonStatus()">
- <cd-table-actions class="table-actions"
- [permission]="permission"
- [selection]="selection"
- [tableActions]="tableActions">
- </cd-table-actions>
+ (fetchData)="loadDaemonStatus()"
+ (updateSelection)="updateSelection($event)">
</cd-table>
-}
+</section>
+
+.cds--grid.cds--grid--narrow {
+ margin-left: 20px;
+ padding-left: 0;
+}
+
+.jump-in-row {
+ align-items: stretch;
+
+ cds-clickable-tile,
+ .cds--tile {
+ height: 100%;
+ }
+}
+import { NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { CephfsMirroringListComponent } from './cephfs-mirroring-list.component';
import { CephfsService } from '~/app/shared/api/cephfs.service';
-import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
import { Daemon, MirroringRow } from '~/app/shared/models/cephfs.model';
describe('CephfsMirroringListComponent', () => {
await TestBed.configureTestingModule({
declarations: [CephfsMirroringListComponent],
- providers: [ActionLabelsI18n, { provide: CephfsService, useValue: cephfsServiceMock }]
+ providers: [{ provide: CephfsService, useValue: cephfsServiceMock }],
+ schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
fixture = TestBed.createComponent(CephfsMirroringListComponent);
it('should initialize columns correctly on ngOnInit', () => {
component.ngOnInit();
- expect(component.columns.length).toBe(5);
- expect(component.columns[0].prop).toBe('remote_cluster_name');
+ expect(component.columns.length).toBe(6);
+ expect(component.columns[0].prop).toBe('local_fs_name');
});
- it('should call loadDaemonStatus inside ngOnInit', () => {
- const loadSpy = jest.spyOn(component, 'loadDaemonStatus');
- component.ngOnInit();
- expect(loadSpy).toHaveBeenCalledTimes(1);
+ it('should fetch daemon status when loadDaemonStatus() is called', () => {
+ cephfsServiceMock.listDaemonStatus.mockReturnValue(of([]));
+ component.daemonStatus$.subscribe();
+ component.loadDaemonStatus();
+ expect(cephfsServiceMock.listDaemonStatus).toHaveBeenCalledTimes(1);
});
it('should map daemon status to MirroringRow[] correctly', () => {
-import { Component, ViewChild, OnInit } from '@angular/core';
-import { BehaviorSubject, Observable, of } from 'rxjs';
-import { catchError, switchMap } from 'rxjs/operators';
+import { Component, OnInit, ViewChild, ViewEncapsulation } from '@angular/core';
+import { Subject, of } from 'rxjs';
+import { catchError, map, switchMap } from 'rxjs/operators';
+
+import { CephfsService } from '~/app/shared/api/cephfs.service';
import { TableComponent } from '~/app/shared/datatable/table/table.component';
import { CdTableColumn } from '~/app/shared/models/cd-table-column';
import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
-import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
-import { CephfsService } from '~/app/shared/api/cephfs.service';
-import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
-import { CdTableAction } from '~/app/shared/models/cd-table-action';
-import { URLBuilderService } from '~/app/shared/services/url-builder.service';
-import { Daemon, MirroringRow } from '~/app/shared/models/cephfs.model';
-import { Icons } from '~/app/shared/enum/icons.enum';
-import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
-import { Permission } from '~/app/shared/models/permissions';
+import { Daemon, Filesystem, MirroringRow, Peer } from '~/app/shared/models/cephfs.model';
-export const MIRRORING_PATH = 'cephfs/mirroring';
@Component({
selector: 'cd-cephfs-mirroring-list',
templateUrl: './cephfs-mirroring-list.component.html',
styleUrls: ['./cephfs-mirroring-list.component.scss'],
standalone: false,
- providers: [{ provide: URLBuilderService, useValue: new URLBuilderService(MIRRORING_PATH) }]
+ encapsulation: ViewEncapsulation.None
})
export class CephfsMirroringListComponent implements OnInit {
@ViewChild('table', { static: true }) table: TableComponent;
columns: CdTableColumn[];
selection = new CdTableSelection();
- subject$ = new BehaviorSubject<MirroringRow[]>([]);
- daemonStatus$: Observable<MirroringRow[]>;
- context: CdTableFetchDataContext;
- tableActions: CdTableAction[];
- permission: Permission;
- constructor(
- public actionLabels: ActionLabelsI18n,
- private authStorageService: AuthStorageService,
- private cephfsService: CephfsService,
- private urlBuilder: URLBuilderService
- ) {
- this.permission = this.authStorageService.getPermissions().cephfs;
- }
+ private subject$ = new Subject<void>();
+
+ daemonStatus$ = this.subject$.pipe(
+ switchMap(() =>
+ this.cephfsService.listDaemonStatus().pipe(catchError(() => of([] as Daemon[])))
+ ),
+ map((daemons) => this.buildRows(daemons))
+ );
+
+ constructor(private cephfsService: CephfsService) {}
ngOnInit() {
this.columns = [
- {
- name: $localize`Remote cluster`,
- prop: 'remote_cluster_name',
- flexGrow: 2
- },
- { name: $localize`Local filesystem`, prop: 'local_fs_name', flexGrow: 2 },
- { name: $localize`Remote filesystem`, prop: 'fs_name', flexGrow: 2 },
- { name: $localize`Remote client`, prop: 'client_name', flexGrow: 2 },
- { name: $localize`Snapshot directories`, prop: 'directory_count', flexGrow: 1 }
+ { name: $localize`Filesystem`, prop: 'local_fs_name', flexGrow: 2 },
+ { name: $localize`Destination cluster`, prop: 'remote_cluster_name', flexGrow: 2 },
+ { name: $localize`Mirroring status`, prop: 'mirroring_status', flexGrow: 2 },
+ { name: $localize`Bytes replicated`, prop: 'bytes_replicated', flexGrow: 2 },
+ { name: $localize`Last sync`, prop: 'last_sync', flexGrow: 2 },
+ { name: $localize`Replicated paths`, prop: 'directory_count', flexGrow: 2 }
];
+ }
- const createAction: CdTableAction = {
- permission: 'create',
- icon: Icons.add,
- routerLink: () => this.urlBuilder.getCreate(),
- name: this.actionLabels.CREATE,
- canBePrimary: (selection: CdTableSelection) => !selection.hasSelection
- };
+ loadDaemonStatus() {
+ this.subject$.next();
+ }
- this.tableActions = [createAction];
- this.daemonStatus$ = this.subject$.pipe(
- switchMap(() =>
- this.cephfsService.listDaemonStatus()?.pipe(
- switchMap((daemons: Daemon[]) => {
- const result: MirroringRow[] = [];
+ updateSelection(selection: CdTableSelection) {
+ this.selection = selection;
+ }
- daemons.forEach((d) => {
- d.filesystems.forEach((fs) => {
- if (!fs.peers || fs.peers.length === 0) {
- result.push({
- remote_cluster_name: '-',
- local_fs_name: fs.name,
- fs_name: fs.name,
- client_name: '-',
- directory_count: fs.directory_count,
- peerId: '-',
- id: `${d.daemon_id}-${fs.filesystem_id}`
- });
- } else {
- fs.peers.forEach((peer) => {
- result.push({
- remote_cluster_name: peer.remote.cluster_name,
- local_fs_name: fs.name,
- fs_name: peer.remote.fs_name,
- client_name: peer.remote.client_name,
- directory_count: fs.directory_count,
- id: `${d.daemon_id}-${fs.filesystem_id}`
- });
- });
- }
- });
- });
- return of(result);
- }),
- catchError(() => {
- this.context?.error();
- return of(null);
- })
- )
- )
- );
+ private buildRows(daemons: Daemon[]): MirroringRow[] {
+ const rows: MirroringRow[] = [];
+ if (!daemons?.length) {
+ return rows;
+ }
- this.loadDaemonStatus();
+ for (const daemon of daemons) {
+ if (!daemon?.filesystems) continue;
+ for (const fs of daemon.filesystems) {
+ if (fs.peers?.length) {
+ for (const peer of fs.peers) {
+ rows.push(this.peerToRow(daemon, fs, peer));
+ }
+ } else {
+ rows.push(this.noPeerRow(daemon, fs));
+ }
+ }
+ }
+ return rows;
}
- loadDaemonStatus() {
- this.subject$.next([]);
+ private peerToRow(daemon: Daemon, fs: Filesystem, peer: Peer): MirroringRow {
+ return {
+ remote_cluster_name: peer.remote?.cluster_name ?? '-',
+ local_fs_name: fs.name,
+ fs_name: peer.remote?.fs_name ?? '-',
+ client_name: peer.remote?.client_name ?? '-',
+ directory_count: fs.directory_count ?? 0,
+ id: `${daemon.daemon_id}-${fs.filesystem_id}`
+ };
}
- updateSelection(selection: CdTableSelection) {
- this.selection = selection;
+ private noPeerRow(daemon: Daemon, fs: Filesystem): MirroringRow {
+ return {
+ remote_cluster_name: '-',
+ local_fs_name: fs.name,
+ fs_name: fs.name,
+ client_name: '-',
+ directory_count: fs.directory_count ?? 0,
+ peerId: '-',
+ id: `${daemon.daemon_id}-${fs.filesystem_id}`
+ };
}
}
+++ /dev/null
-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,
- StepTitles.SelectFilesystem,
- StepTitles.CreateOrSelectEntity,
- StepTitles.GenerateBootstrapToken
-];
-
-export const LOCAL_ROLE = 'local';
-export const REMOTE_ROLE = 'remote';
+++ /dev/null
-<cd-tearsheet
- [steps]="steps"
- [title]="title"
- [description]="description"
- (submitRequested)="onSubmit()"
- (closeRequested)="onCancel()">
- <cd-tearsheet-step>
- <form [formGroup]="form">
- <div>
- <div class="cds--type-heading-03"
- i18n>Choose mirror peer role</div>
- <p i18n>Select how the cluster will participate in the CephFS mirroring relationship.</p>
- </div>
-
- <div cdsStack="horizontal">
- <div class="cds-mr-5">
- <cds-tile>
- <cds-radio-group formControlName="localRole">
- <cds-radio
- [value]="LOCAL_ROLE"
- [checked]="form.get('localRole')?.value === LOCAL_ROLE"
- (click)="onLocalRoleChange()">
- <div>
- <div class="cds--type-heading-compact-02"
- i18n>Configure local peer</div>
- <div class="cds--type-label-01 cds-mt-3"
- i18n>
- This cluster will act as the initiating peer and send snapshots to a remote
- peer.
- </div>
- <ul class="cds--type-body-compact-01 cds-mt-6">
- @for (item of sourceList; track $index) {
- <li class="cds-mb-6 cds-mt-3">→ {{ item }}</li>
- }
- </ul>
- </div>
- </cds-radio>
- </cds-radio-group>
- </cds-tile>
- </div>
- <cds-tile>
- <cds-radio-group formControlName="remoteRole">
- <cds-radio
- [value]="REMOTE_ROLE"
- [checked]="form.get('remoteRole')?.value === REMOTE_ROLE"
- (click)="onRemoteRoleChange()">
- <div>
- <div class="cds--type-heading-compact-02"
- i18n>Configure remote peer</div>
- <div class="cds--type-label-01 cds-mt-3"
- i18n>
- A remote cluster will act as the receiving peer and store replicated snapshots.
- </div>
- <ul class="cds--type-body-compact-01 cds-mt-6">
- @for (item of targetList; track $index) {
- <li class="cds-mb-6 cds-mt-3">→ {{ item }}</li>
- }
- </ul>
- </div>
- </cds-radio>
- </cds-radio-group>
- </cds-tile>
- </div>
-
- @if (form.get('localRole')?.value !== LOCAL_ROLE && showMessage) {
- <cd-alert-panel
- type="info"
- spacingClass="mb-3 mt-3"
- dismissible="true"
- (dismissed)="showMessage = false">
- <div>
- <div
- class="cds--type-heading-compact-01 cds-mb-2"
- i18n>About remote peer setup</div>
- <div
- class="cds--type-body-compact-01 cds-mb-3"
- i18n>
- As a remote peer, this cluster prepares to receive mirrored data from an initiating
- cluster. The setup includes environment validation, enabling filesystem mirroring,
- creating required Ceph users, and generating a bootstrap token.
- </div>
- <div
- class="cds--type-heading-compact-01 cds-mb-1 cds-mt-6"
- i18n>What happens next:</div>
- <ul class="list-disc cds-ml-5 cds--type-body-compact-01">
- <li i18n>Environment validation</li>
- <li i18n>Ceph user creation</li>
- <li i18n>Filesystem mirroring activation</li>
- <li i18n>Bootstrap token generation</li>
- </ul>
- </div>
- </cd-alert-panel>
- }
- </form>
- </cd-tearsheet-step>
-
- <!-- Step 1 -->
- <cd-tearsheet-step>
- <cd-cephfs-filesystem-selector (filesystemSelected)="onFilesystemSelected($event)">
- </cd-cephfs-filesystem-selector>
- </cd-tearsheet-step>
-
- <!-- Step 2 -->
- <cd-tearsheet-step>
- <cd-cephfs-mirroring-entity [selectedFilesystem]="selectedFilesystem"
- (entitySelected)="onEntitySelected($event)">
- </cd-cephfs-mirroring-entity>
- </cd-tearsheet-step>
-
- <!-- Step 3 -->
- <cd-tearsheet-step>
- <div>
- Test 3
- </div>
- </cd-tearsheet-step>
-</cd-tearsheet>
+++ /dev/null
-form {
- max-width: 77%;
-}
-
-.list-disc {
- list-style-type: disc;
-}
+++ /dev/null
-import { ComponentFixture, TestBed } from '@angular/core/testing';
-import { CephfsMirroringWizardComponent } from './cephfs-mirroring-wizard.component';
-import { WizardStepsService } from '~/app/shared/services/wizard-steps.service';
-import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
-import { Router } from '@angular/router';
-import { BehaviorSubject } from 'rxjs';
-import {
- STEP_TITLES_MIRRORING_CONFIGURED,
- LOCAL_ROLE,
- REMOTE_ROLE
-} from './cephfs-mirroring-wizard-step.enum';
-import { WizardStepModel } from '~/app/shared/models/wizard-steps';
-import { NO_ERRORS_SCHEMA } from '@angular/core';
-import { RadioModule } from 'carbon-components-angular';
-
-describe('CephfsMirroringWizardComponent', () => {
- let component: CephfsMirroringWizardComponent;
- let fixture: ComponentFixture<CephfsMirroringWizardComponent>;
- let wizardStepsService: jest.Mocked<WizardStepsService>;
- let router: jest.Mocked<Router>;
-
- const mockSteps: WizardStepModel[] = [
- { stepIndex: 0, isComplete: false },
- { stepIndex: 1, isComplete: false }
- ];
-
- beforeEach(async () => {
- wizardStepsService = ({
- setTotalSteps: jest.fn(),
- setCurrentStep: jest.fn(),
- steps$: new BehaviorSubject<WizardStepModel[]>(mockSteps)
- } as unknown) as jest.Mocked<WizardStepsService>;
-
- router = ({
- navigate: jest.fn()
- } as unknown) as jest.Mocked<Router>;
-
- await TestBed.configureTestingModule({
- imports: [ReactiveFormsModule, RadioModule],
- declarations: [CephfsMirroringWizardComponent],
- providers: [
- FormBuilder,
- { provide: WizardStepsService, useValue: wizardStepsService },
- { provide: Router, useValue: router }
- ],
- schemas: [NO_ERRORS_SCHEMA]
- }).compileComponents();
-
- fixture = TestBed.createComponent(CephfsMirroringWizardComponent);
- component = fixture.componentInstance;
- fixture.detectChanges();
- });
-
- it('should create the component', () => {
- expect(component).toBeTruthy();
- });
-
- it('should initialize wizard steps on ngOnInit', () => {
- expect(wizardStepsService.setTotalSteps).toHaveBeenCalledWith(
- STEP_TITLES_MIRRORING_CONFIGURED.length
- );
-
- expect(component.steps.length).toBe(STEP_TITLES_MIRRORING_CONFIGURED.length);
- });
-
- it('should navigate to step when goToStep is called', () => {
- component.goToStep(mockSteps[0]);
-
- expect(wizardStepsService.setCurrentStep).toHaveBeenCalledWith(mockSteps[0]);
- });
-
- it('should initialize form with local role selected', () => {
- expect(component.form.value).toEqual({
- localRole: LOCAL_ROLE,
- remoteRole: null
- });
- });
-
- it('should update form on local role change', () => {
- component.onLocalRoleChange();
-
- expect(component.form.value).toEqual({
- localRole: LOCAL_ROLE,
- remoteRole: null
- });
- });
-
- it('should update form on remote role change', () => {
- component.onRemoteRoleChange();
-
- expect(component.form.value).toEqual({
- localRole: null,
- remoteRole: REMOTE_ROLE
- });
- });
-
- it('should navigate to mirroring list on cancel', () => {
- component.onCancel();
- expect(router.navigate).toHaveBeenCalledWith(['/cephfs/mirroring']);
- });
-});
+++ /dev/null
-import { Component, OnInit, inject } from '@angular/core';
-import { Step } from 'carbon-components-angular';
-import { Router } from '@angular/router';
-import {
- STEP_TITLES_MIRRORING_CONFIGURED,
- LOCAL_ROLE,
- REMOTE_ROLE
-} from './cephfs-mirroring-wizard-step.enum';
-import { WizardStepsService } from '~/app/shared/services/wizard-steps.service';
-import { WizardStepModel } from '~/app/shared/models/wizard-steps';
-import { FormBuilder, FormGroup } from '@angular/forms';
-import { FilesystemRow } from '~/app/shared/models/cephfs.model';
-@Component({
- selector: 'cd-cephfs-mirroring-wizard',
- templateUrl: './cephfs-mirroring-wizard.component.html',
- standalone: false,
- styleUrls: ['./cephfs-mirroring-wizard.component.scss']
-})
-export class CephfsMirroringWizardComponent implements OnInit {
- steps: Step[] = [];
- title: string = $localize`Create new CephFS Mirroring`;
- description: string = $localize`Configure a new mirroring relationship between clusters`;
- form: FormGroup;
- showMessage: boolean = true;
- selectedFilesystem: FilesystemRow | null = null;
- selectedEntity: string | null = null;
-
- LOCAL_ROLE = LOCAL_ROLE;
- REMOTE_ROLE = REMOTE_ROLE;
-
- private wizardStepsService = inject(WizardStepsService);
- private fb = inject(FormBuilder);
- private router = inject(Router);
-
- sourceList: string[] = [
- $localize`Sends data to remote clusters`,
- $localize`Requires bootstrap token from target`,
- $localize`Manages snapshot schedules`
- ];
-
- targetList: string[] = [
- $localize`Receives data from source clusters`,
- $localize`Generates bootstrap token`,
- $localize`Stores replicated snapshots`
- ];
-
- constructor() {
- this.form = this.fb.group({
- localRole: [LOCAL_ROLE],
- remoteRole: [null]
- });
- }
-
- ngOnInit() {
- this.wizardStepsService.setTotalSteps(STEP_TITLES_MIRRORING_CONFIGURED.length);
-
- const stepsData = this.wizardStepsService.steps$.value;
- this.steps = STEP_TITLES_MIRRORING_CONFIGURED.map((title, index) => ({
- label: title,
- onClick: () => this.goToStep(stepsData[index]),
- invalid: true
- }));
- }
-
- onFilesystemSelected(filesystem: FilesystemRow) {
- this.selectedFilesystem = filesystem;
- if (this.steps[1]) {
- this.steps[1].invalid = !filesystem;
- }
- }
-
- onEntitySelected(entity: string) {
- this.selectedEntity = entity;
- if (this.steps[2]) {
- this.steps[2].invalid = !entity;
- }
- }
-
- goToStep(step: WizardStepModel) {
- if (step) {
- this.wizardStepsService.setCurrentStep(step);
- }
- }
-
- onLocalRoleChange() {
- this.form.patchValue({ localRole: LOCAL_ROLE, remoteRole: null });
- this.showMessage = false;
- if (this.steps[0]) {
- this.steps[0].invalid = false;
- }
- }
-
- onRemoteRoleChange() {
- this.form.patchValue({ localRole: null, remoteRole: REMOTE_ROLE });
- this.showMessage = true;
- if (this.steps[0]) {
- this.steps[0].invalid = false;
- }
- }
-
- onSubmit() {}
-
- onCancel() {
- this.router.navigate(['/cephfs/mirroring']);
- }
-}
import Close from '@carbon/icons/es/close/32';
import Trash from '@carbon/icons/es/trash-can/32';
import Renew16 from '@carbon/icons/es/renew/16';
-import { CephfsMirroringWizardComponent } from './cephfs-mirroring-wizard/cephfs-mirroring-wizard.component';
-import { CephfsFilesystemSelectorComponent } from './cephfs-filesystem-selector/cephfs-filesystem-selector.component';
-import { CephfsMirroringEntityComponent } from './cephfs-mirroring-entity/cephfs-mirroring-entity.component';
+import ReplicateIcon from '@carbon/icons/es/replicate/32';
+import ReplicateIcon24 from '@carbon/icons/es/replicate/24';
+import ShareIcon from '@carbon/icons/es/share/32';
+import ShareIcon24 from '@carbon/icons/es/share/24';
@NgModule({
imports: [
CephfsMountDetailsComponent,
CephfsAuthModalComponent,
CephfsMirroringListComponent,
- CephfsMirroringWizardComponent,
- CephfsFilesystemSelectorComponent,
- CephfsMirroringErrorComponent,
- CephfsMirroringEntityComponent
+ CephfsMirroringErrorComponent
],
providers: [provideCharts(withDefaultRegisterables())]
})
export class CephfsModule {
constructor(private iconService: IconService) {
- this.iconService.registerAll([AddIcon, LaunchIcon, Close, Trash, Renew16]);
+ this.iconService.registerAll([
+ AddIcon,
+ LaunchIcon,
+ Close,
+ Trash,
+ Renew16,
+ ReplicateIcon,
+ ReplicateIcon24,
+ ShareIcon,
+ ShareIcon24
+ ]);
}
}
novalidate
>
<div
- i18n="form title|Example: Create Pool@@formTitle"
+ i18n="form title|Example: Create Pool@@poolFormTitle"
class="form-header"
>
{{ action | titlecase }} {{ resource | upperFirst }}
@if(pageHeaderTitle) {
<cd-page-header
[title]="pageHeaderTitle"
+ [subtitle]="pageHeaderSubtitle"
[description]="pageHeaderDescription">
</cd-page-header>
}
private subs = new Subscription();
permissions: Permissions;
pageHeaderTitle: string | null = null;
+ pageHeaderSubtitle: string | null = null;
pageHeaderDescription: string | null = null;
enabledFeature$: Observable<FeatureTogglesMap>;
route = route.firstChild;
}
const pageHeader = route?.routeConfig?.data?.['pageHeader'] as
- | { title?: string; description?: string }
+ | { title?: string; subtitle?: string; description?: string }
| undefined;
this.pageHeaderTitle = pageHeader?.title ?? null;
+ this.pageHeaderSubtitle = pageHeader?.subtitle ?? null;
this.pageHeaderDescription = pageHeader?.description ?? null;
}
<div>
<cds-tile class="border-top padding-inline-0">
<p class="cds--type-heading-04">{{ title }}</p>
+ @if(subtitle) {
+ <p class="cds--type-heading-03">{{ subtitle }}</p>
+ }
@if(description) {
<p class="cds--type-body-01">{{ description }}</p>
}
* @see https://ibm-products.carbondesignsystem.com/?path=/docs/components-pageheader--overview
*
* Usage:
- * <cd-page-header title="Page title" description="Optional description">
+ * <cd-page-header title="Page title" subtitle="Optional subtitle" description="Optional description">
* </cd-page-header>
*/
@Component({
})
export class PageHeaderComponent {
@Input({ required: true }) title: string;
+ @Input() subtitle: string = '';
@Input() description: string = '';
}
export const VERSION_PREFIX = 'ceph version';
export const CEPHFS_MIRRORING_PAGE_HEADER = {
- title: $localize`CephFS Mirroring`,
- description: $localize`Centralised view of all CephFS Mirroring relationships.`
+ title: $localize`Filesystem Mirroring`,
+ subtitle: $localize`Manage snapshot-based replication for CephFS across clusters.`,
+ description: $localize`Configure mirroring between filesystems and monitor replication status.`
};
expand = 'maximize', // Expand cluster
user = 'user', // User, Initiators
users = 'user--multiple', // Users, Groups
+ replicate = 'replicate', // replicate
share = 'share', // share
key = 'password', // S3 Keys, Swift Keys, Authentication
warning = 'warning--alt--filled', // Notification warning
rightArrow: 'caret--right',
locked: 'locked',
cloudMonitoring: 'cloud--monitoring',
- trash: 'trash-can'
+ trash: 'trash-can',
+ replicate: 'replicate',
+ share: 'share'
} as const;
export const EMPTY_STATE_IMAGE = {
margin-right: layout.$spacing-05;
}
+.cds-mr-12 {
+ margin-right: layout.$spacing-12;
+}
+
.cds-pt-6 {
padding-top: layout.$spacing-06;
}