]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: NVMe-oF – Design cards for the gateway group, subsystem, namespace... fix-3991
authorpujaoshahu <pshahu@redhat.com>
Mon, 25 May 2026 10:02:46 +0000 (15:32 +0530)
committerpujaoshahu <pshahu@redhat.com>
Tue, 26 May 2026 12:40:59 +0000 (18:10 +0530)
Fixes:https://tracker.ceph.com/issues/75683

Signed-off-by: pujaoshahu <pshahu@redhat.com>
15 files changed:
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway/nvmeof-gateway.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway/nvmeof-gateway.component.scss
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway/nvmeof-gateway.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.scss
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/active-alert-list/active-alert-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/performance-card.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/performance-card.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/enum/dashboard-promqls.enum.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/helpers/nvmeof-alert.helper.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/helpers/nvmeof-alert.helper.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/models/prometheus-alerts.ts

index 8e1e7c13a19ed5c1e7e1e2a6173dc118a03b7a5e..c30fdced9516dc946bdb2b5703f5bfd6cf4e35e5 100644 (file)
@@ -93,6 +93,7 @@ import SubtractAlt from '@carbon/icons/es/subtract--alt/20';
 import ProgressBarRound from '@carbon/icons/es/progress-bar--round/32';
 import Search from '@carbon/icons/es/search/32';
 import Datastore from '@carbon/icons/es/datastore/16';
+import ArrowRight from '@carbon/icons/es/arrow--right/16';
 import { NvmeofGatewaySubsystemComponent } from './nvmeof-gateway-subsystem/nvmeof-gateway-subsystem.component';
 import { NvmeofNamespaceExpandModalComponent } from './nvmeof-namespace-expand-modal/nvmeof-namespace-expand-modal.component';
 import { NvmeGatewayViewComponent } from './nvme-gateway-view/nvme-gateway-view.component';
@@ -107,6 +108,7 @@ import { NvmeofSubsystemPerformanceComponent } from './nvmeof-subsystem-performa
 import { NvmeofTabsComponent } from './nvmeof-tabs/nvmeof-tabs.component';
 import { NvmeofSetupCardsComponent } from './nvmeof-setup-cards/nvmeof-setup-cards.component';
 import { NvmeofGatewayGroupFilterComponent } from './nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component';
+import { ProductiveCardComponent } from '~/app/shared/components/productive-card/productive-card.component';
 
 @NgModule({
   imports: [
@@ -145,7 +147,8 @@ import { NvmeofGatewayGroupFilterComponent } from './nvmeof-gateway-group-filter
     LayoutModule,
     ThemeModule,
     NvmeofSetupCardsComponent,
-    NvmeofGatewayGroupFilterComponent
+    NvmeofGatewayGroupFilterComponent,
+    ProductiveCardComponent
   ],
   declarations: [
     RbdListComponent,
@@ -214,7 +217,8 @@ export class BlockModule {
       ProgressBarRound,
       SubtractAlt,
       Search,
-      Datastore
+      Datastore,
+      ArrowRight
     ]);
   }
 }
index 6b2552078ac193d890b58e3457b2c656a5e56219..19c6ceb03b6a04aa34be07fd5ded6be557e94afc 100644 (file)
@@ -1,9 +1,173 @@
 <fieldset>
   <legend>
     <h1 class="cds--type-heading-03">NVMe over Fabrics (TCP)</h1>
-  <cd-help-text>Monitor and manage NVMe-over-TCP resources for high-performance block storage.</cd-help-text>
+    <cd-help-text i18n>Monitor and manage NVMe-over-TCP resources for high-performance block storage.</cd-help-text>
   </legend>
 </fieldset>
+
+<ng-container *ngIf="nvmeof$ | async as stats">
+  <ng-container *ngIf="stats?.hasData">
+    <div cdsGrid
+         [fullWidth]="true"
+         [narrow]="true"
+         class="nvmeof-overview-cards cds-mt-5 cds-mb-5">
+      <div cdsRow>
+        <div cdsCol
+             [columnNumbers]="{lg: 5, md: 8, sm: 16}"
+             class="cds-mb-5">
+          <cd-productive-card>
+            <ng-template #header>
+              <h2 class="cds--type-heading-compact-02"
+                  i18n>Resources status</h2>
+            </ng-template>
+            <div class="nvmeof-resources-status">
+              <div class="nvmeof-resources-status__item">
+                <span class="cds--type-label-01"
+                      i18n>Gateway groups</span>
+                <div [cdsStack]="'horizontal'"
+                     gap="3">
+                  <cd-icon type="success"></cd-icon>
+                  <a cdsLink
+                     class="cds--type-body-compact-01"
+                     (click)="onSelected(Tabs.gateways)">{{ stats.gatewayGroups }}</a>
+                </div>
+              </div>
+              <div class="nvmeof-resources-status__item">
+                <span class="cds--type-label-01"
+                      i18n>Subsystems</span>
+                <div [cdsStack]="'horizontal'"
+                     gap="3">
+                  <cd-icon type="success"></cd-icon>
+                  <a cdsLink
+                     class="cds--type-body-compact-01"
+                     (click)="onSelected(Tabs.subsystem)">{{ stats.subsystems }}</a>
+                </div>
+              </div>
+              <div class="nvmeof-resources-status__item">
+                <span class="cds--type-label-01"
+                      i18n>Namespaces</span>
+                <div [cdsStack]="'horizontal'"
+                     gap="3">
+                  <cd-icon type="success"></cd-icon>
+                  <a cdsLink
+                     class="cds--type-body-compact-01"
+                     (click)="onSelected(Tabs.namespace)">{{ stats.namespaces }}</a>
+                </div>
+              </div>
+              <div class="nvmeof-resources-status__item">
+                <span class="cds--type-label-01"
+                      i18n>Hosts</span>
+                <div [cdsStack]="'horizontal'"
+                     gap="3">
+                  <cd-icon type="success"></cd-icon>
+                  <span class="cds--type-body-compact-01">{{ stats.hosts }}</span>
+                </div>
+              </div>
+            </div>
+          </cd-productive-card>
+        </div>
+
+        <div cdsCol
+             [columnNumbers]="{lg: 5, md: 8, sm: 16}"
+             class="cds-mb-5">
+          <cd-productive-card class="nvmeof-alerts-card">
+            <ng-template #header>
+              <h2 class="cds--type-heading-compact-02"
+                  i18n>Alert notifications</h2>
+              <a cdsLink
+                 [routerLink]="['/monitoring/active-alerts']"
+                 [queryParams]="alertQueryParams('all')"
+                 i18n>View alerts</a>
+            </ng-template>
+            <ng-container *ngIf="nvmeofAlerts$ | async as alertVM">
+              <div [cdsStack]="'horizontal'" gap="2">
+                <span class="cds--type-heading-07">{{ alertVM.total }}</span>
+                <cd-icon [type]="alertVM.total === 0 ? 'success' : alertVM.critical > 0 ? 'error' : 'warning'"></cd-icon>
+              </div>
+              <p class="cds--type-label-01 nvmeof-alerts-card__status cds-mb-5">
+                {{ alertVM.total > 0 ? 'Need attention' : 'No active alerts' }}
+              </p>
+              <div *ngIf="alertVM.total > 0"
+                   [cdsStack]="'horizontal'"
+                   gap="4">
+                <span *ngIf="alertVM.critical > 0"
+                      class="nvmeof-alerts-card__badge">
+                  <a cdsLink
+                     [routerLink]="['/monitoring/active-alerts']"
+                     [queryParams]="alertQueryParams('critical')">
+                    <cd-icon type="error"></cd-icon>
+                    <span class="cds-ml-2">{{ alertVM.critical }}</span>
+                  </a>
+                </span>
+                <span *ngIf="alertVM.warning > 0"
+                      class="nvmeof-alerts-card__badge">
+                  <a cdsLink
+                     [routerLink]="['/monitoring/active-alerts']"
+                     [queryParams]="alertQueryParams('warning')">
+                    <cd-icon type="warning"></cd-icon>
+                    <span class="cds-ml-2">{{ alertVM.warning }}</span>
+                  </a>
+                </span>
+              </div>
+              <div *ngIf="alertVM.total > 0"
+                   class="nvmeof-alerts-card__categories cds-mt-4">
+                <div *ngFor="let entry of alertVM.byCategory | keyvalue"
+                     class="nvmeof-alerts-card__category-row">
+                  <span class="cds--type-label-01 nvmeof-alerts-card__category-name">{{ entry.key | titlecase }}</span>
+                  <a cdsLink
+                     class="cds--type-body-compact-01"
+                     [routerLink]="['/monitoring/active-alerts']"
+                     [queryParams]="alertQueryParams('all', entry.key)">{{ entry.value }}</a>
+                </div>
+              </div>
+            </ng-container>
+          </cd-productive-card>
+        </div>
+
+        <div cdsCol
+             [columnNumbers]="{lg: 6, md: 8, sm: 16}"
+             class="cds-mb-5">
+          <cd-productive-card class="nvmeof-throughput-card">
+            <ng-template #header>
+              <h2 class="cds--type-heading-compact-02"
+                  i18n>Throughput</h2>
+            </ng-template>
+            <p class="cds--type-heading-05 cds-mb-1">
+              {{ (stats.reads + stats.writes) | number:'1.2-2' }} MB/s
+            </p>
+            <p class="cds--type-label-01 cds-mb-5"
+               i18n>combined R/W</p>
+            <div class="nvmeof-throughput__row">
+              <span class="cds--type-label-01"
+                    i18n>Reads</span>
+              <a cdsLink
+                 class="cds--type-body-compact-01">{{ stats.reads | number:'1.2-2' }} MB/s</a>
+            </div>
+            <div class="nvmeof-throughput__row">
+              <span class="cds--type-label-01"
+                    i18n>Writes</span>
+              <a cdsLink
+                 class="cds--type-body-compact-01">{{ stats.writes | number:'1.2-2' }} MB/s</a>
+            </div>
+            <p class="cds--type-label-01 cds-mt-5">
+              <span i18n>Active connections</span>: {{ stats.activeConnections }}
+            </p>
+            <ng-template #footer>
+              <a cdsLink
+                 class="nvmeof-throughput__footer-link"
+                 (click)="onSelected(Tabs.gateways)">
+                <span i18n>View detailed information</span>
+                <svg cdsIcon="arrow--right"
+                     size="16"></svg>
+              </a>
+            </ng-template>
+          </cd-productive-card>
+        </div>
+      </div>
+    </div>
+  </ng-container>
+</ng-container>
+
 <section>
   <cds-tabs type="contained"
             followFocus="true"
     (selected)="onSelected(Tabs.gateways)">
   </cds-tab>
   <cds-tab
