]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: introduce multi-cluster overview page 55574/head
authorNizamudeen A <nia@redhat.com>
Tue, 6 Feb 2024 12:39:41 +0000 (18:09 +0530)
committerAashish Sharma <aasharma@li-e74156cc-2f67-11b2-a85c-e98659a63c5c.ibm.com>
Tue, 5 Mar 2024 13:35:37 +0000 (19:05 +0530)
https://tracker.ceph.com/issues/64530
Signed-off-by: Nizamudeen A <nia@redhat.com>
Signed-off-by: Aashish Sharma <aasharma@redhat.com>
27 files changed:
monitoring/ceph-mixin/dashboards/multi-cluster.libsonnet
monitoring/ceph-mixin/dashboards_out/multi-cluster-overview.json
src/pybind/mgr/dashboard/controllers/multi_cluster.py
src/pybind/mgr/dashboard/controllers/prometheus.py
src/pybind/mgr/dashboard/frontend/cypress/e2e/ui/navigation.po.ts
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/multi-cluster/multi-cluster-form/multi-cluster-form.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster-form/multi-cluster-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster-list/multi-cluster-list.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard-v3/dashboard-area-chart/dashboard-area-chart.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard-v3/dashboard-area-chart/dashboard-area-chart.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard-v3/dashboard-v3.module.ts
src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/api/multi-cluster.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/card-group/card-group.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/card-group/card-group.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/card-group/card-group.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/card-group/card-group.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/enum/dashboard-promqls.enum.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/multi-cluster.ts
src/pybind/mgr/dashboard/openapi.yaml

index ec725f4dfa281a2598660eaca116bb73d5a3449a..a770565400839d0573aeedf45877730027592562 100644 (file)
@@ -5,7 +5,7 @@ local g = import 'grafonnet/grafana.libsonnet';
     $.dashboardSchema(
       'Ceph - Multi-cluster',
       '',
-      'BnxelG7Sz',
+      'BnxelG7Sx',
       'now-1h',
       '30s',
       22,
index 91b2934f065460b5ac681fa7d22848512c476f94..ff8bcdd0254436984e8716c770da939b61678b29 100644 (file)
    },
    "timezone": "",
    "title": "Ceph - Multi-cluster",
-   "uid": "BnxelG7Sz",
+   "uid": "BnxelG7Sx",
    "version": 0
 }
index c918c2ec3c2407c701069c81296ad52a4d8fb586..d69a7da26094c5f109b70af5957c751a45dad03e 100644 (file)
@@ -6,6 +6,7 @@ import time
 
 import requests
 
+from .. import mgr
 from ..exceptions import DashboardException
 from ..security import Scope
 from ..settings import Settings
@@ -18,9 +19,10 @@ from . import APIDoc, APIRouter, CreatePermission, DeletePermission, Endpoint, \
 @APIDoc('Multi-cluster Management API', 'Multi-cluster')
 class MultiCluster(RESTController):
     def _proxy(self, method, base_url, path, params=None, payload=None, verify=False,
-               token=None):
+               token=None, cert=None):
         if not base_url.endswith('/'):
             base_url = base_url + '/'
+
         try:
             if token:
                 headers = {
@@ -33,7 +35,7 @@ class MultiCluster(RESTController):
                     'Content-Type': 'application/json',
                 }
             response = requests.request(method, base_url + path, params=params,
-                                        json=payload, verify=verify, headers=headers)
+                                        json=payload, verify=verify, cert=cert, headers=headers)
         except Exception as e:
             raise DashboardException(
                 "Could not reach {}, {}".format(base_url+path, e),
@@ -51,10 +53,10 @@ class MultiCluster(RESTController):
     @Endpoint('POST')
     @CreatePermission
     @EndpointDoc("Authenticate to a remote cluster")
-    def auth(self, url: str, cluster_alias: str, username=None,
-             password=None, token=None, hub_url=None, cluster_fsid=None):
-
-        if username and password:
+    def auth(self, url: str, cluster_alias: str, username: str,
+             password=None, token=None, hub_url=None, cluster_fsid=None,
+             prometheus_api_url=None, ssl_verify=False, ssl_certificate=None):
+        if password:
             payload = {
                 'username': username,
                 'password': password
@@ -69,16 +71,28 @@ class MultiCluster(RESTController):
             cluster_token = content['token']
 
             self._proxy('PUT', url, 'ui-api/multi-cluster/set_cors_endpoint',
-                        payload={'url': hub_url}, token=cluster_token)
-
+                        payload={'url': hub_url}, token=cluster_token, verify=ssl_verify,
+                        cert=ssl_certificate)
             fsid = self._proxy('GET', url, 'api/health/get_cluster_fsid', token=cluster_token)
 
-            self.set_multi_cluster_config(fsid, username, url, cluster_alias, cluster_token)
+            # add prometheus targets
+            prometheus_url = self._proxy('GET', url, 'api/settings/PROMETHEUS_API_HOST',
+                                         token=cluster_token)
+            _set_prometheus_targets(prometheus_url['value'])
 
-        if token and cluster_fsid and username:
-            self.set_multi_cluster_config(cluster_fsid, username, url, cluster_alias, token)
+            self.set_multi_cluster_config(fsid, username, url, cluster_alias,
+                                          cluster_token, prometheus_url['value'],
+                                          ssl_verify, ssl_certificate)
+            return
 
-    def set_multi_cluster_config(self, fsid, username, url, cluster_alias, token):
+        if token and cluster_fsid and prometheus_api_url:
+            _set_prometheus_targets(prometheus_api_url)
+            self.set_multi_cluster_config(cluster_fsid, username, url,
+                                          cluster_alias, token, prometheus_api_url,
+                                          ssl_verify, ssl_certificate)
+
+    def set_multi_cluster_config(self, fsid, username, url, cluster_alias, token,
+                                 prometheus_url=None, ssl_verify=False, ssl_certificate=None):
         multi_cluster_config = self.load_multi_cluster_config()
         if fsid in multi_cluster_config['config']:
             existing_entries = multi_cluster_config['config'][fsid]
@@ -89,6 +103,9 @@ class MultiCluster(RESTController):
                     "cluster_alias": cluster_alias,
                     "user": username,
                     "token": token,
+                    "prometheus_url": prometheus_url if prometheus_url else '',
+                    "ssl_verify": ssl_verify,
+                    "ssl_certificate": ssl_certificate if ssl_certificate else ''
                 })
         else:
             multi_cluster_config['current_user'] = username
@@ -98,6 +115,9 @@ class MultiCluster(RESTController):
                 "cluster_alias": cluster_alias,
                 "user": username,
                 "token": token,
+                "prometheus_url": prometheus_url if prometheus_url else '',
+                "ssl_verify": ssl_verify,
+                "ssl_certificate": ssl_certificate if ssl_certificate else ''
             }]
         Settings.MULTICLUSTER_CONFIG = multi_cluster_config
 
@@ -123,16 +143,18 @@ class MultiCluster(RESTController):
         return Settings.MULTICLUSTER_CONFIG
 
     @Endpoint('PUT')
-    @CreatePermission
+    @UpdatePermission
     # pylint: disable=unused-variable
-    def reconnect_cluster(self, url: str, username=None, password=None, token=None):
+    def reconnect_cluster(self, url: str, username=None, password=None, token=None,
+                          ssl_verify=False, ssl_certificate=None):
         multicluster_config = self.load_multi_cluster_config()
         if username and password:
             payload = {
                 'username': username,
                 'password': password
             }
