mgr/dashboard: Updates to empty state component

- added state for no storage in empty state component
- extended the icon component to take into account the scenario of button with icon
- fix unit tests

Signed-off-by: Afreen Misbah <afreen@ibm.com>
This commit is contained in:
Afreen Misbah 2026-05-06 02:35:11 +05:30
parent c259c6f3fc
commit 2eb19f35e3
21 changed files with 111 additions and 135 deletions

View File

@ -21,7 +21,7 @@
it must be explicitly loaded in the configuration file or code (see https://github.com/openssl/openssl/blob/master/README-PROVIDERS.md).
* RGW: Fixed bucket notification events so the 'x_amz_request_id' in NotificationEvent now matches the 'x_amz_request_id' returned by the corresponding S3 operation.
* DASHBOARD: Introduces a new landing page - "Overview". This revamps UX and adds more information in the landing page - overall cluster health, health checks, resilency panel (showing active/clean Pgs status), total and used raw capacity, alerts, capacity breakdown by object, file and block and performance charts - throughput, latency and IOPS. This renames teh landing page from "Dashboard" to "Overview"
* DASHBOARD: Introduces a new landing page - "Overview". This revamps UX and adds more information in the landing page - overall cluster health, health checks, resilency panel (showing PG status, active/clean percent), total and used raw capacity, alerts, capacity breakdown by object, file and block and performance charts - throughput, latency and IOPS. This renames the landing page from "Dashboard" to "Overview"
* DASHBOARD: Removed the older landing page which was deprecated in Quincy.
Admins can no longer enable the older, deprecated landing page layout by
adjusting FEATURE_TOGGLE_DASHBOARD.

View File

@ -143,20 +143,22 @@
aria-label="Add Storage"
i18n>
Add storage
<svg [cdsIcon]="icons.add"
[size]="icons.size20"
class="cds--btn__icon">
</svg>
<cd-icon
[cdsIcon]="icons.add"
[size]="icons.size20"
class="cds--btn__icon">
</cd-icon>
</button>
<button cdsButton="tertiary"
(click)="skipClusterCreation()"
aria-label="View cluster overview"
i18n>
View cluster overview
<svg [cdsIcon]="icons.right"
[size]="icons.size20"
class="cds--btn__icon">
</svg>
<cd-icon
[cdsIcon]="icons.right"
[size]="icons.size20"
class="cds--btn__icon">
</cd-icon>
</button>
</div>
</div>

View File

@ -86,7 +86,6 @@ export class OverviewHealthCardComponent {
private readonly authStorageService = inject(AuthStorageService);
@Input({ required: true }) vm!: HealthCardVM;
@Input() emptyStateText: string | null = '';
@Output() viewIncidents = new EventEmitter<void>();
@Output() viewPGStates = new EventEmitter<void>();
@Output() activeSectionChange = new EventEmitter<HealthCardTabSection | null>();

View File

@ -8,6 +8,8 @@
@let storageCard = (storageCardVm$ | async);
@let health = (healthCardVm$ | async);
@let storageEmptyState = (storageEmptyState$ | async);
@let prometheusEmptyState = (prometheusEmptyState$ | async);
<main cdsGrid
[fullWidth]="true"
[narrow]="true"
@ -43,8 +45,8 @@
[breakdownData]="storageCard?.breakdownData ?? []"
[isBreakdownLoaded]="storageCard?.isBreakdownLoaded ?? false"
[threshold]="storageCard?.threshold"
[storageEmptyState]="storageEmptyState$ | async"
[prometheusEmptyState]="prometheusEmptyState$ | async">
[storageEmptyState]="storageEmptyState"
[prometheusEmptyState]="prometheusEmptyState">
</cd-overview-storage-card>
</div>
</div>
@ -52,7 +54,9 @@
<div cdsCol
class="overview-pr-0"
[columnNumbers]="{ lg: 16 }">
<cd-performance-card [emptyStateText]="prometheusEmptyState$ | async"></cd-performance-card>
<cd-performance-card
[prometheusEmptyState]="prometheusEmptyState"
[storageEmptyState]="storageEmptyState"></cd-performance-card>
</div>
</div>
</main>

View File

@ -104,21 +104,12 @@ export class OverviewComponent {
);
readonly storageEmptyState$ = this.hasNoOSDs$.pipe(startWith(false)).pipe(
map((hasNoOSDs) => {
if (hasNoOSDs) {
return $localize`You can view capacity usage and related metrics here once you add storage.`;
}
return '';
}),
map((hasNoOSDs) => hasNoOSDs),
shareReplay({ bufferSize: 1, refCount: true })
);
readonly prometheusEmptyState$ = this.isPromethuesConfigured$.pipe(
map((isPromethuesConfigured) =>
isPromethuesConfigured
? ''
: $localize`You must have Prometheus configured to access this capability.`
),
map((isPromethuesConfigured) => !isPromethuesConfigured),
shareReplay({ bufferSize: 1, refCount: true })
);

View File

@ -57,7 +57,23 @@
</div>
}
@else {
<cd-empty-state [emptyStateText]="storageEmptyState"></cd-empty-state>
<cd-empty-state
text="You can view capacity usage and related metrics here once you add storage."
title="Storage is not configured yet"
i18n-title
i18n-text>
<button
cdsButton="primary"
i18n
size="sm"
[routerLink]="['/add-storage']"
>
Add storage
<cd-icon
type="add"
class="cds--btn__icon"></cd-icon>
</button>
</cd-empty-state>
}
<!-- CAPACITY BREAKDOWN CHART -->
@if (!prometheusEmptyState && !storageEmptyState) {

View File

@ -11,7 +11,8 @@ import {
TooltipModule,
SkeletonModule,
LayoutModule,
TagModule
TagModule,
ButtonModule
} from 'carbon-components-angular';
import { ProductiveCardComponent } from '~/app/shared/components/productive-card/productive-card.component';
import { MeterChartComponent, MeterChartOptions } from '@carbon/charts-angular';
@ -20,6 +21,7 @@ import { AreaChartComponent } from '~/app/shared/components/area-chart/area-char
import { ComponentsModule } from '~/app/shared/components/components.module';
import { BreakdownChartData, CapacityThreshold, TrendPoint } from '~/app/shared/models/overview';
import { EmptyStateComponent } from '~/app/shared/components/empty-state/empty-state.component';
import { RouterModule } from '@angular/router';
const CHART_HEIGHT = '45px';
@ -35,7 +37,9 @@ const CHART_HEIGHT = '45px';
AreaChartComponent,
ComponentsModule,
TagModule,
EmptyStateComponent
EmptyStateComponent,
ButtonModule,
RouterModule
],
standalone: true,
templateUrl: './overview-storage-card.component.html',
@ -47,8 +51,8 @@ export class OverviewStorageCardComponent {
private readonly formatterService = inject(FormatterService);
private readonly cdr = inject(ChangeDetectorRef);
@Input() storageEmptyState: string | null = '';
@Input() prometheusEmptyState: string | null = '';
@Input() storageEmptyState: boolean = false;
@Input() prometheusEmptyState: boolean = false;
@Input()
set totalCapacity(value: number) {

View File

@ -6,23 +6,13 @@ import { AlertmanagerNotification } from '../models/prometheus-alerts';
import { PrometheusService } from './prometheus.service';
import { SettingsService } from './settings.service';
import moment from 'moment';
import { of } from 'rxjs';
import { MgrModuleService } from './mgr-module.service';
describe('PrometheusService', () => {
let service: PrometheusService;
let httpTesting: HttpTestingController;
const mockMgrModuleService = {
list: jest.fn(() => of([])) // no modules enabled
};
configureTestBed({
providers: [
PrometheusService,
SettingsService,
{ provide: MgrModuleService, useValue: mockMgrModuleService }
],
providers: [PrometheusService, SettingsService],
imports: [HttpClientTestingModule]
});

View File

@ -1,7 +1,7 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { EMPTY, Observable, Subject, Subscription, forkJoin, of, timer } from 'rxjs';
import { Observable, Subject, Subscription, forkJoin, of, timer } from 'rxjs';
import { catchError, map, switchMap } from 'rxjs/operators';
import { AlertmanagerSilence } from '../models/alertmanager-silence';
@ -12,7 +12,6 @@ import {
PrometheusRuleGroup
} from '../models/prometheus-alerts';
import moment from 'moment';
import { MgrModuleService } from './mgr-module.service';
export type PromethuesGaugeMetricResult = {
metric: Record<string, string>; // metric metadata
@ -24,8 +23,6 @@ export type PromqlGuageMetric = {
result: PromethuesGaugeMetricResult[];
};
const PROMETHEUS_MODULE = 'prometheus';
@Injectable({
providedIn: 'root'
})
@ -45,7 +42,7 @@ export class PrometheusService {
private settings: Record<string, string | undefined> = {};
updatedChrtData = new Subject<any>();
constructor(private http: HttpClient, private mgrModuleService: MgrModuleService) {}
constructor(private http: HttpClient) {}
unsubscribe() {
if (this.timerGetPrometheusDataSub) {
@ -71,25 +68,6 @@ export class PrometheusService {
this.disableSetting(this.settingsKey.prometheus);
}
withPrometheusEnabled<T>(
source$: Observable<T>,
fallback$: Observable<T> = EMPTY
): Observable<T> {
return this.isPrometheusModuleEnabled().pipe(
switchMap((enabled) => (enabled ? source$ : fallback$)),
catchError(() => fallback$)
);
}
isPrometheusModuleEnabled(): Observable<boolean> {
return this.mgrModuleService.list().pipe(
map((modules) =>
modules.some((module) => module.name === PROMETHEUS_MODULE && module.enabled)
),
catchError(() => of(false))
);
}
isPrometheusUsable(): Observable<boolean> {
return this.isSettingConfigured(this.settingsKey.prometheus).pipe(
map((isConfigured) => isConfigured),
@ -103,10 +81,8 @@ export class PrometheusService {
}
isAlertmanagerUsable(): Observable<boolean> {
return this.isPrometheusModuleEnabled().pipe(
switchMap((enabled) =>
enabled ? this.isSettingConfigured(this.settingsKey.alertmanager) : of(false)
),
return this.isSettingConfigured(this.settingsKey.alertmanager).pipe(
map((isConfigured) => isConfigured),
catchError(() => of(false))
);
}

View File

@ -1,8 +1,15 @@
<div class="empty-state">
<img src="assets/locked.png"
[alt]="emptyStateText"/>
<span class="cds--type-label-01"
i18n>
{{ emptyStateText }}
</span>
<div
class="empty-state"
[class.empty-state-title]="title">
<img
src="{{ imgSrc }}"
[alt]="text"/>
@if(title) {
<p class="cds--type-body-compact-02 cds-mb-0">{{title}}</p>
}
<p class="cds--type-label-01 empty-state-text"
i18n>
{{ text }}
</p>
<ng-content></ng-content>
</div>

View File

@ -5,17 +5,21 @@
flex-direction: column;
justify-content: flex-end;
gap: var(--cds-spacing-05);
margin-top: 283px;
padding: var(--cds-spacing-05) var(--cds-spacing-05) var(--cds-spacing-07) var(--cds-spacing-05);
width: 264px;
margin-left: var(--cds-spacing-05);
margin-top: 267px; // 283px -16px;
img {
width: 80px !important;
height: 80px !important;
}
span {
&-text {
color: var(--cds-text-secondary);
}
&-title {
margin-top: 140px; // 156px - 16px
}
}

View File

@ -1,4 +1,5 @@
import { Component, Input } from '@angular/core';
import { EMPTY_STATE_IMAGE } from '../../enum/icons.enum';
@Component({
selector: 'cd-empty-state',
@ -7,6 +8,7 @@ import { Component, Input } from '@angular/core';
styleUrl: './empty-state.component.scss'
})
export class EmptyStateComponent {
/* Optional: Custom empty state text, when empty state is displyed*/
@Input() emptyStateText: string | null = '';
@Input() text: string | null = '';
@Input() title: string | null = '';
@Input() imgSrc: string = EMPTY_STATE_IMAGE.default;
}

View File

@ -1,4 +1,4 @@
<svg [cdsIcon]="icon"
[size]="size"
[ngClass]="!useDefault ? [type + '-icon', class] : []">
[ngClass]="!useDefault ? [type + '-icon', customClass] : []">
</svg>

Before

Width:  |  Height:  |  Size: 112 B

After

Width:  |  Height:  |  Size: 118 B

View File

@ -18,7 +18,7 @@ import { ICON_TYPE, Icons, IconSize } from '../../enum/icons.enum';
export class IconComponent implements OnInit, OnChanges {
@Input() type!: keyof typeof ICON_TYPE;
@Input() size: IconSize = IconSize.size16;
@Input() class: string = '';
@Input() customClass: string = '';
// No CSS class will be applied.
@Input() useDefault: boolean = false;

View File

@ -7,7 +7,7 @@
<ng-template #header>
<h2 class="cds--type-heading-compact-02"
i18n>Performance</h2>
@if(!emptyStateText) {
@if(!prometheusEmptyState && !storageEmptyState) {
<div cdsStack="horizontal"
gap="2">
<cd-time-picker
@ -18,9 +18,18 @@
</div>
}
</ng-template>
@if(emptyStateText) {
<cd-empty-state [emptyStateText]="emptyStateText"></cd-empty-state>
} @else {
@if(storageEmptyState) {
<cd-empty-state
text="You must have storage configured to access this capability."
i18n-text></cd-empty-state>
}
@else if(prometheusEmptyState) {
<cd-empty-state
text="You must have Prometheus configured to access this capability."
i18n-text
[imgSrc]="emptyState.locked"></cd-empty-state>
}
@else {
<div cdsGrid
[narrow]="true"
[condensed]="false"

View File

@ -1,7 +1,7 @@
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { PerformanceCardComponent } from './performance-card.component';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { EMPTY, of } from 'rxjs';
import { of } from 'rxjs';
import { PrometheusService } from '../../api/prometheus.service';
import { PerformanceCardService } from '../../api/performance-card.service';
import { PerformanceData } from '../../models/performance-data';
@ -13,7 +13,6 @@ import { Permissions } from '../../models/permissions';
describe('PerformanceCardComponent', () => {
let component: PerformanceCardComponent;
let fixture: ComponentFixture<PerformanceCardComponent>;
let prometheusService: PrometheusService;
const mockChartData: PerformanceData = {
iops: [{ timestamp: new Date(), values: { 'Read IOPS': 100, 'Write IOPS': 50 } }],
@ -25,8 +24,7 @@ describe('PerformanceCardComponent', () => {
beforeEach(async () => {
const prometheusServiceMock = {
lastHourDateObject: { start: 1000, end: 2000, step: 14 },
withPrometheusEnabled: jest.fn((source$) => source$)
lastHourDateObject: { start: 1000, end: 2000, step: 14 }
};
const performanceCardServiceMock = {
@ -71,7 +69,6 @@ describe('PerformanceCardComponent', () => {
fixture = TestBed.createComponent(PerformanceCardComponent);
component = fixture.componentInstance;
prometheusService = TestBed.inject(PrometheusService);
});
it('should create', () => {
@ -93,17 +90,18 @@ describe('PerformanceCardComponent', () => {
expect(component.chartDataSignal()).toEqual(mockChartData);
}));
it('should set emptyStateText when prometheus is enabled', fakeAsync(() => {
it('should not load chart data when no storage', fakeAsync(() => {
component.storageEmptyState = true;
const time = { start: 1000, end: 2000, step: 14 };
component.loadCharts(time);
tick();
expect(component.emptyStateText).toBe('');
expect(component.chartDataSignal()).toBeNull();
}));
it('should not load chart data when prometheus is disabled', fakeAsync(() => {
(prometheusService.withPrometheusEnabled as jest.Mock).mockReturnValue(EMPTY);
component.prometheusEmptyState = true;
const time = { start: 1000, end: 2000, step: 14 };
component.loadCharts(time);

View File

@ -8,7 +8,7 @@ import {
computed,
Input
} from '@angular/core';
import { Icons, IconSize } from '~/app/shared/enum/icons.enum';
import { EMPTY_STATE_IMAGE, Icons, IconSize } from '~/app/shared/enum/icons.enum';
import { PrometheusService } from '~/app/shared/api/prometheus.service';
import {
METRIC_UNIT_MAP,
@ -18,7 +18,7 @@ import {
} from '~/app/shared/models/performance-data';
import { PerformanceCardService } from '../../api/performance-card.service';
import { DropdownModule, GridModule, LayoutModule, ListItem } from 'carbon-components-angular';
import { of, Subject, Subscription } from 'rxjs';
import { Subject, Subscription } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { ProductiveCardComponent } from '../productive-card/productive-card.component';
import { CommonModule } from '@angular/common';
@ -44,7 +44,8 @@ import { EmptyStateComponent } from '../empty-state/empty-state.component';
encapsulation: ViewEncapsulation.None
})
export class PerformanceCardComponent implements OnInit, OnDestroy {
@Input() emptyStateText: string | null = '';
@Input() prometheusEmptyState: boolean = false;
@Input() storageEmptyState: boolean = false;
chartDataSignal = signal<PerformanceData | null>(null);
chartDataLengthSignal = computed(() => {
@ -55,6 +56,7 @@ export class PerformanceCardComponent implements OnInit, OnDestroy {
metricUnitMap = METRIC_UNIT_MAP;
icons = Icons;
iconSize = IconSize;
emptyState = EMPTY_STATE_IMAGE;
private destroy$ = new Subject<void>();
@ -94,8 +96,13 @@ export class PerformanceCardComponent implements OnInit, OnDestroy {
this.chartSub?.unsubscribe();
this.chartSub = this.prometheusService
.withPrometheusEnabled(this.performanceCardService.getChartData(time))
if (this.storageEmptyState || this.prometheusEmptyState) {
this.chartDataSignal.set(null);
return;
}
this.chartSub = this.performanceCardService
.getChartData(time)
.pipe(takeUntil(this.destroy$))
.subscribe((data) => {
this.chartDataSignal.set(data);

View File

@ -1,28 +1,14 @@
<cds-tile class="productive-card"
[ngClass]="{'productive-card--shadow': applyShadow}"
[cdsLayer]="0">
@if(!emptyStateText) {
<div class="productive-card-header">
<ng-container *ngTemplateOutlet="headerTemplate"></ng-container>
</div>
}
<section class="productive-card-section cds--type-body-compact-01"
[ngClass]="{'productive-card-section--footer': footerTemplate}">
@if(emptyStateText) {
<div class="productive-card-empty-state">
<img src="assets/locked.png"
[alt]="emptyStateText"/>
<span class="cds--type-label-01"
i18n>
{{ emptyStateText }}
</span>
</div>
}
@else {
<ng-content></ng-content>
}
</section>
@if(!!footerTemplate && !emptyStateText) {
@if(!!footerTemplate) {
<footer class="productive-card-footer">
<ng-container *ngTemplateOutlet="footerTemplate"></ng-container>
</footer>

View File

@ -40,21 +40,4 @@
radial-gradient(120% 60% at 50% 100%, rgba(colors.$magenta-60, 0.11) 0%, transparent 70%);
box-shadow: var(--cds-ai-drop-shadow), inset 0 0 0 1px var(--cds-ai-inner-shadow);
}
&-empty-state {
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: var(--cds-spacing-05);
height: 350px;
p {
font-size: 12px !important;
}
img {
width: 100px !important;
height: 100px !important;
}
}
}

View File

@ -24,9 +24,6 @@ export class ProductiveCardComponent {
/* Optional: Applies a tinted-colored background to card */
@Input() applyShadow: boolean = false;
/* Optional: Custom empty state text, when empty state is displyed*/
@Input() emptyStateText: string | null = '';
/* Optional: Header action template, appears alongwith title in top-right corner */
@ContentChild('header', {
read: TemplateRef

View File

@ -175,5 +175,6 @@ export const ICON_TYPE = {
export const EMPTY_STATE_IMAGE = {
default: 'assets/empty-state.png',
search: 'assets/empty-state-search.png'
search: 'assets/empty-state-search.png',
locked: 'assets/locked.png'
} as const;