]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: fix notification and tearsheet UI issues 70677/head
authorAfreen Misbah <afreen@ibm.com>
Wed, 29 Jul 2026 12:19:25 +0000 (17:49 +0530)
committerAfreen Misbah <afreen@ibm.com>
Mon, 3 Aug 2026 22:05:04 +0000 (03:35 +0530)
- Add Carbon typography classes to running tasks section
- Fix tearsheet footer cutoff by adding min-height: 0 to grid wrapper
- Add launch icon to View in Prometheus link
- Restrict notification detail width to prevent overflow
- Render HTML in notification detail view, strip tags in list preview
- Add gap between timestamp and View more in toast notifications

Signed-off-by: Afreen Misbah <afreen@ibm.com>
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.html
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.html
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.scss
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/notification-toast/notification-toast.component.scss
src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.scss
src/pybind/mgr/dashboard/frontend/src/app/shared/services/api-interceptor.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.ts

index 1e33b037d526189ad6e53b230185a2b97ca57be6..c334f33252234883e74256f4ee2b3544ce2fb9c1 100644 (file)
@@ -1,4 +1,4 @@
-import { Component, DestroyRef, OnInit, SecurityContext, ViewChild } from '@angular/core';
+import { Component, DestroyRef, OnInit, ViewChild } from '@angular/core';
 import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
 import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
 
@@ -19,7 +19,6 @@ import { from, Observable, of } from 'rxjs';
 import { NotificationService } from '~/app/shared/services/notification.service';
 import { NotificationType } from '~/app/shared/enum/notification-type.enum';
 import { catchError, concatMap, map, tap } from 'rxjs/operators';
