From d2006f9e8fde1ebd9870f74c0ad5858ae72dbafd Mon Sep 17 00:00:00 2001 From: pujaoshahu Date: Mon, 25 May 2026 15:32:46 +0530 Subject: [PATCH] =?utf8?q?mgr/dashboard:=20NVMe-oF=20=E2=80=93=20Design=20?= =?utf8?q?cards=20for=20the=20gateway=20group,=20subsystem,=20namespace=20?= =?utf8?q?page=20when=20the=20table=20has=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit Fixes:https://tracker.ceph.com/issues/75683 Signed-off-by: pujaoshahu --- .../src/app/ceph/block/block.module.ts | 8 +- .../nvmeof-gateway.component.html | 170 +++++++++- .../nvmeof-gateway.component.scss | 62 ++++ .../nvmeof-gateway.component.ts | 142 ++++++++- .../nvmeof-tabs/nvmeof-tabs.component.html | 178 +++++++++++ .../nvmeof-tabs/nvmeof-tabs.component.scss | 68 ++++ .../nvmeof-tabs/nvmeof-tabs.component.spec.ts | 293 +++++++++++++++++- .../nvmeof-tabs/nvmeof-tabs.component.ts | 164 +++++++++- .../active-alert-list.component.ts | 42 ++- .../api/performance-card.service.spec.ts | 21 ++ .../shared/api/performance-card.service.ts | 28 +- .../app/shared/enum/dashboard-promqls.enum.ts | 5 + .../helpers/nvmeof-alert.helper.spec.ts | 77 +++++ .../app/shared/helpers/nvmeof-alert.helper.ts | 68 ++++ .../app/shared/models/prometheus-alerts.ts | 1 + 15 files changed, 1312 insertions(+), 15 deletions(-) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/helpers/nvmeof-alert.helper.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/helpers/nvmeof-alert.helper.ts diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts index 8e1e7c13a19..c30fdced951 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts @@ -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 ]); } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway/nvmeof-gateway.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway/nvmeof-gateway.component.html index 6b2552078ac..19c6ceb03b6 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway/nvmeof-gateway.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway/nvmeof-gateway.component.html @@ -1,9 +1,173 @@

NVMe over Fabrics (TCP)

- Monitor and manage NVMe-over-TCP resources for high-performance block storage. + Monitor and manage NVMe-over-TCP resources for high-performance block storage.
+ + + +
+
+
+ + +

Resources status

+
+
+
+ Gateway groups + +
+
+ Subsystems + +
+
+ Namespaces + +
+
+ Hosts +
+ + {{ stats.hosts }} +
+
+
+
+
+ +
+ + +

Alert notifications

+ View alerts +
+ +
+ {{ alertVM.total }} + +
+

+ {{ alertVM.total > 0 ? 'Need attention' : 'No active alerts' }} +

+ +
+
+ {{ entry.key | titlecase }} + {{ entry.value }} +
+
+
+
+
+ +
+ + +

Throughput

+
+

+ {{ (stats.reads + stats.writes) | number:'1.2-2' }} MB/s +

+

combined R/W

+ + +

+ Active connections: {{ stats.activeConnections }} +

+ + + View detailed information + + + +
+
+
+
+
+
+
; +} + +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; selection = new CdTableSelection(); + nvmeof$: Observable = of(null); + nvmeofAlerts$: Observable = of({ + critical: 0, + warning: 0, + total: 0, + byCategory: {} + }); + + private destroy$ = new Subject(); 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 = {}; + 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(); + 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; } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.html index 31530e009fc..c313e3255c5 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.html @@ -4,6 +4,184 @@ Monitor and manage NVMe-over-TCP resources for high-
performance block storage.
+ + + +
+
+
+ + +

Resources status

+
+
+
+ Gateway groups +
+ @if (stats.gatewayGroups - stats.gatewayGroupsDown > 0) { + + + {{ stats.gatewayGroups - stats.gatewayGroupsDown }} + + } + @if (stats.gatewayGroupsDown > 0) { + + + {{ stats.gatewayGroupsDown }} + + } +
+
+
+ Subsystems + +
+
+ Namespaces + +
+
+ Hosts +
+ + {{ stats.hosts }} +
+
+
+
+
+ +
+ + +

Alert notifications

+ View alerts +
+ +
+ {{ alertVM.total }} + +
+

+ {{ alertVM.total > 0 ? 'Need attention' : 'No active alerts' }} +

+ +
+
+ {{ entry.key | titlecase }} + {{ entry.value }} +
+
+
+
+
+ +
+ + +

Throughput

+
+ +

+ {{ (throughput.reads + throughput.writes) | number:'1.2-2' }} MB/s +

+

combined R/W

+ + +
+

+ Active connections: {{ (nvmeof$ | async)?.activeConnections ?? 0 }} +

+ + + View detailed information + + + +
+
+
+
+
+
+ @if (showSetupCards) { } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.scss index e69de29bb2d..d38f10ef8f8 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.scss @@ -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; + } + } + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.spec.ts index cdd1ea2e9cf..c1ce21ad00b 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.spec.ts @@ -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; 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); + })); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.ts index eac0908a795..32c7c44172d 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.ts @@ -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; +} 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 = of(null); + nvmeofThroughput$: Observable = of({ reads: 0, writes: 0 }); + nvmeofAlerts$: Observable = of({ + critical: 0, + warning: 0, + total: 0, + byCategory: {} + }); + + private destroy$ = new Subject(); - 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(); + 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 = {}; + 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; } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/active-alert-list/active-alert-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/active-alert-list/active-alert-list.component.ts index 2930710b55b..c23dfc232ae 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/active-alert-list/active-alert-list.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/prometheus/active-alert-list/active-alert-list.component.ts @@ -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]; + } }); } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/performance-card.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/performance-card.service.spec.ts index f39048c8f00..fe72dd31b12 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/performance-card.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/performance-card.service.spec.ts @@ -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 = { + 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 = [ diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/performance-card.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/performance-card.service.ts index 0d5e409c06a..37888b0829e 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/performance-card.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/performance-card.service.ts @@ -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 { + return this.prometheusService + .getRangeQueriesData(time, NvmeofPromqls, true) + .pipe(map((raw) => this.convertNvmeofThroughput(raw))); + } + + convertNvmeofThroughput(raw: Record): 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 { return this.prometheusService.getRangeQueriesData(time, AllStoragetypesQueries, true).pipe( map((raw) => { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/dashboard-promqls.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/dashboard-promqls.enum.ts index 5f224ccd2d0..1cc718dc269 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/dashboard-promqls.enum.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/dashboard-promqls.enum.ts @@ -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 index 00000000000..a3400c5733e --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/helpers/nvmeof-alert.helper.spec.ts @@ -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, 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 index 00000000000..96eaa4a6e3a --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/helpers/nvmeof-alert.helper.ts @@ -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 = { + 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; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/prometheus-alerts.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/prometheus-alerts.ts index b3360d44001..09d621a28c8 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/prometheus-alerts.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/prometheus-alerts.ts @@ -5,6 +5,7 @@ export class PrometheusAlertLabels { instance: string; job: string; severity: string; + category?: string; } class Annotations { -- 2.47.3