]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: add multi-cluster management using context sitcher in
authorAashish Sharma <aasharma@li-e74156cc-2f67-11b2-a85c-e98659a63c5c.ibm.com>
Wed, 17 Jan 2024 11:44:44 +0000 (17:14 +0530)
committerAashish Sharma <aasharma@li-e74156cc-2f67-11b2-a85c-e98659a63c5c.ibm.com>
Tue, 30 Jan 2024 11:43:10 +0000 (17:13 +0530)
dashboard

Allow the user to add a cluster using a form for multi-cluster
management and add a context switcher at the top of the navigation bar
to allow the user to switch between the clusters that are connected.

Signed-off-by: Aashish Sharma <aasharma@redhat.com>
25 files changed:
src/pybind/mgr/dashboard/controllers/auth.py
src/pybind/mgr/dashboard/controllers/multi_cluster.py
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 [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster-form/multi-cluster-form.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster-form/multi-cluster-form.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster-form/multi-cluster-form.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/core/auth/login/login.component.ts
src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.html
src/pybind/mgr/dashboard/frontend/src/app/core/layouts/workbench-layout/workbench-layout.component.ts
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.scss
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/multi-cluster.service.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/api/multi-cluster.service.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/modal/modal.component.scss
src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/api-interceptor.service.ts
src/pybind/mgr/dashboard/openapi.yaml
src/pybind/mgr/dashboard/tests/test_auth.py

index 196f027b293ee5ecaa29a863777d7cb326b0c552..e8bb4bbef8e159f02e9ff1c9921b54fee62a75b6 100644 (file)
@@ -1,9 +1,12 @@
 # -*- coding: utf-8 -*-
 
 import http.cookies
+import json
 import logging
 import sys
 
+import cherrypy
+
 from .. import mgr
 from ..exceptions import InvalidCredentialsError, UserDoesNotExist
 from ..services.auth import AuthManager, JwtManager
@@ -34,17 +37,66 @@ class Auth(RESTController, ControllerAuthMixin):
     """
     Provide authenticates and returns JWT token.
     """
-
+    # pylint: disable=R0912
     def create(self, username, password):
         user_data = AuthManager.authenticate(username, password)
         user_perms, pwd_expiration_date, pwd_update_required = None, None, None
         max_attempt = Settings.ACCOUNT_LOCKOUT_ATTEMPTS
+        origin = cherrypy.request.headers.get('Origin', None)
+        try:
+            fsid = mgr.get('config')['fsid']
+        except KeyError:
+            fsid = ''
         if max_attempt == 0 or mgr.ACCESS_CTRL_DB.get_attempt(username) < max_attempt:
             if user_data:
                 user_perms = user_data.get('permissions')
                 pwd_expiration_date = user_data.get('pwdExpirationDate', None)
                 pwd_update_required = user_data.get('pwdUpdateRequired', False)
 
+            if isinstance(Settings.MULTICLUSTER_CONFIG, str):
+                try:
+                    item_to_dict = json.loads(Settings.MULTICLUSTER_CONFIG)
+                except json.JSONDecodeError:
+                    item_to_dict = {}
+                multicluster_config = item_to_dict.copy()
+            else:
+                multicluster_config = Settings.MULTICLUSTER_CONFIG.copy()
+            try:
+                if fsid in multicluster_config['config']:
+                    existing_entries = multicluster_config['config'][fsid]
+                    if not any(entry['user'] == username for entry in existing_entries):
+                        existing_entries.append({
+                            "name": fsid,
+                            "url": origin,
+                            "cluster_alias": "local-cluster",
+                            "user": username
+                        })
+                else:
+                    multicluster_config['config'][fsid] = [{
+                        "name": fsid,
+                        "url": origin,
+                        "cluster_alias": "local-cluster",
+                        "user": username
+                    }]
+
+            except KeyError:
+                multicluster_config = {
+                    'current_url': origin,
+                    'current_user': username,
+                    'hub_url': origin,
+                    'config': {
+                        fsid: [
+                            {
+                                "name": fsid,
+                                "url": origin,
+                                "cluster_alias": "local-cluster",
+                                "user": username
+                            }
+                        ]
+                    }
+                }
+            Settings.MULTICLUSTER_CONFIG = multicluster_config
+
             if user_perms is not None:
                 url_prefix = 'https' if mgr.get_localized_module_option('ssl') else 'http'
 
index cfb99d201d1b428e185914258d13dbbef139a740..d7acec22bebbf62049205d90a3a7c1838d5e07b8 100644 (file)
@@ -9,13 +9,14 @@ from ..security import Scope
 from ..settings import Settings
 from ..tools import configure_cors
 from . import APIDoc, APIRouter, CreatePermission, Endpoint, EndpointDoc, \
-    RESTController, UIRouter, UpdatePermission
+    ReadPermission, RESTController, UIRouter, UpdatePermission
 
 
 @APIRouter('/multi-cluster', Scope.CONFIG_OPT)
 @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):
