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
# -*- 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)
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 = {
@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 = {
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):
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 = {
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):
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 {
},
{
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',
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: [
UpgradeStartModalComponent,
UpgradeProgressComponent,
MultiClusterComponent,
- MultiClusterFormComponent
+ MultiClusterFormComponent,
+ MultiClusterListComponent
],
providers: [NgbActiveModal]
})
<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>
-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';
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({
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;
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,
) {
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) => {
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
+ );
+ })
+ ]
})
});
}
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() {
})
);
}
+
+ toggleToken() {
+ this.showToken = !this.showToken;
+ }
}
--- /dev/null
+<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>
--- /dev/null
+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();
+ });
+});
--- /dev/null
+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;
+ }
+}
</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>
}
openRemoteClusterInfoModal() {
- this.bsModalRef = this.modalService.show(MultiClusterFormComponent, {
+ const initialState = {
+ action: 'connect'
+ };
+ this.bsModalRef = this.modalService.show(MultiClusterFormComponent, initialState, {
size: 'xl'
});
}
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();
<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>
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 {
permissions: Permissions;
enabledFeature$: FeatureTogglesMap$;
+ clusterTokenStatus: object = {};
summaryData: any;
icons = Icons;
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 =
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) {
-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';
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 {
.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());
}
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,
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,
});
}
+ 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 });
+ }
}
MIGRATE: string;
START_UPGRADE: string;
CONNECT: string;
+ DISCONNECT: string;
+ RECONNECT: string;
constructor() {
/* Create a new item */
this.START_UPGRADE = $localize`Start Upgrade`;
this.CONNECT = $localize`Connect`;
+ this.DISCONNECT = $localize`Disconnect`;
+ this.RECONNECT = $localize`Reconnect`;
}
}
--- /dev/null
+export interface MultiCluster {
+ name: string;
+ url: string;
+ user: string;
+ token: string;
+ cluster_alias: string;
+ cluster_connection_status: number;
+}
});
}
- 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 = [
properties:
cluster_alias:
type: string
+ cluster_fsid:
+ type: string
hub_url:
type: string
password:
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:
- 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:
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':
- 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:
- 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:
- 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: []