+++ /dev/null
-<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>
+++ /dev/null
-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({});
- });
-});
+++ /dev/null
-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 || {};
- }
- }
- }
-}
[columns]="columns"
columnMode="flex"
selectionType="single"
- [hasDetails]="true"
id="key"
- (setExpandedRow)="setExpandedRow($event)"
(updateSelection)="updateSelection($event)"
(fetchData)="fetchData()"
>
[tableActions]="tableActions"
>
</cd-table-actions>
- <cd-rgw-topic-details
- *cdTableDetail
- [selection]="expandedRow"
- ></cd-rgw-topic-details>
</cd-table>
</ng-container>
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';
let rgwTopicServiceListSpy: jasmine.Spy;
configureTestBed({
- declarations: [RgwTopicListComponent, RgwTopicDetailsComponent],
+ declarations: [RgwTopicListComponent, RgwTopicResourceSidebarComponent],
imports: [BrowserAnimationsModule, RouterTestingModule, HttpClientTestingModule, SharedModule]
});
HttpClientTestingModule,
RouterTestingModule
],
- declarations: [RgwTopicListComponent]
+ declarations: [RgwTopicListComponent, RgwTopicResourceSidebarComponent]
}).compileComponents();
fixture = TestBed.createComponent(RgwTopicListComponent);
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({
})
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,
{
name: $localize`Name`,
prop: 'name',
- flexGrow: 2
+ flexGrow: 2,
+ cellTransformation: CellTemplate.routerLink
},
{
name: $localize`Owner`,
}
];
- const getBucketUri = () =>
+ const getTopicUri = () =>
this.selection.first() && `${encodeURIComponent(this.selection.first().key)}`;
const addAction: CdTableAction = {
permission: 'create',
const editAction: CdTableAction = {
permission: 'update',
icon: Icons.edit,
- routerLink: () => this.urlBuilder.getEdit(getBucketUri()),
+ routerLink: () => this.urlBuilder.getEdit(getTopicUri()),
name: this.actionLabels.EDIT
};
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)
);
}
--- /dev/null
+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) }];
+ }
+}
--- /dev/null
+@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>
+ }
+ }
+}
--- /dev/null
+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');
+ });
+});
--- /dev/null
+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 || {};
+ }
+}
--- /dev/null
+<cd-sidebar-layout
+ class="rgw-topic-details-layout"
+ [title]="topicName"
+ [items]="sidebarItems"
+>
+</cd-sidebar-layout>
--- /dev/null
+.rgw-topic-details-layout .sidebar-header {
+ padding-right: var(--cds-spacing-07);
+}
--- /dev/null
+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');
+ });
+});
--- /dev/null
+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);
+ }
+ })
+ );
+ }
+}
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';
RgwBucketLifecycleListComponent,
RgwRateLimitDetailsComponent,
RgwTopicListComponent,
- RgwTopicDetailsComponent,
+ RgwTopicResourceSidebarComponent,
+ RgwTopicResourcePageComponent,
RgwTopicFormComponent,
RgwBucketNotificationListComponent,
RgwNotificationFormComponent,
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' }
+ }
+ ]
}
]
}
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({
export class RgwTopicService extends ApiClient {
baseURL = 'api/rgw/topic';
+ private topicCache = new Map<string, Observable<Topic>>();
+
constructor(
private http: HttpClient,
private rgwDaemonService: RgwDaemonService
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> {
})
);
}
+
+ /**
+ * 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();
+ }
+ }
}
--- /dev/null
+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);
+ });
+ });
+});
--- /dev/null
+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;
+ }
+}