]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: migrated notif dest table tabs to resource pages 69924/head
authorSyed Ali Ul Hasan <syedaliulhasan19@gmail.com>
Tue, 21 Jul 2026 09:09:59 +0000 (14:39 +0530)
committerSyed Ali Ul Hasan <syedaliulhasan19@gmail.com>
Tue, 21 Jul 2026 09:09:59 +0000 (14:39 +0530)
- Fixes: https://tracker.ceph.com/issues/77541

Signed-off-by: Syed Ali Ul Hasan <syedaliulhasan19@gmail.com>
19 files changed:
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-details/rgw-topic-details.component.html [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-details/rgw-topic-details.component.scss [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-details/rgw-topic-details.component.spec.ts [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-details/rgw-topic-details.component.ts [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-list/rgw-topic-list.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-list/rgw-topic-list.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-list/rgw-topic-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-resource-page/rgw-topic-resource-breadcrumb.resolver.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-resource-page/rgw-topic-resource-page.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-resource-page/rgw-topic-resource-page.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-resource-page/rgw-topic-resource-page.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-resource-sidebar/rgw-topic-resource-sidebar.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-resource-sidebar/rgw-topic-resource-sidebar.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-resource-sidebar/rgw-topic-resource-sidebar.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-resource-sidebar/rgw-topic-resource-sidebar.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-topic.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/rgw-topic-key.service.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/services/rgw-topic-key.service.ts [new file with mode: 0644]

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 (file)
index 21ea0b4..0000000
+++ /dev/null
@@ -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>
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 (file)
index e69de29..0000000
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 (file)
index 78bbed0..0000000
+++ /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<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({});
-  });
-});
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 (file)
index 269316b..0000000
+++ /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 || {};
-      }
-    }
-  }
-}
index d353153fa48e0faf7e707fadab5f709925ef1118..6a80c707c462aa468348a78fafa8024b7d81589d 100644 (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>
index d7224318078d7f1bdb7f5937308e554b2d1fb268..d8fe3677726f8b2612a85a95a970a885c35d0ff3 100644 (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);
index 7bc4618fcbaa0a5f0f62d03433536ef04e5282bc..43d86b9815595d30d9a2194680a61c5f71ae5613 100644 (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)
     );
   }
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 (file)
index 0000000..4338f0c
--- /dev/null
@@ -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 (file)
index 0000000..643258d
--- /dev/null
@@ -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>
+    }
+  }
+}
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 (file)
index 0000000..1216200
--- /dev/null
@@ -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');
+  });
+});
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 (file)
index 0000000..fccbf8d
--- /dev/null
@@ -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 (file)
index 0000000..dd21955
--- /dev/null
@@ -0,0 +1,6 @@
+<cd-sidebar-layout
+  class="rgw-topic-details-layout"
+  [title]="topicName"
+  [items]="sidebarItems"
+>
+</cd-sidebar-layout>
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 (file)
index 0000000..4544f4d
--- /dev/null
@@ -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 (file)
index 0000000..a3edd54
--- /dev/null
@@ -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');
+  });
+});
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 (file)
index 0000000..adbf52f
--- /dev/null
@@ -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);
+        }
+      })
+    );
+  }
+}
index cd112ad8edab3fcceab993b07283eccad886b8ca..dfdd0e68fc3ed2175af838674fa1875ff5d6c66d 100644 (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' }
+          }
+        ]
       }
     ]
   }
index 23a62a5844a6490f904ad00601da50923cd73fef..e443fc16b795763ecd06c5cd9000d2196ccc12d7 100644 (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();
+    }
+  }
 }
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 (file)
index 0000000..560f7e3
--- /dev/null
@@ -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 (file)
index 0000000..dcddf6f
--- /dev/null
@@ -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;
+  }
+}