]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: cluster upgrade base UI component
authorNizamudeen A <nia@redhat.com>
Fri, 7 Jul 2023 05:50:21 +0000 (11:20 +0530)
committerNizamudeen A <nia@redhat.com>
Thu, 17 Aug 2023 17:31:18 +0000 (23:01 +0530)
- Base layout for the Upgrade Component
- Shows the Upgrade available button when an upgrade is available

Fixes: https://tracker.ceph.com/issues/61927
Signed-off-by: Nizamudeen A <nia@redhat.com>
(cherry picked from commit d95b68ab10d6d94a0fac910db7b7f4f8f21f4d66)

src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/upgrade/upgrade.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/upgrade/upgrade.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/upgrade/upgrade.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/upgrade/upgrade.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/upgrade.service.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/api/upgrade.service.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/models/upgrade.interface.ts [new file with mode: 0644]

index 6aa96ee49e17694c2f75d6011bb1b7d5f0d0f92e..595759e5d4924209cfffe5fce1938e8940830577 100644 (file)
@@ -46,6 +46,7 @@ import { FeatureTogglesGuardService } from './shared/services/feature-toggles-gu
 import { ModuleStatusGuardService } from './shared/services/module-status-guard.service';
 import { NoSsoGuardService } from './shared/services/no-sso-guard.service';
 import { CephfsVolumeFormComponent } from './ceph/cephfs/cephfs-form/cephfs-form.component';
+import { UpgradeComponent } from './ceph/cluster/upgrade/upgrade.component';
 
 @Injectable()
 export class PerformanceCounterBreadcrumbsResolver extends BreadcrumbsResolver {
@@ -283,6 +284,11 @@ const routes: Routes = [
           }
         ]
       },
