]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Add a manage clusters page to the multi-cluster nav to multi-cluster-manage-clusters
authorAashish Sharma <aasharma@li-e74156cc-2f67-11b2-a85c-e98659a63c5c.ibm.com>
Thu, 25 Jan 2024 07:14:01 +0000 (12:44 +0530)
committerAashish Sharma <aasharma@li-e74156cc-2f67-11b2-a85c-e98659a63c5c.ibm.com>
Wed, 7 Feb 2024 06:22:25 +0000 (11:52 +0530)
list/connect/disconnect/edit clusters in multi-cluster setup

Signed-off-by: Aashish Sharma <aasharma@redhat.com>
20 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
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 [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster-list/multi-cluster-list.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster-list/multi-cluster-list.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster-list/multi-cluster-list.component.ts [new file with mode: 0644]
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.ts
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.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/multi-cluster.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/multi-cluster.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/services/api-interceptor.service.ts
src/pybind/mgr/dashboard/openapi.yaml

index e8bb4bbef8e159f02e9ff1c9921b54fee62a75b6..eca87a9a84edb5dc3aca06a03a4e58d7dbf8acc8 100644 (file)
@@ -38,6 +38,7 @@ 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
index d7acec22bebbf62049205d90a3a7c1838d5e07b8..2ce60c2054b222d9cee810b315c06c6bbf1b4377 100644 (file)
@@ -1,15 +1,17 @@
 # -*- coding: utf-8 -*-
 
 import json
+import time
 
 import requests
 
-from ..exceptions import DashboardException
+from ..exceptions import DashboardException, ExpiredSignatureError, InvalidTokenError
 from ..security import Scope
+from ..services.auth import JwtManager
 from ..settings import Settings
 from ..tools import configure_cors
-from . import APIDoc, APIRouter, CreatePermission, Endpoint, EndpointDoc, \
-    ReadPermission, RESTController, UIRouter, UpdatePermission
+from . import APIDoc, APIRouter, CreatePermission, DeletePermission, Endpoint, \
+    EndpointDoc, ReadPermission, RESTController, UIRouter, UpdatePermission
 
 
 @APIRouter('/multi-cluster', Scope.CONFIG_OPT)
@@ -17,6 +19,8 @@ from . import APIDoc, APIRouter, CreatePermission, Endpoint, EndpointDoc, \
 class MultiCluster(RESTController):
     def _proxy(self, method, base_url, path, params=None, payload=None, verify=False,
                token=None):
+        if not base_url.endswith('/'):
+            base_url = base_url + '/'
         try:
             if token:
                 headers = {
@@ -48,12 +52,7 @@ class MultiCluster(RESTController):
     @CreatePermission
     @EndpointDoc("Authenticate to a remote cluster")
     def auth(self, url: str, cluster_alias: str, username=None,
-             password=None, token=None, hub_url=None):
-
-        multi_cluster_config = self.load_multi_cluster_config()
-
-        if not url.endswith('/'):
-            url = url + '/'
+             password=None, token=None, hub_url=None, cluster_fsid=None):
 
         if username and password:
             payload = {
@@ -67,41 +66,40 @@ class MultiCluster(RESTController):
                     http_status_code=400,
                     component='dashboard')
 
-            token = content['token']
+            cluster_token = content['token']
 
-        if token:
             self._proxy('PUT', url, 'ui-api/multi-cluster/set_cors_endpoint',
-                        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] = [{
+                        payload={'url': hub_url}, token=cluster_token)
+
+            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)
+
+        if token and cluster_fsid and username:
+            self.set_multi_cluster_config(cluster_fsid, username, url, cluster_alias, token)
+
+    def set_multi_cluster_config(self, fsid, username, url, cluster_alias, token):
+        multi_cluster_config = self.load_multi_cluster_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,
-                }]
-
-            Settings.MULTICLUSTER_CONFIG = multi_cluster_config
+                })
+        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):
@@ -124,13 +122,71 @@ class MultiCluster(RESTController):
         Settings.MULTICLUSTER_CONFIG = multicluster_config
         return Settings.MULTICLUSTER_CONFIG
 
-    @Endpoint('POST')
+    @Endpoint('PUT')
     @CreatePermission
-    # pylint: disable=R0911
-    def verify_connection(self, url: str, username=None, password=None, token=None):
-        if not url.endswith('/'):
-            url = url + '/'
+    # pylint: disable=unused-variable
+    def reconnect_cluster(self, url: str, username=None, password=None, token=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)
+            if 'token' not in content:
+                raise DashboardException(
+                    "Could not authenticate to remote cluster",
+                    http_status_code=400,
+                    component='dashboard')
 
