Merge pull request #69924 from syedali237/dashboard/notif-dest

mgr/dashboard : migrated notification destination tabs to resource pages

Reviewed-by: Afreen Misbah <afreen@ibm.com>
Reviewed-by: Abhishek Desai <abhishek.desai1@ibm.com>
This commit is contained in:
Afreen Misbah 2026-07-21 19:13:29 +05:30 committed by GitHub
commit 64e8dc794d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 616 additions and 292 deletions

View File

@ -1,137 +0,0 @@
<ng-container *ngIf="!!selection">
<cds-tabs
type="contained"
theme="light"
>
<cds-tab
heading="Details"
i18n-heading
>
<table
class="cds--data-table--sort cds--data-table--no-border cds--data-table cds--data-table--md"
data-testid="rgw-topic-details"
>
<tbody>
<tr>
<td
i18n
class="bold"
>
Push endpoint arguments
</td>
<td>{{ selection?.dest?.push_endpoint_args }}</td>
</tr>
<tr>
<td
i18n
class="bold w-25"
>
Stored secret
</td>
<td class="w-75">{{ selection?.dest?.stored_secret }}</td>
</tr>
<tr>
<td
i18n
class="bold"
>
Persistent
</td>
<td>{{ selection?.dest?.persistent }}</td>
</tr>
<tr>
<td
i18n
class="bold"
>
Persistent queue
</td>
<td>{{ selection?.dest?.persistent_queue }}</td>
</tr>
<tr>
<td
i18n
class="bold"
>
Time to live
</td>
<td>{{ selection?.dest?.time_to_live }}</td>
</tr>
<tr>
<td
i18n
class="bold"
>
Max retries
</td>
<td>{{ selection?.dest?.max_retries }}</td>
</tr>
<tr>
<td
i18n
class="bold"
>
Retry sleep duration
</td>
<td>{{ selection?.dest?.retry_sleep_duration }}</td>
</tr>
<tr>
<td
i18n
class="bold"
>
Opaque data
</td>
<td>{{ selection?.opaqueData }}</td>
</tr>
</tbody>
</table>
</cds-tab>
<cds-tab
heading="Policies"
i18n-heading
>
<div>
<table
class="cds--data-table--sort cds--data-table--no-border cds--data-table cds--data-table--md"
>
<tbody>
<tr>
<td
i18n
class="bold w-25"
>
Policy
</td>
<td>
<pre>{{ policy | json }}</pre>
</td>
</tr>
</tbody>
</table>
</div>
</cds-tab>
<cds-tab
heading="Subscribed buckets"
i18n-heading
>
<table
class="cds--data-table--sort cds--data-table--no-border cds--data-table cds--data-table--md"
>
<tbody>
<tr>
<td
i18n
class="bold w-25"
>
Subscribed buckets
</td>
<td>
<pre>{{ selection.subscribed_buckets | json }}</pre>
</td>
</tr>
</tbody>
</table>
</cds-tab>
</cds-tabs>
</ng-container>

View File

@ -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<RgwTopicDetailsComponent>;
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({});
});
});

View File

@ -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 || {};
}
}
}
}

View File

@ -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"
>
</cd-table-actions>
<cd-rgw-topic-details
*cdTableDetail
[selection]="expandedRow"
></cd-rgw-topic-details>
</cd-table>
</ng-container>

View File

@ -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);

View File

@ -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<Topic[]>([]);
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)
);
}

View File

@ -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) }];
}
}

View File

@ -0,0 +1,28 @@
@if (hasTopic) {
@switch (section) {
@case ('overview') {
<cd-resource-overview-card
title="Notification destination details"
[columns]="4"
[fields]="topicOverviewFields"
>
</cd-resource-overview-card>
}
@case ('policies') {
<cd-table-key-value
[data]="[{ key: 'Policy', value: (policy | json) }]"
[autoReload]="false"
[showMultiLineText]="true"
[multilineTextKeys]="['Policy']"
></cd-table-key-value>
}
@case ('subscribed-buckets') {
<cd-table-key-value
[data]="[{ key: 'Subscribed buckets', value: (topic.subscribed_buckets | json) }]"
[autoReload]="false"
[showMultiLineText]="true"
[multilineTextKeys]="['Subscribed buckets']"
></cd-table-key-value>
}
}
}

View File

@ -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<RgwTopicResourcePageComponent>;
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');
});
});

View File

@ -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 || {};
}
}

View File

@ -0,0 +1,6 @@
<cd-sidebar-layout
class="rgw-topic-details-layout"
[title]="topicName"
[items]="sidebarItems"
>
</cd-sidebar-layout>

View File

@ -0,0 +1,3 @@
.rgw-topic-details-layout .sidebar-header {
padding-right: var(--cds-spacing-07);
}

View File

@ -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<RgwTopicResourceSidebarComponent>;
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');
});
});

View File

@ -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);
}
})
);
}
}

View File

@ -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' }
}
]
}
]
}

View File

@ -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<string, Observable<Topic>>();
constructor(
private http: HttpClient,
private rgwDaemonService: RgwDaemonService
@ -24,20 +26,30 @@ export class RgwTopicService extends ApiClient {
return this.http.get<Topic[]>(this.baseURL);
}
getTopic(key: string) {
return this.http.get<Topic>(`${this.baseURL}/${encodeURIComponent(key)}`);
getTopic(key: string): Observable<Topic> {
if (!this.topicCache.has(key)) {
const request$ = this.http
.get<Topic>(`${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<boolean> {
@ -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();
}
}
}

View File

@ -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);
});
});
});

View File

@ -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;
}
}