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';
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: [
LayoutModule,
ThemeModule,
NvmeofSetupCardsComponent,
- NvmeofGatewayGroupFilterComponent
+ NvmeofGatewayGroupFilterComponent,
+ ProductiveCardComponent
],
declarations: [
RbdListComponent,
ProgressBarRound,
SubtractAlt,
Search,
- Datastore
+ Datastore,
+ ArrowRight
]);
}
}
<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
+.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;
+ }
+ }
+}
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',
@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 {
}
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();
}
public get Tabs(): typeof TABS {
return TABS;
}
+
+ readonly alertQueryParams = nvmeofAlertQueryParams;
}
<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>
}
+: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;
+ }
+ }
+ }
+}
-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', () => {
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);
+ }));
+ });
});
-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',
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) {
public get Tabs(): typeof TABS {
return TABS;
}
+
+ readonly alertQueryParams = nvmeofAlertQueryParams;
}
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';
all: $localize`All`
};
+const ScopeFilterIndex = 2;
+const CategoryFilterIndex = 3;
+
@Component({
selector: 'cd-active-alert-list',
providers: [{ provide: URLBuilderService, useValue: new URLBuilderService(BASE_URL) }],
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)
}
];
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];
+ }
});
}
});
});
+ 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 = [
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) => {
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]))'
+};
--- /dev/null
+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'
+ });
+ });
+ });
+});
--- /dev/null
+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;
+}
instance: string;
job: string;
severity: string;
+ category?: string;
}
class Annotations {