import { SmbOverviewComponent } from './ceph/smb/smb-overview/smb-overview.component';
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 { CephfsMirroringFsTabsComponent } from './ceph/cephfs/cephfs-mirroring-fs-tabs/cephfs-mirroring-fs-tabs.component';
+import { CephfsMirroringFsOverviewComponent } from './ceph/cephfs/cephfs-mirroring-fs-overview/cephfs-mirroring-fs-overview.component';
+import { CephfsMirroringFsMirrorPathsComponent } from './ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component';
+import { CephfsMirroringFsSchedulesComponent } from './ceph/cephfs/cephfs-mirroring-fs-schedules/cephfs-mirroring-fs-schedules.component';
+import { CephfsMirroringFsBreadcrumbResolver } from './ceph/cephfs/cephfs-mirroring-fs-tabs/cephfs-mirroring-fs-breadcrumb.resolver';
import { NotificationsPageComponent } from './core/navigation/notification-panel/notifications-page/notifications-page.component';
import { CephfsMirroringErrorComponent } from './ceph/cephfs/cephfs-mirroring-error/cephfs-mirroring-error.component';
import { OverviewComponent } from './ceph/overview/overview.component';
{
path: 'mirroring',
canActivate: [ModuleStatusGuardService],
- component: CephfsMirroringListComponent,
data: {
moduleStatusGuardConfig: {
uiApiPath: 'cephfs/mirror',
redirectTo: 'cephfs/mirroring/error',
module_name: 'mirroring',
navigate_to: 'File/Mirroring'
+ }
+ },
+ children: [
+ {
+ path: '',
+ component: CephfsMirroringListComponent,
+ data: {
+ breadcrumbs: 'File/Mirroring',
+ pageHeader: CEPHFS_MIRRORING_PAGE_HEADER
+ }
},
- breadcrumbs: 'File/Mirroring',
- pageHeader: CEPHFS_MIRRORING_PAGE_HEADER
- }
+ {
+ path: ':fsName',
+ component: CephfsMirroringFsTabsComponent,
+ data: {
+ breadcrumbs: CephfsMirroringFsBreadcrumbResolver,
+ // PageHeader included within the CephfsMirroringFsTabsComponent
+ pageHeaderHidden: true
+ },
+ children: [
+ { path: '', redirectTo: 'overview', pathMatch: 'full' },
+ {
+ path: 'overview',
+ component: CephfsMirroringFsOverviewComponent
+ },
+ {
+ path: 'mirror-paths',
+ component: CephfsMirroringFsMirrorPathsComponent
+ },
+ {
+ path: 'schedules',
+ component: CephfsMirroringFsSchedulesComponent
+ }
+ ]
+ }
+ ]
},
{
path: 'nfs',
--- /dev/null
+<main class="cephfs-mirroring-fs-mirror-paths">
+ <cds-tile>
+ <p class="cds--type-body-01"
+ i18n>Mirror paths content will be available here.</p>
+ </cds-tile>
+</main>
--- /dev/null
+import { Component, ViewEncapsulation } from '@angular/core';
+
+@Component({
+ selector: 'cd-cephfs-mirroring-fs-mirror-paths',
+ templateUrl: './cephfs-mirroring-fs-mirror-paths.component.html',
+ styleUrls: ['./cephfs-mirroring-fs-mirror-paths.component.scss'],
+ standalone: false,
+ encapsulation: ViewEncapsulation.None
+})
+export class CephfsMirroringFsMirrorPathsComponent {}
--- /dev/null
+<main class="cephfs-mirroring-fs-schedules">
+ <cds-tile>
+ <p class="cds--type-body-01"
+ i18n>Overview content will be available here.</p>
+ </cds-tile>
+</main>
--- /dev/null
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ActivatedRoute, convertToParamMap } from '@angular/router';
+import { of } from 'rxjs';
+
+import { CephfsMirroringFsOverviewComponent } from './cephfs-mirroring-fs-overview.component';
+
+describe('CephfsMirroringFsOverviewComponent', () => {
+ let component: CephfsMirroringFsOverviewComponent;
+ let fixture: ComponentFixture<CephfsMirroringFsOverviewComponent>;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ declarations: [CephfsMirroringFsOverviewComponent],
+ providers: [
+ {
+ provide: ActivatedRoute,
+ useValue: {
+ parent: {
+ paramMap: of(convertToParamMap({ fsName: 'myfs' }))
+ }
+ }
+ }
+ ],
+ schemas: [NO_ERRORS_SCHEMA]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(CephfsMirroringFsOverviewComponent);
+ component = fixture.componentInstance;
+
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should read fsName from parent route params', () => {
+ expect(component.fsName).toBe('myfs');
+ });
+});
--- /dev/null
+import { Component, OnInit, ViewEncapsulation } from '@angular/core';
+import { ActivatedRoute, ParamMap } from '@angular/router';
+
+@Component({
+ selector: 'cd-cephfs-mirroring-fs-overview',
+ templateUrl: './cephfs-mirroring-fs-overview.component.html',
+ styleUrls: ['./cephfs-mirroring-fs-overview.component.scss'],
+ standalone: false,
+ encapsulation: ViewEncapsulation.None
+})
+export class CephfsMirroringFsOverviewComponent implements OnInit {
+ fsName = '';
+
+ constructor(private route: ActivatedRoute) {}
+
+ ngOnInit(): void {
+ this.route.parent?.paramMap.subscribe((paramMap: ParamMap) => {
+ this.fsName = paramMap.get('fsName') ?? '';
+ });
+ }
+}
--- /dev/null
+<main class="cephfs-mirroring-fs-schedules">
+ <cds-tile>
+ <p class="cds--type-body-01"
+ i18n>Schedules content will be available here.</p>
+ </cds-tile>
+</main>
--- /dev/null
+import { Component, ViewEncapsulation } from '@angular/core';
+
+@Component({
+ selector: 'cd-cephfs-mirroring-fs-schedules',
+ templateUrl: './cephfs-mirroring-fs-schedules.component.html',
+ styleUrls: ['./cephfs-mirroring-fs-schedules.component.scss'],
+ standalone: false,
+ encapsulation: ViewEncapsulation.None
+})
+export class CephfsMirroringFsSchedulesComponent {}
--- /dev/null
+import { Injectable } from '@angular/core';
+import { ActivatedRouteSnapshot } from '@angular/router';
+
+import { CEPHFS_MIRRORING_PAGE_HEADER } from '~/app/shared/constants/app.constants';
+import { BreadcrumbsResolver, IBreadcrumb } from '~/app/shared/models/breadcrumbs';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class CephfsMirroringFsBreadcrumbResolver extends BreadcrumbsResolver {
+ resolve(route: ActivatedRouteSnapshot): IBreadcrumb[] {
+ const fsName = route.params?.fsName || '';
+ let decodedFsName = fsName;
+ try {
+ decodedFsName = decodeURIComponent(fsName);
+ } catch {
+ // Fallback to raw value if decoding fails
+ }
+ return [
+ { text: CEPHFS_MIRRORING_PAGE_HEADER.title, path: '/cephfs/mirroring' },
+ { text: decodedFsName, path: null }
+ ];
+ }
+}
--- /dev/null
+<cd-page-header [title]="displayFsName"
+ [tabsLayout]="true">
+ <cds-tabs [followFocus]="true"
+ [isNavigation]="true"
+ [cacheActive]="false"
+ class="cds-mb-0">
+ <cds-tab heading="Overview"
+ i18n-heading
+ [active]="activeTab === Tabs.overview"
+ (selected)="onSelected(Tabs.overview)">
+ </cds-tab>
+ <cds-tab heading="Mirror paths"
+ i18n-heading
+ [active]="activeTab === Tabs.mirrorPaths"
+ (selected)="onSelected(Tabs.mirrorPaths)">
+ </cds-tab>
+ <cds-tab heading="Schedules"
+ i18n-heading
+ [active]="activeTab === Tabs.schedules"
+ (selected)="onSelected(Tabs.schedules)">
+ </cds-tab>
+ </cds-tabs>
+</cd-page-header>
+
+<div class="cds-mt-5">
+ <router-outlet></router-outlet>
+</div>
--- /dev/null
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ActivatedRoute, convertToParamMap } from '@angular/router';
+import { RouterTestingModule } from '@angular/router/testing';
+import { of } from 'rxjs';
+
+import { CephfsMirroringFsTabsComponent } from './cephfs-mirroring-fs-tabs.component';
+
+describe('CephfsMirroringFsTabsComponent', () => {
+ let component: CephfsMirroringFsTabsComponent;
+ let fixture: ComponentFixture<CephfsMirroringFsTabsComponent>;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [RouterTestingModule],
+ declarations: [CephfsMirroringFsTabsComponent],
+ providers: [
+ {
+ provide: ActivatedRoute,
+ useValue: {
+ paramMap: of(convertToParamMap({ fsName: 'testfs' }))
+ }
+ }
+ ],
+ schemas: [NO_ERRORS_SCHEMA]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(CephfsMirroringFsTabsComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should read fsName from route params', () => {
+ expect(component.fsName).toBe('testfs');
+ expect(component.displayFsName).toBe('testfs');
+ });
+});
--- /dev/null
+import { Component, OnDestroy, OnInit } from '@angular/core';
+import { ActivatedRoute, NavigationEnd, ParamMap, Router } from '@angular/router';
+import { Subscription } from 'rxjs';
+import { filter } from 'rxjs/operators';
+
+enum TABS {
+ overview = 'overview',
+ mirrorPaths = 'mirror-paths',
+ schedules = 'schedules'
+}
+
+@Component({
+ selector: 'cd-cephfs-mirroring-fs-tabs',
+ templateUrl: './cephfs-mirroring-fs-tabs.component.html',
+ styleUrls: ['./cephfs-mirroring-fs-tabs.component.scss'],
+ standalone: false
+})
+export class CephfsMirroringFsTabsComponent implements OnInit, OnDestroy {
+ fsName = '';
+ displayFsName = '';
+ activeTab: TABS = TABS.overview;
+
+ private subs = new Subscription();
+
+ constructor(private route: ActivatedRoute, private router: Router) {}
+
+ ngOnInit(): void {
+ this.subs.add(
+ this.route.paramMap.subscribe((paramMap: ParamMap) => {
+ this.fsName = paramMap.get('fsName') ?? '';
+ this.displayFsName = this.decodeFsName(this.fsName);
+ })
+ );
+ this.updateActiveTab(this.router.url);
+ this.subs.add(
+ this.router.events
+ .pipe(filter((event) => event instanceof NavigationEnd))
+ .subscribe(() => this.updateActiveTab(this.router.url))
+ );
+ }
+
+ ngOnDestroy(): void {
+ this.subs.unsubscribe();
+ }
+
+ onSelected(tab: TABS): void {
+ this.router.navigate(['/cephfs/mirroring', this.fsName, tab]);
+ }
+
+ get Tabs(): typeof TABS {
+ return TABS;
+ }
+
+ private updateActiveTab(url: string): void {
+ this.activeTab = Object.values(TABS).find((tab) => url.includes(`/${tab}`)) || TABS.overview;
+ }
+
+ private decodeFsName(fsName: string): string {
+ if (!fsName) {
+ return '';
+ }
+ try {
+ return decodeURIComponent(fsName);
+ } catch {
+ return fsName;
+ }
+ }
+}
import { CephfsService } from '~/app/shared/api/cephfs.service';
import { TableComponent } from '~/app/shared/datatable/table/table.component';
+import { CellTemplate } from '~/app/shared/enum/cell-template.enum';
import { CdTableColumn } from '~/app/shared/models/cd-table-column';
import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
import { Daemon, Filesystem, MirroringRow, Peer } from '~/app/shared/models/cephfs.model';
ngOnInit() {
this.columns = [
- { name: $localize`Filesystem`, prop: 'local_fs_name', flexGrow: 2 },
+ {
+ name: $localize`Filesystem`,
+ prop: 'local_fs_name',
+ flexGrow: 2,
+ cellTransformation: CellTemplate.redirect,
+ customTemplateConfig: {
+ redirectLink: ['/cephfs/mirroring', '::prop', 'overview']
+ }
+ },
{ 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 },
import { CephfsAuthModalComponent } from './cephfs-auth-modal/cephfs-auth-modal.component';
import { CephfsMirroringListComponent } from './cephfs-mirroring-list/cephfs-mirroring-list.component';
import { CephfsMirroringErrorComponent } from './cephfs-mirroring-error/cephfs-mirroring-error.component';
+import { CephfsMirroringFsTabsComponent } from './cephfs-mirroring-fs-tabs/cephfs-mirroring-fs-tabs.component';
+import { CephfsMirroringFsOverviewComponent } from './cephfs-mirroring-fs-overview/cephfs-mirroring-fs-overview.component';
+import { CephfsMirroringFsMirrorPathsComponent } from './cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component';
+import { CephfsMirroringFsSchedulesComponent } from './cephfs-mirroring-fs-schedules/cephfs-mirroring-fs-schedules.component';
import {
ButtonModule,
CheckboxModule,
CephfsMountDetailsComponent,
CephfsAuthModalComponent,
CephfsMirroringListComponent,
- CephfsMirroringErrorComponent
+ CephfsMirroringErrorComponent,
+ CephfsMirroringFsTabsComponent,
+ CephfsMirroringFsOverviewComponent,
+ CephfsMirroringFsMirrorPathsComponent,
+ CephfsMirroringFsSchedulesComponent
],
providers: [provideCharts(withDefaultRegisterables())]
})
<div class="breadcrumbs--padding">
<cd-breadcrumbs></cd-breadcrumbs>
</div>
- @if(pageHeaderTitle) {
+ @if(pageHeaderTitle && !pageHeaderHidden) {
<cd-page-header
[title]="pageHeaderTitle"
[subtitle]="pageHeaderSubtitle"
pageHeaderTitle: string | null = null;
pageHeaderSubtitle: string | null = null;
pageHeaderDescription: string | null = null;
+ pageHeaderHidden = false;
enabledFeature$: Observable<FeatureTogglesMap>;
@HostBinding('class') get class(): string {
while (route?.firstChild) {
route = route.firstChild;
}
+
+ const hiddenRoute = this.findRouteWithData(route, 'pageHeaderHidden');
+ if (hiddenRoute?.routeConfig?.data?.['pageHeaderHidden']) {
+ this.pageHeaderHidden = true;
+ this.pageHeaderTitle = null;
+ this.pageHeaderSubtitle = null;
+ this.pageHeaderDescription = null;
+ return;
+ }
+
+ this.pageHeaderHidden = false;
+
+ const titleFromParamRoute = this.findRouteWithData(route, 'pageHeaderTitleFromParam');
+ const titleFromParam = titleFromParamRoute?.routeConfig?.data?.['pageHeaderTitleFromParam'] as
+ | string
+ | undefined;
+
+ if (titleFromParam && titleFromParamRoute?.params[titleFromParam]) {
+ try {
+ this.pageHeaderTitle = decodeURIComponent(titleFromParamRoute.params[titleFromParam]);
+ } catch {
+ this.pageHeaderTitle = titleFromParamRoute.params[titleFromParam];
+ }
+ this.pageHeaderSubtitle = null;
+ this.pageHeaderDescription = null;
+ return;
+ }
+
const pageHeader = route?.routeConfig?.data?.['pageHeader'] as
| { title?: string; subtitle?: string; description?: string }
| undefined;
this.pageHeaderDescription = pageHeader?.description ?? null;
}
+ private findRouteWithData(
+ route: ActivatedRouteSnapshot | null,
+ key: string
+ ): ActivatedRouteSnapshot | null {
+ let current = route;
+ while (current) {
+ if (current.routeConfig?.data?.[key]) {
+ return current;
+ }
+ current = current.parent;
+ }
+ return null;
+ }
+
showTopNotification(name: string, isDisplayed: boolean) {
if (isDisplayed) {
if (!this.notifications.includes(name)) {
<header data-testid="page-header">
<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>
- }
+ <cds-tile class="border-top padding-inline-0"
+ [class.cd-page-header__tile--tabs]="tabsLayout">
+ <div cdsStack="vertical"
+ [gap]="6"
+ [class.cd-page-header--tabs-layout]="tabsLayout">
+ <div cdsStack="vertical"
+ [gap]="2">
+ <p class="cds--type-heading-04 cds-m-0">{{ title }}</p>
+ @if(subtitle) {
+ <p class="cds--type-heading-03 cds-m-0">{{ subtitle }}</p>
+ }
+ @if(description) {
+ <p class="cds--type-body-01 cds-m-0">{{ description }}</p>
+ }
+ </div>
+ <div [class.cd-page-header__tabs]="tabsLayout">
+ <ng-content></ng-content>
+ </div>
+ </div>
</cds-tile>
</div>
</header>
.border-top {
border-top: 1px solid var(--cds-border-subtle-01);
}
+
+ // Remove tile bottom padding so tabs sit on the tile edge
+ .cd-page-header__tile--tabs {
+ padding-bottom: 0;
+ }
+
+ // Align tabs with the title
+ .cd-page-header__tabs {
+ margin-inline-start: calc(-1 * var(--cds-layout-density-padding-inline-local, 1rem));
+ }
}
@Input({ required: true }) title: string;
@Input() subtitle: string = '';
@Input() description: string = '';
+ @Input() tabsLayout = false;
}