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.
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>
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>();
@let storageCard = (storageCardVm$ | async);
@let health = (healthCardVm$ | async);
+@let storageEmptyState = (storageEmptyState$ | async);
+@let prometheusEmptyState = (prometheusEmptyState$ | async);
<main cdsGrid
[fullWidth]="true"
[narrow]="true"
[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>
<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>
);
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 })
);
</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) {
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';
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';
AreaChartComponent,
ComponentsModule,
TagModule,
- EmptyStateComponent
+ EmptyStateComponent,
+ ButtonModule,
+ RouterModule
],
standalone: true,
templateUrl: './overview-storage-card.component.html',
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) {
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]
});
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';
PrometheusRuleGroup
} from '../models/prometheus-alerts';
import moment from 'moment';
-import { MgrModuleService } from './mgr-module.service';
export type PromethuesGaugeMetricResult = {
metric: Record<string, string>; // metric metadata
result: PromethuesGaugeMetricResult[];
};
-const PROMETHEUS_MODULE = 'prometheus';
-
@Injectable({
providedIn: 'root'
})
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) {
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),
}
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))
);
}
-<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>
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
+ }
}
import { Component, Input } from '@angular/core';
+import { EMPTY_STATE_IMAGE } from '../../enum/icons.enum';
@Component({
selector: 'cd-empty-state',
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;
}
<svg [cdsIcon]="icon"
[size]="size"
- [ngClass]="!useDefault ? [type + '-icon', class] : []">
+ [ngClass]="!useDefault ? [type + '-icon', customClass] : []">
</svg>
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;
<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
</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"
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';
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 } }],
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 = {
fixture = TestBed.createComponent(PerformanceCardComponent);
component = fixture.componentInstance;
- prometheusService = TestBed.inject(PrometheusService);
});
it('should create', () => {
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);
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,
} 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';
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(() => {
metricUnitMap = METRIC_UNIT_MAP;
icons = Icons;
iconSize = IconSize;
+ emptyState = EMPTY_STATE_IMAGE;
private destroy$ = new Subject<void>();
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);
<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>
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;
- }
- }
}
/* 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
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;