+      {
+        path: 'upgrade',
+        component: UpgradeComponent,
+        data: { breadcrumbs: 'Cluster/Upgrade' }
+      },
       {
         path: 'perf_counters/:type/:id',
         component: PerformanceCounterComponent,
index 610bb79baebc781eea8a1f2e4f78a40b04ea007d..d8bfde368b975feb16696f9c3d8cb95d08c1ed6f 100644 (file)
@@ -57,6 +57,7 @@ import { ServiceDetailsComponent } from './services/service-details/service-deta
 import { ServiceFormComponent } from './services/service-form/service-form.component';
 import { ServicesComponent } from './services/services.component';
 import { TelemetryComponent } from './telemetry/telemetry.component';
+import { UpgradeComponent } from './upgrade/upgrade.component';
 
 @NgModule({
   imports: [
@@ -116,7 +117,8 @@ import { TelemetryComponent } from './telemetry/telemetry.component';
     OsdFlagsIndivModalComponent,
     PlacementPipe,
     CreateClusterComponent,
-    CreateClusterReviewComponent
+    CreateClusterReviewComponent,
+    UpgradeComponent
   ],
   providers: [NgbActiveModal]
 })
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/upgrade/upgrade.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/upgrade/upgrade.component.html
new file mode 100644 (file)
index 0000000..49dab51
--- /dev/null
@@ -0,0 +1,101 @@
+<ng-container *ngIf="{upgradeInfo: upgradeInfo$ | async, error: upgradeInfoError$ } as upgrade">
+  <div class="row h-25 ms-1"
+       *ngIf="!upgrade.upgradeInfoError && upgrade.upgradeInfo as upgradeInfo; else checkUpgrade">
+    <ng-container *ngIf="healthData$ | async as healthData">
+      <div class="col-lg-3 h-50 d-flex flex-column border justify-content-center align-items-center">
+        <span class="bold">Current Version</span>
+        <span class="mt-1">{{ version }}</span>
+      </div>
+      <div class="col-lg-3 h-50 d-flex flex-column border justify-content-center align-items-center">
+        <span class="bold">Cluster Status</span>
+        <ng-template #healthChecks>
+          <ul>
+            <li *ngFor="let check of healthData.health.checks">
+              <span [ngStyle]="check.severity | healthColor"
+                    [class.health-warn-description]="check.severity === 'HEALTH_WARN'">
+              {{ check.type }}</span>: {{ check.summary.message }}
+            </li>
+          </ul>
+        </ng-template>
+        <div class="info-card-content-clickable mt-1"
+             [ngStyle]="healthData.health.status | healthColor"
+             [ngbPopover]="healthChecks"
+             popoverClass="info-card-popover-cluster-status">
+             {{ healthData.health.status | healthLabel | uppercase }}
+          <i *ngIf="healthData.health?.status !== 'HEALTH_OK'"
+             class="fa fa-exclamation-triangle"></i>
+        </div>
+      </div>
+      <div class="col-lg-3 h-50 d-flex flex-column border justify-content-center align-items-center">
+        <span class="bold">Upgrade Status</span>
+        <ng-container *ngIf="upgradeInfo.versions.length > 0; else noUpgradesAvailable">
+          <span class="mt-2"
+                i18n>
+          <i [ngClass]="[icons.up]"
+             class="text-info"></i>
+            Upgrade available</span>
+          <div i18n-ngbTooltip
+               [ngbTooltip]="(healthData.mgr_map | mgrSummary).total <= 1 ? 'To upgrade, you need minimum 2 mgr daemons.' : ''">
+            <button class="btn btn-accent mt-2"
+                    id="upgrade"
+                    aria-label="Upgrade now"
+                    [disabled]="(healthData.mgr_map | mgrSummary).total <= 1"
+                    i18n>Upgrade now</button>
+          </div>
+        </ng-container>
+      </div>
+      <div class="col-lg-3 h-50 d-flex flex-column border justify-content-center align-items-center">
+        <span class="bold">MGR Count</span>
+        <span class="mt-1">
+          <i class="text-success"
+             [ngClass]="[icons.success]"
+             *ngIf="(healthData.mgr_map | mgrSummary).total > 1; else warningIcon">
+          </i>
+          {{ (healthData.mgr_map | mgrSummary).total }}
+        </span>
+      </div>
+
+      <div class="d-flex mt-3 p-0">
+        <dl class="w-50"
+            *ngIf="fsid$ | async as fsid">
+          <dt class="bold mt-5"
+              i18n>Cluster FSID</dt>
+          <dd class="mt-2">{{ fsid }}</dd>
+          <dt class="bold mt-5"
+              i18n>Release Image</dt>
+          <dd class="mt-2">{{ upgradeInfo.image }}</dd>
+          <dt class="bold mt-5"
+              i18n>Registry</dt>
+          <dd class="mt-2">{{ upgradeInfo.registry }}</dd>
+        </dl>
+      </div>
+    </ng-container>
+  </div>
+</ng-container>
+
+<ng-template #checkUpgrade>
+  <div class="row h-75 justify-content-center align-items-center">
+    <h3 class="mt-1 bold text-center"
+        id="checking-for-upgrades"
+        i18n>Checking for upgrades
+      <i [ngClass]="[icons.spin, icons.spinner]"></i>
+    </h3>
+  </div>
+</ng-template>
+
+<ng-template #noUpgradesAvailable>
+  <span class="mt-1"
+        id="no-upgrades-available"
+        i18n>
+    <i [ngClass]="[icons.success]"
+       class="text-success"></i>
+    Cluster is up-to-date
+  </span>
+</ng-template>
+
+<ng-template #warningIcon>
+  <i class="text-warning"
+     [ngClass]="[icons.warning]"
+     title="To upgrade, you need minimum 2 mgr daemons.">
+  </i>
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/upgrade/upgrade.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/upgrade/upgrade.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/upgrade/upgrade.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/upgrade/upgrade.component.spec.ts
new file mode 100644 (file)
index 0000000..d0d3997
--- /dev/null
@@ -0,0 +1,149 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { UpgradeComponent } from './upgrade.component';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { SummaryService } from '~/app/shared/services/summary.service';
+import { BehaviorSubject, of } from 'rxjs';
+import { UpgradeService } from '~/app/shared/api/upgrade.service';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { UpgradeInfoInterface } from '~/app/shared/models/upgrade.interface';
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { HealthService } from '~/app/shared/api/health.service';
+import { SharedModule } from '~/app/shared/shared.module';
+
+export class SummaryServiceMock {
+  summaryDataSource = new BehaviorSubject({
+    version:
+      'ceph version 17.0.0-12222-gcd0cd7cb ' +
+      '(b8193bb4cda16ccc5b028c3e1df62bc72350a15d) quincy (dev)'
+  });
+  summaryData$ = this.summaryDataSource.asObservable();
+
+  subscribe(call: any) {
+    return this.summaryData$.subscribe(call);
+  }
+}
+
+describe('UpgradeComponent', () => {
+  let component: UpgradeComponent;
+  let fixture: ComponentFixture<UpgradeComponent>;
+  let upgradeInfoSpy: jasmine.Spy;
+  let getHealthSpy: jasmine.Spy;
+
+  const healthPayload: Record<string, any> = {
+    health: { status: 'HEALTH_OK' },
+    mon_status: { monmap: { mons: [] }, quorum: [] },
+    osd_map: { osds: [] },
+    mgr_map: { active_name: 'test_mgr', standbys: [] },
+    hosts: 0,
+    rgw: 0,
+    fs_map: { filesystems: [], standbys: [] },
+    iscsi_daemons: 1,
+    client_perf: {},
+    scrub_status: 'Inactive',
+    pools: [],
+    df: { stats: {} },
+    pg_info: { object_stats: { num_objects: 1 } }
+  };
+
+  configureTestBed({
+    imports: [HttpClientTestingModule, SharedModule],
+    schemas: [NO_ERRORS_SCHEMA],
+    declarations: [UpgradeComponent],
+    providers: [UpgradeService, { provide: SummaryService, useClass: SummaryServiceMock }]
+  });
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(UpgradeComponent);
+    component = fixture.componentInstance;
+    upgradeInfoSpy = spyOn(TestBed.inject(UpgradeService), 'list');
+    getHealthSpy = spyOn(TestBed.inject(HealthService), 'getMinimalHealth');
+    getHealthSpy.and.returnValue(of(healthPayload));
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  it('should load the view once check for upgrade is done', () => {
+    const upgradeInfoPayload = {
+      image: 'quay.io/ceph-test/ceph',
+      registry: 'quay.io',
+      versions: ['18.1.0', '18.1.1', '18.1.2']
+    };
+    upgradeInfoSpy.and.returnValue(of(upgradeInfoPayload));
+    component.ngOnInit();
+    fixture.detectChanges();
+    const firstCellSpan = fixture.debugElement.nativeElement.querySelector('span');
+    expect(firstCellSpan.textContent).toBe('Current Version');
+  });
+
+  it('should show button to Upgrade if a new version is available', () => {
+    const upgradeInfoPayload = {
+      image: 'quay.io/ceph-test/ceph',
+      registry: 'quay.io',
+      versions: ['18.1.0', '18.1.1', '18.1.2']
+    };
+    upgradeInfoSpy.and.returnValue(of(upgradeInfoPayload));
+    component.ngOnInit();
+    fixture.detectChanges();
+    const upgradeNowBtn = fixture.debugElement.nativeElement.querySelector('#upgrade');
+    expect(upgradeNowBtn).not.toBeNull();
+  });
+
+  it('should not show the upgrade button if there are no new version available', () => {
+    const upgradeInfoPayload: UpgradeInfoInterface = {
+      image: 'quay.io/ceph-test/ceph',
+      registry: 'quay.io',
+      versions: []
+    };
+    upgradeInfoSpy.and.returnValue(of(upgradeInfoPayload));
+    component.ngOnInit();
+    fixture.detectChanges();
+    const noUpgradesSpan = fixture.debugElement.nativeElement.querySelector(
+      '#no-upgrades-available'
+    );
+    expect(noUpgradesSpan.textContent).toBe(' Cluster is up-to-date ');
+  });
+
+  it('should show the loading screen while the api call is pending', () => {
+    const loading = fixture.debugElement.nativeElement.querySelector('h3');
+    expect(loading.textContent).toBe('Checking for upgrades ');
+  });
+
+  it('should upgrade only when there are more than 1 mgr', () => {
+    const upgradeInfoPayload = {
+      image: 'quay.io/ceph-test/ceph',
+      registry: 'quay.io',
+      versions: ['18.1.0', '18.1.1', '18.1.2']
+    };
+    upgradeInfoSpy.and.returnValue(of(upgradeInfoPayload));
+    component.ngOnInit();
+    fixture.detectChanges();
+    const upgradeBtn = fixture.debugElement.nativeElement.querySelector('#upgrade');
+    expect(upgradeBtn.disabled).toBeTruthy();
+
+    // Add a standby mgr to the payload
+    const healthPayload2: Record<string, any> = {
+      health: { status: 'HEALTH_OK' },
+      mon_status: { monmap: { mons: [] }, quorum: [] },
+      osd_map: { osds: [] },
+      mgr_map: { active_name: 'test_mgr', standbys: ['mgr1'] },
+      hosts: 0,
+      rgw: 0,
+      fs_map: { filesystems: [], standbys: [] },
+      iscsi_daemons: 1,
+      client_perf: {},
+      scrub_status: 'Inactive',
+      pools: [],
+      df: { stats: {} },
+      pg_info: { object_stats: { num_objects: 1 } }
+    };
+
+    getHealthSpy.and.returnValue(of(healthPayload2));
+    component.ngOnInit();
+    fixture.detectChanges();
+    expect(upgradeBtn.disabled).toBeFalsy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/upgrade/upgrade.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/upgrade/upgrade.component.ts
new file mode 100644 (file)
index 0000000..e45914a
--- /dev/null
@@ -0,0 +1,49 @@
+import { Component, OnInit } from '@angular/core';
+import { Observable, of } from 'rxjs';
+import { catchError, ignoreElements } from 'rxjs/operators';
+import { HealthService } from '~/app/shared/api/health.service';
+import { UpgradeService } from '~/app/shared/api/upgrade.service';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { Permission } from '~/app/shared/models/permissions';
+import { UpgradeInfoInterface } from '~/app/shared/models/upgrade.interface';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { SummaryService } from '~/app/shared/services/summary.service';
+
+@Component({
+  selector: 'cd-upgrade',
+  templateUrl: './upgrade.component.html',
+  styleUrls: ['./upgrade.component.scss']
+})
+export class UpgradeComponent implements OnInit {
+  version: string;
+  upgradeInfo$: Observable<UpgradeInfoInterface>;
+  upgradeInfoError$: Observable<any>;
+  permission: Permission;
+  healthData$: Observable<any>;
+  fsid$: Observable<any>;
+
+  icons = Icons;
+
+  constructor(
+    private summaryService: SummaryService,
+    private upgradeService: UpgradeService,
+    private authStorageService: AuthStorageService,
+    private healthService: HealthService
+  ) {
+    this.permission = this.authStorageService.getPermissions().configOpt;
+  }
+
+  ngOnInit(): void {
+    this.summaryService.subscribe((summary) => {
+      const version = summary.version.replace('ceph version ', '').split('-');
+      this.version = version[0];
+    });
+    this.upgradeInfo$ = this.upgradeService.list();
+    this.upgradeInfoError$ = this.upgradeInfo$?.pipe(
+      ignoreElements(),
+      catchError((error) => of(error))
+    );
+    this.healthData$ = this.healthService.getMinimalHealth();
+    this.fsid$ = this.healthService.getClusterFsid();
+  }
+}
index 4a8b6d11ce59a3ac06ced7120e9b1b4a5a743e41..6a05aea981791b9d0749ddc173723279b7216f48 100644 (file)
                      class="badge badge-warning ms-1">{{ prometheusAlertService.activeWarningAlerts }}</small>
             </a>
           </li>
+          <li routerLinkActive="active"
+              class="tc_submenuitem tc_submenuitem_upgrade"
+              *ngIf="permissions.configOpt.read">
+            <a i18n
+               routerLink="/upgrade">Upgrade</a>
+          </li>
         </ul>
       </li>
 
index 64aaca65b5a4e568fc06b38fac89afef5015643c..c8873186eb84f2e4596a6e546c3c25341dd43e0c 100644 (file)
@@ -111,7 +111,12 @@ describe('NavigationComponent', () => {
       [['osd'], ['.tc_submenuitem_osds', '.tc_submenuitem_crush']],
       [
         ['configOpt'],
-        ['.tc_submenuitem_configuration', '.tc_submenuitem_modules', '.tc_submenuitem_users']
+        [
+          '.tc_submenuitem_configuration',
+          '.tc_submenuitem_modules',
+          '.tc_submenuitem_users',
+          '.tc_submenuitem_upgrade'
+        ]
       ],
       [['log'], ['.tc_submenuitem_log']],
       [['prometheus'], ['.tc_submenuitem_monitoring']],
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/upgrade.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/upgrade.service.spec.ts
new file mode 100644 (file)
index 0000000..3bfc2be
--- /dev/null
@@ -0,0 +1,60 @@
+import { UpgradeService } from './upgrade.service';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+import { SummaryService } from '../services/summary.service';
+import { BehaviorSubject } from 'rxjs';
+
+export class SummaryServiceMock {
+  summaryDataSource = new BehaviorSubject({
+    version:
+      'ceph version 18.1.3-12222-gcd0cd7cb ' +
+      '(b8193bb4cda16ccc5b028c3e1df62bc72350a15d) reef (dev)'
+  });
+  summaryData$ = this.summaryDataSource.asObservable();
+
+  subscribe(call: any) {
+    return this.summaryData$.subscribe(call);
+  }
+}
+
+describe('UpgradeService', () => {
+  let service: UpgradeService;
+  let httpTesting: HttpTestingController;
+
+  configureTestBed({
+    imports: [HttpClientTestingModule],
+    providers: [UpgradeService, { provide: SummaryService, useClass: SummaryServiceMock }]
+  });
+
+  beforeEach(() => {
+    service = TestBed.inject(UpgradeService);
+    httpTesting = TestBed.inject(HttpTestingController);
+  });
+
+  afterEach(() => {
+    httpTesting.verify();
+  });
+
+  it('should be created', () => {
+    expect(service).toBeTruthy();
+  });
+
+  it('should call upgrade list', () => {
+    service.list().subscribe();
+    const req = httpTesting.expectOne('api/cluster/upgrade');
+    expect(req.request.method).toBe('GET');
+  });
+
+  it('should not show any version if the registry versions are older than the cluster version', () => {
+    const upgradeInfoPayload = {
+      image: 'quay.io/ceph-test/ceph',
+      registry: 'quay.io',
+      versions: ['18.1.0', '18.1.1', '18.1.2']
+    };
+    const expectedVersions: string[] = [];
+    expect(service.versionAvailableForUpgrades(upgradeInfoPayload).versions).toEqual(
+      expectedVersions
+    );
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/upgrade.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/upgrade.service.ts
new file mode 100644 (file)
index 0000000..c510164
--- /dev/null
@@ -0,0 +1,44 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+import { ApiClient } from './api-client';
+import { map } from 'rxjs/operators';
+import { SummaryService } from '../services/summary.service';
+import { UpgradeInfoInterface } from '../models/upgrade.interface';
+
+@Injectable({
+  providedIn: 'root'
+})
+export class UpgradeService extends ApiClient {
+  baseURL = 'api/cluster/upgrade';
+
+  constructor(private http: HttpClient, private summaryService: SummaryService) {
+    super();
+  }
+
+  list() {
+    return this.http.get(this.baseURL).pipe(
+      map((resp: UpgradeInfoInterface) => {
+        return this.versionAvailableForUpgrades(resp);
+      })
+    );
+  }
+
+  // Filter out versions that are older than the current cluster version
+  // Only allow upgrades to the same major version
+  versionAvailableForUpgrades(upgradeInfo: UpgradeInfoInterface): UpgradeInfoInterface {
+    let version = '';
+    this.summaryService.subscribe((summary) => {
+      version = summary.version.replace('ceph version ', '').split('-')[0];
+    });
+
+    const upgradableVersions = upgradeInfo.versions.filter((targetVersion) => {
+      const cVersion = version.split('.');
+      const tVersion = targetVersion.split('.');
+      return (
+        cVersion[0] === tVersion[0] && (cVersion[1] < tVersion[1] || cVersion[2] < tVersion[2])
+      );
+    });
+    upgradeInfo.versions = upgradableVersions;
+    return upgradeInfo;
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/upgrade.interface.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/upgrade.interface.ts
new file mode 100644 (file)
index 0000000..ada46bc
--- /dev/null
@@ -0,0 +1,5 @@
+export interface UpgradeInfoInterface {
+  image: string;
+  registry: string;
+  versions: string[];
+}