-import { DomSanitizer } from '@angular/platform-browser';
 
 export type SubsystemPayload = {
   nqn: string;
@@ -92,8 +91,7 @@ export class NvmeofSubsystemsFormComponent implements OnInit {
     private destroyRef: DestroyRef,
     private nvmeofService: NvmeofService,
     private notificationService: NotificationService,
-    private router: Router,
-    private sanitizer: DomSanitizer
+    private router: Router
   ) {}
 
   ngOnInit() {
@@ -265,22 +263,19 @@ export class NvmeofSubsystemsFormComponent implements OnInit {
   private showFinalNotification(stepResults: StepResult[]) {
     this.isSubmitLoading = false;
 
-    const messageLines = stepResults.map((stepResult) =>
-      stepResult.success
-        ? $localize`<div>${stepResult.step} step created successfully</div><br/>`
-        : $localize`<div>${stepResult.step} step failed: <code>${stepResult.error}</code></div><br/>`
-    );
-
-    const rawHtml = messageLines.join('<br/>');
-    const sanitizedHtml = this.sanitizer.sanitize(SecurityContext.HTML, rawHtml) ?? '';
-
     const hasFailure = stepResults.some((r) => !r.success);
     const type = hasFailure ? NotificationType.error : NotificationType.success;
     const title = hasFailure
       ? $localize`Subsystem created (with errors)`
       : $localize`Subsystem created`;
 
-    this.notificationService.show(type, title, sanitizedHtml);
+    const messageLines = stepResults.map((stepResult) =>
+      stepResult.success
+        ? $localize`${stepResult.step}:step: created successfully`
+        : $localize`${stepResult.step}:step: failed (${stepResult.error}:error:)`
+    );
+
+    this.notificationService.show(type, title, messageLines.join('<br>'));
     this.router.navigate(['block/nvmeof/subsystems'], {
       queryParams: {
         group: this.group,
index e85813bf05b4b33a861d43338af6df5de84191a4..ba44060639c5869933fefb4d2a70b2fb54aedd93 100644 (file)
@@ -13,7 +13,7 @@
           <cd-icon type="infoCircle"></cd-icon>
 
           <div class="notification-content">
-            <div class="notification-title">
+            <div class="notification-title cds--type-body-short-01">
               {{ task.description }}
             </div>
 
@@ -27,7 +27,7 @@
             </cds-progress-bar>
 
             <div class="task-row">
-              <span class="notification-timestamp">
+              <span class="notification-timestamp cds--type-label-01">
                 {{ task.begin_time | relativeDate }}
               </span>
 
index 68f8224e89ee8527f5f2a37ec8e8bf1d4b90be40..c2fb6d497603bbd0b454a741e3e044f43db62046 100644 (file)
               Occurrences: {{ selected.occurrences }}
             </p>
           }
-          <p class="notifications-page__detail-text cds--type-body-01">
-            {{ selected.displayPreview }}
-          </p>
+          <p
+            class="notifications-page__detail-text cds--type-body-01"
+            [innerHTML]="selected.displayDetail | sanitizeHtml"
+          ></p>
           @if (selected.prometheusAlert?.sourceUrl) {
             <a
               cdsLink
-              class="cds-mt-5"
+              class="notifications-page__prometheus-link cds-mt-5"
               [href]="selected.prometheusAlert.sourceUrl"
               target="_blank"
               rel="noopener noreferrer"
-              i18n
             >
-              View in Prometheus
+              <span i18n>View in Prometheus</span>
+              <cd-icon
+                type="launch"
+                [useDefault]="true"
+              ></cd-icon>
             </a>
           }
         </div>
index fea934ca1b26983406ded11ac1c11486d383d7be..b9ce554935aa2c1b4f775164f20dac490bd72c59 100644 (file)
 
   &__detail {
     overflow-y: auto;
+    overflow-x: hidden;
     padding: var(--cds-spacing-06) var(--cds-spacing-07);
     background-color: var(--cds-layer-01);
+    min-width: 0;
   }
 
   &__detail-header cd-notification-item {
   &__detail-text {
     margin: 0;
     color: var(--cds-text-primary);
+    overflow-wrap: break-word;
+  }
+
+  &__prometheus-link {
+    display: inline-flex;
+    align-items: center;
+    gap: var(--cds-spacing-02);
   }
 
   &__empty {
index f65a1cb41bfe8ac145895af006792930fd4c5de6..f9ed7af4fed8d60fc4d30ffd0469a905481b5c37 100644 (file)
@@ -316,6 +316,28 @@ describe('NotificationsPageComponent', () => {
       fixture.detectChanges();
       expect(component.notifications()[0].displayPreview).toBe('');
     });
+
+    it('should strip HTML tags from displayPreview', () => {
+      const htmlNotification = createMockNotification({
+        id: '6',
+        message: 'Disk full. <a href="http://example.com">View details</a>'
+      });
+      dataSourceSubject.next([htmlNotification]);
+      fixture.detectChanges();
+      expect(component.notifications()[0].displayPreview).toBe('Disk full. View details');
+    });
+
+    it('should preserve HTML in displayDetail', () => {
+      const htmlNotification = createMockNotification({
+        id: '6',
+        message: 'Disk full. <a href="http://example.com">View details</a>'
+      });
+      dataSourceSubject.next([htmlNotification]);
+      fixture.detectChanges();
+      expect(component.notifications()[0].displayDetail).toBe(
+        'Disk full. <a href="http://example.com">View details</a>'
+      );
+    });
   });
 
   describe('query param pre-selection', () => {
index 89401784a2a394c007898f60b96e60555994f09e..5735d5bbfaee902a329d896bd7191c1be0be33c9 100644 (file)
@@ -20,6 +20,7 @@ import { IconSize } from '~/app/shared/enum/icons.enum';
 interface DisplayNotification extends CdNotification {
   displayTitle: string;
   displayPreview: string;
+  displayDetail: string;
 }
 
 @Component({
@@ -74,12 +75,14 @@ export class NotificationsPageComponent implements OnInit, OnDestroy {
 
     this.sub = this.notificationService.data$.subscribe((notifications) => {
       this.notifications.set(
-        notifications.map((n) =>
-          Object.assign(n, {
+        notifications.map((n) => {
+          const rawMessage = n.prometheusAlert?.description || n.message || '';
+          return Object.assign(n, {
             displayTitle: n.prometheusAlert?.alertName || n.title || '',
-            displayPreview: n.prometheusAlert?.description || n.message || ''
-          })
-        )
+            displayPreview: rawMessage.replace(/<[^>]*>/g, ''),
+            displayDetail: rawMessage
+          });
+        })
       );
 
       this._tryPreselect(notifications);
index 9c84ef47f1777f2dc53a9ea7a502077eb18e879d..961a30d6311fafdb0e66df5a632a992ea7fbf5c5 100644 (file)
@@ -63,6 +63,7 @@
         justify-content: flex-start;
         align-items: center;
         width: 100%;
+        gap: $spacing-03;
       }
 
       .toast-caption-container .date {
index 48e7bd3f98ae3b1368cb6946b712d017aa002bc4..002c4414bc250ebadbe76ea3d3a036c260bb7e80 100644 (file)
@@ -158,12 +158,13 @@ export class ApiInterceptorService implements HttpInterceptor {
     return this.notificationService.show(() => {
       let message = '';
       if (_.isPlainObject(resp.error) && _.isString(resp.error.detail)) {
-        message = resp.error.detail; // Error was triggered by the backend.
+        message = resp.error.detail;
       } else if (_.isString(resp.error)) {
         message = resp.error;
       } else if (_.isString(resp.message)) {
         message = resp.message;
       }
+
       return new CdNotificationConfig(
         NotificationType.error,
         `${resp.status} - ${resp.statusText}`,
index 0af14122d0ada08f196ecc36a78b3ea5d8e9f511..d09ddbf52caed91f4894ec2fec26db38948df96a 100644 (file)
@@ -25,6 +25,13 @@ function escapeHtml(str: string): string {
     .replace(/'/g, '&#39;');
 }
 
+function toPlainText(html: string): string {
+  return html
+    .replace(/<[^>]*>/g, ' ')
+    .replace(/\s+/g, ' ')
+    .trim();
+}
+
 @Injectable({
   providedIn: 'root'
 })
@@ -310,7 +317,7 @@ export class NotificationService {
     const lowContrast = notification.options?.lowContrast || false;
 
     const escapedTitle = escapeHtml(notification.title || '');
-    const escapedMessage = escapeHtml(notification.message || '');
+    const escapedMessage = escapeHtml(toPlainText(notification.message || ''));
     const existing = this.activeToasts.find(
       (t) =>
         t.title === escapedTitle && t.type === carbonType && t.originalSubtitle === escapedMessage