+    def _proxy(self, method, base_url, path, params=None, payload=None, verify=False,
+               token=None):
         try:
             if token:
                 headers = {
@@ -46,33 +47,13 @@ class MultiCluster(RESTController):
     @Endpoint('POST')
     @CreatePermission
     @EndpointDoc("Authenticate to a remote cluster")
-    def auth(self, url: str, name: str, username=None, password=None, token=None, hub_url=None):
-        multicluster_config = {}
+    def auth(self, url: str, cluster_alias: str, username=None,
+             password=None, token=None, hub_url=None):
 
-        if isinstance(Settings.MULTICLUSTER_CONFIG, str):
-            try:
-                item_to_dict = json.loads(Settings.MULTICLUSTER_CONFIG)
-            except json.JSONDecodeError:
-                item_to_dict = {}
-            multicluster_config = item_to_dict.copy()
-        else:
-            multicluster_config = Settings.MULTICLUSTER_CONFIG.copy()
-
-        if 'hub_url' not in multicluster_config:
-            multicluster_config['hub_url'] = hub_url
-
-        if 'config' not in multicluster_config:
-            multicluster_config['config'] = []
-
-        if token:
-            multicluster_config['config'].append({
-                'name': name,
-                'url': url,
-                'token': token
-            })
+        multi_cluster_config = self.load_multi_cluster_config()
 
-            Settings.MULTICLUSTER_CONFIG = multicluster_config
-            return
+        if not url.endswith('/'):
+            url = url + '/'
 
         if username and password:
             payload = {
@@ -87,17 +68,109 @@ class MultiCluster(RESTController):
                     component='dashboard')
 
             token = content['token']
-            # Set CORS endpoint on remote cluster
+
+        if token:
             self._proxy('PUT', url, 'ui-api/multi-cluster/set_cors_endpoint',
-                        payload={'url': multicluster_config['hub_url']}, token=token)
+                        payload={'url': hub_url}, token=token)
+            fsid = self._proxy('GET', url, 'api/health/get_cluster_fsid', token=token)
+            content = self._proxy('POST', url, 'api/auth/check', payload={'token': token},
+                                  token=token)
+            if 'username' in content:
+                username = content['username']
+
+            if 'config' not in multi_cluster_config:
+                multi_cluster_config['config'] = {}
+
+            if fsid in multi_cluster_config['config']:
+                existing_entries = multi_cluster_config['config'][fsid]
+                if not any(entry['user'] == username for entry in existing_entries):
+                    existing_entries.append({
+                        "name": fsid,
+                        "url": url,
+                        "cluster_alias": cluster_alias,
+                        "user": username,
+                        "token": token,
+                    })
+            else:
+                multi_cluster_config['current_user'] = username
+                multi_cluster_config['config'][fsid] = [{
+                    "name": fsid,
+                    "url": url,
+                    "cluster_alias": cluster_alias,
+                    "user": username,
+                    "token": token,
+                }]
+
+            Settings.MULTICLUSTER_CONFIG = multi_cluster_config
+
+    def load_multi_cluster_config(self):
+        if isinstance(Settings.MULTICLUSTER_CONFIG, str):
+            try:
+                itemw_to_dict = json.loads(Settings.MULTICLUSTER_CONFIG)
+            except json.JSONDecodeError:
+                itemw_to_dict = {}
+            multi_cluster_config = itemw_to_dict.copy()
+        else:
+            multi_cluster_config = Settings.MULTICLUSTER_CONFIG.copy()
+
+        return multi_cluster_config
+
+    @Endpoint('PUT')
+    @UpdatePermission
+    def set_config(self, config: object):
+        multicluster_config = self.load_multi_cluster_config()
+        multicluster_config.update({'current_url': config['url']})
+        multicluster_config.update({'current_user': config['user']})
+        Settings.MULTICLUSTER_CONFIG = multicluster_config
+        return Settings.MULTICLUSTER_CONFIG
+
+    @Endpoint('POST')
+    @CreatePermission
+    # pylint: disable=R0911
+    def verify_connection(self, url: str, username=None, password=None, token=None):
+        if not url.endswith('/'):
+            url = url + '/'
 
-            multicluster_config['config'].append({
-                'name': name,
-                'url': url,
-                'token': token
-            })
+        if token:
+            try:
+                payload = {
+                    'token': token
+                }
+                content = self._proxy('POST', url, 'api/auth/check', payload=payload)
+                if 'permissions' not in content:
+                    return content['detail']
+                user_content = self._proxy('GET', url, f'api/user/{username}',
+                                           token=content['token'])
+                if 'status' in user_content and user_content['status'] == '403 Forbidden':
+                    return 'User is not an administrator'
+            except Exception as e:  # pylint: disable=broad-except
+                if '[Errno 111] Connection refused' in str(e):
+                    return 'Connection refused'
+                return 'Connection failed'
 
-            Settings.MULTICLUSTER_CONFIG = multicluster_config
+        if username and password:
+            try:
+                payload = {
+                    'username': username,
+                    'password': password
+                }
+                content = self._proxy('POST', url, 'api/auth', payload=payload)
+                if 'token' not in content:
+                    return content['detail']
+                user_content = self._proxy('GET', url, f'api/user/{username}',
+                                           token=content['token'])
+                if 'status' in user_content and user_content['status'] == '403 Forbidden':
+                    return 'User is not an administrator'
+            except Exception as e:  # pylint: disable=broad-except
+                if '[Errno 111] Connection refused' in str(e):
+                    return 'Connection refused'
+                return 'Connection failed'
+        return 'Connection successful'
+
+    @Endpoint()
+    @ReadPermission
+    def get_config(self):
+        return Settings.MULTICLUSTER_CONFIG
 
 
 @UIRouter('/multi-cluster', Scope.CONFIG_OPT)
index 2ba634fa25d0f6715efaefb83b161276c587619d..48224c844d47bee01a8c1af0a7f76123b2cc8991 100644 (file)
@@ -48,6 +48,7 @@ import { NoSsoGuardService } from './shared/services/no-sso-guard.service';
 import { UpgradeComponent } from './ceph/cluster/upgrade/upgrade.component';
 import { CephfsVolumeFormComponent } from './ceph/cephfs/cephfs-form/cephfs-form.component';
 import { UpgradeProgressComponent } from './ceph/cluster/upgrade/upgrade-progress/upgrade-progress.component';
+import { MultiClusterComponent } from './ceph/cluster/multi-cluster/multi-cluster.component';
 
 @Injectable()
 export class PerformanceCounterBreadcrumbsResolver extends BreadcrumbsResolver {
@@ -184,6 +185,10 @@ const routes: Routes = [
           }
         ]
       },