+            token = content['token']
+
+        if username and token:
+            if "config" in multicluster_config:
+                for key, cluster_details in multicluster_config["config"].items():
+                    for cluster in cluster_details:
+                        if cluster["url"] == url and cluster["user"] == username:
+                            cluster['token'] = token
+            Settings.MULTICLUSTER_CONFIG = multicluster_config
+        return Settings.MULTICLUSTER_CONFIG
+
+    @Endpoint('PUT')
+    @UpdatePermission
+    # pylint: disable=unused-variable
+    def edit_cluster(self, url, cluster_alias, username):
+        multicluster_config = self.load_multi_cluster_config()
+        if "config" in multicluster_config:
+            for key, cluster_details in multicluster_config["config"].items():
+                for cluster in cluster_details:
+                    if cluster["url"] == url and cluster["user"] == username:
+                        cluster['cluster_alias'] = cluster_alias
+        Settings.MULTICLUSTER_CONFIG = multicluster_config
+        return Settings.MULTICLUSTER_CONFIG
+
+    @Endpoint(method='DELETE')
+    @DeletePermission
+    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]
+
+        Settings.MULTICLUSTER_CONFIG = multicluster_config
+        return Settings.MULTICLUSTER_CONFIG
+
+    @Endpoint()
+    @ReadPermission
+    # pylint: disable=R0911
+    def verify_connection(self, url=None, username=None, password=None, token=None):
         if token:
             try:
                 payload = {
@@ -172,6 +228,40 @@ class MultiCluster(RESTController):
     def get_config(self):
         return Settings.MULTICLUSTER_CONFIG
 
+    def is_token_expired(self, jwt_token):
+        try:
+            decoded_token = JwtManager.decode_token(jwt_token)
+            expiration_time = decoded_token['exp']
+            current_time = time.time()
+            return expiration_time < current_time
+        except ExpiredSignatureError:
+            return True
+        except InvalidTokenError:
+            return True
+
+    def check_token_status_expiration(self, token):
+        if self.is_token_expired(token):
+            return 1
+        return 0
+
+    def check_token_status_array(self, clusters_token_array):
+        token_status_map = {}
+
+        for item in clusters_token_array:
+            cluster_name = item['name']
+            token = item['token']
+            user = item['user']
+            status = self.check_token_status_expiration(token)
+            token_status_map[cluster_name] = {'status': status, 'user': user}
+
+        return token_status_map
+
+    @Endpoint()
+    @ReadPermission
+    def check_token_status(self, clustersTokenMap=None):
+        clusters_token_map = json.loads(clustersTokenMap)
+        return self.check_token_status_array(clusters_token_map)
+
 
 @UIRouter('/multi-cluster', Scope.CONFIG_OPT)
 class MultiClusterUi(RESTController):
index 48224c844d47bee01a8c1af0a7f76123b2cc8991..c54681b065f27043e7f4a70a05023ca10e9b00f6 100644 (file)
@@ -49,6 +49,7 @@ 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';
+import { MultiClusterListComponent } from './ceph/cluster/multi-cluster/multi-cluster-list/multi-cluster-list.component';
 
 @Injectable()
 export class PerformanceCounterBreadcrumbsResolver extends BreadcrumbsResolver {
@@ -187,7 +188,22 @@ const routes: Routes = [
       },
       {
         path: 'multi-cluster',
-        component: MultiClusterComponent
+        children: [
+          {
+            path: 'overview',
+            component: MultiClusterComponent,
+            data: {
+              breadcrumbs: 'Multi-Cluster/Overview'
+            }
+          },
+          {
+            path: 'manage-clusters',
+            component: MultiClusterListComponent,
+            data: {
+              breadcrumbs: 'Multi-Cluster/Manage Clusters'
+            }
+          }
+        ]
       },
       {
         path: 'inventory',
index b1eb9275a462ceb84157758a84aa3fcff5711d1c..2f0734885d857b40a63a0427a6fe8d6903543073 100644 (file)
@@ -63,6 +63,7 @@ import { UpgradeStartModalComponent } from './upgrade/upgrade-form/upgrade-start
 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';
+import { MultiClusterListComponent } from './multi-cluster/multi-cluster-list/multi-cluster-list.component';
 
 @NgModule({
   imports: [
@@ -128,7 +129,8 @@ import { MultiClusterFormComponent } from './multi-cluster/multi-cluster-form/mu
     UpgradeStartModalComponent,
     UpgradeProgressComponent,
     MultiClusterComponent,
-    MultiClusterFormComponent
+    MultiClusterFormComponent,
+    MultiClusterListComponent
   ],
   providers: [NgbActiveModal]
 })
index cc9ed7453fc4d094e0509780a94a501071e8eb01..c875557306a855f3292b40c3825ff38cadea8423 100644 (file)
@@ -1,6 +1,6 @@
 <cd-modal [modalRef]="activeModal">
   <ng-container i18n="form title"
-                class="modal-title">Connect Cluster
+                class="modal-title">{{ action | titlecase }} Cluster
   </ng-container>
   <ng-container class="modal-content">
     <form name="remoteClusterForm"
                   *ngIf="remoteClusterForm.showError('clusterAlias', frm, 'required')"
                   i18n>This field is required.
             </span>
+            <span class="invalid-feedback"
+                  *ngIf="remoteClusterForm.showError('clusterAlias', frm, 'uniqueName')"
+                  i18n>The chosen alias name is already in use.
+            </span>
           </div>
         </div>
         <div class="form-group row"
-             *ngIf="!remoteClusterForm.getValue('showToken') && !showCrossOriginError">
+             *ngIf="action !== 'edit'">
           <label class="cd-col-form-label required"
-                 for="apiToken"
+                 for="username"
                  i18n>Username
           </label>
           <div class="cd-col-form-input">
                   *ngIf="remoteClusterForm.showError('username', frm, 'required')"
                   i18n>This field is required.
             </span>
+            <span class="invalid-feedback"
+                  *ngIf="remoteClusterForm.showError('username', frm, 'uniqueUrlandUser')"
+                  i18n>A cluster with the chosen user is already connected.
+            </span>
+          </div>
+        </div>
+        <div class="form-group row"
+             *ngIf="remoteClusterForm.getValue('showToken') && action !== 'edit'">
+          <label class="cd-col-form-label required"
+                 for="clusterFsid"
+                 i18n>Cluster FSID
+          </label>
+          <div class="cd-col-form-input">
+            <input id="clusterFsid"
+                   name="clusterFsid"
+                   class="form-control"
+                   type="text"
+                   formControlName="clusterFsid">
+            <span class="invalid-feedback"
+                  *ngIf="remoteClusterForm.showError('clusterFsid', frm, 'required')"
+                  i18n>This field is required.
+            </span>
           </div>
         </div>
         <div class="form-group row"
-             *ngIf="!remoteClusterForm.getValue('showToken') && !showCrossOriginError">
+             *ngIf="!remoteClusterForm.getValue('showToken') && !showCrossOriginError && action !== 'edit'">
           <label class="cd-col-form-label required"
                  for="password"
                  i18n>Password
             </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')">
+             *ngIf="remoteClusterForm.getValue('showToken') && action !== 'edit'">
           <label class="cd-col-form-label required"
                  for="apiToken"
                  i18n>Token
           </div>
         </div>
         <div class="form-group row"
-             *ngIf="!showCrossOriginError">
+             *ngIf="action !== 'edit'">
+          <div class="cd-col-form-offset">
+            <div class="custom-control custom-checkbox">
+              <input class="custom-control-input"
+                     id="showToken"
+                     type="checkbox"
+                     [checked]="showToken"
+                     (change)="toggleToken()"
+                     formControlName="showToken">
+              <label class="custom-control-label"
+                     for="showToken"
+                     i18n>Auth with token</label>
+            </div>
+          </div>
+        </div>
+        <div class="form-group row"
+             *ngIf="!showCrossOriginError && action !== 'edit' && !remoteClusterForm.getValue('showToken')">
           <div class="cd-col-form-offset">
             <div class="custom-control">
               <button class="btn btn-primary"
       </div>
       <div class="modal-footer">
         <cd-form-button-panel (submitActionEvent)="onSubmit()"
-                              [submitText]="actionLabels.CONNECT"
-                              [disabled]="!connectionVerified && !showCrossOriginError"
+                              [submitText]="(action | titlecase) + ' ' + 'Cluster'"
                               [form]="remoteClusterForm">
         </cd-form-button-panel>
       </div>
index 473a49dab7f79702f4dae91d4a9febeeca28960e..83eb9fb5d51ee8b18f5a76a22f3182d5641cf02c 100644 (file)
@@ -1,4 +1,4 @@
-import { Component, OnDestroy, OnInit } from '@angular/core';
+import { Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core';
 import { FormControl, Validators } from '@angular/forms';
 import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
 import _ from 'lodash';
@@ -8,6 +8,7 @@ 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 { MultiCluster } from '~/app/shared/models/multi-cluster';
 import { NotificationService } from '~/app/shared/services/notification.service';
 
 @Component({
@@ -16,6 +17,8 @@ import { NotificationService } from '~/app/shared/services/notification.service'
   styleUrls: ['./multi-cluster-form.component.scss']
 })
 export class MultiClusterFormComponent implements OnInit, OnDestroy {
+  @Output()
+  submitAction = new EventEmitter();
   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;
@@ -26,6 +29,13 @@ export class MultiClusterFormComponent implements OnInit, OnDestroy {
   private subs = new Subscription();
   showCrossOriginError = false;
   crossOriginCmd: string;
+  action: string;
+  cluster: MultiCluster;
+  clustersData: MultiCluster[];
+  clusterAliasNames: string[];
+  clusterUrls: string[];
+  clusterUsers: string[];
+  clusterUrlUserMap: Map<string, string>;
 
   constructor(
     public activeModal: NgbActiveModal,
@@ -35,21 +45,57 @@ export class MultiClusterFormComponent implements OnInit, OnDestroy {
   ) {
     this.createForm();
   }
-  ngOnInit(): void {}
+  ngOnInit(): void {
+    if (this.action === 'edit') {
+      this.remoteClusterForm.get('remoteClusterUrl').setValue(this.cluster.url);
+      this.remoteClusterForm.get('remoteClusterUrl').disable();
+      this.remoteClusterForm.get('clusterAlias').setValue(this.cluster.cluster_alias);
+    }
+    if (this.action === 'reconnect') {
+      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('clusterAlias').disable();
+      this.remoteClusterForm.get('username').setValue(this.cluster.user);
+      this.remoteClusterForm.get('username').disable();
+      this.remoteClusterForm.get('clusterFsid').setValue(this.cluster.name);
+      this.remoteClusterForm.get('clusterFsid').disable();
+    }
+    [this.clusterAliasNames, this.clusterUrls, this.clusterUsers] = [
+      'cluster_alias',
+      'url',
+      'user'
+    ].map((prop) => this.clustersData?.map((cluster) => cluster[prop]));
+  }
 
   createForm() {
     this.remoteClusterForm = new CdFormGroup({
       showToken: new FormControl(false),
       username: new FormControl('', [
-        CdValidators.requiredIf({
-          showToken: false
+        CdValidators.custom('uniqueUrlandUser', (username: string) => {
+          let remoteClusterUrl = '';
+          if (
+            this.remoteClusterForm &&
+            this.remoteClusterForm.getValue('remoteClusterUrl') &&
+            this.remoteClusterForm.getValue('remoteClusterUrl').endsWith('/')
+          ) {
+            remoteClusterUrl = this.remoteClusterForm.getValue('remoteClusterUrl').slice(0, -1);
+          } else if (this.remoteClusterForm) {
+            remoteClusterUrl = this.remoteClusterForm.getValue('remoteClusterUrl');
+          }
+          return (
+            this.remoteClusterForm &&
+            this.clusterUrls?.includes(remoteClusterUrl) &&
+            this.clusterUsers?.includes(username)
+          );
         })
       ]),
-      password: new FormControl('', [
+      clusterFsid: new FormControl('', [
         CdValidators.requiredIf({
-          showToken: false
+          showToken: true
         })
       ]),
+      password: new FormControl('', []),
       remoteClusterUrl: new FormControl(null, {
         validators: [
           CdValidators.custom('endpoint', (value: string) => {
@@ -71,8 +117,17 @@ export class MultiClusterFormComponent implements OnInit, OnDestroy {
           showToken: true
         })
       ]),
-      clusterAlias: new FormControl('', {
-        validators: [Validators.required]
+      clusterAlias: new FormControl(null, {
+        validators: [
+          Validators.required,
+          CdValidators.custom('uniqueName', (clusterAlias: string) => {
+            return (
+              (this.action === 'connect' || this.action === 'edit') &&
+              this.clusterAliasNames &&
+              this.clusterAliasNames.indexOf(clusterAlias) !== -1
+            );
+          })
+        ]
       })
     });
   }
@@ -83,27 +138,78 @@ export class MultiClusterFormComponent implements OnInit, OnDestroy {
 
   onSubmit() {
     const url = this.remoteClusterForm.getValue('remoteClusterUrl');
+    const updatedUrl = url.endsWith('/') ? url.slice(0, -1) : url;
     const clusterAlias = this.remoteClusterForm.getValue('clusterAlias');
     const username = this.remoteClusterForm.getValue('username');
     const password = this.remoteClusterForm.getValue('password');
     const token = this.remoteClusterForm.getValue('apiToken');
+    const clusterFsid = this.remoteClusterForm.getValue('clusterFsid');
 
-    this.subs.add(
-      this.multiClusterService
-        .addCluster(url, clusterAlias, username, password, token, window.location.origin)
-        .subscribe({
+    if (this.action === 'edit') {
+      this.subs.add(
+        this.multiClusterService
+          .editCluster(this.cluster.url, clusterAlias, this.cluster.user)
+          .subscribe({
+            error: () => {
+              this.remoteClusterForm.setErrors({ cdSubmitButton: true });
+            },
+            complete: () => {
+              this.notificationService.show(
+                NotificationType.success,
+                $localize`Cluster updated successfully`
+              );
+              this.submitAction.emit();
+              this.activeModal.close();
+            }
+          })
+      );
+    }
+
+    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 added successfully`
+              $localize`Cluster reconnected successfully`
             );
+            this.submitAction.emit();
             this.activeModal.close();
           }
         })
-    );
+      );
+    }
+
+    if (this.action === 'connect') {
+      this.subs.add(
+        this.multiClusterService
+          .addCluster(
+            updatedUrl,
+            clusterAlias,
+            username,
+            password,
+            token,
+            window.location.origin,
+            clusterFsid
+          )
+          .subscribe({
+            error: () => {
+              this.remoteClusterForm.setErrors({ cdSubmitButton: true });
+            },
+            complete: () => {
+              this.notificationService.show(
+                NotificationType.success,
+                $localize`Cluster connected successfully`
+              );
+              this.submitAction.emit();
+              this.activeModal.close();
+            }
+          })
+      );
+    }
   }
 
   verifyConnection() {
@@ -149,4 +255,8 @@ export class MultiClusterFormComponent implements OnInit, OnDestroy {
         })
     );
   }
+
+  toggleToken() {
+    this.showToken = !this.showToken;
+  }
 }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster-list/multi-cluster-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster-list/multi-cluster-list.component.html
new file mode 100644 (file)
index 0000000..7aea2f4
--- /dev/null
@@ -0,0 +1,37 @@
+<nav ngbNav
+     #nav="ngbNav"
+     class="nav-tabs">
+  <ng-container ngbNavItem>
+    <a ngbNavLink
+       i18n>Clusters List</a>
+    <ng-template ngbNavContent>
+      <cd-table #table
+                [data]="data"
+                [columns]="columns"
+                columnMode="flex"
+                selectionType="single"
+                [maxLimit]="25"
+                (updateSelection)="updateSelection($event)">
+        <div class="table-actions btn-toolbar">
+          <cd-table-actions [permission]="permissions.user"
+                            [selection]="selection"
+                            class="btn-group"
+                            id="cluster-actions"
+                            [tableActions]="tableActions">
+          </cd-table-actions>
+        </div>
+      </cd-table>
+    </ng-template>
+  </ng-container>
+</nav>
+
+<ng-template #urlTpl
+             let-row="row">
+  <a target="_blank"
+     [href]="row.url">
+      {{ row.url.endsWith('/') ? row.url.slice(0, -1) : row.url }}
+    <i class="fa fa-external-link"></i>
+  </a>
+</ng-template>
+
+<div [ngbNavOutlet]="nav"></div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster-list/multi-cluster-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster-list/multi-cluster-list.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-list/multi-cluster-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster-list/multi-cluster-list.component.spec.ts
new file mode 100644 (file)
index 0000000..d69b3a4
--- /dev/null
@@ -0,0 +1,30 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ToastrModule } from 'ngx-toastr';
+import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
+
+import { MultiClusterListComponent } from './multi-cluster-list.component';
+import { CdDatePipe } from '~/app/shared/pipes/cd-date.pipe';
+import { TableActionsComponent } from '~/app/shared/datatable/table-actions/table-actions.component';
+import { SharedModule } from '~/app/shared/shared.module';
+
+describe('MultiClusterListComponent', () => {
+  let component: MultiClusterListComponent;
+  let fixture: ComponentFixture<MultiClusterListComponent>;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      imports: [HttpClientTestingModule, ToastrModule.forRoot(), NgbNavModule, SharedModule],
+      declarations: [MultiClusterListComponent],
+      providers: [CdDatePipe, TableActionsComponent]
+    }).compileComponents();
+
+    fixture = TestBed.createComponent(MultiClusterListComponent);
+    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-list/multi-cluster-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/multi-cluster/multi-cluster-list/multi-cluster-list.component.ts
new file mode 100644 (file)
index 0000000..4496b3a
--- /dev/null
@@ -0,0 +1,213 @@
+import { Component, TemplateRef, ViewChild } from '@angular/core';
+import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
+import { MultiClusterService } from '~/app/shared/api/multi-cluster.service';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { CdTableAction } from '~/app/shared/models/cd-table-action';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { ModalService } from '~/app/shared/services/modal.service';
+import { MultiClusterFormComponent } from '../multi-cluster-form/multi-cluster-form.component';
+import { TableComponent } from '~/app/shared/datatable/table/table.component';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { Permissions } from '~/app/shared/models/permissions';
+import { CriticalConfirmationModalComponent } from '~/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { CellTemplate } from '~/app/shared/enum/cell-template.enum';
+import { MultiCluster } from '~/app/shared/models/multi-cluster';
+import { SummaryService } from '~/app/shared/services/summary.service';
+import { Router } from '@angular/router';
+
+@Component({
+  selector: 'cd-multi-cluster-list',
+  templateUrl: './multi-cluster-list.component.html',
+  styleUrls: ['./multi-cluster-list.component.scss']
+})
+export class MultiClusterListComponent {
+  @ViewChild(TableComponent)
+  table: TableComponent;
+  @ViewChild('urlTpl', { static: true })
+  public urlTpl: TemplateRef<any>;
+
+  permissions: Permissions;
+  tableActions: CdTableAction[];
+  clusterTokenStatus: object = {};
+  columns: Array<CdTableColumn> = [];
+  data: any;
+  selection = new CdTableSelection();
+  bsModalRef: NgbModalRef;
+  clustersTokenMap: Map<string, string> = new Map<string, string>();
+  newData: any;
+  modalRef: NgbModalRef;
+
+  constructor(
+    private multiClusterService: MultiClusterService,
+    private router: Router,
+    private summaryService: SummaryService,
+    public actionLabels: ActionLabelsI18n,
+    private notificationService: NotificationService,
+    private authStorageService: AuthStorageService,
+    private modalService: ModalService
+  ) {
+    this.tableActions = [
+      {
+        permission: 'create',
+        icon: Icons.add,
+        name: this.actionLabels.CONNECT,
+        click: () => this.openRemoteClusterInfoModal('connect')
+      },
+      {
+        permission: 'update',
+        icon: Icons.edit,
+        name: this.actionLabels.EDIT,
+        disable: (selection: CdTableSelection) => this.getDisable('edit', selection),
+        click: () => this.openRemoteClusterInfoModal('edit')
+      },
+      {
+        permission: 'update',
+        icon: Icons.refresh,
+        name: this.actionLabels.RECONNECT,
+        disable: (selection: CdTableSelection) => this.getDisable('reconnect', selection),
+        click: () => this.openRemoteClusterInfoModal('reconnect')
+      },
+      {
+        permission: 'delete',
+        icon: Icons.destroy,
+        name: this.actionLabels.DISCONNECT,
+        disable: (selection: CdTableSelection) => this.getDisable('disconnect', selection),
+        click: () => this.openDeleteClusterModal()
+      }
+    ];
+    this.permissions = this.authStorageService.getPermissions();
+  }
+
+  ngOnInit(): void {
+    this.multiClusterService.subscribe((resp: object) => {
+      if (resp && resp['config']) {
+        const clusterDetailsArray = Object.values(resp['config']).flat();
+        this.data = clusterDetailsArray;
+        this.checkClusterConnectionStatus();
+      }
+    });
+
+    this.columns = [
+      {
+        prop: 'cluster_alias',
+        name: $localize`Alias`,
+        flexGrow: 2
+      },
+      {
+        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: 'name',
+        name: $localize`FSID`,
+        flexGrow: 2
+      },
+      {
+        prop: 'url',
+        name: $localize`URL`,
+        flexGrow: 2,
+        cellTemplate: this.urlTpl
+      },
+      {
+        prop: 'user',
+        name: $localize`User`,
+        flexGrow: 2
+      }
+    ];
+
+    this.multiClusterService.subscribeClusterTokenStatus((resp: object) => {
+      this.clusterTokenStatus = resp;
+      this.checkClusterConnectionStatus();
+    });
+  }
+
+  checkClusterConnectionStatus() {
+    if (this.clusterTokenStatus && this.data) {
+      this.data.forEach((cluster: MultiCluster) => {
+        const clusterStatus = this.clusterTokenStatus[cluster.name];
+
+        if (clusterStatus !== undefined) {
+          cluster.cluster_connection_status = clusterStatus.status;
+        } else {
+          cluster.cluster_connection_status = 2;
+        }
+
+        if (cluster.cluster_alias === 'local-cluster') {
+          cluster.cluster_connection_status = 0;
+        }
+      });
+    }
+  }
+
+  openRemoteClusterInfoModal(action: string) {
+    const initialState = {
+      clustersData: this.data,
+      action: action,
+      cluster: this.selection.first()
+    };
+    this.bsModalRef = this.modalService.show(MultiClusterFormComponent, initialState, {
+      size: 'xl'
+    });
+    this.bsModalRef.componentInstance.submitAction.subscribe(() => {
+      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]);
+        });
+      }
+    });
+  }
+
+  updateSelection(selection: CdTableSelection) {
+    this.selection = selection;
+  }
+
+  openDeleteClusterModal() {
+    const cluster = this.selection.first();
+    this.modalRef = this.modalService.show(CriticalConfirmationModalComponent, {
+      actionDescription: $localize`Disconnect`,
+      itemDescription: $localize`Cluster`,
+      itemNames: [cluster['cluster_alias'] + ' - ' + cluster['user']],
+      submitAction: () =>
+        this.multiClusterService.deleteCluster(cluster['name'], cluster['user']).subscribe(() => {
+          this.modalRef.close();
+          this.notificationService.show(
+            NotificationType.success,
+            $localize`Disconnected cluster '${cluster['cluster_alias']}'`
+          );
+        })
+    });
+  }
+
+  getDisable(action: string, selection: CdTableSelection): string | boolean {
+    if (!selection.hasSelection) {
+      return $localize`Please select one or more clusters to ${action}`;
+    }
+    if (selection.hasSingleSelection) {
+      const cluster = selection.first();
+      if (cluster['cluster_alias'] === 'local-cluster') {
+        return $localize`Cannot ${action} local cluster`;
+      }
+    }
+    return false;
+  }
+}
index 5009909ea3fac59a67f03db296f98c11cd6d5fbe..c826f155c40bfa532095202fc6769c0f28299651 100644 (file)
   </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>
index 2630c839a424527d105d357a77eaebbd0aefebfa..dbbf10e74848bd1b19b9dc9b43c4fc3aa2f12dd2 100644 (file)
@@ -50,7 +50,10 @@ export class MultiClusterComponent implements OnInit {
   }
 
   openRemoteClusterInfoModal() {
-    this.bsModalRef = this.modalService.show(MultiClusterFormComponent, {
+    const initialState = {
+      action: 'connect'
+    };
+    this.bsModalRef = this.modalService.show(MultiClusterFormComponent, initialState, {
       size: 'xl'
     });
   }
index 1d7c4bb751cb6ae1fba1f5f4b828fa1e8e5565bf..8ddbddf2fe81493fe93625139df5ddeec7f1844b 100644 (file)
@@ -27,6 +27,7 @@ export class WorkbenchLayoutComponent implements OnInit, OnDestroy {
 
   ngOnInit() {
     this.subs.add(this.multiClusterService.startPolling());
+    this.subs.add(this.multiClusterService.startClusterTokenStatusPolling());
     this.subs.add(this.summaryService.startPolling());
     this.subs.add(this.taskManagerService.init(this.summaryService));
     this.faviconService.init();
index 6af3799b4ef84734abb22debccd18e6f67b6e40b..8f2633ed0a15a19fc3dfe1b8ccdc7e9ecb3a4e0a 100644 (file)
@@ -44,7 +44,8 @@
             <div ngbDropdownMenu>
               <ng-container *ngFor="let cluster of clustersMap | keyvalue">
                 <button ngbDropdownItem
-                        (click)="onClusterSelection(cluster.value)">
+                        (click)="onClusterSelection(cluster.value)"
+                        [disabled]="cluster.value.cluster_connection_status === 1">
                   <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>
           <li routerLinkActive="active"
               class="tc_submenuitem tc_submenuitem_multiCluster_overview">
             <a i18n
-               routerLink="/multi-cluster">Overview</a>
+               routerLink="/multi-cluster/overview">Overview</a>
+          </li>
+          <li routerLinkActive="active"
+              class="tc_submenuitem tc_submenuitem_multiCluster_manage_clusters">
+            <a i18n
+               routerLink="/multi-cluster/manage-clusters">Manage Clusters</a>
           </li>
         </ul>
       </li>
index 10963042d25fdfd4ae0fa81680ddc62432ea1484..4ae8d1897e27e9c82b24e44f60b4975c2431263e 100644 (file)
@@ -6,6 +6,7 @@ import { Subscription } from 'rxjs';
 import { MultiClusterService } from '~/app/shared/api/multi-cluster.service';
 
 import { Icons } from '~/app/shared/enum/icons.enum';
+import { MultiCluster } from '~/app/shared/models/multi-cluster';
 import { Permissions } from '~/app/shared/models/permissions';
 import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
 import {
@@ -31,6 +32,7 @@ export class NavigationComponent implements OnInit, OnDestroy {
 
   permissions: Permissions;
   enabledFeature$: FeatureTogglesMap$;
+  clusterTokenStatus: object = {};
   summaryData: any;
   icons = Icons;
 
@@ -62,22 +64,17 @@ export class NavigationComponent implements OnInit, OnDestroy {
 
   ngOnInit() {
     this.subs.add(
-      this.multiClusterService.subscribe((resp: any) => {
+      this.multiClusterService.subscribe((resp: object) => {
         const clustersConfig = resp['config'];
         if (clustersConfig) {
           Object.keys(clustersConfig).forEach((clusterKey: string) => {
             const clusterDetailsList = clustersConfig[clusterKey];
-            clusterDetailsList.forEach((clusterDetails: any) => {
-              const clusterName = clusterDetails['name'];
+            clusterDetailsList.forEach((clusterDetails: MultiCluster) => {
               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.clustersMap.set(clusterUniqueKey, clusterDetails);
+              this.checkClusterConnectionStatus();
             });
           });
           this.selectedCluster =
@@ -111,12 +108,40 @@ export class NavigationComponent implements OnInit, OnDestroy {
         this.showTopNotification('motdNotificationEnabled', _.isPlainObject(motd));
       })
     );
+    this.subs.add(
+      this.multiClusterService.subscribeClusterTokenStatus((resp: object) => {
+        this.clusterTokenStatus = resp;
+        this.checkClusterConnectionStatus();
+      })
+    );
   }
 
   ngOnDestroy(): void {
     this.subs.unsubscribe();
   }
 
+  checkClusterConnectionStatus() {
+    this.clustersMap.forEach((clusterDetails, clusterName) => {
+      const clusterTokenStatus = this.clusterTokenStatus[clusterDetails.name];
+      const connectionStatus = clusterTokenStatus ? clusterTokenStatus.status : 0;
+      const user = clusterTokenStatus ? clusterTokenStatus.user : clusterDetails.user;
+
+      this.clustersMap.set(clusterName, {
+        ...clusterDetails,
+        cluster_connection_status: connectionStatus,
+        user: user
+      });
+
+      if (clusterDetails.cluster_alias === 'local-cluster') {
+        this.clustersMap.set(clusterName, {
+          ...clusterDetails,
+          cluster_connection_status: 0,
+          user: user
+        });
+      }
+    });
+  }
+
   blockHealthColor() {
     if (this.summaryData && this.summaryData.rbd_mirroring) {
       if (this.summaryData.rbd_mirroring.errors > 0) {
index 5a17645092df2726e06a713ac2ecfcb2b283f92a..7252e969e60ae490cea37799df508085705d13b0 100644 (file)
@@ -1,6 +1,6 @@
-import { HttpClient } from '@angular/common/http';
+import { HttpClient, HttpParams } from '@angular/common/http';
 import { Injectable } from '@angular/core';
-import { BehaviorSubject, Subscription } from 'rxjs';
+import { BehaviorSubject, Observable, Subscription } from 'rxjs';
 import { TimerService } from '../services/timer.service';
 import { filter } from 'rxjs/operators';
 
@@ -8,8 +8,11 @@ import { filter } from 'rxjs/operators';
   providedIn: 'root'
 })
 export class MultiClusterService {
+  TOKEN_CHECK_INTERVAL = 600000; // 10m interval
   private msSource = new BehaviorSubject<any>(null);
   msData$ = this.msSource.asObservable();
+  private tokenStatusSource = new BehaviorSubject<any>(null);
+  tokenStatusSource$ = this.tokenStatusSource.asObservable();
   constructor(private http: HttpClient, private timerService: TimerService) {}
 
   startPolling(): Subscription {
@@ -18,6 +21,44 @@ export class MultiClusterService {
       .subscribe(this.getClusterObserver());
   }
 
+  startClusterTokenStatusPolling() {
+    let clustersTokenMap = new Map<string, { token: string; user: string }>();
+    const dataSubscription = this.subscribe((resp: any) => {
+      const clustersConfig = resp['config'];
+      const tempMap = new Map<string, { token: string; user: string }>();
+      if (clustersConfig) {
+        Object.keys(clustersConfig).forEach((clusterKey: string) => {
+          const clusterDetailsList = clustersConfig[clusterKey];
+          clusterDetailsList.forEach((clusterDetails: any) => {
+            if (clusterDetails['token'] && clusterDetails['name'] && clusterDetails['user']) {
+              tempMap.set(clusterDetails['name'], {
+                token: clusterDetails['token'],
+                user: clusterDetails['user']
+              });
+            }
+          });
+        });
+
+        if (tempMap.size > 0) {
+          clustersTokenMap = tempMap;
+          dataSubscription.unsubscribe();
+          this.checkAndStartTimer(clustersTokenMap);
+        }
+      }
+    });
+  }
+
+  private checkAndStartTimer(clustersTokenMap: Map<string, { token: string; user: string }>) {
+    this.checkTokenStatus(clustersTokenMap).subscribe(this.getClusterTokenStatusObserver());
+    this.timerService
+      .get(() => this.checkTokenStatus(clustersTokenMap), this.TOKEN_CHECK_INTERVAL)
+      .subscribe(this.getClusterTokenStatusObserver());
+  }
+
+  subscribeClusterTokenStatus(next: (data: any) => void, error?: (error: any) => void) {
+    return this.tokenStatusSource$.pipe(filter((value) => !!value)).subscribe(next, error);
+  }
+
   refresh(): Subscription {
     return this.getCluster().subscribe(this.getClusterObserver());
   }
@@ -34,13 +75,26 @@ export class MultiClusterService {
     return this.http.get('api/multi-cluster/get_config');
   }
 
+  deleteCluster(clusterName: string, clusterUser: string): Observable<any> {
+    return this.http.delete(`api/multi-cluster/delete_cluster/${clusterName}/${clusterUser}`);
+  }
+
+  editCluster(url: any, clusterAlias: string, username: string) {
+    return this.http.put('api/multi-cluster/edit_cluster', {
+      url,
+      cluster_alias: clusterAlias,
+      username
+    });
+  }
+
   addCluster(
     url: any,
     clusterAlias: string,
     username: string,
     password: string,
     token = '',
-    hub_url = ''
+    hub_url = '',
+    clusterFsid = ''
   ) {
     return this.http.post('api/multi-cluster/auth', {
       url,
@@ -48,12 +102,13 @@ export class MultiClusterService {
       username,
       password,
       token,
-      hub_url
+      hub_url,
+      cluster_fsid: clusterFsid
     });
   }
 
-  verifyConnection(url: string, username: string, password: string, token = '') {
-    return this.http.post('api/multi-cluster/verify_connection', {
+  reConnectCluster(url: any, username: string, password: string, token = '') {
+    return this.http.put('api/multi-cluster/reconnect_cluster', {
       url,
       username,
       password,
@@ -61,9 +116,36 @@ export class MultiClusterService {
     });
   }
 
+  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 });
+  }
+
   private getClusterObserver() {
     return (data: any) => {
       this.msSource.next(data);
     };
   }
+
+  private getClusterTokenStatusObserver() {
+    return (data: any) => {
+      this.tokenStatusSource.next(data);
+    };
+  }
+
+  checkTokenStatus(
+    clustersTokenMap: Map<string, { token: string; user: string }>
+  ): Observable<object> {
+    let data = [...clustersTokenMap].map(([key, { token, user }]) => ({ name: key, token, user }));
+
+    let params = new HttpParams();
+    params = params.set('clustersTokenMap', JSON.stringify(data));
+
+    return this.http.get<object>('api/multi-cluster/check_token_status', { params });
+  }
 }
index a606fffbcf52e3de535d6b694fd7bb4c04519cb1..2e6555731ef3b5ae9cf68f6f69ae3ede2598be3b 100644 (file)
@@ -142,6 +142,8 @@ export class ActionLabelsI18n {
   MIGRATE: string;
   START_UPGRADE: string;
   CONNECT: string;
+  DISCONNECT: string;
+  RECONNECT: string;
 
   constructor() {
     /* Create a new item */
@@ -221,6 +223,8 @@ export class ActionLabelsI18n {
     this.START_UPGRADE = $localize`Start Upgrade`;
 
     this.CONNECT = $localize`Connect`;
+    this.DISCONNECT = $localize`Disconnect`;
+    this.RECONNECT = $localize`Reconnect`;
   }
 }
 
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/multi-cluster.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/multi-cluster.ts
new file mode 100644 (file)
index 0000000..ce4e026
--- /dev/null
@@ -0,0 +1,8 @@
+export interface MultiCluster {
+  name: string;
+  url: string;
+  user: string;
+  token: string;
+  cluster_alias: string;
+  cluster_connection_status: number;
+}
index e4e30d6a3682e37b7ee55e2a4a2d732d93e584fa..5cbd99911fb2d6709fd8e0a8029b037181f9da55 100644 (file)
@@ -76,7 +76,11 @@ export class ApiInterceptorService implements HttpInterceptor {
       });
     }
 
-    const apiUrl = localStorage.getItem('cluster_api_url');
+    let apiUrl = localStorage.getItem('cluster_api_url');
+
+    if (apiUrl && !apiUrl.endsWith('/')) {
+      apiUrl += '/';
+    }
     const currentRoute = this.router.url.split('?')[0];
 
     const ALWAYS_TO_HUB_APIs = [
index bb50d24e8032fb5fe45b5dba1239db04d0932b01..1006b6f0383aeb9d6ef2353efe644224f56caef1 100644 (file)
@@ -6700,6 +6700,8 @@ paths:
               properties:
                 cluster_alias:
                   type: string
+                cluster_fsid:
+                  type: string
                 hub_url:
                   type: string
                 password:
@@ -6739,9 +6741,14 @@ paths:
       summary: Authenticate to a remote cluster
       tags:
       - Multi-cluster
-  /api/multi-cluster/get_config:
+  /api/multi-cluster/check_token_status:
     get:
-      parameters: []
+      parameters:
+      - allowEmptyValue: true
+        in: query
+        name: clustersTokenMap
+        schema:
+          type: string
       responses:
         '200':
           content:
@@ -6761,7 +6768,44 @@ paths:
       - jwt: []
       tags:
       - Multi-cluster
-  /api/multi-cluster/set_config:
+  /api/multi-cluster/delete_cluster/{cluster_name}/{cluster_user}:
+    delete:
+      parameters:
+      - in: path
+        name: cluster_name
+        required: true
+        schema:
+          type: string
+      - in: path
+        name: cluster_user
+        required: true
+        schema:
+          type: string
+      responses:
+        '202':
+          content:
+            application/vnd.ceph.api.v1.0+json:
+              type: object
+          description: Operation is still executing. Please check the task queue.
+        '204':
+          content:
+            application/vnd.ceph.api.v1.0+json:
+              type: object
+          description: Resource deleted.
+        '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/edit_cluster:
     put:
       parameters: []
       requestBody:
@@ -6769,10 +6813,16 @@ paths:
           application/json:
             schema:
               properties:
-                config:
+                cluster_alias:
+                  type: string
+                url:
+                  type: string
+                username:
                   type: string
               required:
-              - config
+              - url
+              - cluster_alias
+              - username
               type: object
       responses:
         '200':
@@ -6798,8 +6848,30 @@ paths:
       - jwt: []
       tags:
       - Multi-cluster
-  /api/multi-cluster/verify_connection:
-    post:
+  /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/reconnect_cluster:
+    put:
       parameters: []
       requestBody:
         content:
@@ -6818,11 +6890,48 @@ paths:
               - url
               type: object
       responses:
-        '201':
+        '200':
           content:
             application/vnd.ceph.api.v1.0+json:
               type: object
-          description: Resource created.
+          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/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:
@@ -6841,6 +6950,48 @@ paths:
       - jwt: []
       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
+      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/nfs-ganesha/cluster:
     get:
       parameters: []