-    heading="Subsystem"
+    heading="Subsystems"
     [tabContent]="subsystem_content"
 
     i18n-heading
     (selected)="onSelected(Tabs.subsystem)">
   </cds-tab>
   <cds-tab
-    heading="Namespace"
+    heading="Namespaces"
     [tabContent]="namespace_content"
 
     i18n-heading
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..fb0348a320cd26722a0ecc273c82343be91a7847 100644 (file)
@@ -0,0 +1,62 @@
+.nvmeof-overview-cards {
+  .nvmeof-resources-status {
+    &__item {
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      padding: 0.75rem 0;
+      border-bottom: 1px solid var(--cds-layer-accent-01, #e0e0e0);
+
+      &:last-child {
+        border-bottom: none;
+      }
+    }
+  }
+
+  .nvmeof-throughput {
+    &__row {
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      padding: 0.5rem 0;
+    }
+
+    &__footer-link {
+      display: inline-flex;
+      align-items: center;
+      gap: 0.5rem;
+    }
+  }
+
+  .nvmeof-alerts-card {
+    height: 100%;
+
+    &__status {
+      margin-top: 0.25rem;
+    }
+
+    &__badge {
+      a {
+        display: inline-flex;
+        align-items: center;
+        gap: 0.25rem;
+      }
+    }
+
+    &__categories {
+      display: flex;
+      flex-direction: column;
+      gap: 0.25rem;
+    }
+
+    &__category-row {
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+    }
+
+    &__category-name {
+      text-transform: capitalize;
+    }
+  }
+}
index 88d2d4f5fd59b0152ab897841f58fc1e35a16f45..3bba3143ab5612613f0da69cfcbf3f4f9fc8754c 100644 (file)
@@ -1,11 +1,39 @@
 import { Component, OnDestroy, OnInit, TemplateRef, ViewChild } from '@angular/core';
 import { ActivatedRoute, Router } from '@angular/router';
+import { Observable, Subject, forkJoin, of, timer } from 'rxjs';
+import { catchError, map, shareReplay, switchMap, takeUntil } from 'rxjs/operators';
 
 import _ from 'lodash';
 
 import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
 import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
 import { BreadcrumbService } from '~/app/shared/services/breadcrumb.service';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+import { PrometheusService } from '~/app/shared/api/prometheus.service';
+import { CephServiceSpec } from '~/app/shared/models/service.interface';
+import { NvmeofSubsystem, NvmeofSubsystemNamespace } from '~/app/shared/models/nvmeof';
+import { AlertmanagerAlert } from '~/app/shared/models/prometheus-alerts';
+import { isNvmeofAlert, nvmeofAlertQueryParams } from '~/app/shared/helpers/nvmeof-alert.helper';
+
+const ALERT_POLL_INTERVAL = 30000;
+
+export interface NvmeAlerts {
+  critical: number;
+  warning: number;
+  total: number;
+  byCategory: Record<string, number>;
+}
+
+export interface ResourceStats {
+  gatewayGroups: number;
+  subsystems: number;
+  namespaces: number;
+  hosts: number;
+  reads: number;
+  writes: number;
+  activeConnections: number;
+  hasData: boolean;
+}
 
 enum TABS {
   gateways = 'gateways',
@@ -32,16 +60,27 @@ export class NvmeofGatewayComponent implements OnInit, OnDestroy {
   @ViewChild('statusTpl', { static: true })
   statusTpl: TemplateRef<any>;
   selection = new CdTableSelection();
+  nvmeof$: Observable<ResourceStats | null> = of(null);
+  nvmeofAlerts$: Observable<NvmeAlerts> = of({
+    critical: 0,
+    warning: 0,
+    total: 0,
+    byCategory: {}
+  });
+
+  private destroy$ = new Subject<void>();
 
   constructor(
     public actionLabels: ActionLabelsI18n,
     private route: ActivatedRoute,
     private router: Router,
-    private breadcrumbService: BreadcrumbService
+    private breadcrumbService: BreadcrumbService,
+    private nvmeofService: NvmeofService,
+    private prometheusService: PrometheusService
   ) {}
 
   ngOnInit() {
-    this.route.queryParams.subscribe((params) => {
+    this.route.queryParams.pipe(takeUntil(this.destroy$)).subscribe((params) => {
       if (params['tab'] && Object.values(TABS).includes(params['tab'])) {
         this.activeTab = params['tab'] as TABS;
       } else {
@@ -49,9 +88,106 @@ export class NvmeofGatewayComponent implements OnInit, OnDestroy {
       }
       this.breadcrumbService.setTabCrumb(TAB_LABELS[this.activeTab]);
     });
+    this.loadResourceStats();
+    this.loadAlerts();
+  }
+
+  loadAlerts(): void {
+    this.nvmeofAlerts$ = timer(0, ALERT_POLL_INTERVAL).pipe(
+      switchMap(() => this.prometheusService.isAlertmanagerUsable()),
+      switchMap((usable) => {
+        if (!usable) return of([] as AlertmanagerAlert[]);
+        return this.prometheusService
+          .getAlerts(true)
+          .pipe(catchError(() => of([] as AlertmanagerAlert[])));
+      }),
+      map((alerts: AlertmanagerAlert[]) => {
+        const nvmeAlerts = alerts.filter(isNvmeofAlert);
+        const critical = nvmeAlerts.filter(
+          (a) => a.labels.severity === 'critical' && a.status.state === 'active'
+        ).length;
+        const warning = nvmeAlerts.filter(
+          (a) => a.labels.severity === 'warning' && a.status.state === 'active'
+        ).length;
+        const byCategory: Record<string, number> = {};
+        nvmeAlerts
+          .filter((a) => a.status.state === 'active' && a.labels.category)
+          .forEach((a) => {
+            const cat = a.labels.category!;
+            byCategory[cat] = (byCategory[cat] ?? 0) + 1;
+          });
+        return { critical, warning, total: critical + warning, byCategory };
+      }),
+      catchError(() => of({ critical: 0, warning: 0, total: 0, byCategory: {} })),
+      takeUntil(this.destroy$),
+      shareReplay({ bufferSize: 1, refCount: true })
+    );
+  }
+
+  loadResourceStats() {
+    this.nvmeof$ = this.nvmeofService.listGatewayGroups().pipe(
+      switchMap((gatewayGroups: CephServiceSpec[][]) => {
+        const firstItem = (gatewayGroups as any)?.[0];
+        const rawGroups: CephServiceSpec[] = Array.isArray(firstItem)
+          ? (firstItem as CephServiceSpec[])
+          : Array.isArray(gatewayGroups)
+            ? (gatewayGroups as unknown as CephServiceSpec[])
+            : [];
+        const groups = rawGroups.filter((g: CephServiceSpec) => g?.spec?.group);
+        if (groups.length === 0) {
+          return of(null);
+        }
+        const hostsSet = new Set<string>();
+        groups.forEach((group: CephServiceSpec) => {
+          (group.placement?.hosts ?? []).forEach((h: string) => hostsSet.add(h));
+        });
+        const subsystemCalls = groups.map((group: CephServiceSpec) =>
+          this.nvmeofService.listSubsystems(group.spec.group).pipe(catchError(() => of([])))
+        );
+        const namespaceCalls = groups.map((group: CephServiceSpec) =>
+          this.nvmeofService.listNamespaces(group.spec.group).pipe(catchError(() => of([])))
+        );
+        return forkJoin([forkJoin(subsystemCalls), forkJoin(namespaceCalls)]).pipe(
+          map(([subsystemsPerGroup, namespacesPerGroup]: [any[], any[]]) => {
+            const allSubs: NvmeofSubsystem[] = (subsystemsPerGroup as NvmeofSubsystem[][]).flat();
+            const allNs: NvmeofSubsystemNamespace[] = (namespacesPerGroup as NvmeofSubsystemNamespace[][]).flat();
+            const totalNamespaces = allSubs.reduce((sum, s) => sum + (s.namespace_count || 0), 0);
+            const reads = allNs.reduce((s, ns) => s + (Number(ns.r_mbytes_per_second) || 0), 0);
+            const writes = allNs.reduce((s, ns) => s + (Number(ns.w_mbytes_per_second) || 0), 0);
+            const activeConnections = allSubs.reduce((s, sub) => s + (sub.initiator_count || 0), 0);
+            return {
+              gatewayGroups: groups.length,
+              subsystems: allSubs.length,
+              namespaces: totalNamespaces,
+              hosts: hostsSet.size,
+              reads,
+              writes,
+              activeConnections,
+              hasData: true
+            } as ResourceStats;
+          }),
+          catchError(() =>
+            of({
+              gatewayGroups: groups.length,
+              subsystems: 0,
+              namespaces: 0,
+              hosts: hostsSet.size,
+              reads: 0,
+              writes: 0,
+              activeConnections: 0,
+              hasData: true
+            } as ResourceStats)
+          )
+        );
+      }),
+      catchError(() => of(null)),
+      shareReplay({ bufferSize: 1, refCount: true })
+    );
   }
 
   ngOnDestroy() {
+    this.destroy$.next();
+    this.destroy$.complete();
     this.breadcrumbService.clearTabCrumb();
   }
 
@@ -70,4 +206,6 @@ export class NvmeofGatewayComponent implements OnInit, OnDestroy {
   public get Tabs(): typeof TABS {
     return TABS;
   }
+
+  readonly alertQueryParams = nvmeofAlertQueryParams;
 }
index 31530e009fc4a1109b5364f18bc1cf9be224b4f8..c313e3255c5768b6e712536909c7cffd6694ac81 100644 (file)
@@ -4,6 +4,184 @@
   <cd-help-text>Monitor and manage NVMe-over-TCP resources for high-<br>performance block storage.</cd-help-text>
   </legend>
 </fieldset>
+
+<ng-container *ngIf="nvmeof$ | async as stats">
+  <ng-container *ngIf="stats?.hasData">
+    <div cdsGrid
+         [fullWidth]="true"
+         [narrow]="true"
+         class="nvmeof-overview-cards cds-mt-5 cds-mb-5">
+      <div cdsRow>
+        <div cdsCol
+             [columnNumbers]="{lg: 5, md: 8, sm: 16}"
+             class="cds-mb-5">
+          <cd-productive-card class="nvmeof-resources-status-card">
+            <ng-template #header>
+              <h2 class="cds--type-heading-compact-02"
+                  i18n>Resources status</h2>
+            </ng-template>
+            <div class="nvmeof-resources-status">
+              <div class="nvmeof-resources-status__item">
+                <span class="cds--type-label-01"
+                      i18n>Gateway groups</span>
+                <div [cdsStack]="'horizontal'"
+                     gap="3">
+                  @if (stats.gatewayGroups - stats.gatewayGroupsDown > 0) {
+                  <span [ngClass]="stats.gatewayGroupsDown > 0 ? 'cds-mr-3' : ''">
+                    <cd-icon type="success"></cd-icon>
+                    <a cdsLink
+                       class="cds--type-body-compact-01 cds-ml-3"
+                       (click)="onSelected(Tabs.gateways)">{{ stats.gatewayGroups - stats.gatewayGroupsDown }}</a>
+                  </span>
+                  }
+                  @if (stats.gatewayGroupsDown > 0) {
+                  <span>
+                    <cd-icon type="error"></cd-icon>
+                    <a cdsLink
+                       class="cds--type-body-compact-01 cds-ml-3"
+                       (click)="onSelected(Tabs.gateways)">{{ stats.gatewayGroupsDown }}</a>
+                  </span>
+                  }
+                </div>
+              </div>
+              <div class="nvmeof-resources-status__item">
+                <span class="cds--type-label-01"
+                      i18n>Subsystems</span>
+                <div [cdsStack]="'horizontal'"
+                     gap="3">
+                  <cd-icon type="success"></cd-icon>
+                  <a cdsLink
+                     class="cds--type-body-compact-01"
+                     (click)="onSelected(Tabs.subsystems)">{{ stats.subsystems }}</a>
+                </div>
+              </div>
+              <div class="nvmeof-resources-status__item">
+                <span class="cds--type-label-01"
+                      i18n>Namespaces</span>
+                <div [cdsStack]="'horizontal'"
+                     gap="3">
+                  <cd-icon type="success"></cd-icon>
+                  <a cdsLink
+                     class="cds--type-body-compact-01"
+                     (click)="onSelected(Tabs.namespaces)">{{ stats.namespaces }}</a>
+                </div>
+              </div>
+              <div class="nvmeof-resources-status__item">
+                <span class="cds--type-label-01"
+                      i18n>Hosts</span>
+                <div [cdsStack]="'horizontal'"
+                     gap="3">
+                  <cd-icon type="success"></cd-icon>
+                  <span class="cds--type-body-compact-01">{{ stats.hosts }}</span>
+                </div>
+              </div>
+            </div>
+          </cd-productive-card>
+        </div>
+
+        <div cdsCol
+             [columnNumbers]="{lg: 5, md: 8, sm: 16}"
+             class="cds-mb-5">
+          <cd-productive-card class="nvmeof-alerts-card">
+            <ng-template #header>
+              <h2 class="cds--type-heading-compact-02"
+                  i18n>Alert notifications</h2>
+              <a cdsLink
+                 [routerLink]="['/monitoring/active-alerts']"
+                 [queryParams]="alertQueryParams('all')"
+                 i18n>View alerts</a>
+            </ng-template>
+            <ng-container *ngIf="nvmeofAlerts$ | async as alertVM">
+              <div [cdsStack]="'horizontal'" gap="2">
+                <span class="cds--type-heading-07">{{ alertVM.total }}</span>
+                <cd-icon [type]="alertVM.total === 0 ? 'success' : alertVM.critical > 0 ? 'error' : 'warning'"></cd-icon>
+              </div>
+              <p class="cds--type-label-01 nvmeof-alerts-card__status cds-mb-5">
+                {{ alertVM.total > 0 ? 'Need attention' : 'No active alerts' }}
+              </p>
+              <div *ngIf="alertVM.total > 0"
+                   [cdsStack]="'horizontal'"
+                   gap="4">
+                <span *ngIf="alertVM.critical > 0"
+                      class="nvmeof-alerts-card__badge">
+                  <a cdsLink
+                     [routerLink]="['/monitoring/active-alerts']"
+                     [queryParams]="alertQueryParams('critical')">
+                    <cd-icon type="error"></cd-icon>
+                    <span class="cds-ml-2">{{ alertVM.critical }}</span>
+                  </a>
+                </span>
+                <span *ngIf="alertVM.warning > 0"
+                      class="nvmeof-alerts-card__badge">
+                  <a cdsLink
+                     [routerLink]="['/monitoring/active-alerts']"
+                     [queryParams]="alertQueryParams('warning')">
+                    <cd-icon type="warning"></cd-icon>
+                    <span class="cds-ml-2">{{ alertVM.warning }}</span>
+                  </a>
+                </span>
+              </div>
+              <div *ngIf="alertVM.total > 0"
+                   class="nvmeof-alerts-card__categories cds-mt-4">
+                <div *ngFor="let entry of alertVM.byCategory | keyvalue"
+                     class="nvmeof-alerts-card__category-row">
+                  <span class="cds--type-label-01 nvmeof-alerts-card__category-name">{{ entry.key | titlecase }}</span>
+                  <a cdsLink
+                     class="cds--type-body-compact-01"
+                     [routerLink]="['/monitoring/active-alerts']"
+                     [queryParams]="alertQueryParams('all', entry.key)">{{ entry.value }}</a>
+                </div>
+              </div>
+            </ng-container>
+          </cd-productive-card>
+        </div>
+
+        <div cdsCol
+             [columnNumbers]="{lg: 6, md: 8, sm: 16}"
+             class="cds-mb-5">
+          <cd-productive-card class="nvmeof-throughput-card">
+            <ng-template #header>
+              <h2 class="cds--type-heading-compact-02"
+                  i18n>Throughput</h2>
+            </ng-template>
+            <ng-container *ngIf="nvmeofThroughput$ | async as throughput">
+              <p class="cds--type-heading-05 cds-mb-1">
+                {{ (throughput.reads + throughput.writes) | number:'1.2-2' }} MB/s
+              </p>
+              <p class="cds--type-label-01 cds-mb-5"
+                 i18n>combined R/W</p>
+              <div class="nvmeof-throughput__row">
+                <span class="cds--type-label-01"
+                      i18n>Reads</span>
+                <a cdsLink
+                   class="cds--type-body-compact-01">{{ throughput.reads | number:'1.2-2' }} MB/s</a>
+              </div>
+              <div class="nvmeof-throughput__row">
+                <span class="cds--type-label-01"
+                      i18n>Writes</span>
+                <a cdsLink
+                   class="cds--type-body-compact-01">{{ throughput.writes | number:'1.2-2' }} MB/s</a>
+              </div>
+            </ng-container>
+            <p class="cds--type-label-01 cds-mt-5">
+              <span i18n>Active connections</span>: {{ (nvmeof$ | async)?.activeConnections ?? 0 }}
+            </p>
+            <ng-template #footer>
+              <a cdsLink
+                 class="nvmeof-throughput__footer-link"
+                 (click)="onSelected(Tabs.gateways)">
+                <span i18n>View detailed information</span>
+                <svg cdsIcon="arrow--right"
+                     size="16"></svg>
+              </a>
+            </ng-template>
+          </cd-productive-card>
+        </div>
+      </div>
+    </div>
+  </ng-container>
+</ng-container>
+
 @if (showSetupCards) {
 <cd-nvmeof-setup-cards></cd-nvmeof-setup-cards>
 }
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..d38f10ef8f8962719f4d8d2e71093c8ad5916dc2 100644 (file)
@@ -0,0 +1,68 @@
+:host {
+  display: block;
+}
+
+.nvmeof-overview-cards {
+  [cdsCol] {
+    display: flex;
+    flex-direction: column;
+
+    cd-productive-card {
+      flex: 1;
+
+      ::ng-deep cds-tile,
+      ::ng-deep .productive-card {
+        height: 100%;
+      }
+    }
+  }
+
+  .nvmeof-resources-status-card {
+    height: 100%;
+  }
+
+  .nvmeof-resources-status {
+    &__item {
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      padding: 0.75rem 0;
+      border-bottom: 1px solid var(--cds-layer-accent-01, #e0e0e0);
+
+      &:last-child {
+        border-bottom: none;
+      }
+    }
+  }
+
+  .nvmeof-throughput {
+    &__row {
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      padding: 0.5rem 0;
+    }
+
+    &__footer-link {
+      display: inline-flex;
+      align-items: center;
+      gap: 0.5rem;
+    }
+  }
+
+  .nvmeof-alerts-card {
+    height: 100%;
+
+    &__status {
+      margin-top: 0.25rem;
+    }
+
+    &__badge {
+      a {
+        display: inline-flex;
+        align-items: center;
+        gap: 0.25rem;
+      }
+    }
+  }
+}
index cdd1ea2e9cfda690be80d2b5bef933e0055105e8..c1ce21ad00bcdbe1b47c147e9e8fd63c8962bbb7 100644 (file)
@@ -1,26 +1,86 @@
-import { ComponentFixture, TestBed } from '@angular/core/testing';
+import {
+  ComponentFixture,
+  TestBed,
+  discardPeriodicTasks,
+  fakeAsync,
+  tick
+} from '@angular/core/testing';
 import { Router } from '@angular/router';
 import { RouterTestingModule } from '@angular/router/testing';
+import { of } from 'rxjs';
 
 import { TabsModule } from 'carbon-components-angular';
 
 import { NvmeofTabsComponent } from './nvmeof-tabs.component';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+import { PerformanceCardService } from '~/app/shared/api/performance-card.service';
+import { PrometheusService } from '~/app/shared/api/prometheus.service';
+import { CephServiceSpec } from '~/app/shared/models/service.interface';
 import { SharedModule } from '~/app/shared/shared.module';
 
+const makeGroup = (name: string, running: number, size: number): CephServiceSpec => ({
+  service_name: `nvmeof.${name}`,
+  service_type: 'nvmeof',
+  service_id: name,
+  unmanaged: false,
+  spec: { group: name } as CephServiceSpec['spec'],
+  status: {
+    container_image_id: '',
+    container_image_name: '',
+    size,
+    running,
+    last_refresh: new Date('2026-05-25T00:00:00'),
+    created: new Date('2026-05-25T00:00:00')
+  },
+  placement: { hosts: [`host-${name}`] }
+});
+
+const mockSubsystems = [{ namespace_count: 2, initiator_count: 1 }];
+const mockNamespaces: any[] = [];
+
 describe('NvmeofTabsComponent', () => {
   let component: NvmeofTabsComponent;
   let fixture: ComponentFixture<NvmeofTabsComponent>;
   let router: Router;
+  let nvmeofService: NvmeofService;
+  let performanceCardService: PerformanceCardService;
+  let prometheusService: PrometheusService;
 
   beforeEach(async () => {
     await TestBed.configureTestingModule({
       declarations: [NvmeofTabsComponent],
-      imports: [RouterTestingModule, SharedModule, TabsModule]
+      imports: [RouterTestingModule, SharedModule, TabsModule],
+      providers: [
+        {
+          provide: NvmeofService,
+          useValue: {
+            listGatewayGroups: jest.fn().mockReturnValue(of([[]])),
+            listSubsystems: jest.fn().mockReturnValue(of(mockSubsystems)),
+            listNamespaces: jest.fn().mockReturnValue(of(mockNamespaces))
+          }
+        },
+        {
+          provide: PerformanceCardService,
+          useValue: {
+            getNvmeofThroughput: jest.fn().mockReturnValue(of({ reads: 0, writes: 0 }))
+          }
+        },
+        {
+          provide: PrometheusService,
+          useValue: {
+            isAlertmanagerUsable: jest.fn().mockReturnValue(of(false)),
+            getAlerts: jest.fn().mockReturnValue(of([]))
+          }
+        }
+      ]
     }).compileComponents();
 
     fixture = TestBed.createComponent(NvmeofTabsComponent);
     component = fixture.componentInstance;
     router = TestBed.inject(Router);
+    nvmeofService = TestBed.inject(NvmeofService);
+    performanceCardService = TestBed.inject(PerformanceCardService);
+    prometheusService = TestBed.inject(PrometheusService);
   });
 
   it('should create', () => {
@@ -78,4 +138,233 @@ describe('NvmeofTabsComponent', () => {
     expect(tabs.subsystems).toBe('subsystems');
     expect(tabs.namespaces).toBe('namespaces');
   });
+
+  describe('loadResourceStats – gatewayGroupsDown', () => {
+    it('should load stats when gateway groups response is indexable object with numeric keys', fakeAsync(() => {
+      const indexedResponse = {
+        0: [makeGroup('default', 1, 1)],
+        1: 1
+      };
+
+      jest
+        .spyOn(nvmeofService, 'listGatewayGroups')
+        .mockReturnValue(of(indexedResponse as unknown as CephServiceSpec[][]));
+
+      component.loadResourceStats();
+      let stats: any;
+      component.nvmeof$.subscribe((s) => (stats = s));
+      tick();
+
+      expect(stats.gatewayGroups).toBe(1);
+      expect(stats.gatewayGroupsDown).toBe(0);
+      expect(stats.hasData).toBe(true);
+    }));
+
+    it('should load stats when gateway groups response is a flat array', fakeAsync(() => {
+      jest
+        .spyOn(nvmeofService, 'listGatewayGroups')
+        .mockReturnValue(of([makeGroup('default', 1, 1)] as unknown as CephServiceSpec[][]));
+
+      component.loadResourceStats();
+      let stats: any;
+      component.nvmeof$.subscribe((s) => (stats = s));
+      tick();
+
+      expect(stats.gatewayGroups).toBe(1);
+      expect(stats.gatewayGroupsDown).toBe(0);
+      expect(stats.hasData).toBe(true);
+    }));
+
+    it('should set gatewayGroupsDown to 0 when all gateways are running', fakeAsync(() => {
+      jest
+        .spyOn(nvmeofService, 'listGatewayGroups')
+        .mockReturnValue(of([[makeGroup('default', 1, 1), makeGroup('default1', 2, 2)]]));
+
+      component.loadResourceStats();
+      let stats: any;
+      component.nvmeof$.subscribe((s) => (stats = s));
+      tick();
+
+      expect(stats.gatewayGroups).toBe(2);
+      expect(stats.gatewayGroupsDown).toBe(0);
+    }));
+
+    it('should count groups with at least one down gateway in gatewayGroupsDown', fakeAsync(() => {
+      jest
+        .spyOn(nvmeofService, 'listGatewayGroups')
+        .mockReturnValue(of([[makeGroup('default', 0, 1), makeGroup('default1', 1, 2)]]));
+
+      component.loadResourceStats();
+      let stats: any;
+      component.nvmeof$.subscribe((s) => (stats = s));
+      tick();
+
+      expect(stats.gatewayGroups).toBe(2);
+      expect(stats.gatewayGroupsDown).toBe(2);
+    }));
+
+    it('should count only the groups that have errors', fakeAsync(() => {
+      jest
+        .spyOn(nvmeofService, 'listGatewayGroups')
+        .mockReturnValue(of([[makeGroup('default', 1, 1), makeGroup('default1', 0, 1)]]));
+
+      component.loadResourceStats();
+      let stats: any;
+      component.nvmeof$.subscribe((s) => (stats = s));
+      tick();
+
+      expect(stats.gatewayGroups).toBe(2);
+      expect(stats.gatewayGroupsDown).toBe(1);
+    }));
+
+    it('should return null when no gateway groups exist', fakeAsync(() => {
+      jest.spyOn(nvmeofService, 'listGatewayGroups').mockReturnValue(of([[]]));
+
+      component.loadResourceStats();
+      let stats: any;
+      component.nvmeof$.subscribe((s) => (stats = s));
+      tick();
+
+      expect(stats).toBeNull();
+    }));
+  });
+
+  describe('loadThroughput', () => {
+    it('should load throughput from PerformanceCardService', fakeAsync(() => {
+      jest
+        .spyOn(performanceCardService, 'getNvmeofThroughput')
+        .mockReturnValue(of({ reads: 12.5, writes: 7.25 }));
+
+      component.loadThroughput();
+      let throughput: any;
+      component.nvmeofThroughput$.subscribe((t) => (throughput = t));
+      tick();
+
+      expect(performanceCardService.getNvmeofThroughput).toHaveBeenCalled();
+      expect(throughput).toEqual({ reads: 12.5, writes: 7.25 });
+    }));
+  });
+
+  describe('loadAlerts', () => {
+    it('should return zero counts when alertmanager is not usable', fakeAsync(() => {
+      jest.spyOn(prometheusService, 'isAlertmanagerUsable').mockReturnValue(of(false));
+
+      component.loadAlerts();
+      let alerts: any;
+      component.nvmeofAlerts$.subscribe((a) => (alerts = a));
+      tick(0);
+      discardPeriodicTasks();
+
+      expect(alerts.total).toBe(0);
+      expect(alerts.critical).toBe(0);
+      expect(alerts.warning).toBe(0);
+    }));
+
+    it('should count active nvmeof critical and warning alerts', fakeAsync(() => {
+      const mockAlerts = [
+        {
+          labels: { alertname: 'NVMeoFHighGatewayCPU', category: 'gateway', severity: 'critical' },
+          status: { state: 'active' }
+        },
+        {
+          labels: {
+            alertname: 'NVMeoFInterfaceDuplex',
+            category: 'listener',
+            severity: 'warning'
+          },
+          status: { state: 'active' }
+        },
+        {
+          labels: { alertname: 'NVMeoFMissingListener', category: 'listener', severity: 'warning' },
+          status: { state: 'suppressed' }
+        },
+        {
+          labels: { alertname: 'CephDaemonCrash', severity: 'critical' },
+          status: { state: 'active' }
+        }
+      ];
+
+      jest.spyOn(prometheusService, 'isAlertmanagerUsable').mockReturnValue(of(true));
+      jest.spyOn(prometheusService, 'getAlerts').mockReturnValue(of(mockAlerts as any));
+
+      component.loadAlerts();
+      let alerts: any;
+      component.nvmeofAlerts$.subscribe((a) => (alerts = a));
+      tick(0);
+      discardPeriodicTasks();
+
+      expect(alerts.critical).toBe(1);
+      expect(alerts.warning).toBe(1);
+      expect(alerts.total).toBe(2);
+      expect(alerts.byCategory).toEqual({ gateway: 1, listener: 1 });
+    }));
+
+    it('should match nvmeof alerts by prometheus job label', fakeAsync(() => {
+      const mockAlerts = [
+        {
+          labels: { job: 'nvmeof', severity: 'warning', alertname: 'SomeAlert' },
+          status: { state: 'active' }
+        }
+      ];
+
+      jest.spyOn(prometheusService, 'isAlertmanagerUsable').mockReturnValue(of(true));
+      jest.spyOn(prometheusService, 'getAlerts').mockReturnValue(of(mockAlerts as any));
+
+      component.loadAlerts();
+      let alerts: any;
+      component.nvmeofAlerts$.subscribe((a) => (alerts = a));
+      tick(0);
+      discardPeriodicTasks();
+
+      expect(alerts.warning).toBe(1);
+      expect(alerts.total).toBe(1);
+    }));
+
+    it('should match nvmeof alerts by alertname when category label is absent', fakeAsync(() => {
+      const mockAlerts = [
+        {
+          labels: { alertname: 'NVMeofInterfaceDuplex', severity: 'warning' },
+          status: { state: 'active' }
+        }
+      ];
+
+      jest.spyOn(prometheusService, 'isAlertmanagerUsable').mockReturnValue(of(true));
+      jest.spyOn(prometheusService, 'getAlerts').mockReturnValue(of(mockAlerts as any));
+
+      component.loadAlerts();
+      let alerts: any;
+      component.nvmeofAlerts$.subscribe((a) => (alerts = a));
+      tick(0);
+      discardPeriodicTasks();
+
+      expect(alerts.warning).toBe(1);
+      expect(alerts.total).toBe(1);
+    }));
+
+    it('should not count inactive nvmeof alerts', fakeAsync(() => {
+      const mockAlerts = [
+        {
+          labels: { alertname: 'NVMeoFHighGatewayCPU', category: 'gateway', severity: 'critical' },
+          status: { state: 'inactive' }
+        },
+        {
+          labels: { alertname: 'NVMeoFInterfaceDuplex', category: 'listener', severity: 'warning' },
+          status: { state: 'pending' }
+        }
+      ];
+
+      jest.spyOn(prometheusService, 'isAlertmanagerUsable').mockReturnValue(of(true));
+      jest.spyOn(prometheusService, 'getAlerts').mockReturnValue(of(mockAlerts as any));
+
+      component.loadAlerts();
+      let alerts: any;
+      component.nvmeofAlerts$.subscribe((a) => (alerts = a));
+      tick(0);
+      discardPeriodicTasks();
+
+      expect(alerts.critical).toBe(0);
+      expect(alerts.warning).toBe(0);
+      expect(alerts.total).toBe(0);
+    }));
+  });
 });
index eac0908a79569acec45de1e82c8c95324e2efa41..32c7c44172d703a14e7126e9884f6f0aeabcdd38 100644 (file)
@@ -1,7 +1,38 @@
-import { Component, Input, OnInit } from '@angular/core';
+import { Component, Input, OnDestroy, OnInit } from '@angular/core';
 import { Router } from '@angular/router';
+import { Observable, Subject, forkJoin, of, timer } from 'rxjs';
+import { catchError, map, shareReplay, switchMap, takeUntil } from 'rxjs/operators';
+
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+import {
+  NvmeofThroughput,
+  PerformanceCardService
+} from '~/app/shared/api/performance-card.service';
+import { PrometheusService } from '~/app/shared/api/prometheus.service';
+import { CephServiceSpec } from '~/app/shared/models/service.interface';
+import { NvmeofSubsystem } from '~/app/shared/models/nvmeof';
+import { AlertmanagerAlert } from '~/app/shared/models/prometheus-alerts';
+import { isNvmeofAlert, nvmeofAlertQueryParams } from '~/app/shared/helpers/nvmeof-alert.helper';
 
 const NVMEOF_PATH = 'block/nvmeof';
+const ALERT_POLL_INTERVAL = 30000;
+
+export interface ResourceStats {
+  gatewayGroups: number;
+  gatewayGroupsDown: number;
+  subsystems: number;
+  namespaces: number;
+  hosts: number;
+  activeConnections: number;
+  hasData: boolean;
+}
+
+export interface NvmeAlerts {
+  critical: number;
+  warning: number;
+  total: number;
+  byCategory: Record<string, number>;
+}
 
 enum TABS {
   gateways = 'gateways',
@@ -15,17 +46,140 @@ enum TABS {
   styleUrls: ['./nvmeof-tabs.component.scss'],
   standalone: false
 })
-export class NvmeofTabsComponent implements OnInit {
+export class NvmeofTabsComponent implements OnInit, OnDestroy {
   @Input() showSetupCards = false;
 
-  selectedTab: TABS;
+  selectedTab: TABS | undefined;
   activeTab: TABS = TABS.gateways;
+  nvmeof$: Observable<ResourceStats | null> = of(null);
+  nvmeofThroughput$: Observable<NvmeofThroughput> = of({ reads: 0, writes: 0 });
+  nvmeofAlerts$: Observable<NvmeAlerts> = of({
+    critical: 0,
+    warning: 0,
+    total: 0,
+    byCategory: {}
+  });
+
+  private destroy$ = new Subject<void>();
 
-  constructor(private router: Router) {}
+  constructor(
+    private router: Router,
+    private nvmeofService: NvmeofService,
+    private performanceCardService: PerformanceCardService,
+    private prometheusService: PrometheusService
+  ) {}
 
   ngOnInit(): void {
     const currentPath = this.router.url;
     this.activeTab = Object.values(TABS).find((tab) => currentPath.includes(tab)) || TABS.gateways;
+    this.loadResourceStats();
+    this.loadThroughput();
+    this.loadAlerts();
+  }
+
+  ngOnDestroy(): void {
+    this.destroy$.next();
+    this.destroy$.complete();
+  }
+
+  loadResourceStats(): void {
+    this.nvmeof$ = this.nvmeofService.listGatewayGroups().pipe(
+      switchMap((gatewayGroups: CephServiceSpec[][]) => {
+        const firstItem = (gatewayGroups as any)?.[0];
+        const rawGroups: CephServiceSpec[] = Array.isArray(firstItem)
+          ? (firstItem as CephServiceSpec[])
+          : Array.isArray(gatewayGroups)
+            ? (gatewayGroups as unknown as CephServiceSpec[])
+            : [];
+        const groups = rawGroups.filter((g: CephServiceSpec) => g?.spec?.group);
+        if (groups.length === 0) {
+          return of(null);
+        }
+        const hostsSet = new Set<string>();
+        groups.forEach((group: CephServiceSpec) => {
+          (group.placement?.hosts ?? []).forEach((h: string) => hostsSet.add(h));
+        });
+        const subsystemCalls = groups.map((group: CephServiceSpec) =>
+          this.nvmeofService.listSubsystems(group.spec.group).pipe(catchError(() => of([])))
+        );
+        const namespaceCalls = groups.map((group: CephServiceSpec) =>
+          this.nvmeofService.listNamespaces(group.spec.group).pipe(catchError(() => of([])))
+        );
+        const gatewayGroupsDown = groups.filter(
+          (g: CephServiceSpec) => (g.status?.running ?? 0) < (g.status?.size ?? 0)
+        ).length;
+        return forkJoin([forkJoin(subsystemCalls), forkJoin(namespaceCalls)]).pipe(
+          map(([subsystemsPerGroup]: [any[], any[]]) => {
+            const allSubs: NvmeofSubsystem[] = (subsystemsPerGroup as NvmeofSubsystem[][]).flat();
+            const totalNamespaces = allSubs.reduce((sum, s) => sum + (s.namespace_count || 0), 0);
+            const activeConnections = allSubs.reduce((s, sub) => s + (sub.initiator_count || 0), 0);
+            return {
+              gatewayGroups: groups.length,
+              gatewayGroupsDown,
+              subsystems: allSubs.length,
+              namespaces: totalNamespaces,
+              hosts: hostsSet.size,
+              activeConnections,
+              hasData: true
+            } as ResourceStats;
+          }),
+          catchError(() =>
+            of({
+              gatewayGroups: groups.length,
+              gatewayGroupsDown,
+              subsystems: 0,
+              namespaces: 0,
+              hosts: hostsSet.size,
+              activeConnections: 0,
+              hasData: true
+            } as ResourceStats)
+          )
+        );
+      }),
+      catchError(() => of(null)),
+      takeUntil(this.destroy$),
+      shareReplay({ bufferSize: 1, refCount: true })
+    );
+  }
+
+  loadThroughput(): void {
+    this.nvmeofThroughput$ = this.performanceCardService.getNvmeofThroughput().pipe(
+      catchError(() => of({ reads: 0, writes: 0 })),
+      takeUntil(this.destroy$),
+      shareReplay({ bufferSize: 1, refCount: true })
+    );
+  }
+
+  loadAlerts(): void {
+    this.nvmeofAlerts$ = timer(0, ALERT_POLL_INTERVAL).pipe(
+      switchMap(() => this.prometheusService.isAlertmanagerUsable()),
+      switchMap((usable) => {
+        if (!usable) return of([] as AlertmanagerAlert[]);
+        return this.prometheusService
+          .getAlerts(true)
+          .pipe(catchError(() => of([] as AlertmanagerAlert[])));
+      }),
+      map((alerts: AlertmanagerAlert[]) => {
+        const nvmeAlerts = alerts.filter(isNvmeofAlert);
+        const critical = nvmeAlerts.filter(
+          (a) => a.labels.severity === 'critical' && a.status.state === 'active'
+        ).length;
+        const warning = nvmeAlerts.filter(
+          (a) => a.labels.severity === 'warning' && a.status.state === 'active'
+        ).length;
+        const byCategory: Record<string, number> = {};
+        nvmeAlerts
+          .filter((a) => a.status.state === 'active' && a.labels.category)
+          .forEach((a) => {
+            const cat = a.labels.category!;
+            byCategory[cat] = (byCategory[cat] ?? 0) + 1;
+          });
+        return { critical, warning, total: critical + warning, byCategory };
+      }),
+      catchError(() => of({ critical: 0, warning: 0, total: 0, byCategory: {} })),
+      takeUntil(this.destroy$),
+      shareReplay({ bufferSize: 1, refCount: true })
+    );
   }
 
   onSelected(tab: TABS) {
@@ -36,4 +190,6 @@ export class NvmeofTabsComponent implements OnInit {
   public get Tabs(): typeof TABS {
     return TABS;
   }
+
+  readonly alertQueryParams = nvmeofAlertQueryParams;
 }
index 2930710b55bb1a56e484b58ef369660e6c735fe2..c23dfc232aeb1e6abbbba6e278477efe0e03c341 100644 (file)
@@ -11,6 +11,14 @@ import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
 import { Permission } from '~/app/shared/models/permissions';
 import { AlertState } from '~/app/shared/models/prometheus-alerts';
 import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import {
+  isNvmeofAlert,
+  NVMEOF_ALERT_SCOPE,
+  NVMEOF_CATEGORY_FILTER_OPTIONS,
+  NVMEOF_CATEGORY_LABELS,
+  NVMEOF_SCOPE_LABELS,
+  nvmeofCategoryFilterPredicate
+} from '~/app/shared/helpers/nvmeof-alert.helper';
 import { PrometheusAlertService } from '~/app/shared/services/prometheus-alert.service';
 import { URLBuilderService } from '~/app/shared/services/url-builder.service';
 
@@ -22,6 +30,9 @@ const SeverityMap = {
   all: $localize`All`
 };
 
+const ScopeFilterIndex = 2;
+const CategoryFilterIndex = 3;
+
 @Component({
   selector: 'cd-active-alert-list',
   providers: [{ provide: URLBuilderService, useValue: new URLBuilderService(BASE_URL) }],
@@ -65,6 +76,25 @@ export class ActiveAlertListComponent extends PrometheusListHelper implements On
         if (value === SeverityMap['all']) return true;
         return false;
       }
+    },
+    {
+      name: $localize`Service`,
+      prop: 'labels.job',
+      filterOptions: [NVMEOF_SCOPE_LABELS.all, NVMEOF_SCOPE_LABELS.nvmeof],
+      filterInitValue: NVMEOF_SCOPE_LABELS.all,
+      filterPredicate: (row, value) => {
+        if (value === NVMEOF_SCOPE_LABELS.nvmeof) {
+          return isNvmeofAlert(row);
+        }
+        return true;
+      }
+    },
+    {
+      name: $localize`Category`,
+      prop: 'labels.category',
+      filterOptions: NVMEOF_CATEGORY_FILTER_OPTIONS,
+      filterInitValue: NVMEOF_CATEGORY_LABELS.all,
+      filterPredicate: (row, value) => nvmeofCategoryFilterPredicate(row, value)
     }
   ];
 
@@ -161,7 +191,17 @@ export class ActiveAlertListComponent extends PrometheusListHelper implements On
     this.prometheusAlertService.getGroupedAlerts(true);
     this.route.queryParams.subscribe((params) => {
       const severity = params['severity'];
-      this.filters[1].filterInitValue = SeverityMap[severity];
+      if (severity && SeverityMap[severity]) {
+        this.filters[1].filterInitValue = SeverityMap[severity];
+      }
+      const scope = params['scope'];
+      if (scope === NVMEOF_ALERT_SCOPE) {
+        this.filters[ScopeFilterIndex].filterInitValue = NVMEOF_SCOPE_LABELS.nvmeof;
+      }
+      const category = params['category'];
+      if (category && NVMEOF_CATEGORY_LABELS[category]) {
+        this.filters[CategoryFilterIndex].filterInitValue = NVMEOF_CATEGORY_LABELS[category];
+      }
     });
   }
 
index f39048c8f00973171ba972f635b7439cd2457513..fe72dd31b1237b2d2b7af352963d2d0fa912a5e6 100644 (file)
@@ -101,6 +101,27 @@ describe('PerformanceCardService', () => {
     });
   });
 
+  describe('convertNvmeofThroughput', () => {
+    it('should convert raw NVMe-oF throughput to MB/s using the latest sample', () => {
+      const raw: Record<string, [number, string][]> = {
+        NVMEOF_READ_BYTES: [
+          [1609459200, String(2 * 1024 * 1024)],
+          [1609459260, String(4 * 1024 * 1024)]
+        ],
+        NVMEOF_WRITE_BYTES: [[1609459260, String(1024 * 1024)]]
+      };
+
+      const result = service.convertNvmeofThroughput(raw);
+
+      expect(result.reads).toBe(4);
+      expect(result.writes).toBe(1);
+    });
+
+    it('should return zero throughput when metrics are missing', () => {
+      expect(service.convertNvmeofThroughput({})).toEqual({ reads: 0, writes: 0 });
+    });
+  });
+
   describe('mergeSeries', () => {
     it('should merge multiple series into one', () => {
       const series1 = [
index 0d5e409c06a9ae502d7a0ba30396cf237d029973..37888b0829e6612b61bc9b96e0e485c7c2943197 100644 (file)
@@ -1,17 +1,43 @@
 import { inject, Injectable } from '@angular/core';
 import { PrometheusService } from './prometheus.service';
 import { PerformanceData } from '../models/performance-data';
-import { AllStoragetypesQueries } from '../enum/dashboard-promqls.enum';
+import { AllStoragetypesQueries, NvmeofPromqls } from '../enum/dashboard-promqls.enum';
 import { map } from 'rxjs/operators';
 import { Observable } from 'rxjs';
 import { ChartPoint } from '../models/area-chart-point';
 
+export interface NvmeofThroughput {
+  reads: number;
+  writes: number;
+}
+
+const BYTES_PER_MB = 1024 * 1024;
+
 @Injectable({
   providedIn: 'root'
 })
 export class PerformanceCardService {
   private prometheusService = inject(PrometheusService);
 
+  getNvmeofThroughput(
+    time: { start: number; end: number; step: number } = this.prometheusService.lastHourDateObject
+  ): Observable<NvmeofThroughput> {
+    return this.prometheusService
+      .getRangeQueriesData(time, NvmeofPromqls, true)
+      .pipe(map((raw) => this.convertNvmeofThroughput(raw)));
+  }
+
+  convertNvmeofThroughput(raw: Record<string, [number, string][]>): NvmeofThroughput {
+    const readValues = raw?.NVMEOF_READ_BYTES ?? [];
+    const writeValues = raw?.NVMEOF_WRITE_BYTES ?? [];
+    const lastRead = readValues.length ? Number(readValues[readValues.length - 1][1]) : 0;
+    const lastWrite = writeValues.length ? Number(writeValues[writeValues.length - 1][1]) : 0;
+    return {
+      reads: lastRead / BYTES_PER_MB,
+      writes: lastWrite / BYTES_PER_MB
+    };
+  }
+
   getChartData(time: { start: number; end: number; step: number }): Observable<PerformanceData> {
     return this.prometheusService.getRangeQueriesData(time, AllStoragetypesQueries, true).pipe(
       map((raw) => {
index 5f224ccd2d09d9bd0ec41b094be91b04fff71dda..1cc718dc26967cd362acc6f20ab5355d37da6d4f 100644 (file)
@@ -70,3 +70,8 @@ export const AllStoragetypesQueries = {
 
   WRITELATENCY: 'avg_over_time(ceph_osd_commit_latency_ms[1m])'
 };
+
+export const NvmeofPromqls = {
+  NVMEOF_READ_BYTES: 'sum(rate(ceph_nvmeof_bdev_read_bytes_total[1m]))',
+  NVMEOF_WRITE_BYTES: 'sum(rate(ceph_nvmeof_bdev_written_bytes_total[1m]))'
+};
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/helpers/nvmeof-alert.helper.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/helpers/nvmeof-alert.helper.spec.ts
new file mode 100644 (file)
index 0000000..a3400c5
--- /dev/null
@@ -0,0 +1,77 @@
+import {
+  isNvmeofAlert,
+  nvmeofAlertQueryParams,
+  nvmeofCategoryFilterPredicate
+} from './nvmeof-alert.helper';
+import { AlertmanagerAlert } from '~/app/shared/models/prometheus-alerts';
+
+describe('nvmeof-alert.helper', () => {
+  const alert = (labels: Record<string, string>, state = 'active'): AlertmanagerAlert => {
+    const alertLabels: AlertmanagerAlert['labels'] = {
+      alertname: labels.alertname ?? '',
+      instance: labels.instance ?? '',
+      job: labels.job ?? '',
+      severity: labels.severity ?? '',
+      category: labels.category,
+      ...labels
+    };
+
+    return {
+      labels: alertLabels,
+      annotations: { description: '', summary: '' },
+      startsAt: new Date().toISOString(),
+      endsAt: new Date().toISOString(),
+      generatorURL: '',
+      status: {
+        state: state as AlertmanagerAlert['status']['state'],
+        silencedBy: null,
+        inhibitedBy: null
+      },
+      receivers: [],
+      fingerprint: 'test-fingerprint',
+      alert_count: 1
+    };
+  };
+
+  describe('isNvmeofAlert', () => {
+    it('should match job nvmeof', () => {
+      expect(isNvmeofAlert(alert({ job: 'nvmeof', alertname: 'X' }))).toBe(true);
+    });
+
+    it('should match known category labels', () => {
+      expect(isNvmeofAlert(alert({ category: 'gateway', alertname: 'X' }))).toBe(true);
+    });
+
+    it('should match NVMeoF alertname prefix', () => {
+      expect(isNvmeofAlert(alert({ alertname: 'NVMeoFHighGatewayCPU' }))).toBe(true);
+    });
+
+    it('should not match unrelated alerts', () => {
+      expect(isNvmeofAlert(alert({ alertname: 'CephDaemonCrash', severity: 'critical' }))).toBe(
+        false
+      );
+    });
+  });
+
+  describe('nvmeofCategoryFilterPredicate', () => {
+    it('should pass all rows when filter is All', () => {
+      expect(nvmeofCategoryFilterPredicate(alert({ category: 'gateway' }), 'All' as any)).toBe(
+        true
+      );
+    });
+  });
+
+  describe('nvmeofAlertQueryParams', () => {
+    it('should include scope and optional category', () => {
+      expect(nvmeofAlertQueryParams('critical')).toEqual({
+        severity: 'critical',
+        scope: 'nvmeof'
+      });
+      expect(nvmeofAlertQueryParams('all', 'gateway')).toEqual({
+        severity: 'all',
+        scope: 'nvmeof',
+        category: 'gateway'
+      });
+    });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/helpers/nvmeof-alert.helper.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/helpers/nvmeof-alert.helper.ts
new file mode 100644 (file)
index 0000000..96eaa4a
--- /dev/null
@@ -0,0 +1,68 @@
+import { AlertmanagerAlert } from '~/app/shared/models/prometheus-alerts';
+
+/** Query param value used by NVMe-oF dashboard links to pre-filter active alerts. */
+export const NVMEOF_ALERT_SCOPE = 'nvmeof';
+
+/** Matches monitoring/ceph-mixin NVMe-oF alert rule category labels. */
+export const NVMEOF_ALERT_CATEGORIES = new Set([
+  'gateway',
+  'subsystem',
+  'listener',
+  'namespace',
+  'performance',
+  'host'
+]);
+
+export const NVMEOF_SCOPE_LABELS = {
+  all: $localize`All`,
+  nvmeof: $localize`NVMe-oF`
+};
+
+export const NVMEOF_CATEGORY_LABELS: Record<string, string> = {
+  all: $localize`All`,
+  gateway: $localize`Gateway`,
+  subsystem: $localize`Subsystem`,
+  listener: $localize`Listener`,
+  namespace: $localize`Namespace`,
+  performance: $localize`Performance`,
+  host: $localize`Host`
+};
+
+export const NVMEOF_CATEGORY_FILTER_OPTIONS = Object.values(NVMEOF_CATEGORY_LABELS);
+
+export function isNvmeofAlert(alert: AlertmanagerAlert): boolean {
+  const labels = alert.labels;
+  if (!labels) {
+    return false;
+  }
+  if (labels.job === 'nvme' || labels.job === 'nvmeof') {
+    return true;
+  }
+  if (labels.category && NVMEOF_ALERT_CATEGORIES.has(labels.category)) {
+    return true;
+  }
+  return /^NVMeo[fF]/i.test(labels.alertname ?? '');
+}
+
+export function nvmeofCategoryFilterPredicate(row: AlertmanagerAlert, value: string): boolean {
+  const key =
+    Object.entries(NVMEOF_CATEGORY_LABELS).find(([, label]) => label === value)?.[0] ?? 'all';
+  if (key === 'all') {
+    return true;
+  }
+  return row.labels?.category === key;
+}
+
+export function nvmeofAlertQueryParams(
+  severity: string,
+  category?: string
+): { severity: string; scope: string; category?: string } {
+  const params: { severity: string; scope: string; category?: string } = {
+    severity,
+    scope: NVMEOF_ALERT_SCOPE
+  };
+  if (category) {
+    params.category = category;
+  }
+  return params;
+}
index b3360d44001a468c6d837c2c1df9067b38d4b2a3..09d621a28c831853c6a2b11c6d08d0e8f7eb68a8 100644 (file)
@@ -5,6 +5,7 @@ export class PrometheusAlertLabels {
   instance: string;
   job: string;
   severity: string;
+  category?: string;
 }
 
 class Annotations {