+      {
+        path: 'multi-cluster',
+        component: MultiClusterComponent
+      },
       {
         path: 'inventory',
         canActivate: [ModuleStatusGuardService],
index 74657ec4010f0ef58c20cf9a9b0732ab86e9cbc8..b1eb9275a462ceb84157758a84aa3fcff5711d1c 100644 (file)
@@ -61,6 +61,8 @@ import { TelemetryComponent } from './telemetry/telemetry.component';
 import { UpgradeComponent } from './upgrade/upgrade.component';
 import { UpgradeStartModalComponent } from './upgrade/upgrade-form/upgrade-start-modal.component';
 import { UpgradeProgressComponent } from './upgrade/upgrade-progress/upgrade-progress.component';
+import { MultiClusterComponent } from './multi-cluster/multi-cluster.component';
+import { MultiClusterFormComponent } from './multi-cluster/multi-cluster-form/multi-cluster-form.component';
 
 @NgModule({
   imports: [
@@ -124,7 +126,9 @@ import { UpgradeProgressComponent } from './upgrade/upgrade-progress/upgrade-pro
     CreateClusterReviewComponent,
     UpgradeComponent,
     UpgradeStartModalComponent,
-    UpgradeProgressComponent
+    UpgradeProgressComponent,
+    MultiClusterComponent,
+    MultiClusterFormComponent
   ],
   providers: [NgbActiveModal]
 })
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster-form/multi-cluster-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster-form/multi-cluster-form.component.html
new file mode 100644 (file)
index 0000000..cc9ed74
--- /dev/null
@@ -0,0 +1,160 @@
+<cd-modal [modalRef]="activeModal">
+  <ng-container i18n="form title"
+                class="modal-title">Connect Cluster
+  </ng-container>
+  <ng-container class="modal-content">
+    <form name="remoteClusterForm"
+          #frm="ngForm"
+          [formGroup]="remoteClusterForm">
+      <div class="modal-body">
+        <cd-alert-panel *ngIf="connectionVerified !== undefined && !connectionVerified && connectionMessage !== 'Connection refused'"
+                        type="error"
+                        spacingClass="mb-3"
+                        i18n>{{ connectionMessage }}
+        </cd-alert-panel>
+        <cd-alert-panel *ngIf="connectionVerified !== undefined && connectionVerified"
+                        type="success"
+                        spacingClass="mb-3"
+                        i18n>{{ connectionMessage }}
+        </cd-alert-panel>
+        <cd-alert-panel type="info"
+                        spacingClass="mb-3"
+                        i18n
+                        *ngIf="connectionVerified !== undefined && !connectionVerified && connectionMessage === 'Connection refused'">
+        <p>You need to set this cluster's url as the cross origin url in the remote cluster you are trying to connect.
+          You can do it by running this CLI command in your remote cluster and proceed with authentication via token.</p>
+          <cd-code-block [codes]="[crossOriginCmd]"></cd-code-block>
+        </cd-alert-panel>
+        <div class="form-group row">
+          <label class="cd-col-form-label required"
+                 for="remoteClusterUrl"
+                 i18n>Cluster API URL
+            <cd-helper>Enter the Dashboard API URL. You can retrieve it from the CLI with: <b>ceph mgr services</b></cd-helper>
+          </label>
+          <div class="cd-col-form-input">
+            <input class="form-control"
+                   type="text"
+                   placeholder="https://localhost:4202"
+                   id="remoteClusterUrl"
+                   name="remoteClusterUrl"
+                   formControlName="remoteClusterUrl">
+            <span class="invalid-feedback"
+                  *ngIf="remoteClusterForm.showError('remoteClusterUrl', frm, 'required')"
+                  i18n>This field is required.
+            </span>
+            <span class="invalid-feedback"
+                  *ngIf="remoteClusterForm.showError('remoteClusterUrl', frm, 'endpoint')"
+                  i18n>Please enter a valid URL.
+            </span>
+          </div>
+        </div>
+        <div class="form-group row">
+          <label class="cd-col-form-label required"
+                 for="clusterAlias"
+                 i18n>Alias Name
+          </label>
+          <div class="cd-col-form-input">
+            <input id="clusterAlias"
+                   name="clusterAlias"
+                   class="form-control"
+                   type="text"
+                   placeholder="Name/Text to uniquely identify cluster"
+                   formControlName="clusterAlias">
+            <span class="invalid-feedback"
+                  *ngIf="remoteClusterForm.showError('clusterAlias', frm, 'required')"
+                  i18n>This field is required.
+            </span>
+          </div>
+        </div>
+        <div class="form-group row"
+             *ngIf="!remoteClusterForm.getValue('showToken') && !showCrossOriginError">
+          <label class="cd-col-form-label required"
+                 for="apiToken"
+                 i18n>Username
+          </label>
+          <div class="cd-col-form-input">
+            <input id="username"
+                   name="username"
+                   class="form-control"
+                   type="text"
+                   formControlName="username">
+            <span class="invalid-feedback"
+                  *ngIf="remoteClusterForm.showError('username', frm, 'required')"
+                  i18n>This field is required.
+            </span>
+          </div>
+        </div>
+        <div class="form-group row"
+             *ngIf="!remoteClusterForm.getValue('showToken') && !showCrossOriginError">
+          <label class="cd-col-form-label required"
+                 for="password"
+                 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>
+        </div>
+        <div class="form-group row">
+          <div class="cd-col-form-offset">
+            <div class="custom-control custom-checkbox">
+              <input class="custom-control-input"
+                     id="showToken"
+                     type="checkbox"
+                     (click)="showToken = !showToken"
+                     formControlName="showToken"
+                     [readonly]="true">
+              <label class="custom-control-label"
+                     for="showToken"
+                     i18n>Auth with token</label>
+            </div>
+          </div>
+        </div>
+        <div class="form-group row"
+             *ngIf="remoteClusterForm.getValue('showToken')">
+          <label class="cd-col-form-label required"
+                 for="apiToken"
+                 i18n>Token
+          </label>
+          <div class="cd-col-form-input">
+            <input id="apiToken"
+                   name="apiToken"
+                   class="form-control"
+                   type="text"
+                   formControlName="apiToken">
+            <span class="invalid-feedback"
+                  *ngIf="remoteClusterForm.showError('apiToken', frm, 'required')"
+                  i18n>This field is required.</span>
+          </div>
+        </div>
+        <div class="form-group row"
+             *ngIf="!showCrossOriginError">
+          <div class="cd-col-form-offset">
+            <div class="custom-control">
+              <button class="btn btn-primary"
+                      type="button"
+                      [disabled]="(remoteClusterForm.getValue('showToken') && remoteClusterForm.getValue('apiToken') === '') || (!remoteClusterForm.getValue('showToken') && (remoteClusterForm.getValue('username') === '' || remoteClusterForm.getValue('password') === ''))"
+                      (click)="verifyConnection()">
+                Verify Connection
+              </button>
+            </div>
+          </div>
+        </div>
+      </div>
+      <div class="modal-footer">
+        <cd-form-button-panel (submitActionEvent)="onSubmit()"
+                              [submitText]="actionLabels.CONNECT"
+                              [disabled]="!connectionVerified && !showCrossOriginError"
+                              [form]="remoteClusterForm">
+        </cd-form-button-panel>
+      </div>
+    </form>
+  </ng-container>
+</cd-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster-form/multi-cluster-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster-form/multi-cluster-form.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster-form/multi-cluster-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster-form/multi-cluster-form.component.spec.ts
new file mode 100644 (file)
index 0000000..71521de
--- /dev/null
@@ -0,0 +1,39 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { MultiClusterFormComponent } from './multi-cluster-form.component';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import { ToastrModule } from 'ngx-toastr';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { CdDatePipe } from '~/app/shared/pipes/cd-date.pipe';
+import { DatePipe } from '@angular/common';
+import { ReactiveFormsModule } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+import { SharedModule } from '~/app/shared/shared.module';
+
+describe('MultiClusterFormComponent', () => {
+  let component: MultiClusterFormComponent;
+  let fixture: ComponentFixture<MultiClusterFormComponent>;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      imports: [
+        SharedModule,
+        ReactiveFormsModule,
+        RouterTestingModule,
+        HttpClientTestingModule,
+        ToastrModule.forRoot()
+      ],
+      declarations: [MultiClusterFormComponent],
+      providers: [NgbActiveModal, NotificationService, CdDatePipe, DatePipe]
+    }).compileComponents();
+
+    fixture = TestBed.createComponent(MultiClusterFormComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster-form/multi-cluster-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster-form/multi-cluster-form.component.ts
new file mode 100644 (file)
index 0000000..473a49d
--- /dev/null
@@ -0,0 +1,152 @@
+import { Component, OnDestroy, OnInit } from '@angular/core';
+import { FormControl, Validators } from '@angular/forms';
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import _ from 'lodash';
+import { Subscription } from 'rxjs';
+import { MultiClusterService } from '~/app/shared/api/multi-cluster.service';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { CdValidators } from '~/app/shared/forms/cd-validators';
+import { NotificationService } from '~/app/shared/services/notification.service';
+
+@Component({
+  selector: 'cd-multi-cluster-form',
+  templateUrl: './multi-cluster-form.component.html',
+  styleUrls: ['./multi-cluster-form.component.scss']
+})
+export class MultiClusterFormComponent implements OnInit, OnDestroy {
+  readonly endpoints = /^((https?:\/\/)|(www.))(?:([a-zA-Z]+)|(\d+\.\d+.\d+.\d+)):\d{2,5}\/?$/;
+  readonly ipv4Rgx = /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/i;
+  readonly ipv6Rgx = /^(?:[a-f0-9]{1,4}:){7}[a-f0-9]{1,4}$/i;
+  remoteClusterForm: CdFormGroup;
+  showToken = false;
+  connectionVerified: boolean;
+  connectionMessage = '';
+  private subs = new Subscription();
+  showCrossOriginError = false;
+  crossOriginCmd: string;
+
+  constructor(
+    public activeModal: NgbActiveModal,
+    public actionLabels: ActionLabelsI18n,
+    public notificationService: NotificationService,
+    private multiClusterService: MultiClusterService
+  ) {
+    this.createForm();
+  }
+  ngOnInit(): void {}
+
+  createForm() {
+    this.remoteClusterForm = new CdFormGroup({
+      showToken: new FormControl(false),
+      username: new FormControl('', [
+        CdValidators.requiredIf({
+          showToken: false
+        })
+      ]),
+      password: new FormControl('', [
+        CdValidators.requiredIf({
+          showToken: false
+        })
+      ]),
+      remoteClusterUrl: new FormControl(null, {
+        validators: [
+          CdValidators.custom('endpoint', (value: string) => {
+            if (_.isEmpty(value)) {
+              return false;
+            } else {
+              return (
+                !this.endpoints.test(value) &&
+                !this.ipv4Rgx.test(value) &&
+                !this.ipv6Rgx.test(value)
+              );
+            }
+          }),
+          Validators.required
+        ]
+      }),
+      apiToken: new FormControl('', [
+        CdValidators.requiredIf({
+          showToken: true
+        })
+      ]),
+      clusterAlias: new FormControl('', {
+        validators: [Validators.required]
+      })
+    });
+  }
+
+  ngOnDestroy() {
+    this.subs.unsubscribe();
+  }
+
+  onSubmit() {
+    const url = this.remoteClusterForm.getValue('remoteClusterUrl');
+    const clusterAlias = this.remoteClusterForm.getValue('clusterAlias');
+    const username = this.remoteClusterForm.getValue('username');
+    const password = this.remoteClusterForm.getValue('password');
+    const token = this.remoteClusterForm.getValue('apiToken');
+
+    this.subs.add(
+      this.multiClusterService
+        .addCluster(url, clusterAlias, username, password, token, window.location.origin)
+        .subscribe({
+          error: () => {
+            this.remoteClusterForm.setErrors({ cdSubmitButton: true });
+          },
+          complete: () => {
+            this.notificationService.show(
+              NotificationType.success,
+              $localize`Cluster added successfully`
+            );
+            this.activeModal.close();
+          }
+        })
+    );
+  }
+
+  verifyConnection() {
+    const url = this.remoteClusterForm.getValue('remoteClusterUrl');
+    const username = this.remoteClusterForm.getValue('username');
+    const password = this.remoteClusterForm.getValue('password');
+    const token = this.remoteClusterForm.getValue('apiToken');
+
+    this.subs.add(
+      this.multiClusterService
+        .verifyConnection(url, username, password, token)
+        .subscribe((resp: string) => {
+          switch (resp) {
+            case 'Connection successful':
+              this.connectionVerified = true;
+              this.connectionMessage = 'Connection Verified Successfully';
+              this.notificationService.show(
+                NotificationType.success,
+                $localize`Connection Verified Successfully`
+              );
+              break;
+
+            case 'Connection refused':
+              this.connectionVerified = false;
+              this.showCrossOriginError = true;
+              this.connectionMessage = resp;
+              this.crossOriginCmd = `ceph config set mgr mgr/dashboard/cross_origin_url ${window.location.origin} `;
+              this.notificationService.show(
+                NotificationType.error,
+                $localize`Connection to the cluster failed`
+              );
+              break;
+
+            default:
+              this.connectionVerified = false;
+              this.connectionMessage = resp;
+              this.notificationService.show(
+                NotificationType.error,
+                $localize`Connection to the cluster failed`
+              );
+              break;
+          }
+        })
+    );
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster.component.html
new file mode 100644 (file)
index 0000000..5009909
--- /dev/null
@@ -0,0 +1,61 @@
+<ng-template #emptyCluster>
+  <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.wrench">
+        </i>
+      <div class="mt-4 text-center">
+        <h3><b>Connect Cluster </b></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 class="mt-4">
+        <div class="text-center">
+          <button class="btn btn-primary"
+                  (click)="openRemoteClusterInfoModal()">
+            <i class="mx-auto"
+               [ngClass]="icons.add">
+            </i> Connect Cluster
+          </button>
+        </div>
+      </div>
+      </div>
+    </div>
+  </div>
+</ng-template>
+
+<ng-template #nametpl>
+  <div class="datatable-body-cell-label">
+    <span title="{{cluster}}">
+      <a href="#">
+        {{cluster}}
+      </a>
+    </span>
+  </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">
+      <div class="mt-4">
+        <div class="text-center">
+          <button class="btn btn-primary"
+                  (click)="openRemoteClusterInfoModal()">
+            <i class="mx-auto"
+               [ngClass]="icons.add">
+            </i> Connect Cluster
+          </button>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster.component.scss
new file mode 100644 (file)
index 0000000..2931ef9
--- /dev/null
@@ -0,0 +1,7 @@
+@use '../../../../styles/vendor/variables' as vv;
+
+.fa-wrench {
+  color: vv.$info;
+  font-size: 6em;
+  margin-top: 200px;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster.component.spec.ts
new file mode 100644 (file)
index 0000000..8db81cd
--- /dev/null
@@ -0,0 +1,25 @@
+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';
+
+describe('MultiClusterComponent', () => {
+  let component: MultiClusterComponent;
+  let fixture: ComponentFixture<MultiClusterComponent>;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      imports: [HttpClientTestingModule],
+      declarations: [MultiClusterComponent],
+      providers: [NgbActiveModal]
+    }).compileComponents();
+
+    fixture = TestBed.createComponent(MultiClusterComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster.component.ts
new file mode 100644 (file)
index 0000000..2630c83
--- /dev/null
@@ -0,0 +1,57 @@
+import { Component, OnInit, ViewChild } from '@angular/core';
+import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
+import { Subscription } from 'rxjs';
+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';
+
+@Component({
+  selector: 'cd-multi-cluster',
+  templateUrl: './multi-cluster.component.html',
+  styleUrls: ['./multi-cluster.component.scss']
+})
+export class MultiClusterComponent implements OnInit {
+  @ViewChild('nameTpl', { static: true })
+  nameTpl: any;
+
+  private subs = new Subscription();
+  dashboardClustersMap: Map<string, string> = new Map<string, string>();
+  icons = Icons;
+  loading = true;
+  bsModalRef: NgbModalRef;
+
+  constructor(
+    private multiClusterService: MultiClusterService,
+    private modalService: ModalService
+  ) {}
+
+  ngOnInit(): void {
+    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;
+          }
+        }
+      })
+    );
+  }
+
+  openRemoteClusterInfoModal() {
+    this.bsModalRef = this.modalService.show(MultiClusterFormComponent, {
+      size: 'xl'
+    });
+  }
+}
index a98548f94c766086934804eb8618ddc91a42ec22..57039c0f6d0c40cf9a11a5f76e3401c2bf8975f1 100644 (file)
@@ -64,6 +64,7 @@ export class LoginComponent implements OnInit {
   }
 
   login() {
+    localStorage.setItem('cluster_api_url', window.location.origin);
     this.authService.login(this.model).subscribe(() => {
       const urlPath = this.postInstalled ? '/' : '/expand-cluster';
       let url = _.get(this.route.snapshot.queryParams, 'returnUrl', urlPath);
index fe3bfc6acf9ea639f657791daa325b36d32d1e84..1c1846dae15eafa425fbc4e93d55fcf55e1611c7 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'), 'rgw-dashboard': (router.url == '/rgw/overview')}">
+         [ngClass]="{'dashboard': (router.url == '/dashboard' || router.url == '/dashboard_3' || router.url == '/multi-cluster'), 'rgw-dashboard': (router.url == '/rgw/overview')}">
     <cd-context></cd-context>
       <cd-breadcrumbs></cd-breadcrumbs>
       <router-outlet></router-outlet>
index afc7a83bb277e5d1fdf9156eec57b22062c98701..1d7c4bb751cb6ae1fba1f5f4b828fa1e8e5565bf 100644 (file)
@@ -2,6 +2,7 @@ import { Component, OnDestroy, OnInit } from '@angular/core';
 import { Router } from '@angular/router';
 
 import { Subscription } from 'rxjs';
+import { MultiClusterService } from '~/app/shared/api/multi-cluster.service';
 
 import { FaviconService } from '~/app/shared/services/favicon.service';
 import { SummaryService } from '~/app/shared/services/summary.service';
@@ -20,10 +21,12 @@ export class WorkbenchLayoutComponent implements OnInit, OnDestroy {
     public router: Router,
     private summaryService: SummaryService,
     private taskManagerService: TaskManagerService,
+    private multiClusterService: MultiClusterService,
     private faviconService: FaviconService
   ) {}
 
   ngOnInit() {
+    this.subs.add(this.multiClusterService.startPolling());
     this.subs.add(this.summaryService.startPolling());
     this.subs.add(this.taskManagerService.init(this.summaryService));
     this.faviconService.init();
index abd2e1ae6506e518b78006b0f9dcb0d4410c9024..6af3799b4ef84734abb22debccd18e6f67b6e40b 100644 (file)
 
       <div class="collapse navbar-collapse"
            [ngClass]="{'show': rightSidebarOpen}">
+        <ng-container *ngIf="clustersMap?.size > 1">
+          <div ngbDropdown
+               placement="bottom-left"
+               class="d-inline-block ms-5">
+            <button ngbDropdownToggle
+                    class="btn btn-outline-light cd-context-bar"
+                    i18n-title
+                    title="Selected Cluster:">
+              <span class="dropdown-text"> {{ selectedCluster?.name }} </span>
+              <span>- {{ selectedCluster?.cluster_alias }} - {{ selectedCluster?.user }}</span>
+            </button>
+            <div ngbDropdownMenu>
+              <ng-container *ngFor="let cluster of clustersMap | keyvalue">
+                <button ngbDropdownItem
+                        (click)="onClusterSelection(cluster.value)">
+                  <div class="dropdown-text">{{ cluster.value.name }}</div>
+                  <div *ngIf="cluster.value.cluster_alias"
+                       class="text-secondary">{{ cluster.value.cluster_alias }} - {{ cluster.value.user }}</div>
+                </button>
+              </ng-container>
+            </div>
+          </div>
+        </ng-container>
         <ul class="nav navbar-nav cd-navbar-utility my-2 my-md-0">
           <ng-container *ngTemplateOutlet="cd_utilities"> </ng-container>
         </ul>
           </i>
         </a>
       </li>
-
+      <!-- Multi-cluster Dashboard -->
+      <li routerLinkActive="active"
+          class="nav-item tc_menuitem_multi_cluster">
+        <a (click)="toggleSubMenu('multiCluster')"
+           class="nav-link dropdown-toggle"
+           [attr.aria-expanded]="displayedSubMenu.multiCluster"
+           aria-controls="multi-cluster-nav"
+           role="button">
+          <ng-container i18n>
+            <i [ngClass]="[icons.sitemap]"></i>
+              Multi-Cluster
+          </ng-container>
+        </a>
+        <ul class="list-unstyled"
+            id="multi-cluster-nav"
+            [ngbCollapse]="!displayedSubMenu.multiCluster">
+          <li routerLinkActive="active"
+              class="tc_submenuitem tc_submenuitem_multiCluster_overview">
+            <a i18n
+               routerLink="/multi-cluster">Overview</a>
+          </li>
+        </ul>
+      </li>
       <!-- Cluster -->
       <li routerLinkActive="active"
           class="nav-item tc_menuitem_cluster"
index 8e0dfa364cd968f97206762fd579ff2377b2d87d..4d2c829e93da69e8dd6c3846bcf5de515fdf734c 100644 (file)
       background-color: vv.$primary;
     }
   }
+
+  .cd-context-bar {
+    background-color: vv.$white;
+    color: vv.$secondary;
+    cursor: pointer;
+  }
+
+  .dropdown-text {
+    font-size: small;
+    font-weight: 600;
+  }
 }
 
 /* ---------------------------------------------------
index e5615012e3701b7d484b86dd53c6e0d70b1d963f..10963042d25fdfd4ae0fa81680ddc62432ea1484 100644 (file)
@@ -1,7 +1,9 @@
 import { Component, HostBinding, OnDestroy, OnInit } from '@angular/core';
+import { Router } from '@angular/router';
 
 import * as _ from 'lodash';
 import { Subscription } from 'rxjs';
+import { MultiClusterService } from '~/app/shared/api/multi-cluster.service';
 
 import { Icons } from '~/app/shared/enum/icons.enum';
 import { Permissions } from '~/app/shared/models/permissions';
@@ -22,6 +24,7 @@ import { TelemetryNotificationService } from '~/app/shared/services/telemetry-no
 })
 export class NavigationComponent implements OnInit, OnDestroy {
   notifications: string[] = [];
+  clusterDetails: any[] = [];
   @HostBinding('class') get class(): string {
     return 'top-notification-' + this.notifications.length;
   }
@@ -40,8 +43,13 @@ export class NavigationComponent implements OnInit, OnDestroy {
   displayedSubMenu = {};
   private subs = new Subscription();
 
+  clustersMap: Map<string, any> = new Map<string, any>();
+  selectedCluster: object;
+
   constructor(
     private authStorageService: AuthStorageService,
+    private multiClusterService: MultiClusterService,
+    private router: Router,
     private summaryService: SummaryService,
     private featureToggles: FeatureTogglesService,
     private telemetryNotificationService: TelemetryNotificationService,
@@ -53,6 +61,31 @@ export class NavigationComponent implements OnInit, OnDestroy {
   }
 
   ngOnInit() {
+    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 clusterName = clusterDetails['name'];
+              const clusterUser = clusterDetails['user'];
+              const clusterUrl = clusterDetails['url'];
+              const clusterUniqueKey = `${clusterUrl}-${clusterUser}`;
+              this.clustersMap.set(clusterUniqueKey, {
+                name: clusterName,
+                cluster_alias: clusterDetails['cluster_alias'],
+                user: clusterDetails['user'],
+                url: clusterUrl
+              });
+            });
+          });
+          this.selectedCluster =
+            this.clustersMap.get(`${resp['current_url']}-${resp['current_user']}`) || {};
+        }
+      })
+    );
+
     this.subs.add(
       this.summaryService.subscribe((summary) => {
         this.summaryData = summary;
@@ -116,4 +149,47 @@ export class NavigationComponent implements OnInit, OnDestroy {
       }
     }
   }
+
+  onClusterSelection(value: object) {
+    this.multiClusterService.setCluster(value).subscribe(
+      (resp: any) => {
+        localStorage.setItem('cluster_api_url', value['url']);
+        this.selectedCluster = this.clustersMap.get(`${value['url']}-${value['user']}`) || {};
+        const clustersConfig = resp['config'];
+        if (clustersConfig && typeof clustersConfig === 'object') {
+          Object.keys(clustersConfig).forEach((clusterKey: string) => {
+            const clusterDetailsList = clustersConfig[clusterKey];
+
+            clusterDetailsList.forEach((clusterDetails: any) => {
+              const clusterName = clusterDetails['name'];
+              const clusterToken = clusterDetails['token'];
+              const clusterUser = clusterDetails['user'];
+
+              if (
+                clusterName === this.selectedCluster['name'] &&
+                clusterUser === this.selectedCluster['user']
+              ) {
+                localStorage.setItem('token_of_selected_cluster', clusterToken);
+              }
+            });
+          });
+        }
+      },
+      () => {},
+      () => {
+        this.multiClusterService.refresh();
+        this.summaryService.refresh();
+        const currentRoute = this.router.url.split('?')[0];
+        if (currentRoute.includes('dashboard')) {
+          this.router.navigateByUrl('/pool', { skipLocationChange: true }).then(() => {
+            this.router.navigate([currentRoute]);
+          });
+        } else {
+          this.router.navigateByUrl('/', { skipLocationChange: true }).then(() => {
+            this.router.navigate([currentRoute]);
+          });
+        }
+      }
+    );
+  }
 }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/multi-cluster.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/multi-cluster.service.spec.ts
new file mode 100644 (file)
index 0000000..88140fe
--- /dev/null
@@ -0,0 +1,19 @@
+import { TestBed } from '@angular/core/testing';
+
+import { MultiClusterService } from './multi-cluster.service';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+
+describe('MultiClusterService', () => {
+  let service: MultiClusterService;
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      imports: [HttpClientTestingModule]
+    });
+    service = TestBed.inject(MultiClusterService);
+  });
+
+  it('should be created', () => {
+    expect(service).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/multi-cluster.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/multi-cluster.service.ts
new file mode 100644 (file)
index 0000000..5a17645
--- /dev/null
@@ -0,0 +1,69 @@
+import { HttpClient } from '@angular/common/http';
+import { Injectable } from '@angular/core';
+import { BehaviorSubject, Subscription } from 'rxjs';
+import { TimerService } from '../services/timer.service';
+import { filter } from 'rxjs/operators';
+
+@Injectable({
+  providedIn: 'root'
+})
+export class MultiClusterService {
+  private msSource = new BehaviorSubject<any>(null);
+  msData$ = this.msSource.asObservable();
+  constructor(private http: HttpClient, private timerService: TimerService) {}
+
+  startPolling(): Subscription {
+    return this.timerService
+      .get(() => this.getCluster(), 5000)
+      .subscribe(this.getClusterObserver());
+  }
+
+  refresh(): Subscription {
+    return this.getCluster().subscribe(this.getClusterObserver());
+  }
+
+  subscribe(next: (data: any) => void, error?: (error: any) => void) {
+    return this.msData$.pipe(filter((value) => !!value)).subscribe(next, error);
+  }
+
+  setCluster(cluster: object) {
+    return this.http.put('api/multi-cluster/set_config', { config: cluster });
+  }
+
+  getCluster() {
+    return this.http.get('api/multi-cluster/get_config');
+  }
+
+  addCluster(
+    url: any,
+    clusterAlias: string,
+    username: string,
+    password: string,
+    token = '',
+    hub_url = ''
+  ) {
+    return this.http.post('api/multi-cluster/auth', {
+      url,
+      cluster_alias: clusterAlias,
+      username,
+      password,
+      token,
+      hub_url
+    });
+  }
+
+  verifyConnection(url: string, username: string, password: string, token = '') {
+    return this.http.post('api/multi-cluster/verify_connection', {
+      url,
+      username,
+      password,
+      token
+    });
+  }
+
+  private getClusterObserver() {
+    return (data: any) => {
+      this.msSource.next(data);
+    };
+  }
+}
index ceeb614273b9fdeeefa8cdaa5d6be1f82fce658f..1681472cf859cbd2d6df148f3f0d1c9ba28bd893 100644 (file)
     overflow-x: hidden;
     overflow-y: auto;
   }
+
+  .modal-content {
+    display: table;
+  }
 }
 
 button.close {
index 7edce5ff6671d036d8e7a82ce2023b828bb7afae..a606fffbcf52e3de535d6b694fd7bb4c04519cb1 100644 (file)
@@ -141,6 +141,7 @@ export class ActionLabelsI18n {
   IMPORT: any;
   MIGRATE: string;
   START_UPGRADE: string;
+  CONNECT: string;
 
   constructor() {
     /* Create a new item */
@@ -218,6 +219,8 @@ export class ActionLabelsI18n {
     this.DEMOTE = $localize`Demote`;
 
     this.START_UPGRADE = $localize`Start Upgrade`;
+
+    this.CONNECT = $localize`Connect`;
   }
 }
 
index fb7a9f73395f00ca5b381cac6b5f6bdd2202082f..e4e30d6a3682e37b7ee55e2a4a2d732d93e584fa 100644 (file)
@@ -16,8 +16,10 @@ import { CdHelperClass } from '~/app/shared/classes/cd-helper.class';
 import { NotificationType } from '../enum/notification-type.enum';
 import { CdNotificationConfig } from '../models/cd-notification';
 import { FinishedTask } from '../models/finished-task';
-import { AuthStorageService } from './auth-storage.service';
 import { NotificationService } from './notification.service';
+import { MultiClusterService } from '../api/multi-cluster.service';
+import { SummaryService } from './summary.service';
+import { AuthStorageService } from './auth-storage.service';
 
 export class CdHttpErrorResponse extends HttpErrorResponse {
   preventDefault: Function;
@@ -28,15 +30,42 @@ export class CdHttpErrorResponse extends HttpErrorResponse {
   providedIn: 'root'
 })
 export class ApiInterceptorService implements HttpInterceptor {
+  localClusterDetails: object;
+  dashboardClustersMap: Map<string, string> = new Map<string, string>();
   constructor(
     private router: Router,
+    public notificationService: NotificationService,
+    private summaryService: SummaryService,
     private authStorageService: AuthStorageService,
-    public notificationService: NotificationService
-  ) {}
+    private multiClusterService: MultiClusterService
+  ) {
+    this.multiClusterService.subscribe((resp: any) => {
+      const clustersConfig = resp['config'];
+      const hub_url = resp['hub_url'];
+      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 (clusterDetails['url'] === hub_url) {
+              this.localClusterDetails = clusterDetails;
+            }
+          });
+        });
+      }
+    });
+  }
 
   intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
     const acceptHeader = request.headers.get('Accept');
     let reqWithVersion: HttpRequest<any>;
