From: Syed Ali Ul Hasan Date: Tue, 21 Jul 2026 09:09:59 +0000 (+0530) Subject: mgr/dashboard: migrated notif dest table tabs to resource pages X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=5293360de78629f93a6f8474e2dab557ed352367;p=ceph.git mgr/dashboard: migrated notif dest table tabs to resource pages - Fixes: https://tracker.ceph.com/issues/77541 Signed-off-by: Syed Ali Ul Hasan --- diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-details/rgw-topic-details.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-details/rgw-topic-details.component.html deleted file mode 100644 index 21ea0b45512..00000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-details/rgw-topic-details.component.html +++ /dev/null @@ -1,137 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- Push endpoint arguments - {{ selection?.dest?.push_endpoint_args }}
- Stored secret - {{ selection?.dest?.stored_secret }}
- Persistent - {{ selection?.dest?.persistent }}
- Persistent queue - {{ selection?.dest?.persistent_queue }}
- Time to live - {{ selection?.dest?.time_to_live }}
- Max retries - {{ selection?.dest?.max_retries }}
- Retry sleep duration - {{ selection?.dest?.retry_sleep_duration }}
- Opaque data - {{ selection?.opaqueData }}
-
- -
- - - - - - - -
- Policy - -
{{ policy | json }}
-
-
-
- - - - - - - - -
- Subscribed buckets - -
{{ selection.subscribed_buckets | json }}
-
-
-
-
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-details/rgw-topic-details.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-details/rgw-topic-details.component.scss deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-details/rgw-topic-details.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-details/rgw-topic-details.component.spec.ts deleted file mode 100644 index 78bbed0af9e..00000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-details/rgw-topic-details.component.spec.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { RgwTopicDetailsComponent } from './rgw-topic-details.component'; -import { Topic } from '~/app/shared/models/topic.model'; - -interface Destination { - push_endpoint: string; - push_endpoint_args: string; - push_endpoint_topic: string; - stored_secret: boolean; - persistent: boolean; - persistent_queue: string; - time_to_live: number; - max_retries: number; - retry_sleep_duration: number; -} - -const mockDestination: Destination = { - push_endpoint: 'http://localhost:8080', - push_endpoint_args: 'args', - push_endpoint_topic: 'topic', - stored_secret: false, - persistent: true, - persistent_queue: 'queue', - time_to_live: 3600, - max_retries: 5, - retry_sleep_duration: 10 -}; - -describe('RgwTopicDetailsComponent', () => { - let component: RgwTopicDetailsComponent; - let fixture: ComponentFixture; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - declarations: [RgwTopicDetailsComponent] - }).compileComponents(); - - fixture = TestBed.createComponent(RgwTopicDetailsComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should parse policy string correctly', () => { - const mockSelection: Topic = { - name: 'testHttp', - owner: 'ownerName', - arn: 'arnValue', - dest: mockDestination, - policy: '{"key": "value"}', - key: 'topic:ownerName:testHttp', - opaqueData: 'test@12345', - subscribed_buckets: [] - }; - - component.selection = mockSelection; - component.ngOnChanges({ - selection: { - currentValue: mockSelection, - previousValue: null, - firstChange: true, - isFirstChange: () => true - } - }); - - expect(component.policy).toEqual({ key: 'value' }); - }); - - it('should set policy to empty object if policy is not a string', () => { - const mockSelection: Topic = { - name: 'testHttp', - owner: 'ownerName', - arn: 'arnValue', - dest: mockDestination, - policy: '{}', - key: 'topic:ownerName:testHttp', - subscribed_buckets: [], - opaqueData: '' - }; - - component.selection = mockSelection; - component.ngOnChanges({ - selection: { - currentValue: mockSelection, - previousValue: null, - firstChange: true, - isFirstChange: () => true - } - }); - - expect(component.policy).toEqual({}); - }); -}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-details/rgw-topic-details.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-details/rgw-topic-details.component.ts deleted file mode 100644 index 269316b2dba..00000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-details/rgw-topic-details.component.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Component, Input, SimpleChanges, OnChanges } from '@angular/core'; - -import { Topic } from '~/app/shared/models/topic.model'; -import * as _ from 'lodash'; - -@Component({ - selector: 'cd-rgw-topic-details', - templateUrl: './rgw-topic-details.component.html', - styleUrls: ['./rgw-topic-details.component.scss'], - standalone: false -}) -export class RgwTopicDetailsComponent implements OnChanges { - @Input() - selection: Topic; - policy: string | object = '{}'; - constructor() {} - ngOnChanges(changes: SimpleChanges): void { - if (changes['selection'] && this.selection) { - if (_.isString(this.selection.policy)) { - try { - this.policy = JSON.parse(this.selection.policy); - } catch (e) { - this.policy = '{}'; - } - } else { - this.policy = this.selection.policy || {}; - } - } - } -} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-list/rgw-topic-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-list/rgw-topic-list.component.html index d353153fa48..6a80c707c46 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-list/rgw-topic-list.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-list/rgw-topic-list.component.html @@ -14,9 +14,7 @@ [columns]="columns" columnMode="flex" selectionType="single" - [hasDetails]="true" id="key" - (setExpandedRow)="setExpandedRow($event)" (updateSelection)="updateSelection($event)" (fetchData)="fetchData()" > @@ -27,9 +25,5 @@ [tableActions]="tableActions" > - diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-list/rgw-topic-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-list/rgw-topic-list.component.spec.ts index d7224318078..d8fe3677726 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-list/rgw-topic-list.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-list/rgw-topic-list.component.spec.ts @@ -3,7 +3,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { RgwTopicListComponent } from './rgw-topic-list.component'; import { SharedModule } from '~/app/shared/shared.module'; import { configureTestBed, PermissionHelper } from '~/testing/unit-test-helper'; -import { RgwTopicDetailsComponent } from '../rgw-topic-details/rgw-topic-details.component'; +import { RgwTopicResourceSidebarComponent } from '../rgw-topic-resource-sidebar/rgw-topic-resource-sidebar.component'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { RouterTestingModule } from '@angular/router/testing'; import { HttpClientTestingModule } from '@angular/common/http/testing'; @@ -17,7 +17,7 @@ describe('RgwTopicListComponent', () => { let rgwTopicServiceListSpy: jasmine.Spy; configureTestBed({ - declarations: [RgwTopicListComponent, RgwTopicDetailsComponent], + declarations: [RgwTopicListComponent, RgwTopicResourceSidebarComponent], imports: [BrowserAnimationsModule, RouterTestingModule, HttpClientTestingModule, SharedModule] }); @@ -29,7 +29,7 @@ describe('RgwTopicListComponent', () => { HttpClientTestingModule, RouterTestingModule ], - declarations: [RgwTopicListComponent] + declarations: [RgwTopicListComponent, RgwTopicResourceSidebarComponent] }).compileComponents(); fixture = TestBed.createComponent(RgwTopicListComponent); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-list/rgw-topic-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-list/rgw-topic-list.component.ts index 7bc4618fcba..43d86b98155 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-list/rgw-topic-list.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-list/rgw-topic-list.component.ts @@ -14,13 +14,14 @@ import { RgwTopicService } from '~/app/shared/api/rgw-topic.service'; import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; import { URLBuilderService } from '~/app/shared/services/url-builder.service'; import { Icons } from '~/app/shared/enum/icons.enum'; +import { CellTemplate } from '~/app/shared/enum/cell-template.enum'; import { ModalCdsService } from '~/app/shared/services/modal-cds.service'; import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; import { FinishedTask } from '~/app/shared/models/finished-task'; import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component'; import { Topic } from '~/app/shared/models/topic.model'; import { BehaviorSubject, Observable, of, Subscriber } from 'rxjs'; -import { catchError, shareReplay, switchMap } from 'rxjs/operators'; +import { catchError, map, shareReplay, switchMap } from 'rxjs/operators'; const BASE_URL = 'rgw/destination'; @Component({ @@ -32,16 +33,16 @@ const BASE_URL = 'rgw/destination'; }) export class RgwTopicListComponent extends ListWithDetails implements OnInit { @ViewChild('table', { static: true }) - table: TableComponent; - columns: CdTableColumn[]; + table!: TableComponent; + columns!: CdTableColumn[]; permission: Permission; - tableActions: CdTableAction[]; - context: CdTableFetchDataContext; - errorMessage: string; + tableActions!: CdTableAction[]; + context!: CdTableFetchDataContext; + errorMessage!: string; selection: CdTableSelection = new CdTableSelection(); topicsSubject = new BehaviorSubject([]); topics$ = this.topicsSubject.asObservable(); - name: string; + name!: string; constructor( private authStorageService: AuthStorageService, public actionLabels: ActionLabelsI18n, @@ -60,7 +61,8 @@ export class RgwTopicListComponent extends ListWithDetails implements OnInit { { name: $localize`Name`, prop: 'name', - flexGrow: 2 + flexGrow: 2, + cellTransformation: CellTemplate.routerLink }, { name: $localize`Owner`, @@ -79,7 +81,7 @@ export class RgwTopicListComponent extends ListWithDetails implements OnInit { } ]; - const getBucketUri = () => + const getTopicUri = () => this.selection.first() && `${encodeURIComponent(this.selection.first().key)}`; const addAction: CdTableAction = { permission: 'create', @@ -92,7 +94,7 @@ export class RgwTopicListComponent extends ListWithDetails implements OnInit { const editAction: CdTableAction = { permission: 'update', icon: Icons.edit, - routerLink: () => this.urlBuilder.getEdit(getBucketUri()), + routerLink: () => this.urlBuilder.getEdit(getTopicUri()), name: this.actionLabels.EDIT }; @@ -111,10 +113,16 @@ export class RgwTopicListComponent extends ListWithDetails implements OnInit { this.rgwTopicService.listTopic().pipe( catchError(() => { this.context.error(); - return of(null); + return of([]); }) ) ), + map((topics: Topic[]) => + topics.map((topic: Topic) => ({ + ...topic, + cdLink: `/rgw/destination/${encodeURIComponent(topic.key)}/overview` + })) + ), shareReplay(1) ); } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-resource-page/rgw-topic-resource-breadcrumb.resolver.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-resource-page/rgw-topic-resource-breadcrumb.resolver.ts new file mode 100644 index 00000000000..4338f0c94de --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-resource-page/rgw-topic-resource-breadcrumb.resolver.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot } from '@angular/router'; + +import { BreadcrumbsResolver, IBreadcrumb } from '~/app/shared/models/breadcrumbs'; +import { RgwTopicKeyService } from '~/app/shared/services/rgw-topic-key.service'; + +@Injectable({ + providedIn: 'root' +}) +export class RgwTopicResourceBreadcrumbResolver extends BreadcrumbsResolver { + constructor(private rgwTopicKeyService: RgwTopicKeyService) { + super(); + } + + resolve(route: ActivatedRouteSnapshot): IBreadcrumb[] { + const topicKey = route.params?.name || ''; + const topicName = this.rgwTopicKeyService.extractTopicName(topicKey); + + return [{ text: topicName, path: this.getFullPath(route) }]; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-resource-page/rgw-topic-resource-page.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-resource-page/rgw-topic-resource-page.component.html new file mode 100644 index 00000000000..643258d3028 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-resource-page/rgw-topic-resource-page.component.html @@ -0,0 +1,28 @@ +@if (hasTopic) { + @switch (section) { + @case ('overview') { + + + } + @case ('policies') { + + } + @case ('subscribed-buckets') { + + } + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-resource-page/rgw-topic-resource-page.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-resource-page/rgw-topic-resource-page.component.spec.ts new file mode 100644 index 00000000000..12162001790 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-resource-page/rgw-topic-resource-page.component.spec.ts @@ -0,0 +1,73 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { ActivatedRoute, convertToParamMap } from '@angular/router'; +import { of } from 'rxjs'; + +import { ComponentsModule } from '~/app/shared/components/components.module'; +import { PipesModule } from '~/app/shared/pipes/pipes.module'; +import { RgwTopicResourcePageComponent } from './rgw-topic-resource-page.component'; +import { RgwTopicService } from '~/app/shared/api/rgw-topic.service'; +import { Topic } from '~/app/shared/models/topic.model'; + +describe('RgwTopicResourcePageComponent', () => { + let component: RgwTopicResourcePageComponent; + let fixture: ComponentFixture; + let rgwTopicServiceSpy: { getTopic: jest.Mock }; + + const mockTopic: Topic = { + name: 'test-topic', + owner: 'test-user', + arn: 'arn:aws:sns:us-east-1:123456789012:test-topic', + policy: '{"Version":"2012-10-17"}', + subscribed_buckets: [], + dest: { + push_endpoint: 'http://localhost:8080', + push_endpoint_args: '', + push_endpoint_topic: 'test-topic', + stored_secret: false, + persistent: false, + persistent_queue: 'false', + time_to_live: 60, + max_retries: 3, + retry_sleep_duration: 10 + }, + key: 'test-key', + opaqueData: '' + }; + + beforeEach(async () => { + rgwTopicServiceSpy = { + getTopic: jest.fn().mockReturnValue(of(mockTopic)) + }; + + await TestBed.configureTestingModule({ + declarations: [RgwTopicResourcePageComponent], + imports: [ComponentsModule, HttpClientTestingModule, PipesModule, RouterTestingModule], + providers: [ + { provide: RgwTopicService, useValue: rgwTopicServiceSpy }, + { + provide: ActivatedRoute, + useValue: { + data: of({ section: 'overview' }), + parent: { + paramMap: of(convertToParamMap({ name: 'test-topic' })) + } + } + } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(RgwTopicResourcePageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should set section from route data', () => { + expect(component.section).toBe('overview'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-resource-page/rgw-topic-resource-page.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-resource-page/rgw-topic-resource-page.component.ts new file mode 100644 index 00000000000..fccbf8dbf7e --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-resource-page/rgw-topic-resource-page.component.ts @@ -0,0 +1,152 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { ActivatedRoute, ParamMap } from '@angular/router'; +import _ from 'lodash'; +import { Subscription } from 'rxjs'; + +import { RgwTopicService } from '~/app/shared/api/rgw-topic.service'; +import { OverviewField } from '~/app/shared/components/resource-overview-card/resource-overview-card.component'; +import { Topic } from '~/app/shared/models/topic.model'; +import { RgwTopicKeyService } from '~/app/shared/services/rgw-topic-key.service'; + +@Component({ + selector: 'cd-rgw-topic-resource-page', + templateUrl: './rgw-topic-resource-page.component.html', + standalone: false +}) +export class RgwTopicResourcePageComponent implements OnInit, OnDestroy { + private sub = new Subscription(); + + section = ''; + topic!: Topic; + hasTopic = false; + loading = false; + loadError = false; + topicOverviewFields: OverviewField[] = []; + policy: string | object = '{}'; + + constructor( + private route: ActivatedRoute, + private rgwTopicService: RgwTopicService, + private rgwTopicKeyService: RgwTopicKeyService + ) {} + + ngOnInit(): void { + this.sub.add( + this.route.data.subscribe((data) => { + this.section = data['section'] ?? ''; + }) + ); + + this.sub.add( + this.route.parent?.paramMap.subscribe((pm: ParamMap) => { + this.loadTopic(pm.get('name') ?? ''); + }) + ); + } + + ngOnDestroy(): void { + this.sub.unsubscribe(); + } + + private loadTopic(topicKeyParam: string): void { + if (!topicKeyParam) { + this.hasTopic = false; + this.loading = false; + this.loadError = false; + this.topicOverviewFields = []; + this.policy = {}; + return; + } + + const topicKey = this.rgwTopicKeyService.decodeTopicKey(topicKeyParam); + + this.loading = true; + this.loadError = false; + + this.sub.add( + this.rgwTopicService.getTopic(topicKey).subscribe({ + next: (topic: Topic) => { + this.topic = topic; + this.hasTopic = true; + this.topicOverviewFields = this.buildOverviewSections(topic); + this.policy = this.parsePolicy(topic?.policy); + this.loading = false; + }, + error: () => { + this.hasTopic = false; + this.topicOverviewFields = []; + this.policy = {}; + this.loading = false; + this.loadError = true; + } + }) + ); + } + + private buildOverviewSections(topic?: Topic): OverviewField[] { + const details = topic ?? ({} as Topic); + + return [ + { + label: $localize`Name`, + value: details?.name + }, + { + label: $localize`Owner`, + value: details?.owner + }, + { + label: $localize`Amazon resource name`, + value: details?.arn + }, + { + label: $localize`Push endpoint`, + value: details?.dest?.push_endpoint + }, + { + label: $localize`Push endpoint arguments`, + value: details?.dest?.push_endpoint_args + }, + { + label: $localize`Stored secret`, + value: details?.dest?.stored_secret + }, + { + label: $localize`Persistent`, + value: details?.dest?.persistent + }, + { + label: $localize`Persistent queue`, + value: details?.dest?.persistent_queue + }, + { + label: $localize`Time to live`, + value: details?.dest?.time_to_live + }, + { + label: $localize`Max retries`, + value: details?.dest?.max_retries + }, + { + label: $localize`Retry sleep duration`, + value: details?.dest?.retry_sleep_duration + }, + { + label: $localize`Opaque data`, + value: details?.opaqueData + } + ]; + } + + private parsePolicy(policy: string | object): string | object { + if (_.isString(policy)) { + try { + return JSON.parse(policy); + } catch { + return '{}'; + } + } + + return policy || {}; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-resource-sidebar/rgw-topic-resource-sidebar.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-resource-sidebar/rgw-topic-resource-sidebar.component.html new file mode 100644 index 00000000000..dd21955357e --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-resource-sidebar/rgw-topic-resource-sidebar.component.html @@ -0,0 +1,6 @@ + + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-resource-sidebar/rgw-topic-resource-sidebar.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-resource-sidebar/rgw-topic-resource-sidebar.component.scss new file mode 100644 index 00000000000..4544f4daa53 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-resource-sidebar/rgw-topic-resource-sidebar.component.scss @@ -0,0 +1,3 @@ +.rgw-topic-details-layout .sidebar-header { + padding-right: var(--cds-spacing-07); +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-resource-sidebar/rgw-topic-resource-sidebar.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-resource-sidebar/rgw-topic-resource-sidebar.component.spec.ts new file mode 100644 index 00000000000..a3edd546ced --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-resource-sidebar/rgw-topic-resource-sidebar.component.spec.ts @@ -0,0 +1,82 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, convertToParamMap } from '@angular/router'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { of } from 'rxjs'; + +import { RgwTopicResourceSidebarComponent } from './rgw-topic-resource-sidebar.component'; +import { RgwTopicService } from '~/app/shared/api/rgw-topic.service'; +import { Topic } from '~/app/shared/models/topic.model'; + +interface Destination { + push_endpoint: string; + push_endpoint_args: string; + push_endpoint_topic: string; + stored_secret: boolean; + persistent: boolean; + persistent_queue: string; + time_to_live: number; + max_retries: number; + retry_sleep_duration: number; +} + +const mockDestination: Destination = { + push_endpoint: 'http://localhost:8080', + push_endpoint_args: 'args', + push_endpoint_topic: 'topic', + stored_secret: false, + persistent: true, + persistent_queue: 'queue', + time_to_live: 3600, + max_retries: 5, + retry_sleep_duration: 10 +}; + +describe('RgwTopicResourceSidebarComponent', () => { + let component: RgwTopicResourceSidebarComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [RgwTopicResourceSidebarComponent], + providers: [ + { + provide: ActivatedRoute, + useValue: { + paramMap: of(convertToParamMap({ name: 'topic:ownerName:testHttp' })) + } + }, + { + provide: RgwTopicService, + useValue: { + getTopic: () => + of({ + name: 'testHttp', + owner: 'ownerName', + arn: 'arnValue', + dest: mockDestination, + policy: '{}', + key: 'topic:ownerName:testHttp', + opaqueData: 'test@12345', + subscribed_buckets: [] + } as Topic) + } + } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + + fixture = TestBed.createComponent(RgwTopicResourceSidebarComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should set topic title and sidebar items', () => { + expect(component.topicName).toBe('testHttp'); + expect(component.sidebarItems.length).toBe(3); + expect(component.sidebarItems[0].label).toBe('Overview'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-resource-sidebar/rgw-topic-resource-sidebar.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-resource-sidebar/rgw-topic-resource-sidebar.component.ts new file mode 100644 index 00000000000..adbf52f8f42 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-resource-sidebar/rgw-topic-resource-sidebar.component.ts @@ -0,0 +1,87 @@ +import { Component, OnDestroy, OnInit, ViewEncapsulation } from '@angular/core'; +import { ActivatedRoute, ParamMap } from '@angular/router'; +import { Subscription } from 'rxjs'; + +import { RgwTopicService } from '~/app/shared/api/rgw-topic.service'; +import { SidebarItem } from '~/app/shared/components/sidebar-layout/sidebar-layout.component'; +import { Topic } from '~/app/shared/models/topic.model'; +import { RgwTopicKeyService } from '~/app/shared/services/rgw-topic-key.service'; + +@Component({ + selector: 'cd-rgw-topic-resource-sidebar', + templateUrl: './rgw-topic-resource-sidebar.component.html', + styleUrls: ['./rgw-topic-resource-sidebar.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false +}) +export class RgwTopicResourceSidebarComponent implements OnInit, OnDestroy { + private sub = new Subscription(); + + topicKey = ''; + topicName = ''; + selection: Topic | undefined; + sidebarItems: SidebarItem[] = []; + + constructor( + private route: ActivatedRoute, + private rgwTopicService: RgwTopicService, + private rgwTopicKeyService: RgwTopicKeyService + ) {} + + ngOnInit(): void { + this.sub.add( + this.route.paramMap.subscribe((pm: ParamMap) => { + this.topicKey = pm.get('name') ?? ''; + this.buildSidebarItems(); + this.loadTopic(); + }) + ); + } + + ngOnDestroy(): void { + this.sub.unsubscribe(); + } + + private buildSidebarItems(): void { + this.sidebarItems = [ + { + label: $localize`Overview`, + route: ['/rgw/destination', this.topicKey, 'overview'], + routerLinkActiveOptions: { exact: true } + }, + { + label: $localize`Policies`, + route: ['/rgw/destination', this.topicKey, 'policies'], + routerLinkActiveOptions: { exact: true } + }, + { + label: $localize`Subscribed buckets`, + route: ['/rgw/destination', this.topicKey, 'subscribed-buckets'], + routerLinkActiveOptions: { exact: true } + } + ]; + } + + private loadTopic(): void { + if (!this.topicKey) { + this.selection = undefined; + this.topicName = ''; + return; + } + + const key = this.rgwTopicKeyService.decodeTopicKey(this.topicKey); + + this.sub.add( + this.rgwTopicService.getTopic(key).subscribe({ + next: (topic: Topic) => { + this.selection = topic; + this.topicName = topic?.name || this.rgwTopicKeyService.extractTopicName(key); + }, + error: () => { + this.selection = undefined; + this.topicName = this.rgwTopicKeyService.extractTopicName(key); + } + }) + ); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts index cd112ad8eda..dfdd0e68fc3 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts @@ -120,7 +120,9 @@ import { RgwRateLimitComponent } from './rgw-rate-limit/rgw-rate-limit.component import { RgwRateLimitDetailsComponent } from './rgw-rate-limit-details/rgw-rate-limit-details.component'; import { NfsClusterComponent } from '../nfs/nfs-cluster/nfs-cluster.component'; import { RgwTopicListComponent } from './rgw-topic-list/rgw-topic-list.component'; -import { RgwTopicDetailsComponent } from './rgw-topic-details/rgw-topic-details.component'; +import { RgwTopicResourceSidebarComponent } from './rgw-topic-resource-sidebar/rgw-topic-resource-sidebar.component'; +import { RgwTopicResourcePageComponent } from './rgw-topic-resource-page/rgw-topic-resource-page.component'; +import { RgwTopicResourceBreadcrumbResolver } from './rgw-topic-resource-page/rgw-topic-resource-breadcrumb.resolver'; import { RgwTopicFormComponent } from './rgw-topic-form/rgw-topic-form.component'; import { RgwBucketNotificationListComponent } from './rgw-bucket-notification-list/rgw-bucket-notification-list.component'; import { RgwNotificationFormComponent } from './rgw-notification-form/rgw-notification-form.component'; @@ -233,7 +235,8 @@ import { RgwAccountRoleFormComponent } from './rgw-account-role-form/rgw-account RgwBucketLifecycleListComponent, RgwRateLimitDetailsComponent, RgwTopicListComponent, - RgwTopicDetailsComponent, + RgwTopicResourceSidebarComponent, + RgwTopicResourcePageComponent, RgwTopicFormComponent, RgwBucketNotificationListComponent, RgwNotificationFormComponent, @@ -451,6 +454,29 @@ const routes: Routes = [ path: `${URLVerbs.EDIT}/:name`, component: RgwTopicFormComponent, data: { breadcrumbs: ActionLabels.EDIT } + }, + { + path: ':name', + component: RgwTopicResourceSidebarComponent, + data: { breadcrumbs: RgwTopicResourceBreadcrumbResolver }, + children: [ + { path: '', redirectTo: 'overview', pathMatch: 'full' }, + { + path: 'overview', + component: RgwTopicResourcePageComponent, + data: { breadcrumbs: 'Overview', section: 'overview' } + }, + { + path: 'policies', + component: RgwTopicResourcePageComponent, + data: { breadcrumbs: 'Policies', section: 'policies' } + }, + { + path: 'subscribed-buckets', + component: RgwTopicResourcePageComponent, + data: { breadcrumbs: 'Subscribed buckets', section: 'subscribed-buckets' } + } + ] } ] } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-topic.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-topic.service.ts index 23a62a5844a..e443fc16b79 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-topic.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-topic.service.ts @@ -4,7 +4,7 @@ import _ from 'lodash'; import { Observable, of as observableOf } from 'rxjs'; import { ApiClient } from './api-client'; import { Topic, TopicRequest } from '~/app/shared/models/topic.model'; -import { catchError, mapTo } from 'rxjs/operators'; +import { catchError, mapTo, shareReplay, tap } from 'rxjs/operators'; import { RgwDaemonService } from './rgw-daemon.service'; @Injectable({ @@ -13,6 +13,8 @@ import { RgwDaemonService } from './rgw-daemon.service'; export class RgwTopicService extends ApiClient { baseURL = 'api/rgw/topic'; + private topicCache = new Map>(); + constructor( private http: HttpClient, private rgwDaemonService: RgwDaemonService @@ -24,20 +26,30 @@ export class RgwTopicService extends ApiClient { return this.http.get(this.baseURL); } - getTopic(key: string) { - return this.http.get(`${this.baseURL}/${encodeURIComponent(key)}`); + getTopic(key: string): Observable { + if (!this.topicCache.has(key)) { + const request$ = this.http + .get(`${this.baseURL}/${encodeURIComponent(key)}`) + .pipe(tap({ error: () => this.topicCache.delete(key) }), shareReplay(1)); + this.topicCache.set(key, request$); + } + return this.topicCache.get(key)!; } create(createParam: TopicRequest) { return this.rgwDaemonService.request((params: HttpParams) => { - return this.http.post(`${this.baseURL}`, createParam, { params: params }); + return this.http + .post(`${this.baseURL}`, createParam, { params: params }) + .pipe(tap(() => this.clearCache())); }); } delete(key: string) { - return this.http.delete(`${this.baseURL}/${key}`, { - observe: 'response' - }); + return this.http + .delete(`${this.baseURL}/${key}`, { + observe: 'response' + }) + .pipe(tap(() => this.clearCache(key))); } exists(key: string): Observable { @@ -52,4 +64,16 @@ export class RgwTopicService extends ApiClient { }) ); } + + /** + * Helper method to manually invalidate cache. + * If a key is provided, clears just that topic. Otherwise, clears all. + */ + private clearCache(key?: string): void { + if (key) { + this.topicCache.delete(key); + } else { + this.topicCache.clear(); + } + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/rgw-topic-key.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/rgw-topic-key.service.spec.ts new file mode 100644 index 00000000000..560f7e3d68d --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/rgw-topic-key.service.spec.ts @@ -0,0 +1,63 @@ +import { TestBed } from '@angular/core/testing'; + +import { configureTestBed } from '~/testing/unit-test-helper'; +import { RgwTopicKeyService } from './rgw-topic-key.service'; + +describe('RgwTopicKeyService', () => { + let service: RgwTopicKeyService; + + configureTestBed({ + providers: [RgwTopicKeyService] + }); + + beforeEach(() => { + service = TestBed.inject(RgwTopicKeyService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('decodeTopicKey', () => { + it('should decode a valid URL-encoded topic key', () => { + const input = 'arn%3Aaws%3Asns%3Aus-east-1%3A12345%3Amy-topic'; + const expected = 'arn:aws:sns:us-east-1:12345:my-topic'; + expect(service.decodeTopicKey(input)).toBe(expected); + }); + + it('should return the original string if no encoding is present', () => { + const input = 'arn:aws:sns:us-east-1:12345:my-topic'; + expect(service.decodeTopicKey(input)).toBe(input); + }); + + it('should catch errors and return original string for a malformed URI', () => { + // '%1' is a malformed URI component that triggers decodeURIComponent to throw + const malformedInput = 'arn%3Aaws%3Asns%3A%1'; + expect(service.decodeTopicKey(malformedInput)).toBe(malformedInput); + }); + }); + + describe('extractTopicName', () => { + it('should extract the last segment of a decoded topic key', () => { + const input = 'arn:aws:sns:us-east-1:12345:my-topic'; + expect(service.extractTopicName(input)).toBe('my-topic'); + }); + + it('should extract the last segment of an encoded topic key', () => { + const input = 'arn%3Aaws%3Asns%3Aus-east-1%3A12345%3Amy-topic'; + expect(service.extractTopicName(input)).toBe('my-topic'); + }); + + it('should return the whole string if there are no colons', () => { + const input = 'my-topic-only'; + expect(service.extractTopicName(input)).toBe('my-topic-only'); + }); + + it('should return the full decoded string if the last segment is empty', () => { + // Since split(':') creates an empty string at the end, + // the || operator in your service falls back to the full decoded string. + const input = 'arn:aws:sns:us-east-1:12345:'; + expect(service.extractTopicName(input)).toBe(input); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/rgw-topic-key.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/rgw-topic-key.service.ts new file mode 100644 index 00000000000..dcddf6fa963 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/rgw-topic-key.service.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root' +}) +export class RgwTopicKeyService { + decodeTopicKey(topicKey: string): string { + try { + return decodeURIComponent(topicKey); + } catch { + return topicKey; + } + } + + extractTopicName(topicKey: string): string { + const decoded = this.decodeTopicKey(topicKey); + const segments = decoded.split(':'); + return segments[segments.length - 1] || decoded; + } +}