Merge pull request #43903 from rhcs-dashboard/edit-service-feature

mgr/dashboard: Edit a service feature 

Reviewed-by: Avan Thakkar <athakkar@redhat.com>
Reviewed-by: Ernesto Puerta <epuertat@redhat.com>
Reviewed-by: Nizamudeen A <nia@redhat.com>
Reviewed-by: Pere Diaz Bou <pdiazbou@redhat.com>
This commit is contained in:
Ernesto Puerta 2021-11-15 17:25:33 +01:00 committed by GitHub
commit 689c213d1a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 252 additions and 27 deletions

View File

@ -70,6 +70,16 @@ export class ServicesPageHelper extends PageHelper {
}
}
editService(name: string, count: string) {
this.navigateEdit(name, true, false);
cy.get(`${this.pages.create.id}`).within(() => {
cy.get('#service_type').should('be.disabled');
cy.get('#service_id').should('be.disabled');
cy.get('#count').clear().type(count);
cy.get('cd-submit-button').click();
});
}
checkServiceStatus(daemon: string) {
this.getTableCell(this.serviceDetailColumnIndex.daemonType, daemon)
.parent()
@ -80,6 +90,16 @@ export class ServicesPageHelper extends PageHelper {
});
}
expectPlacementCount(serviceName: string, expectedCount: string) {
this.getTableCell(this.columnIndex.service_name, serviceName)
.parent()
.find(`datatable-body-cell:nth-child(${this.columnIndex.placement})`)
.should(($ele) => {
const running = $ele.text().split(';');
expect(running).to.include(`count:${expectedCount}`);
});
}
checkExist(serviceName: string, exist: boolean) {
this.getTableCell(this.columnIndex.service_name, serviceName).should(($elements) => {
const services = $elements.map((_, el) => el.textContent).get();

View File

@ -2,6 +2,7 @@ import { ServicesPageHelper } from '../cluster/services.po';
describe('Services page', () => {
const services = new ServicesPageHelper();
const serviceName = 'rgw.foo';
beforeEach(() => {
cy.login();
@ -14,7 +15,13 @@ describe('Services page', () => {
services.navigateTo('create');
services.addService('rgw');
services.checkExist('rgw.foo', true);
services.checkExist(serviceName, true);
});
it('should edit a service', () => {
const count = '2';
services.editService(serviceName, count);
services.expectPlacementCount(serviceName, count);
});
it('should create and delete an ingress service', () => {

View File

@ -20,11 +20,19 @@ describe('Create cluster create services page', () => {
});
describe('when Orchestrator is available', () => {
const serviceName = 'rgw.foo';
it('should create an rgw service', () => {
cy.get('.btn.btn-accent').first().click({ force: true });
createClusterServicePage.addService('rgw', false, '3');
createClusterServicePage.checkExist('rgw.foo', true);
createClusterServicePage.addService('rgw', false, '2');
createClusterServicePage.checkExist(serviceName, true);
});
it('should edit a service', () => {
const count = '3';
createClusterServicePage.editService(serviceName, count);
createClusterServicePage.expectPlacementCount(serviceName, count);
});
it('should create and delete an ingress service', () => {

View File

@ -52,14 +52,16 @@ export abstract class PageHelper {
/**
* Navigates to the edit page
*/
navigateEdit(name: string, select = true) {
navigateEdit(name: string, select = true, breadcrumb = true) {
if (select) {
this.navigateTo();
this.getFirstTableCell(name).click();
}
cy.contains('Creating...').should('not.exist');
cy.contains('button', 'Edit').click();
this.expectBreadcrumbText('Edit');
if (breadcrumb) {
this.expectBreadcrumbText('Edit');
}
}
/**

View File

@ -139,6 +139,11 @@ const routes: Routes = [
path: URLVerbs.CREATE,
component: ServiceFormComponent,
outlet: 'modal'
},
{
path: `${URLVerbs.EDIT}/:type/:name`,
component: ServiceFormComponent,
outlet: 'modal'
}
]
},

View File

@ -65,7 +65,7 @@
<cd-services [hasDetails]="false"
[hiddenServices]="['mon', 'mgr', 'crash', 'agent']"
[hiddenColumns]="['status.running', 'status.size', 'status.last_refresh']"
[modal]="false"></cd-services>
[routedModal]="false"></cd-services>
</div>
<div *ngSwitchCase="'4'"
class="ml-5">

View File

@ -215,10 +215,10 @@
<option *ngIf="pools === null"
[ngValue]="null"
i18n>Loading...</option>
<option *ngIf="pools !== null && pools.length === 0"
<option *ngIf="pools && pools.length === 0"
[ngValue]="null"
i18n>-- No pools available --</option>
<option *ngIf="pools !== null && pools.length > 0"
<option *ngIf="pools && pools.length > 0"
[ngValue]="null"
i18n>-- Select a pool --</option>
<option *ngFor="let pool of pools"

View File

@ -433,5 +433,22 @@ x4Ea7kGVgx9kWh5XjWz9wjZvY49UKIT5ppIAWPMbLl3UpfckiuNhTA==
formHelper.expectError('monitor_port', 'pattern');
});
});
describe('check edit fields', () => {
beforeEach(() => {
component.editing = true;
});
it('should check whether edit field is correctly loaded', () => {
const cephServiceSpy = spyOn(cephServiceService, 'list').and.callThrough();
component.ngOnInit();
expect(cephServiceSpy).toBeCalledTimes(2);
expect(component.action).toBe('Edit');
const serviceType = fixture.debugElement.query(By.css('#service_type')).nativeElement;
const serviceId = fixture.debugElement.query(By.css('#service_id')).nativeElement;
expect(serviceType.disabled).toBeTruthy();
expect(serviceId.disabled).toBeTruthy();
});
});
});
});

View File

@ -1,6 +1,6 @@
import { Component, Input, OnInit, ViewChild } from '@angular/core';
import { AbstractControl, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { ActivatedRoute, Router } from '@angular/router';
import { NgbActiveModal, NgbTypeahead } from '@ng-bootstrap/ng-bootstrap';
import _ from 'lodash';
@ -31,7 +31,13 @@ export class ServiceFormComponent extends CdForm implements OnInit {
@ViewChild(NgbTypeahead, { static: false })
typeahead: NgbTypeahead;
@Input() public hiddenServices: string[] = [];
@Input() hiddenServices: string[] = [];
@Input() editing = false;
@Input() serviceName: string;
@Input() serviceType: string;
serviceForm: CdFormGroup;
action: string;
@ -53,6 +59,7 @@ export class ServiceFormComponent extends CdForm implements OnInit {
private poolService: PoolService,
private router: Router,
private taskWrapperService: TaskWrapperService,
private route: ActivatedRoute,
public activeModal: NgbActiveModal
) {
super();
@ -213,10 +220,17 @@ export class ServiceFormComponent extends CdForm implements OnInit {
}
ngOnInit(): void {
if (this.router.url.includes('services')) {
this.pageURL = 'services';
}
this.action = this.actionLabels.CREATE;
if (this.router.url.includes('services/(modal:create')) {
this.pageURL = 'services';
} else if (this.router.url.includes('services/(modal:edit')) {
this.editing = true;
this.pageURL = 'services';
this.route.params.subscribe((params: { type: string; name: string }) => {
this.serviceName = params.name;
this.serviceType = params.type;
});
}
this.cephServiceService.getKnownTypes().subscribe((resp: Array<string>) => {
// Remove service types:
// osd - This is deployed a different way.
@ -244,6 +258,79 @@ export class ServiceFormComponent extends CdForm implements OnInit {
this.cephServiceService.list().subscribe((services: CephServiceSpec[]) => {
this.services = services.filter((service: any) => service.service_type === 'rgw');
});
if (this.editing) {
this.action = this.actionLabels.EDIT;
this.disableForEditing(this.serviceType);
this.cephServiceService.list(this.serviceName).subscribe((response: CephServiceSpec[]) => {
const formKeys = ['service_type', 'service_id', 'unmanaged'];
formKeys.forEach((keys) => {
this.serviceForm.get(keys).setValue(response[0][keys]);
});
if (!response[0]['unmanaged']) {
const placementKey = Object.keys(response[0]['placement'])[0];
let placementValue: string;
['hosts', 'label'].indexOf(placementKey) >= 0
? (placementValue = placementKey)
: (placementValue = 'hosts');
this.serviceForm.get('placement').setValue(placementValue);
this.serviceForm.get('count').setValue(response[0]['placement']['count']);
if (response[0]?.placement[placementValue]) {
this.serviceForm.get(placementValue).setValue(response[0]?.placement[placementValue]);
}
}
switch (this.serviceType) {
case 'iscsi':
const specKeys = ['pool', 'api_password', 'api_user', 'trusted_ip_list', 'api_port'];
specKeys.forEach((key) => {
this.serviceForm.get(key).setValue(response[0].spec[key]);
});
this.serviceForm.get('ssl').setValue(response[0].spec?.api_secure);
if (response[0].spec?.api_secure) {
this.serviceForm.get('ssl_cert').setValue(response[0].spec?.ssl_cert);
this.serviceForm.get('ssl_key').setValue(response[0].spec?.ssl_key);
}
break;
case 'rgw':
this.serviceForm.get('rgw_frontend_port').setValue(response[0].spec?.rgw_frontend_port);
this.serviceForm.get('ssl').setValue(response[0].spec?.ssl);
if (response[0].spec?.ssl) {
this.serviceForm
.get('ssl_cert')
.setValue(response[0].spec?.rgw_frontend_ssl_certificate);
}
break;
case 'ingress':
const ingressSpecKeys = [
'backend_service',
'virtual_ip',
'frontend_port',
'monitor_port',
'virtual_interface_networks',
'ssl'
];
ingressSpecKeys.forEach((key) => {
this.serviceForm.get(key).setValue(response[0].spec[key]);
});
if (response[0].spec?.ssl) {
this.serviceForm.get('ssl_cert').setValue(response[0].spec?.ssl_cert);
this.serviceForm.get('ssl_key').setValue(response[0].spec?.ssl_key);
}
break;
}
});
}
}
disableForEditing(serviceType: string) {
const disableForEditKeys = ['service_type', 'service_id'];
disableForEditKeys.forEach((key) => {
this.serviceForm.get(key).disable();
});
switch (serviceType) {
case 'ingress':
this.serviceForm.get('backend_service').disable();
}
}
searchLabels = (text$: Observable<string>) => {
@ -282,8 +369,12 @@ export class ServiceFormComponent extends CdForm implements OnInit {
onSubmit() {
const self = this;
const values: object = this.serviceForm.value;
const values: object = this.serviceForm.getRawValue();
const serviceType: string = values['service_type'];
let taskUrl = `service/${URLVerbs.CREATE}`;
if (this.editing) {
taskUrl = `service/${URLVerbs.EDIT}`;
}
const serviceSpec: object = {
service_type: serviceType,
placement: {},
@ -327,7 +418,7 @@ export class ServiceFormComponent extends CdForm implements OnInit {
}
serviceSpec['ssl'] = values['ssl'];
if (values['ssl']) {
serviceSpec['rgw_frontend_ssl_certificate'] = values['ssl_cert'].trim();
serviceSpec['rgw_frontend_ssl_certificate'] = values['ssl_cert']?.trim();
}
break;
case 'iscsi':
@ -342,8 +433,8 @@ export class ServiceFormComponent extends CdForm implements OnInit {
serviceSpec['api_password'] = values['api_password'];
serviceSpec['api_secure'] = values['ssl'];
if (values['ssl']) {
serviceSpec['ssl_cert'] = values['ssl_cert'].trim();
serviceSpec['ssl_key'] = values['ssl_key'].trim();
serviceSpec['ssl_cert'] = values['ssl_cert']?.trim();
serviceSpec['ssl_key'] = values['ssl_key']?.trim();
}
break;
case 'ingress':
@ -360,16 +451,17 @@ export class ServiceFormComponent extends CdForm implements OnInit {
}
serviceSpec['ssl'] = values['ssl'];
if (values['ssl']) {
serviceSpec['ssl_cert'] = values['ssl_cert'].trim();
serviceSpec['ssl_key'] = values['ssl_key'].trim();
serviceSpec['ssl_cert'] = values['ssl_cert']?.trim();
serviceSpec['ssl_key'] = values['ssl_key']?.trim();
}
serviceSpec['virtual_interface_networks'] = values['virtual_interface_networks'];
break;
}
}
this.taskWrapperService
.wrapTaskAroundCall({
task: new FinishedTask(`service/${URLVerbs.CREATE}`, {
task: new FinishedTask(taskUrl, {
service_name: serviceName
}),
call: this.cephServiceService.create(serviceSpec)

View File

@ -49,7 +49,7 @@ export class ServicesComponent extends ListWithDetails implements OnChanges, OnI
@Input() hasDetails = true;
@Input() modal = true;
@Input() routedModal = true;
permissions: Permissions;
tableActions: CdTableAction[];
@ -59,6 +59,7 @@ export class ServicesComponent extends ListWithDetails implements OnChanges, OnI
orchStatus: OrchestratorStatus;
actionOrchFeatures = {
create: [OrchestratorFeature.SERVICE_CREATE],
update: [OrchestratorFeature.SERVICE_EDIT],
delete: [OrchestratorFeature.SERVICE_DELETE]
};
@ -88,6 +89,13 @@ export class ServicesComponent extends ListWithDetails implements OnChanges, OnI
canBePrimary: (selection: CdTableSelection) => !selection.hasSelection,
disable: (selection: CdTableSelection) => this.getDisable('create', selection)
},
{
permission: 'update',
icon: Icons.edit,
click: () => this.openModal(true),
name: this.actionLabels.EDIT,
disable: (selection: CdTableSelection) => this.getDisable('update', selection)
},
{
permission: 'delete',
icon: Icons.destroy,
@ -98,12 +106,36 @@ export class ServicesComponent extends ListWithDetails implements OnChanges, OnI
];
}
openModal() {
if (this.modal) {
this.router.navigate([BASE_URL, { outlets: { modal: [URLVerbs.CREATE] } }]);
openModal(edit = false) {
if (this.routedModal) {
edit
? this.router.navigate([
BASE_URL,
{
outlets: {
modal: [
URLVerbs.EDIT,
this.selection.first().service_type,
this.selection.first().service_name
]
}
}
])
: this.router.navigate([BASE_URL, { outlets: { modal: [URLVerbs.CREATE] } }]);
} else {
this.bsModalRef = this.modalService.show(ServiceFormComponent);
this.bsModalRef.componentInstance.hiddenServices = this.hiddenServices;
let initialState = {};
edit
? (initialState = {
serviceName: this.selection.first()?.service_name,
serviceType: this.selection?.first()?.service_type,
hiddenServices: this.hiddenServices,
editing: edit
})
: (initialState = {
hiddenServices: this.hiddenServices,
editing: edit
});
this.bsModalRef = this.modalService.show(ServiceFormComponent, initialState, { size: 'lg' });
}
}
@ -155,12 +187,21 @@ export class ServicesComponent extends ListWithDetails implements OnChanges, OnI
}
}
getDisable(action: 'create' | 'delete', selection: CdTableSelection): boolean | string {
getDisable(
action: 'create' | 'update' | 'delete',
selection: CdTableSelection
): boolean | string {
if (action === 'delete') {
if (!selection?.hasSingleSelection) {
return true;
}
}
if (action === 'update') {
const disableEditServices = ['osd', 'container'];
if (disableEditServices.indexOf(this.selection.first()?.service_type) >= 0) {
return true;
}
}
return this.orchService.getTableActionDisableDesc(
this.orchStatus,
this.actionOrchFeatures[action]

View File

@ -10,6 +10,7 @@ export enum OrchestratorFeature {
SERVICE_LIST = 'describe_service',
SERVICE_CREATE = 'apply',
SERVICE_EDIT = 'apply',
SERVICE_DELETE = 'remove_service',
SERVICE_RELOAD = 'service_action',
DAEMON_LIST = 'list_daemons',

View File

@ -14,4 +14,32 @@ export interface CephServiceSpec {
service_id: string;
unmanaged: boolean;
status: CephServiceStatus;
spec: CephServiceAdditionalSpec;
placement: CephServicePlacement;
}
export interface CephServiceAdditionalSpec {
backend_service: string;
api_user: string;
api_password: string;
api_port: number;
api_secure: boolean;
rgw_frontend_port: number;
trusted_ip_list: string[];
virtual_ip: string;
frontend_port: number;
monitor_port: number;
virtual_interface_networks: string[];
pool: string;
rgw_frontend_ssl_certificate: string;
ssl: boolean;
ssl_cert: string;
ssl_key: string;
}
export interface CephServicePlacement {
count: number;
placement: string;
hosts: string[];
label: string;
}

View File

@ -334,6 +334,9 @@ export class TaskMessageService {
'service/create': this.newTaskMessage(this.commonOperations.create, (metadata) =>
this.service(metadata)
),
'service/edit': this.newTaskMessage(this.commonOperations.update, (metadata) =>
this.service(metadata)
),
'service/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) =>
this.service(metadata)
)

View File

@ -201,6 +201,7 @@ class OrchFeature(object):
SERVICE_LIST = 'describe_service'
SERVICE_CREATE = 'apply'
SERVICE_EDIT = 'apply'
SERVICE_DELETE = 'remove_service'
SERVICE_RELOAD = 'service_action'
DAEMON_LIST = 'list_daemons'