+
+    const origin = window.location.origin;
     if (acceptHeader && acceptHeader.startsWith('application/vnd.ceph.api.v')) {
       reqWithVersion = request.clone();
     } else {
@@ -46,6 +75,35 @@ export class ApiInterceptorService implements HttpInterceptor {
         }
       });
     }
+
+    const apiUrl = localStorage.getItem('cluster_api_url');
+    const currentRoute = this.router.url.split('?')[0];
+
+    const ALWAYS_TO_HUB_APIs = [
+      'api/auth/login',
+      'api/auth/logout',
+      'api/multi-cluster/get_config',
+      'api/multi-cluster/set_config',
+      'api/multi-cluster/auth'
+    ];
+
+    const token = localStorage.getItem('token_of_selected_cluster');
+
+    if (
+      !currentRoute.includes('login') &&
+      !ALWAYS_TO_HUB_APIs.includes(request.url) &&
+      apiUrl &&
+      !apiUrl.includes(origin)
+    ) {
+      reqWithVersion = reqWithVersion.clone({
+        url: `${apiUrl}${reqWithVersion.url}`,
+        setHeaders: {
+          'Access-Control-Allow-Origin': origin,
+          Authorization: `Bearer ${token}`
+        }
+      });
+    }
+
     return next.handle(reqWithVersion).pipe(
       catchError((resp: CdHttpErrorResponse) => {
         if (resp instanceof HttpErrorResponse) {
@@ -69,8 +127,26 @@ export class ApiInterceptorService implements HttpInterceptor {
               timeoutId = this.notificationService.notifyTask(finishedTask);
               break;
             case 401:
-              this.authStorageService.remove();
-              this.router.navigate(['/login']);
+              if (this.dashboardClustersMap.size > 1) {
+                this.multiClusterService.setCluster(this.localClusterDetails).subscribe(() => {
+                  localStorage.setItem('cluster_api_url', this.localClusterDetails['url']);
+                });
+                this.multiClusterService.refresh();
+                this.summaryService.refresh();
+                const currentRoute = this.router.url.split('?')[0];
+                if (currentRoute.includes('dashboard')) {
+                  this.router.navigateByUrl('/pool', { skipLocationChange: true }).then(() => {
+                    this.router.navigate([currentRoute]);
+                  });
+                } else {
+                  this.router.navigateByUrl('/', { skipLocationChange: true }).then(() => {
+                    this.router.navigate([currentRoute]);
+                  });
+                }
+              } else {
+                this.authStorageService.remove();
+                this.router.navigate(['/login']);
+              }
               break;
             case 403:
               this.router.navigate(['error'], {
index 68bab35c103e73e395c2bb8df215dcce019f2203..bb50d24e8032fb5fe45b5dba1239db04d0932b01 100644 (file)
@@ -6698,9 +6698,9 @@ paths:
           application/json:
             schema:
               properties:
-                hub_url:
+                cluster_alias:
                   type: string
-                name:
+                hub_url:
                   type: string
                 password:
                   type: string
@@ -6712,7 +6712,7 @@ paths:
                   type: string
               required:
               - url
-              - name
+              - cluster_alias
               type: object
       responses:
         '201':
@@ -6739,6 +6739,108 @@ paths:
       summary: Authenticate to a remote cluster
       tags:
       - Multi-cluster
+  /api/multi-cluster/get_config:
+    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:
+      - Multi-cluster
+  /api/multi-cluster/set_config:
+    put:
+      parameters: []
+      requestBody:
+        content:
+          application/json:
+            schema:
+              properties:
+                config:
+                  type: string
+              required:
+              - config
+              type: object
+      responses:
+        '200':
+          content:
+            application/vnd.ceph.api.v1.0+json:
+              type: object
+          description: Resource updated.
+        '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':
+          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:
+      - Multi-cluster
+  /api/multi-cluster/verify_connection:
+    post:
+      parameters: []
+      requestBody:
+        content:
+          application/json:
+            schema:
+              properties:
+                password:
+                  type: string
+                token:
+                  type: string
+                url:
+                  type: string
+                username:
+                  type: string
+              required:
+              - url
+              type: object
+      responses:
+        '201':
+          content:
+            application/vnd.ceph.api.v1.0+json:
+              type: object
+          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':
+          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:
+      - Multi-cluster
   /api/nfs-ganesha/cluster:
     get:
       parameters: []
index d9755de98e45ad1f8af3528fcc0cc9d2c9bbb1ce..70e841a667bed61fa52c49935b65f22d524e44e2 100644 (file)
@@ -40,6 +40,11 @@ class AuthTest(ControllerTestCase):
         self.assertStatus(401)
 
     @patch('dashboard.controllers.auth.JwtManager.gen_token', Mock(return_value='my-token'))
+    @patch('dashboard.mgr.get', Mock(return_value={
+        'config': {
+            'fsid': '943949f0-ce37-47ca-a33c-3413d46ee9ec'
+        }
+    }))
     @patch('dashboard.controllers.auth.AuthManager.authenticate', Mock(return_value={
         'permissions': {'rgw': ['read']},
         'pwdExpirationDate': 1000000,