-            content = self._proxy('POST', url, 'api/auth', payload=payload)
+            content = self._proxy('POST', url, 'api/auth', payload=payload,
+                                  verify=ssl_verify, cert=ssl_certificate)
             if 'token' not in content:
                 raise DashboardException(
                     "Could not authenticate to remote cluster",
@@ -143,7 +165,7 @@ class MultiCluster(RESTController):
 
         if username and token:
             if "config" in multicluster_config:
-                for key, cluster_details in multicluster_config["config"].items():
+                for _, cluster_details in multicluster_config["config"].items():
                     for cluster in cluster_details:
                         if cluster["url"] == url and cluster["user"] == username:
                             cluster['token'] = token
@@ -168,31 +190,38 @@ class MultiCluster(RESTController):
     def delete_cluster(self, cluster_name, cluster_user):
         multicluster_config = self.load_multi_cluster_config()
         if "config" in multicluster_config:
-            keys_to_remove = []
-            for key, cluster_details in multicluster_config["config"].items():
-                cluster_details_copy = list(cluster_details)
-                for cluster in cluster_details_copy:
-                    if cluster["name"] == cluster_name and cluster["user"] == cluster_user:
-                        cluster_details.remove(cluster)
-                        if not cluster_details:
-                            keys_to_remove.append(key)
-
-            for key in keys_to_remove:
-                del multicluster_config["config"][key]
+            for key, value in list(multicluster_config['config'].items()):
+                if value[0]['name'] == cluster_name and value[0]['user'] == cluster_user:
+
+                    orch_backend = mgr.get_module_option_ex('orchestrator', 'orchestrator')
+                    try:
+                        if orch_backend == 'cephadm':
+                            cmd = {
+                                'prefix': 'orch prometheus remove-target',
+                                'url': value[0]['prometheus_url'].replace('http://', '').replace('https://', '')  # noqa E501 #pylint: disable=line-too-long
+                            }
+                            mgr.mon_command(cmd)
+                    except KeyError:
+                        pass
+
+                    del multicluster_config['config'][key]
+                    break
 
         Settings.MULTICLUSTER_CONFIG = multicluster_config
         return Settings.MULTICLUSTER_CONFIG
 
-    @Endpoint()
-    @ReadPermission
+    @Endpoint('POST')
+    @CreatePermission
     # pylint: disable=R0911
-    def verify_connection(self, url=None, username=None, password=None, token=None):
+    def verify_connection(self, url=None, username=None, password=None, token=None,
+                          ssl_verify=False, ssl_certificate=None):
         if token:
             try:
                 payload = {
                     'token': token
                 }
-                content = self._proxy('POST', url, 'api/auth/check', payload=payload)
+                content = self._proxy('POST', url, 'api/auth/check', payload=payload,
+                                      verify=ssl_verify, cert=ssl_certificate)
                 if 'permissions' not in content:
                     return content['detail']
                 user_content = self._proxy('GET', url, f'api/user/{username}',
@@ -210,7 +239,8 @@ class MultiCluster(RESTController):
                     'username': username,
                     'password': password
                 }
-                content = self._proxy('POST', url, 'api/auth', payload=payload)
+                content = self._proxy('POST', url, 'api/auth', payload=payload,
+                                      verify=ssl_verify, cert=ssl_certificate)
                 if 'token' not in content:
                     return content['detail']
                 user_content = self._proxy('GET', url, f'api/user/{username}',
@@ -266,3 +296,13 @@ class MultiClusterUi(RESTController):
     @UpdatePermission
     def set_cors_endpoint(self, url: str):
         configure_cors(url)
+
+
+def _set_prometheus_targets(prometheus_url: str):
+    orch_backend = mgr.get_module_option_ex('orchestrator', 'orchestrator')
+    if orch_backend == 'cephadm':
+        cmd = {
+            'prefix': 'orch prometheus set-target',
+            'url': prometheus_url.replace('http://', '').replace('https://', '')
+        }
+        mgr.mon_command(cmd)
index b639d88262739a70027a409bf0c34404a2ddd8f2..7f5f193f9ab41b67861ddbae853175d795e17578 100644 (file)
@@ -146,6 +146,11 @@ class Prometheus(PrometheusRESTController):
     def delete_silence(self, s_id):
         return self.alert_proxy('DELETE', '/silence/' + s_id) if s_id else None
 
+    @RESTController.Collection(method='GET', path='/prometheus_query_data')
+    def get_prometeus_query_data(self, **params):
+        params['query'] = params.pop('params')
+        return self.prometheus_proxy('GET', '/query', params)
+
 
 @APIRouter('/prometheus/notifications', Scope.PROMETHEUS)
 @APIDoc("Prometheus Notifications Management API", "PrometheusNotifications")
index 7d84939b8807405343274a3c9d96420f40779867..f2eefd826d8a851210a27bb683cb75b8391c64d4 100644 (file)
@@ -7,6 +7,13 @@ export class NavigationPageHelper extends PageHelper {
 
   navigations = [
     { menu: 'Dashboard', component: 'cd-dashboard' },
+    {
+      menu: 'Multi-Cluster',
+      submenus: [
+        { menu: 'Overview', component: 'cd-multi-cluster' },
+        { menu: 'Manage Clusters', component: 'cd-multi-cluster-list' }
+      ]
+    },
     {
       menu: 'Cluster',
       submenus: [
@@ -78,7 +85,11 @@ export class NavigationPageHelper extends PageHelper {
     cy.intercept('/ui-api/block/rbd/status', { fixture: 'block-rbd-status.json' });
 
     navs.forEach((nav: any) => {
-      cy.contains('.simplebar-content li.nav-item a', nav.menu).click();
+      cy.get('.simplebar-content li.nav-item a').each(($link) => {
+        if ($link.text().trim() === nav.menu.trim()) {
+          cy.wrap($link).click();
+        }
+      });
       if (nav.submenus) {
         this.checkNavSubMenu(nav.menu, nav.submenus);
       } else {
@@ -89,8 +100,10 @@ export class NavigationPageHelper extends PageHelper {
 
   checkNavSubMenu(menu: any, submenu: any) {
     submenu.forEach((nav: any) => {
-      cy.contains('.simplebar-content li.nav-item', menu).within(() => {
-        cy.contains(`ul.list-unstyled li a`, nav.menu).click();
+      cy.get('.simplebar-content li.nav-item a').each(($link) => {
+        if ($link.text().trim() === menu.trim()) {
+          cy.contains(`ul.list-unstyled li a`, nav.menu).click();
+        }
       });
     });
   }
index c54681b065f27043e7f4a70a05023ca10e9b00f6..6744e9cf23b35d045f6b87fd431ecad23af79d2a 100644 (file)
@@ -191,10 +191,7 @@ const routes: Routes = [
         children: [
           {
             path: 'overview',
-            component: MultiClusterComponent,
-            data: {
-              breadcrumbs: 'Multi-Cluster/Overview'
-            }
+            component: MultiClusterComponent
           },
           {
             path: 'manage-clusters',
index 2f0734885d857b40a63a0427a6fe8d6903543073..b76189612b8b3622f8b18a82833b3969b0e59416 100644 (file)
@@ -64,6 +64,7 @@ import { UpgradeProgressComponent } from './upgrade/upgrade-progress/upgrade-pro
 import { MultiClusterComponent } from './multi-cluster/multi-cluster.component';
 import { MultiClusterFormComponent } from './multi-cluster/multi-cluster-form/multi-cluster-form.component';
 import { MultiClusterListComponent } from './multi-cluster/multi-cluster-list/multi-cluster-list.component';
+import { DashboardV3Module } from '../dashboard-v3/dashboard-v3.module';
 
 @NgModule({
   imports: [
@@ -84,7 +85,8 @@ import { MultiClusterListComponent } from './multi-cluster/multi-cluster-list/mu
     NgbPopoverModule,
     NgbDropdownModule,
     NgxPipeFunctionModule,
-    NgbProgressbarModule
+    NgbProgressbarModule,
+    DashboardV3Module
   ],
   declarations: [
     HostsComponent,
index c875557306a855f3292b40c3825ff38cadea8423..a2d36e4232aa40030fbce715acb09e371a2129af 100644 (file)
                  i18n>Password
           </label>
           <div class="cd-col-form-input">
-            <input id="password"
-                   name="password"
-                   class="form-control"
-                   type="password"
-                   formControlName="password">
-            <span class="invalid-feedback"
-                  *ngIf="remoteClusterForm.showError('password', frm, 'required')"
-                  i18n>This field is required.
-            </span>
+            <div class="input-group">
+              <input id="password"
+                     name="password"
+                     class="form-control"
+                     type="password"
+                     formControlName="password">
+              <span class="input-group-button">
+                <button type="button"
+                        class="btn btn-light"
+                        cdPasswordButton="password">
+                </button>
+                <cd-copy-2-clipboard-button source="password">
+                </cd-copy-2-clipboard-button>
+              </span>
+              <span class="invalid-feedback"
+                    *ngIf="remoteClusterForm.showError('password', frm, 'required')"
+                    i18n>This field is required.
+              </span>
+            </div>
           </div>
         </div>
         <div class="form-group row"
             </div>
           </div>
         </div>
+        <!-- ssl -->
+        <div class="form-group row">
+          <div class="cd-col-form-offset">
+            <div class="custom-control custom-checkbox">
+              <input class="custom-control-input"
+                     id="ssl"
+                     type="checkbox"
+                     formControlName="ssl">
+              <label class="custom-control-label"
+                     for="ssl"
+                     i18n>SSL</label>
+            </div>
+          </div>
+        </div>
+
+        <!-- ssl_cert -->
+          <div *ngIf="remoteClusterForm.controls.ssl.value"
+               class="form-group row">
+            <label class="cd-col-form-label"
+                   for="ssl_cert">
+              <span i18n>Certificate</span>
+              <cd-helper i18n>The SSL certificate in PEM format.</cd-helper>
+            </label>
+            <div class="cd-col-form-input">
+              <textarea id="ssl_cert"
+                        class="form-control resize-vertical text-monospace text-pre"
+                        formControlName="ssl_cert"
+                        rows="5">
+              </textarea>
+              <input type="file"
+                     (change)="fileUpload($event.target.files, 'ssl_cert')">
+              <span class="invalid-feedback"
+                    *ngIf="remoteClusterForm.showError('ssl_cert', frm, 'required')"
+                    i18n>This field is required.</span>
+              <span class="invalid-feedback"
+                    *ngIf="remoteClusterForm.showError('ssl_cert', frm, 'pattern')"
+                    i18n>Invalid SSL certificate.</span>
+            </div>
+          </div>
         <div class="form-group row"
              *ngIf="!showCrossOriginError && action !== 'edit' && !remoteClusterForm.getValue('showToken')">
           <div class="cd-col-form-offset">
index 83eb9fb5d51ee8b18f5a76a22f3182d5641cf02c..ee39a51d47001132b73b54c489ed60f92f32c8b7 100644 (file)
@@ -1,5 +1,5 @@
 import { Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core';
-import { FormControl, Validators } from '@angular/forms';
+import { AbstractControl, FormControl, Validators } from '@angular/forms';
 import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
 import _ from 'lodash';
 import { Subscription } from 'rxjs';
@@ -50,6 +50,8 @@ export class MultiClusterFormComponent implements OnInit, OnDestroy {
       this.remoteClusterForm.get('remoteClusterUrl').setValue(this.cluster.url);
       this.remoteClusterForm.get('remoteClusterUrl').disable();
       this.remoteClusterForm.get('clusterAlias').setValue(this.cluster.cluster_alias);
+      this.remoteClusterForm.get('ssl').setValue(this.cluster.ssl_verify);
+      this.remoteClusterForm.get('ssl_cert').setValue(this.cluster.ssl_certificate);
     }
     if (this.action === 'reconnect') {
       this.remoteClusterForm.get('remoteClusterUrl').setValue(this.cluster.url);
@@ -60,6 +62,8 @@ export class MultiClusterFormComponent implements OnInit, OnDestroy {
       this.remoteClusterForm.get('username').disable();
       this.remoteClusterForm.get('clusterFsid').setValue(this.cluster.name);
       this.remoteClusterForm.get('clusterFsid').disable();
+      this.remoteClusterForm.get('ssl').setValue(this.cluster.ssl_verify);
+      this.remoteClusterForm.get('ssl_cert').setValue(this.cluster.ssl_certificate);
     }
     [this.clusterAliasNames, this.clusterUrls, this.clusterUsers] = [
       'cluster_alias',
@@ -128,6 +132,14 @@ export class MultiClusterFormComponent implements OnInit, OnDestroy {
             );
           })
         ]
+      }),
+      ssl: new FormControl(false),
+      ssl_cert: new FormControl('', {
+        validators: [
+          CdValidators.requiredIf({
+            ssl: true
+          })
+        ]
       })
     });
   }
@@ -144,6 +156,8 @@ export class MultiClusterFormComponent implements OnInit, OnDestroy {
     const password = this.remoteClusterForm.getValue('password');
     const token = this.remoteClusterForm.getValue('apiToken');
     const clusterFsid = this.remoteClusterForm.getValue('clusterFsid');
+    const ssl = this.remoteClusterForm.getValue('ssl');
+    const ssl_certificate = this.remoteClusterForm.getValue('ssl_cert')?.trim();
 
     if (this.action === 'edit') {
       this.subs.add(
@@ -167,19 +181,21 @@ export class MultiClusterFormComponent implements OnInit, OnDestroy {
 
     if (this.action === 'reconnect') {
       this.subs.add(
-        this.multiClusterService.reConnectCluster(updatedUrl, username, password, token).subscribe({
-          error: () => {
-            this.remoteClusterForm.setErrors({ cdSubmitButton: true });
-          },
-          complete: () => {
-            this.notificationService.show(
-              NotificationType.success,
-              $localize`Cluster reconnected successfully`
-            );
-            this.submitAction.emit();
-            this.activeModal.close();
-          }
-        })
+        this.multiClusterService
+          .reConnectCluster(updatedUrl, username, password, token, ssl, ssl_certificate)
+          .subscribe({
+            error: () => {
+              this.remoteClusterForm.setErrors({ cdSubmitButton: true });
+            },
+            complete: () => {
+              this.notificationService.show(
+                NotificationType.success,
+                $localize`Cluster reconnected successfully`
+              );
+              this.submitAction.emit();
+              this.activeModal.close();
+            }
+          })
       );
     }
 
@@ -193,7 +209,9 @@ export class MultiClusterFormComponent implements OnInit, OnDestroy {
             password,
             token,
             window.location.origin,
-            clusterFsid
+            clusterFsid,
+            ssl,
+            ssl_certificate
           )
           .subscribe({
             error: () => {
@@ -217,10 +235,12 @@ export class MultiClusterFormComponent implements OnInit, OnDestroy {
     const username = this.remoteClusterForm.getValue('username');
     const password = this.remoteClusterForm.getValue('password');
     const token = this.remoteClusterForm.getValue('apiToken');
+    const ssl = this.remoteClusterForm.getValue('ssl');
+    const ssl_certificate = this.remoteClusterForm.getValue('ssl_cert')?.trim();
 
     this.subs.add(
       this.multiClusterService
-        .verifyConnection(url, username, password, token)
+        .verifyConnection(url, username, password, token, ssl, ssl_certificate)
         .subscribe((resp: string) => {
           switch (resp) {
             case 'Connection successful':
@@ -259,4 +279,17 @@ export class MultiClusterFormComponent implements OnInit, OnDestroy {
   toggleToken() {
     this.showToken = !this.showToken;
   }
+
+  fileUpload(files: FileList, controlName: string) {
+    const file: File = files[0];
+    const reader = new FileReader();
+    reader.addEventListener('load', (event: ProgressEvent<FileReader>) => {
+      const control: AbstractControl = this.remoteClusterForm.get(controlName);
+      control.setValue(event.target.result);
+      control.markAsDirty();
+      control.markAsTouched();
+      control.updateValueAndValidity();
+    });
+    reader.readAsText(file, 'utf8');
+  }
 }
index 7aea2f4707610382800d6c38a5e77d45b73f1e1a..74cfc78ab8af49dacf25fd473b067efbda9ecb64 100644 (file)
@@ -29,7 +29,7 @@
              let-row="row">
   <a target="_blank"
      [href]="row.url">
-      {{ row.url.endsWith('/') ? row.url.slice(0, -1) : row.url }}
+      {{ row?.url?.endsWith('/') ? row?.url?.slice(0, -1) : row.url }}
     <i class="fa fa-external-link"></i>
   </a>
 </ng-template>
index c826f155c40bfa532095202fc6769c0f28299651..0542b1868bae3e7243c170d34585bb942835c90b 100644 (file)
@@ -6,7 +6,7 @@
            [ngClass]="icons.wrench">
         </i>
       <div class="mt-4 text-center">
-        <h3><b>Connect Cluster </b></h3>
+        <h3 class="fw-bold">Connect Cluster</h3>
         <h4 class="mt-3">Upgrade your current cluster to a multi-cluster setup effortlessly.
             Click on the "Connect Cluster" button to begin the process.</h4>
       </div>
   </div>
 </ng-template>
 
-<div class="container-fluid h-100 p-4">
-  <div *ngIf="dashboardClustersMap?.size === 1">
-    <ng-container *ngTemplateOutlet="emptyCluster"></ng-container>
-  </div>
-
-  <span *ngIf="loading"
-        class="d-flex justify-content-center">
-    <i [ngClass]="[icons.large3x, icons.spinner, icons.spin]"></i>
-  </span>
-  <div *ngIf="dashboardClustersMap?.size > 1">
-    <div *ngIf="!loading">
+<ng-template #loadingTpl>
+  <div class="container h-75">
+    <div class="row h-100 justify-content-center align-items-center">
+      <div class="blank-page">
+        <i class="mx-auto d-block"
+           [ngClass]="[icons.large3x, icons.spinner, icons.spin]">
+        </i>
+      </div>
     </div>
   </div>
+</ng-template>
+
+<div class="container-fluid h-100 p-4"
+     *ngIf="isMultiCluster; else emptyCluster">
+  <ng-container *ngIf="!loading; else loadingTpl">
+    <cd-card-group>
+      <div class="col-lg-4">
+        <div class="row">
+          <cd-card cardTitle="Clusters"
+                   i18n-title
+                   class="col-sm-6 m-0 p-0 ps-4 pe-2"
+                   aria-label="Clusters"
+                   [fullHeight]="true"
+                   *ngIf="queriesResults.CLUSTER_COUNT && queriesResults.CLUSTER_COUNT[0]">
+            <span class="text-center">
+              <h3 *ngIf="queriesResults['HEALTH_ERROR_COUNT'][0][1] === '0' && queriesResults['HEALTH_WARNING_COUNT'][0][1] === '0'">{{ queriesResults.CLUSTER_COUNT[0][1] }}</h3>
+              <h3 class="text-danger"
+                  *ngIf="queriesResults.HEALTH_ERROR_COUNT[0][1] !== '0'">
+                <i [ngClass]="icons.danger"></i>
+                {{ queriesResults.HEALTH_ERROR_COUNT[0][1] }}
+              </h3>
+              <h3 class="text-warning"
+                  *ngIf="queriesResults.HEALTH_WARNING_COUNT[0][1] !== '0'">
+                <i [ngClass]="icons.warning"></i>
+                  {{ queriesResults.HEALTH_WARNING_COUNT[0][1] }}
+              </h3>
+            </span>
+          </cd-card>
+          <cd-card cardTitle="Alerts"
+                   i18n-title
+                   class="col-sm-6 m-0 p-0 ps-2 pe-2"
+                   aria-label="Alerts"
+                   *ngIf="queriesResults['ALERTS_COUNT'] && queriesResults['ALERTS_COUNT'][0]">
+            <span class="text-center">
+              <h3 *ngIf="queriesResults['CRITICAL_ALERTS_COUNT'][0][1] === '0' && queriesResults['WARNING_ALERTS_COUNT'][0][1] === '0'">
+                  {{ queriesResults['ALERTS_COUNT'][0][1] }}
+              </h3>
+              <h3 class="text-danger"
+                  *ngIf="queriesResults['CRITICAL_ALERTS_COUNT'][0][1] !== '0'">
+                <i [ngClass]="icons.danger"></i>
+                {{ queriesResults['CRITICAL_ALERTS_COUNT'][0][1] }}
+              </h3>
+              <h3 class="text-warning"
+                  *ngIf="queriesResults['WARNING_ALERTS_COUNT'][0][1] !== '0'">
+                <i [ngClass]="icons.warning"></i>
+                  {{ queriesResults['WARNING_ALERTS_COUNT'][0][1] }}
+              </h3>
+            </span>
+          </cd-card>
+        </div>
+        <div class="row pt-3">
+          <cd-card cardTitle="Connection Errors"
+                   i18n-title
+                   class="col-sm-6 m-0 p-0 ps-4 pe-2"
+                   aria-label="Connection Errors">
+            <span class="text-center">
+              <h3 [ngClass]="{'text-danger': connectionErrorsCount > 0}">
+                <i [ngClass]="icons.danger"
+                   *ngIf="connectionErrorsCount > 0"></i>
+                {{ connectionErrorsCount }}
+              </h3>
+            </span>
+          </cd-card>
+          <cd-card cardTitle="Hosts"
+                   i18n-title
+                   class="col-sm-6 m-0 p-0 ps-2 pe-2"
+                   aria-label="Total number of hosts"
+                   *ngIf="queriesResults['TOTAL_HOSTS'][0][1] !== '0'">
+            <span class="text-center">
+              <h3>{{ queriesResults['TOTAL_HOSTS'][0][1] }}</h3>
+            </span>
+          </cd-card>
+        </div>
+
+        <div class="row pt-3">
+          <cd-card cardTitle="Capacity"
+                   i18n-title
+                   class="col-sm-12 m-0 p-0 ps-4 pe-2"
+                   aria-label="Capacity card"
+                   *ngIf="queriesResults['TOTAL_CLUSTERS_CAPACITY'] && queriesResults['TOTAL_CLUSTERS_CAPACITY'][0] && queriesResults['TOTAL_USED_CAPACITY'] && queriesResults['TOTAL_USED_CAPACITY'][0]">
+            <ng-container class="ms-4 me-4">
+              <cd-dashboard-pie [data]="{max: queriesResults['TOTAL_CLUSTERS_CAPACITY'][0][1], current: queriesResults['TOTAL_USED_CAPACITY'][0][1]}"
+                                lowThreshold=".95"
+                                highThreshold=".99">
+              </cd-dashboard-pie>
+            </ng-container>
+          </cd-card>
+        </div>
+      </div>
+
+      <div class="col-sm-8 ps-2">
+        <cd-card [cardTitle]="'Top ' + COUNT_OF_UTILIZATION_CHARTS + ' Cluster Utilization'"
+                 i18n-title
+                 [fullHeight]="true"
+                 aria-label="Cluster Utilization card"
+                 *ngIf="clusters">
+          <div class="ms-4 me-4 mt-0">
+            <cd-dashboard-time-selector (selectedTime)="getPrometheusData($event)">
+            </cd-dashboard-time-selector>
+            <cd-dashboard-area-chart  chartTitle="Capacity"
+                                      [labelsArray]="capacityLabels"
+                                      dataUnits="B"
+                                      [dataArray]="capacityValues"
+                                      [truncateLabel]="true"
+                                      *ngIf="capacityLabels && capacityValues">
+            </cd-dashboard-area-chart>
+            <cd-dashboard-area-chart chartTitle="IOPS"
+                                     [labelsArray]="iopsLabels"
+                                     dataUnits=""
+                                     decimals="0"
+                                     [dataArray]="iopsValues"
+                                     [truncateLabel]="true"
+                                     *ngIf="iopsLabels && iopsValues">
+            </cd-dashboard-area-chart>
+            <cd-dashboard-area-chart chartTitle="Throughput"
+                                     [labelsArray]="throughputLabels"
+                                     dataUnits="B/s"
+                                     decimals="2"
+                                     [dataArray]="throughputValues"
+                                     [truncateLabel]="true"
+                                     *ngIf="throughputLabels && throughputLabels">
+            </cd-dashboard-area-chart>
+          </div>
+        </cd-card>
+      </div>
+    </cd-card-group>
+
+    <cd-card-group>
+      <div class="col-lg-12 mt-3 m-0 p-0 ps-4 pe-4">
+        <cd-table [data]="clusters"
+                  [columns]="columns"
+                  [limit]="5"
+                  *ngIf="clusters">
+        </cd-table>
+      </div>
+    </cd-card-group>
+
+    <cd-card-group>
+      <div class="col-lg-12 mb-4 m-0 p-0 ps-4 pe-4">
+        <div class="row">
+          <cd-card [cardTitle]="'Top ' + COUNT_OF_UTILIZATION_CHARTS + ' Pools Utilization'"
+                   i18n-title
+                   aria-label="Pools Utilization card"
+                   *ngIf="clusters">
+            <div class="ms-4 me-4 mt-0">
+              <cd-dashboard-time-selector (selectedTime)="getPrometheusData($event)">
+              </cd-dashboard-time-selector>
+              <cd-dashboard-area-chart  chartTitle="Capacity"
+                                        [labelsArray]="poolCapacityLabels"
+                                        dataUnits="B"
+                                        [dataArray]="poolCapacityValues"
+                                        *ngIf="poolCapacityLabels && poolCapacityValues"
+                                        [truncateLabel]="true">
+              </cd-dashboard-area-chart>
+              <cd-dashboard-area-chart chartTitle="IOPS"
+                                       [labelsArray]="poolIOPSLabels"
+                                       dataUnits=""
+                                       decimals="0"
+                                       [dataArray]="poolIOPSValues"
+                                       *ngIf="poolIOPSLabels && poolIOPSValues"
+                                       [truncateLabel]="true">
+              </cd-dashboard-area-chart>
+              <cd-dashboard-area-chart chartTitle="Client Throughput"
+                                       [labelsArray]="poolThroughputLabels"
+                                       dataUnits="B/s"
+                                       decimals="2"
+                                       [dataArray]="poolThroughputValues"
+                                       *ngIf="poolThroughputLabels && poolThroughputValues"
+                                       [truncateLabel]="true">
+              </cd-dashboard-area-chart>
+            </div>
+          </cd-card>
+        </div>
+      </div>
+    </cd-card-group>
+  </ng-container>
 </div>
index 8db81cd790fc68a0e05f28a7978e6ccd2930b3d5..ad210968aa5be2be802837253873492f8b1b3144 100644 (file)
@@ -2,6 +2,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
 import { HttpClientTestingModule } from '@angular/common/http/testing';
 import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
 import { MultiClusterComponent } from './multi-cluster.component';
+import { SharedModule } from '~/app/shared/shared.module';
+import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
 
 describe('MultiClusterComponent', () => {
   let component: MultiClusterComponent;
@@ -9,9 +11,9 @@ describe('MultiClusterComponent', () => {
 
   beforeEach(async () => {
     await TestBed.configureTestingModule({
-      imports: [HttpClientTestingModule],
+      imports: [HttpClientTestingModule, SharedModule],
       declarations: [MultiClusterComponent],
-      providers: [NgbActiveModal]
+      providers: [NgbActiveModal, DimlessBinaryPipe]
     }).compileComponents();
 
     fixture = TestBed.createComponent(MultiClusterComponent);
index dbbf10e74848bd1b19b9dc9b43c4fc3aa2f12dd2..ab8b413e73623d5e557424fc499e42c6839cb88e 100644 (file)
@@ -5,6 +5,11 @@ import { MultiClusterService } from '~/app/shared/api/multi-cluster.service';
 import { Icons } from '~/app/shared/enum/icons.enum';
 import { ModalService } from '~/app/shared/services/modal.service';
 import { MultiClusterFormComponent } from './multi-cluster-form/multi-cluster-form.component';
+import { PrometheusService } from '~/app/shared/api/prometheus.service';
+import { MultiClusterPromqls as queries } from '~/app/shared/enum/dashboard-promqls.enum';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { CellTemplate } from '~/app/shared/enum/cell-template.enum';
+import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
 
 @Component({
   selector: 'cd-multi-cluster',
@@ -12,41 +17,150 @@ import { MultiClusterFormComponent } from './multi-cluster-form/multi-cluster-fo
   styleUrls: ['./multi-cluster.component.scss']
 })
 export class MultiClusterComponent implements OnInit {
+  COUNT_OF_UTILIZATION_CHARTS = 5;
+
   @ViewChild('nameTpl', { static: true })
   nameTpl: any;
 
+  columns: Array<CdTableColumn> = [];
+
+  queriesResults: any = {
+    ALERTS_COUNT: 0,
+    CLUSTER_COUNT: 0,
+    HEALTH_OK_COUNT: 0,
+    HEALTH_WARNING_COUNT: 0,
+    HEALTH_ERROR_COUNT: 0,
+    TOTAL_CLUSTERS_CAPACITY: 0,
+    TOTAL_USED_CAPACITY: 0,
+    CLUSTER_CAPACITY_UTILIZATION: 0,
+    CLUSTER_IOPS_UTILIZATION: 0,
+    CLUSTER_THROUGHPUT_UTILIZATION: 0,
+    POOL_CAPACITY_UTILIZATION: 0,
+    POOL_IOPS_UTILIZATION: 0,
+    POOL_THROUGHPUT_UTILIZATION: 0,
+    TOTAL_CAPACITY: 0,
+    USED_CAPACITY: 0,
+    HOSTS: 0,
+    POOLS: 0,
+    OSDS: 0,
+    CLUSTER_ALERTS: 0,
+    version: ''
+  };
+  alerts: any;
+
   private subs = new Subscription();
   dashboardClustersMap: Map<string, string> = new Map<string, string>();
   icons = Icons;
   loading = true;
   bsModalRef: NgbModalRef;
+  isMultiCluster = true;
+  clusterTokenStatus: object = {};
+  localClusterName: string;
+  clusters: any;
+  connectionErrorsCount = 0;
+
+  capacityLabels: string[] = [];
+  iopsLabels: string[] = [];
+  throughputLabels: string[] = [];
+  poolIOPSLabels: string[] = [];
+  poolCapacityLabels: string[] = [];
+  poolThroughputLabels: string[] = [];
+
+  capacityValues: string[] = [];
+  iopsValues: string[] = [];
+  throughputValues: string[] = [];
+  poolIOPSValues: string[] = [];
+  poolCapacityValues: string[] = [];
+  poolThroughputValues: string[] = [];
 
   constructor(
     private multiClusterService: MultiClusterService,
-    private modalService: ModalService
+    private modalService: ModalService,
+    private prometheusService: PrometheusService,
+    private dimlessBinaryPipe: DimlessBinaryPipe
   ) {}
 
   ngOnInit(): void {
+    this.columns = [
+      {
+        prop: 'cluster',
+        name: $localize`Cluster Name`,
+        flexGrow: 2,
+        cellTemplate: this.nameTpl
+      },
+      {
+        prop: 'cluster_connection_status',
+        name: $localize`Connection`,
+        flexGrow: 2,
+        cellTransformation: CellTemplate.badge,
+        customTemplateConfig: {
+          map: {
+            1: { value: 'DISCONNECTED', class: 'badge-danger' },
+            0: { value: 'CONNECTED', class: 'badge-success' },
+            2: { value: 'CHECKING..', class: 'badge-info' }
+          }
+        }
+      },
+      {
+        prop: 'status',
+        name: $localize`Status`,
+        flexGrow: 1,
+        cellTransformation: CellTemplate.badge,
+        customTemplateConfig: {
+          map: {
+            1: { value: 'WARN', class: 'badge-warning' },
+            0: { value: 'OK', class: 'badge-success' },
+            2: { value: 'ERROR', class: 'badge-danger' }
+          }
+        }
+      },
+      { prop: 'alert', name: $localize`Alerts`, flexGrow: 1 },
+      { prop: 'version', name: $localize`Version`, flexGrow: 2 },
+      {
+        prop: 'total_capacity',
+        name: $localize`Total Capacity`,
+        pipe: this.dimlessBinaryPipe,
+        flexGrow: 1
+      },
+      {
+        prop: 'used_capacity',
+        name: $localize`Used Capacity`,
+        pipe: this.dimlessBinaryPipe,
+        flexGrow: 1
+      },
+      {
+        prop: 'available_capacity',
+        name: $localize`Available Capacity`,
+        pipe: this.dimlessBinaryPipe,
+        flexGrow: 1
+      },
+      { prop: 'pools', name: $localize`Pools`, flexGrow: 1 },
+      { prop: 'hosts', name: $localize`Hosts`, flexGrow: 1 },
+      { prop: 'osds', name: $localize`OSDs`, flexGrow: 1 }
+    ];
+
     this.subs.add(
       this.multiClusterService.subscribe((resp: any) => {
-        const clustersConfig = resp['config'];
-        if (clustersConfig) {
-          Object.keys(clustersConfig).forEach((clusterKey: string) => {
-            const clusterDetailsList = clustersConfig[clusterKey];
-
-            clusterDetailsList.forEach((clusterDetails: any) => {
-              const clusterUrl = clusterDetails['url'];
-              const clusterName = clusterDetails['name'];
-              this.dashboardClustersMap.set(clusterUrl, clusterName);
-            });
-          });
-
-          if (this.dashboardClustersMap.size >= 1) {
-            this.loading = false;
+        this.isMultiCluster = Object.keys(resp['config']).length > 1;
+        const hubUrl = resp['hub_url'];
+        for (const key in resp['config']) {
+          if (resp['config'].hasOwnProperty(key)) {
+            const cluster = resp['config'][key][0];
+            if (hubUrl === cluster.url) {
+              this.localClusterName = cluster.name;
+              break;
+            }
           }
         }
       })
     );
+
+    this.subs.add(
+      this.multiClusterService.subscribeClusterTokenStatus((resp: object) => {
+        this.clusterTokenStatus = resp;
+      })
+    );
+    this.getPrometheusData(this.prometheusService.lastHourDateObject);
   }
 
   openRemoteClusterInfoModal() {
@@ -54,7 +168,165 @@ export class MultiClusterComponent implements OnInit {
       action: 'connect'
     };
     this.bsModalRef = this.modalService.show(MultiClusterFormComponent, initialState, {
-      size: 'xl'
+      size: 'lg'
+    });
+  }
+
+  getPrometheusData(selectedTime: any) {
+    this.prometheusService
+      .getMultiClusterQueriesData(selectedTime, queries, this.queriesResults)
+      .subscribe((data: any) => {
+        this.queriesResults = data;
+        this.loading = false;
+        this.alerts = this.queriesResults.ALERTS;
+        this.getAlertsInfo();
+        this.getClustersInfo();
+      });
+  }
+
+  getAlertsInfo() {
+    interface Alert {
+      alertName: string;
+      alertState: string;
+      severity: string;
+      cluster: string;
+    }
+
+    const alerts: Alert[] = [];
+
+    this.alerts?.forEach((item: any) => {
+      const metric = item.metric;
+      const alert: Alert = {
+        alertName: metric.alertname,
+        cluster: metric.cluster,
+        alertState: metric.alertstate,
+        severity: metric.severity
+      };
+      alerts.push(alert);
+    });
+
+    this.alerts = alerts;
+  }
+
+  getClustersInfo() {
+    interface ClusterInfo {
+      cluster: string;
+      status: number;
+      alert: number;
+      total_capacity: number;
+      used_capacity: number;
+      available_capacity: number;
+      pools: number;
+      osds: number;
+      hosts: number;
+      version: string;
+      cluster_connection_status: number;
+    }
+
+    const clusters: ClusterInfo[] = [];
+
+    this.queriesResults.TOTAL_CAPACITY?.forEach((totalCapacityMetric: any) => {
+      const clusterName = totalCapacityMetric.metric.cluster;
+      const totalCapacity = parseInt(totalCapacityMetric.value[1]);
+      const getMgrMetadata = this.findCluster(this.queriesResults?.MGR_METADATA, clusterName);
+      const version = this.getVersion(getMgrMetadata.metric.ceph_version);
+
+      const usedCapacity = this.findClusterData(this.queriesResults?.USED_CAPACITY, clusterName);
+      const pools = this.findClusterData(this.queriesResults?.POOLS, clusterName);
+      const hosts = this.findClusterData(this.queriesResults?.HOSTS, clusterName);
+      const alert = this.findClusterData(this.queriesResults?.CLUSTER_ALERTS, clusterName);
+      const osds = this.findClusterData(this.queriesResults?.OSDS, clusterName);
+      const status = this.findClusterData(this.queriesResults?.HEALTH_STATUS, clusterName);
+      const available_capacity = totalCapacity - usedCapacity;
+
+      clusters.push({
+        cluster: clusterName,
+        status,
+        alert,
+        total_capacity: totalCapacity,
+        used_capacity: usedCapacity,
+        available_capacity: available_capacity,
+        pools,
+        osds,
+        hosts,
+        version,
+        cluster_connection_status: 2
+      });
     });
+
+    if (this.clusterTokenStatus) {
+      clusters.forEach((cluster: any) => {
+        cluster.cluster_connection_status = this.clusterTokenStatus[cluster.cluster]?.status;
+        if (cluster.cluster === this.localClusterName) {
+          cluster.cluster_connection_status = 0;
+        }
+      });
+      this.connectionErrorsCount = clusters.filter(
+        (cluster) => cluster.cluster_connection_status === 1
+      ).length;
+    }
+
+    this.clusters = clusters;
+
+    // Generate labels and metrics for utilization charts
+    this.capacityLabels = this.generateQueryLabel(this.queriesResults.CLUSTER_CAPACITY_UTILIZATION);
+    this.iopsLabels = this.generateQueryLabel(this.queriesResults.CLUSTER_IOPS_UTILIZATION);
+    this.throughputLabels = this.generateQueryLabel(
+      this.queriesResults.CLUSTER_THROUGHPUT_UTILIZATION
+    );
+    this.poolCapacityLabels = this.generateQueryLabel(
+      this.queriesResults.POOL_CAPACITY_UTILIZATION,
+      true
+    );
+    this.poolIOPSLabels = this.generateQueryLabel(this.queriesResults.POOL_IOPS_UTILIZATION, true);
+    this.poolThroughputLabels = this.generateQueryLabel(
+      this.queriesResults.POOL_THROUGHPUT_UTILIZATION,
+      true
+    );
+
+    this.capacityValues = this.getQueryValues(this.queriesResults.CLUSTER_CAPACITY_UTILIZATION);
+    this.iopsValues = this.getQueryValues(this.queriesResults.CLUSTER_IOPS_UTILIZATION);
+    this.throughputValues = this.getQueryValues(this.queriesResults.CLUSTER_THROUGHPUT_UTILIZATION);
+    this.poolCapacityValues = this.getQueryValues(this.queriesResults.POOL_CAPACITY_UTILIZATION);
+    this.poolIOPSValues = this.getQueryValues(this.queriesResults.POOL_IOPS_UTILIZATION);
+    this.poolThroughputValues = this.getQueryValues(
+      this.queriesResults.POOL_THROUGHPUT_UTILIZATION
+    );
+  }
+
+  findClusterData(metrics: any, clusterName: string) {
+    const clusterMetrics = this.findCluster(metrics, clusterName);
+    return parseInt(clusterMetrics?.value[1] || 0);
+  }
+
+  findCluster(metrics: any, clusterName: string) {
+    return metrics.find((metric: any) => metric?.metric?.cluster === clusterName);
+  }
+
+  getVersion(fullVersion: string) {
+    const version = fullVersion.replace('ceph version ', '').split(' ');
+    return version[0] + ' ' + version.slice(2, version.length).join(' ');
+  }
+
+  generateQueryLabel(query: any, name = false, count = this.COUNT_OF_UTILIZATION_CHARTS) {
+    let labels = [];
+    for (let i = 0; i < count; i++) {
+      let label = '';
+      if (query[i]) {
+        label = query[i]?.metric?.cluster;
+        if (name) label = query[i]?.metric?.name + ' - ' + label;
+      }
+      labels.push(label);
+    }
+    // console.log(labels)
+    return labels;
+  }
+
+  getQueryValues(query: any, count = this.COUNT_OF_UTILIZATION_CHARTS) {
+    let values = [];
+    for (let i = 0; i < count; i++) {
+      if (query[i]) values.push(query[i]?.values);
+    }
+    return values;
   }
 }
index 6151843e4e01d743edcf73ad4cc7bb234ee29976..2b4878e995d2a14c92a86efa3c76e9d8be977156 100644 (file)
         <div class="box"
              [style.background-color]="data.pointBackgroundColor">
         </div>
-        <div *ngIf="!chartTitle.includes(data.label)">{{ data.label }}:</div>
-          {{ data?.currentData || 'N/A' }} {{ data?.currentDataUnits }}
+        <ng-container *ngIf="!chartTitle.includes(data.label)">
+          <span [ngClass]="{'d-inline-block text-truncate': truncateLabel}"
+                [ngStyle]="{'width': truncateLabel ? '10rem' : 'auto'}"
+                [title]="data.label">{{ data.label }}</span>:
+        </ng-container>
+        <span>{{ data?.currentData || 'N/A' }} {{ data?.currentDataUnits }}</span>
         <div *ngIf="maxValue && data.currentData">
           used of {{ maxConvertedValue }} {{ maxConvertedValueUnits }}
         </div>
index ac0b9ac2ff431d6fcb7b52899846efdccc5d4e59..607a3b7d51ad7221334e35b639f475b5f9b8e8a5 100644 (file)
@@ -29,6 +29,8 @@ export class DashboardAreaChartComponent implements OnChanges {
   labelsArray?: string[] = []; // Array of chart labels
   @Input()
   decimals?: number = 1;
+  @Input()
+  truncateLabel = false;
 
   currentDataUnits: string;
   currentData: number;
@@ -201,8 +203,8 @@ export class DashboardAreaChartComponent implements OnChanges {
       this.currentChartData = this.chartData;
       for (let index = 0; index < this.dataArray.length; index++) {
         this.chartData.dataset[index].data = this.formatData(this.dataArray[index]);
-        let currentDataValue = this.dataArray[index][this.dataArray[index].length - 1]
-          ? this.dataArray[index][this.dataArray[index].length - 1][1]
+        let currentDataValue = this.dataArray?.[index]?.[this.dataArray[index]?.length - 1]
+          ? this.dataArray[index][this.dataArray[index]?.length - 1][1]
           : 0;
         if (currentDataValue) {
           [
index 73b4f9fa840fb02535242500dde0ddbdbf62ccd3..82843289b3834a0b0be73d8804b17b0ed26c3855 100644 (file)
@@ -38,6 +38,11 @@ import { PgSummaryPipe } from './pg-summary.pipe';
     DashboardTimeSelectorComponent
   ],
 
-  exports: [DashboardV3Component, DashboardAreaChartComponent, DashboardTimeSelectorComponent]
+  exports: [
+    DashboardV3Component,
+    DashboardAreaChartComponent,
+    DashboardTimeSelectorComponent,
+    DashboardPieComponent
+  ]
 })
 export class DashboardV3Module {}
index 1c1846dae15eafa425fbc4e93d55fcf55e1611c7..2b3c82bfe20be0c7b1e01a05e4cd7fe944aca4f3 100644 (file)
@@ -1,7 +1,7 @@
 <block-ui>
   <cd-navigation>
     <div class="container-fluid h-100"
-         [ngClass]="{'dashboard': (router.url == '/dashboard' || router.url == '/dashboard_3' || router.url == '/multi-cluster'), 'rgw-dashboard': (router.url == '/rgw/overview')}">
+         [ngClass]="{'dashboard': (router.url == '/dashboard' || router.url == '/dashboard_3' || router.url == '/multi-cluster/overview'), 'rgw-dashboard': (router.url == '/rgw/overview')}">
     <cd-context></cd-context>
       <cd-breadcrumbs></cd-breadcrumbs>
       <router-outlet></router-outlet>
index 7252e969e60ae490cea37799df508085705d13b0..ffb312de4d9d723c4f4f34ae010afe8681c0f145 100644 (file)
@@ -94,7 +94,9 @@ export class MultiClusterService {
     password: string,
     token = '',
     hub_url = '',
-    clusterFsid = ''
+    clusterFsid = '',
+    ssl = false,
+    cert = ''
   ) {
     return this.http.post('api/multi-cluster/auth', {
       url,
@@ -103,27 +105,46 @@ export class MultiClusterService {
       password,
       token,
       hub_url,
-      cluster_fsid: clusterFsid
+      cluster_fsid: clusterFsid,
+      ssl_verify: ssl,
+      ssl_certificate: cert
     });
   }
 
-  reConnectCluster(url: any, username: string, password: string, token = '') {
-    return this.http.put('api/multi-cluster/reconnect_cluster', {
+  reConnectCluster(
+    url: any,
+    username: string,
+    password: string,
+    token = '',
+    ssl = false,
+    cert = ''
+  ) {
+    return this.http.post('api/multi-cluster/reconnect_cluster', {
       url,
       username,
       password,
-      token
+      token,
+      ssl_verify: ssl,
+      ssl_certificate: cert
     });
   }
 
-  verifyConnection(url: string, username: string, password: string, token = ''): Observable<any> {
-    let params = new HttpParams()
-      .set('url', url)
-      .set('username', username)
-      .set('password', password)
-      .set('token', token);
-
-    return this.http.get('api/multi-cluster/verify_connection', { params });
+  verifyConnection(
+    url: string,
+    username: string,
+    password: string,
+    token = '',
+    ssl = false,
+    cert = ''
+  ): Observable<any> {
+    return this.http.post('api/multi-cluster/verify_connection', {
+      url: url,
+      username: username,
+      password: password,
+      token: token,
+      ssl_verify: ssl,
+      ssl_certificate: cert
+    });
   }
 
   private getClusterObserver() {
index e1aa7a07cafc223150ac7951c5ead88e76f6b335..b7db0bc2f3cca79f76040f318c56aaeeb52a076d 100644 (file)
@@ -1,7 +1,7 @@
 import { HttpClient } from '@angular/common/http';
 import { Injectable } from '@angular/core';
 
-import { Observable, Subscription, timer } from 'rxjs';
+import { Observable, Subscription, forkJoin, timer } from 'rxjs';
 import { map } from 'rxjs/operators';
 
 import { AlertmanagerSilence } from '../models/alertmanager-silence';
@@ -191,4 +191,99 @@ export class PrometheusService {
     };
     return formattedDate;
   }
+
+  getMultiClusterData(params: any): any {
+    return this.http.get<any>(`${this.baseURL}/prometheus_query_data`, { params });
+  }
+
+  getMultiClusterQueryRangeData(params: any): any {
+    return this.http.get<any>(`${this.baseURL}/data`, { params });
+  }
+
+  getMultiClusterQueriesData(selectedTime: any, queries: any, queriesResults: any) {
+    return new Observable((observer) => {
+      this.ifPrometheusConfigured(() => {
+        if (this.timerGetPrometheusDataSub) {
+          this.timerGetPrometheusDataSub.unsubscribe();
+        }
+
+        this.timerGetPrometheusDataSub = timer(0, this.timerTime).subscribe(() => {
+          selectedTime = this.updateTimeStamp(selectedTime);
+
+          const requests = [];
+          for (const queryName in queries) {
+            if (queries.hasOwnProperty(queryName)) {
+              const validRangeQueries1 = [
+                'CLUSTER_CAPACITY_UTILIZATION',
+                'CLUSTER_IOPS_UTILIZATION',
+                'CLUSTER_THROUGHPUT_UTILIZATION',
+                'POOL_CAPACITY_UTILIZATION',
+                'POOL_IOPS_UTILIZATION',
+                'POOL_THROUGHPUT_UTILIZATION'
+              ];
+              if (validRangeQueries1.includes(queryName)) {
+                const query = queries[queryName];
+                const request = this.getMultiClusterQueryRangeData({
+                  params: encodeURIComponent(query),
+                  start: selectedTime['start'],
+                  end: selectedTime['end'],
+                  step: selectedTime['step']
+                });
+                requests.push(request);
+              } else {
+                const query = queries[queryName];
+                const request = this.getMultiClusterData({
+                  params: encodeURIComponent(query),
+                  start: selectedTime['start'],
+                  end: selectedTime['end'],
+                  step: selectedTime['step']
+                });
+                requests.push(request);
+              }
+            }
+          }
+
+          forkJoin(requests).subscribe(
+            (responses: any[]) => {
+              for (let i = 0; i < responses.length; i++) {
+                const data = responses[i];
+                const queryName = Object.keys(queries)[i];
+                const validQueries = [
+                  'ALERTS',
+                  'MGR_METADATA',
+                  'HEALTH_STATUS',
+                  'TOTAL_CAPACITY',
+                  'USED_CAPACITY',
+                  'POOLS',
+                  'OSDS',
+                  'CLUSTER_CAPACITY_UTILIZATION',
+                  'CLUSTER_IOPS_UTILIZATION',
+                  'CLUSTER_THROUGHPUT_UTILIZATION',
+                  'POOL_CAPACITY_UTILIZATION',
+                  'POOL_IOPS_UTILIZATION',
+                  'POOL_THROUGHPUT_UTILIZATION',
+                  'HOSTS',
+                  'CLUSTER_ALERTS'
+                ];
+                if (data.result.length) {
+                  if (validQueries.includes(queryName)) {
+                    queriesResults[queryName] = data.result;
+                  } else {
+                    queriesResults[queryName] = data.result.map(
+                      (result: { value: any }) => result.value
+                    );
+                  }
+                }
+              }
+              observer.next(queriesResults);
+              observer.complete();
+            },
+            (error: Error) => {
+              observer.error(error);
+            }
+          );
+        });
+      });
+    });
+  }
 }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/card-group/card-group.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/card-group/card-group.component.html
new file mode 100644 (file)
index 0000000..04ecabf
--- /dev/null
@@ -0,0 +1,10 @@
+<div class="row"
+     *ngIf="groupTitle">
+  <div class="info-group-title">
+    <span i18n>{{ groupTitle }}</span>
+  </div>
+</div>
+
+<div class="row">
+  <ng-content></ng-content>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/card-group/card-group.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/card-group/card-group.component.scss
new file mode 100644 (file)
index 0000000..b30e1a7
--- /dev/null
@@ -0,0 +1,4 @@
+.info-group-title {
+  font-size: 1.75rem;
+  margin: 0 0 0.5vw;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/card-group/card-group.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/card-group/card-group.component.spec.ts
new file mode 100644 (file)
index 0000000..35c7955
--- /dev/null
@@ -0,0 +1,22 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { CardGroupComponent } from './card-group.component';
+
+describe('CardGroupComponent', () => {
+  let component: CardGroupComponent;
+  let fixture: ComponentFixture<CardGroupComponent>;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      declarations: [CardGroupComponent]
+    }).compileComponents();
+
+    fixture = TestBed.createComponent(CardGroupComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/card-group/card-group.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/card-group/card-group.component.ts
new file mode 100644 (file)
index 0000000..c7de8ca
--- /dev/null
@@ -0,0 +1,11 @@
+import { Component, Input } from '@angular/core';
+
+@Component({
+  selector: 'cd-card-group',
+  templateUrl: './card-group.component.html',
+  styleUrls: ['./card-group.component.scss']
+})
+export class CardGroupComponent {
+  @Input()
+  groupTitle = '';
+}
index 5b533f1cddb305c5677e0b082d8929548ece199e..867ef3b16d87bb9b4cf0f600b51cb2c7e35a9c6a 100644 (file)
@@ -53,6 +53,7 @@ import { CardComponent } from './card/card.component';
 import { CardRowComponent } from './card-row/card-row.component';
 import { CodeBlockComponent } from './code-block/code-block.component';
 import { VerticalNavigationComponent } from './vertical-navigation/vertical-navigation.component';
+import { CardGroupComponent } from './card-group/card-group.component';
 
 @NgModule({
   imports: [
@@ -109,7 +110,8 @@ import { VerticalNavigationComponent } from './vertical-navigation/vertical-navi
     CardComponent,
     CardRowComponent,
     CodeBlockComponent,
-    VerticalNavigationComponent
+    VerticalNavigationComponent,
+    CardGroupComponent
   ],
   providers: [],
   exports: [
@@ -143,7 +145,8 @@ import { VerticalNavigationComponent } from './vertical-navigation/vertical-navi
     CardComponent,
     CardRowComponent,
     CodeBlockComponent,
-    VerticalNavigationComponent
+    VerticalNavigationComponent,
+    CardGroupComponent
   ]
 })
 export class ComponentsModule {}
index 2d8aa22819dc4fee5f12c92e0b0a0f6b5aba3372..9a85d108a6b566f17b8e4cf58828b15faa4ac2d2 100644 (file)
@@ -16,3 +16,31 @@ export enum RgwPromqls {
   GET_BANDWIDTH = 'sum(rate(ceph_rgw_op_get_obj_bytes[1m]))',
   PUT_BANDWIDTH = 'sum(rate(ceph_rgw_op_put_obj_bytes[1m]))'
 }
+
+export enum MultiClusterPromqls {
+  ALERTS_COUNT = 'count(ALERTS{alertstate="firing"}) or vector(0)',
+  CLUSTER_COUNT = 'count(ceph_health_status) or vector(0)',
+  HEALTH_OK_COUNT = 'count(ceph_health_status==0) or vector(0)',
+  HEALTH_WARNING_COUNT = 'count(ceph_health_status==1) or vector(0)',
+  HEALTH_ERROR_COUNT = 'count(ceph_health_status==2) or vector(0)',
+  TOTAL_CLUSTERS_CAPACITY = 'sum(ceph_cluster_total_bytes) or vector(0)',
+  TOTAL_USED_CAPACITY = 'sum(ceph_cluster_by_class_total_used_bytes) or vector(0)',
+  HEALTH_STATUS = 'ceph_health_status',
+  MGR_METADATA = 'ceph_mgr_metadata',
+  TOTAL_CAPACITY = 'ceph_cluster_total_bytes',
+  USED_CAPACITY = 'ceph_cluster_total_used_bytes',
+  POOLS = 'count by (cluster) (ceph_pool_metadata) or vector(0)',
+  OSDS = 'count by (cluster) (ceph_osd_metadata) or vector(0)',
+  CRITICAL_ALERTS_COUNT = 'count(ALERTS{alertstate="firing",severity="critical"}) or vector(0)',
+  WARNING_ALERTS_COUNT = 'count(ALERTS{alertstate="firing",severity="warning"}) or vector(0)',
+  ALERTS = 'ALERTS{alertstate="firing"}',
+  HOSTS = 'sum by (hostname, cluster) (group by (hostname, cluster) (ceph_osd_metadata)) or vector(0)',
+  TOTAL_HOSTS = 'count by (cluster) (ceph_osd_metadata) or vector(0)',
+  CLUSTER_ALERTS = 'count by (cluster) (ALERTS{alertstate="firing"}) or vector(0)',
+  CLUSTER_CAPACITY_UTILIZATION = 'topk(2, ceph_cluster_total_used_bytes)',
+  CLUSTER_IOPS_UTILIZATION = 'topk(2, sum by (cluster) (rate(ceph_pool_wr[1m])) + sum by (cluster) (rate(ceph_pool_rd[1m])) )',
+  CLUSTER_THROUGHPUT_UTILIZATION = 'topk(2, sum by (cluster) (rate(ceph_pool_wr_bytes[1m])) + sum by (cluster) (rate(ceph_pool_rd_bytes[1m])) )',
+  POOL_CAPACITY_UTILIZATION = 'topk(2, ceph_pool_bytes_used/ceph_pool_max_avail * on(pool_id, cluster) group_left(instance, name) ceph_pool_metadata)',
+  POOL_IOPS_UTILIZATION = 'topk(2, (rate(ceph_pool_rd[1m]) + rate(ceph_pool_wr[1m])) * on(pool_id, cluster) group_left(instance, name) ceph_pool_metadata )',
+  POOL_THROUGHPUT_UTILIZATION = 'topk(2, (irate(ceph_pool_rd_bytes[1m]) + irate(ceph_pool_wr_bytes[1m])) * on(pool_id, cluster) group_left(instance, name) ceph_pool_metadata )'
+}
index ce4e02603f833d69bbb1e583c45455cfa58cc211..329aefb592eb5a5cf678f1d28cacd7f30884cf93 100644 (file)
@@ -5,4 +5,6 @@ export interface MultiCluster {
   token: string;
   cluster_alias: string;
   cluster_connection_status: number;
+  ssl_verify: boolean;
+  ssl_certificate: string;
 }
index 4e58517a9636fa854075216cae8cc4c024027565..21f5ef86000aa315d8b23ad2a83bec8e5ff5cdcf 100644 (file)
@@ -6974,6 +6974,13 @@ paths:
                   type: string
                 password:
                   type: string
+                prometheus_api_url:
+                  type: string
+                ssl_certificate:
+                  type: string
+                ssl_verify:
+                  default: false
+                  type: boolean
                 token:
                   type: string
                 url:
@@ -6983,6 +6990,7 @@ paths:
               required:
               - url
               - cluster_alias
+              - username
               type: object
       responses:
         '201':
@@ -7148,6 +7156,11 @@ paths:
               properties:
                 password:
                   type: string
+                ssl_certificate:
+                  type: string
+                ssl_verify:
+                  default: false
+                  type: boolean
                 token:
                   type: string
                 url:
@@ -7219,34 +7232,38 @@ paths:
       tags:
       - Multi-cluster
   /api/multi-cluster/verify_connection:
-    get:
-      parameters:
-      - allowEmptyValue: true
-        in: query
-        name: url
-        schema:
-          type: string
-      - allowEmptyValue: true
-        in: query
-        name: username
-        schema:
-          type: string
-      - allowEmptyValue: true
-        in: query
-        name: password
-        schema:
-          type: string
-      - allowEmptyValue: true
-        in: query
-        name: token
-        schema:
-          type: string
+    post:
+      parameters: []
+      requestBody:
+        content:
+          application/json:
+            schema:
+              properties:
+                password:
+                  type: string
+                ssl_certificate:
+                  type: string
+                ssl_verify:
+                  default: false
+                  type: boolean
+                token:
+                  type: string
+                url:
+                  type: string
+                username:
+                  type: string
+              type: object
       responses:
-        '200':
+        '201':
           content:
             application/vnd.ceph.api.v1.0+json:
               type: object
-          description: OK
+          description: Resource created.
+        '202':
+          content:
+            application/vnd.ceph.api.v1.0+json:
+              type: object
+          description: Operation is still executing. Please check the task queue.
         '400':
           description: Operation exception. Please check the response body for details.
         '401':
@@ -10017,6 +10034,28 @@ paths:
       - jwt: []
       tags:
       - PrometheusNotifications
+  /api/prometheus/prometheus_query_data:
+    get:
+      parameters: []
+      responses:
+        '200':
+          content:
+            application/vnd.ceph.api.v1.0+json:
+              type: object
+          description: OK
+        '400':
+          description: Operation exception. Please check the response body for details.
+        '401':
+          description: Unauthenticated access. Please login first.
+        '403':
+          description: Unauthorized access. Please check your permissions.
+        '500':
+          description: Unexpected error. Please check the response body for the stack
+            trace.
+      security:
+      - jwt: []
+      tags:
+      - Prometheus
   /api/prometheus/rules:
     get:
       parameters: []