diff --git a/src/ui_ng/lib/src/create-edit-endpoint/create-edit-endpoint.component.ts b/src/ui_ng/lib/src/create-edit-endpoint/create-edit-endpoint.component.ts index bdc87b1ce..9ad2728ff 100644 --- a/src/ui_ng/lib/src/create-edit-endpoint/create-edit-endpoint.component.ts +++ b/src/ui_ng/lib/src/create-edit-endpoint/create-edit-endpoint.component.ts @@ -62,7 +62,6 @@ export class CreateEditEndpointComponent implements AfterViewChecked { currentForm: NgForm; hasChanged: boolean; - endpointHasChanged: boolean; targetNameHasChanged: boolean; @@ -263,7 +262,7 @@ export class CreateEditEndpointComponent implements AfterViewChecked { ngAfterViewChecked(): void { this.targetForm = this.currentForm; if(this.targetForm) { - let comparison: {[key: string]: string} = { + let comparison: {[key: string]: any} = { targetName: this.initVal.name, endpointUrl: this.initVal.endpoint, username: this.initVal.username, diff --git a/src/ui_ng/lib/src/create-edit-rule/create-edit-rule.component.css.ts b/src/ui_ng/lib/src/create-edit-rule/create-edit-rule.component.css.ts new file mode 100644 index 000000000..83aaea83c --- /dev/null +++ b/src/ui_ng/lib/src/create-edit-rule/create-edit-rule.component.css.ts @@ -0,0 +1,5 @@ +export const CREATE_EDIT_RULE_STYLE: string = ` +.form-group-label-override { + font-size: 14px; + font-weight: 400; +}`; \ No newline at end of file diff --git a/src/ui_ng/lib/src/create-edit-rule/create-edit-rule.component.html.ts b/src/ui_ng/lib/src/create-edit-rule/create-edit-rule.component.html.ts new file mode 100644 index 000000000..5e5b5e9c7 --- /dev/null +++ b/src/ui_ng/lib/src/create-edit-rule/create-edit-rule.component.html.ts @@ -0,0 +1,88 @@ +export const CREATE_EDIT_RULE_TEMPLATE: string = ` + + + + + +`; diff --git a/src/ui_ng/lib/src/create-edit-rule/create-edit-rule.component.spec.ts b/src/ui_ng/lib/src/create-edit-rule/create-edit-rule.component.spec.ts new file mode 100644 index 000000000..f07154c89 --- /dev/null +++ b/src/ui_ng/lib/src/create-edit-rule/create-edit-rule.component.spec.ts @@ -0,0 +1,243 @@ +import { ComponentFixture, TestBed, async } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { DebugElement } from '@angular/core'; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; + +import { SharedModule } from '../shared/shared.module'; +import { ConfirmationDialogComponent } from '../confirmation-dialog/confirmation-dialog.component'; +import { ReplicationComponent } from '../replication/replication.component'; + +import { ListReplicationRuleComponent } from '../list-replication-rule/list-replication-rule.component'; + +import { CreateEditRuleComponent } from './create-edit-rule.component'; +import { DatePickerComponent } from '../datetime-picker/datetime-picker.component'; +import { DateValidatorDirective } from '../datetime-picker/date-validator.directive'; +import { FilterComponent } from '../filter/filter.component'; +import { InlineAlertComponent } from '../inline-alert/inline-alert.component'; +import { ReplicationRule, ReplicationJob, Endpoint } from '../service/interface'; + +import { ErrorHandler } from '../error-handler/error-handler'; +import { SERVICE_CONFIG, IServiceConfig } from '../service.config'; +import { ReplicationService, ReplicationDefaultService } from '../service/replication.service'; +import { EndpointService, EndpointDefaultService } from '../service/endpoint.service'; + +describe('CreateEditRuleComponent (inline template)', ()=>{ + + let mockRules: ReplicationRule[] = [ + { + "id": 1, + "project_id": 1, + "project_name": "library", + "target_id": 1, + "target_name": "target_01", + "name": "sync_01", + "enabled": 0, + "description": "", + "cron_str": "", + "error_job_count": 2, + "deleted": 0 + }, + { + "id": 2, + "project_id": 1, + "project_name": "library", + "target_id": 3, + "target_name": "target_02", + "name": "sync_02", + "enabled": 1, + "description": "", + "cron_str": "", + "error_job_count": 1, + "deleted": 0 + }, + { + "id": 3, + "project_id": 1, + "project_name": "library", + "target_id": 2, + "target_name": "target_03", + "name": "sync_03", + "enabled": 0, + "description": "", + "cron_str": "", + "error_job_count": 0, + "deleted": 0 + } + ]; + + let mockJobs: ReplicationJob[] = [ + { + "id": 1, + "status": "stopped", + "repository": "library/busybox", + "policy_id": 1, + "operation": "transfer", + "tags": null + }, + { + "id": 2, + "status": "stopped", + "repository": "library/busybox", + "policy_id": 1, + "operation": "transfer", + "tags": null + }, + { + "id": 3, + "status": "stopped", + "repository": "library/busybox", + "policy_id": 2, + "operation": "transfer", + "tags": null + } + ]; + + let mockEndpoints: Endpoint[] = [ + { + "id": 1, + "endpoint": "https://10.117.4.151", + "name": "target_01", + "username": "admin", + "password": "", + "type": 0 + }, + { + "id": 2, + "endpoint": "https://10.117.5.142", + "name": "target_02", + "username": "AAA", + "password": "", + "type": 0 + }, + { + "id": 3, + "endpoint": "https://101.1.11.111", + "name": "target_03", + "username": "admin", + "password": "", + "type": 0 + }, + { + "id": 4, + "endpoint": "http://4.4.4.4", + "name": "target_04", + "username": "", + "password": "", + "type": 0 + } + ]; + + let mockRule: ReplicationRule = { + "id": 1, + "project_id": 1, + "project_name": "library", + "target_id": 1, + "target_name": "target_01", + "name": "sync_01", + "enabled": 0, + "description": "", + "cron_str": "", + "error_job_count": 2, + "deleted": 0 + }; + + let fixture: ComponentFixture; + let fixtureCreate: ComponentFixture; + + let comp: ReplicationComponent; + let compCreate: CreateEditRuleComponent; + + let replicationService: ReplicationService; + let endpointService: EndpointService; + + let spyRules: jasmine.Spy; + let spyOneRule: jasmine.Spy; + + let spyJobs: jasmine.Spy; + let spyEndpoint: jasmine.Spy; + + let config: IServiceConfig = { + replicationRuleEndpoint: '/api/policies/replication/testing', + replicationJobEndpoint: '/api/jobs/replication/testing', + targetBaseEndpoint: '/api/targets/testing' + }; + + beforeEach(async(()=>{ + TestBed.configureTestingModule({ + imports: [ + SharedModule, + NoopAnimationsModule + ], + declarations: [ + ReplicationComponent, + ListReplicationRuleComponent, + CreateEditRuleComponent, + ConfirmationDialogComponent, + DatePickerComponent, + FilterComponent, + InlineAlertComponent + ], + providers: [ + ErrorHandler, + { provide: SERVICE_CONFIG, useValue: config }, + { provide: ReplicationService, useClass: ReplicationDefaultService }, + { provide: EndpointService, useClass: EndpointDefaultService } + ] + }); + })); + + beforeEach(()=>{ + fixture = TestBed.createComponent(ReplicationComponent); + + comp = fixture.componentInstance; + comp.projectId = 1; + comp.search.ruleId = 1; + + replicationService = fixture.debugElement.injector.get(ReplicationService); + + spyRules = spyOn(replicationService, 'getReplicationRules').and.returnValues(Promise.resolve(mockRules)); + spyOneRule = spyOn(replicationService, 'getReplicationRule').and.returnValue(Promise.resolve(mockRule)); + spyJobs = spyOn(replicationService, 'getJobs').and.returnValues(Promise.resolve(mockJobs)); + fixture.detectChanges(); + }); + + beforeEach(()=>{ + fixtureCreate = TestBed.createComponent(CreateEditRuleComponent); + + compCreate = fixtureCreate.componentInstance; + compCreate.projectId = 1; + + endpointService = fixtureCreate.debugElement.injector.get(EndpointService); + spyEndpoint = spyOn(endpointService, 'getEndpoints').and.returnValues(Promise.resolve(mockEndpoints)); + fixture.detectChanges(); + }); + + it('Should open creation modal and load endpoints', async(()=>{ + fixture.detectChanges(); + comp.openModal(); + fixture.whenStable().then(()=>{ + fixture.detectChanges(); + let de: DebugElement = fixture.debugElement.query(By.css('input')); + expect(de).toBeTruthy(); + let deSelect: DebugElement = fixture.debugElement.query(By.css('select')); + expect(deSelect).toBeTruthy(); + let elSelect: HTMLElement = de.nativeElement; + expect(elSelect).toBeTruthy(); + expect(elSelect.childNodes.item(0).textContent).toEqual('target_01'); + }); + })); + + it('Should open modal to edit replication rule', async(()=>{ + fixture.detectChanges(); + comp.openEditRule(mockRule); + fixture.whenStable().then(()=>{ + fixture.detectChanges(); + let de: DebugElement = fixture.debugElement.query(By.css('input')); + expect(de).toBeTruthy(); + fixture.detectChanges(); + let el: HTMLElement = de.nativeElement; + expect(el).toBeTruthy(); + expect(el.textContent.trim()).toEqual('sync_01'); + }); + })); +}); \ No newline at end of file diff --git a/src/ui_ng/lib/src/create-edit-rule/create-edit-rule.component.ts b/src/ui_ng/lib/src/create-edit-rule/create-edit-rule.component.ts new file mode 100644 index 000000000..0fe03bd23 --- /dev/null +++ b/src/ui_ng/lib/src/create-edit-rule/create-edit-rule.component.ts @@ -0,0 +1,419 @@ +// Copyright (c) 2017 VMware, Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { Component, Input, Output, EventEmitter, ViewChild, AfterViewChecked } from '@angular/core'; + +import { NgForm } from '@angular/forms'; + +import { ReplicationService } from '../service/replication.service'; +import { EndpointService } from '../service/endpoint.service'; + +import { ErrorHandler } from '../error-handler/error-handler'; +import { ActionType } from '../shared/shared.const'; + +import { InlineAlertComponent } from '../inline-alert/inline-alert.component'; + +import { ReplicationRule } from '../service/interface'; +import { Endpoint } from '../service/interface'; + +import { TranslateService } from '@ngx-translate/core'; + +import { CREATE_EDIT_RULE_STYLE } from './create-edit-rule.component.css'; +import { CREATE_EDIT_RULE_TEMPLATE } from './create-edit-rule.component.html'; + +import { toPromise } from '../utils'; + +/** + * Rule form model. + */ +export interface CreateEditRule { + ruleId?: number | string; + name?: string; + description?: string; + enable?: boolean; + endpointId?: number | string; + endpointName?: string; + endpointUrl?: string; + username?: string; + password?: string; +} + +const FAKE_PASSWORD: string = 'ywJZnDTM'; + +@Component({ + selector: 'create-edit-rule', + template: CREATE_EDIT_RULE_TEMPLATE, + styles: [ CREATE_EDIT_RULE_STYLE ] +}) +export class CreateEditRuleComponent implements AfterViewChecked { + + modalTitle: string; + createEditRuleOpened: boolean; + createEditRule: CreateEditRule = this.initCreateEditRule; + initVal: CreateEditRule = this.initCreateEditRule; + + actionType: ActionType; + + isCreateEndpoint: boolean; + @Input() projectId: number; + + @Output() reload = new EventEmitter(); + + endpoints: Endpoint[]; + + pingTestMessage: string; + testOngoing: boolean; + pingStatus: boolean; + + ruleForm: NgForm; + + staticBackdrop: boolean = true; + closable: boolean = false; + + @ViewChild('ruleForm') + currentForm: NgForm; + + hasChanged: boolean; + + editable: boolean; + + get initCreateEditRule(): CreateEditRule { + return { + endpointId: '', + name: '', + enable: false, + description: '', + endpointName: '', + endpointUrl: '', + username: '', + password: '' + }; + } + + get initReplicationRule(): ReplicationRule { + return { + project_id: '', + project_name: '', + target_id: '', + target_name: '', + enabled: 0, + description: '', + cron_str: '', + error_job_count: 0, + deleted: 0 + }; + } + + get initEndpoint(): Endpoint { + return { + endpoint: '', + name: '', + username: '', + password: '', + type: 0 + }; + } + + @ViewChild(InlineAlertComponent) + inlineAlert: InlineAlertComponent; + + get readonly(): boolean { + return this.actionType === ActionType.EDIT && (this.createEditRule.enable || false); + } + + get untoggleable(): boolean { + return this.actionType === ActionType.EDIT && (this.initVal.enable || false); + } + + get showNewDestination(): boolean { + return this.actionType === ActionType.ADD_NEW || (!this.createEditRule.enable || false); + } + + constructor( + private replicationService: ReplicationService, + private endpointService: EndpointService, + private errorHandler: ErrorHandler, + private translateService: TranslateService) {} + + prepareTargets(endpointId?: number | string) { + toPromise(this.endpointService + .getEndpoints()) + .then(endpoints=>{ + this.endpoints = endpoints; + if(this.endpoints && this.endpoints.length > 0) { + let initialEndpoint: Endpoint | undefined; + (endpointId) ? initialEndpoint = this.endpoints.find(t=>t.id===endpointId) : initialEndpoint = this.endpoints[0]; + if(!initialEndpoint) { + return; + } + this.createEditRule.endpointId = initialEndpoint.id; + this.createEditRule.endpointName = initialEndpoint.name; + this.createEditRule.endpointUrl = initialEndpoint.endpoint; + this.createEditRule.username = initialEndpoint.username; + this.createEditRule.password = FAKE_PASSWORD; + + this.initVal.endpointId = this.createEditRule.endpointId; + this.initVal.endpointUrl = this.createEditRule.endpointUrl; + this.initVal.username = this.createEditRule.username; + this.initVal.password = this.createEditRule.password; + } + }) + .catch(error=>{ + this.errorHandler.error(error); + this.createEditRuleOpened = false; + }); + } + + openCreateEditRule(editable: boolean, ruleId?: number | string): void { + + this.createEditRule = this.initCreateEditRule; + this.editable = editable; + + this.isCreateEndpoint = false; + this.hasChanged = false; + + this.pingTestMessage = ''; + this.pingStatus = true; + this.testOngoing = false; + + if(ruleId) { + this.actionType = ActionType.EDIT; + this.translateService.get('REPLICATION.EDIT_POLICY_TITLE').subscribe(res=>this.modalTitle=res); + toPromise(this.replicationService + .getReplicationRule(ruleId)) + .then(rule=>{ + if(rule) { + this.createEditRule.ruleId = ruleId; + this.createEditRule.name = rule.name; + this.createEditRule.description = rule.description; + this.createEditRule.enable = rule.enabled === 1? true : false; + this.prepareTargets(rule.target_id); + + this.initVal.name = this.createEditRule.name; + this.initVal.description = this.createEditRule.description; + this.initVal.enable = this.createEditRule.enable; + + this.createEditRuleOpened = true; + } + }).catch(err=>this.errorHandler.error(err)); + } else { + if(!this.projectId) { + this.errorHandler.error('Project ID cannot be unset'); + return; + } + this.actionType = ActionType.ADD_NEW; + this.translateService.get('REPLICATION.ADD_POLICY').subscribe(res=>this.modalTitle=res); + this.prepareTargets(); + this.createEditRuleOpened = true; + } + } + + newEndpoint(checkedAddNew: boolean): void { + this.isCreateEndpoint = checkedAddNew; + if(this.isCreateEndpoint) { + this.createEditRule.endpointName = ''; + this.createEditRule.endpointUrl = ''; + this.createEditRule.username = ''; + this.createEditRule.password = ''; + } else { + this.prepareTargets(); + } + } + + selectEndpoint(): void { + let result: Endpoint | undefined = this.endpoints.find(target=>target.id == this.createEditRule.endpointId); + if(result) { + this.createEditRule.endpointId = result.id; + this.createEditRule.endpointUrl = result.endpoint; + this.createEditRule.username = result.username; + this.createEditRule.password = FAKE_PASSWORD; + } + } + + getRuleByForm(): ReplicationRule { + let rule: ReplicationRule = this.initReplicationRule; + rule.project_id = this.projectId; + rule.id = this.createEditRule.ruleId; + rule.name = this.createEditRule.name; + rule.description = this.createEditRule.description; + rule.enabled = this.createEditRule.enable ? 1 : 0; + rule.target_id = this.createEditRule.endpointId || ''; + return rule; + } + + getEndpointByForm(): Endpoint { + let endpoint: Endpoint = this.initEndpoint; + endpoint.id = this.createEditRule.ruleId; + endpoint.name = this.createEditRule.endpointName || ''; + endpoint.endpoint = this.createEditRule.endpointUrl || ''; + endpoint.username = this.createEditRule.username; + endpoint.password = this.createEditRule.password; + return endpoint; + } + + createReplicationRule(): void { + toPromise(this.replicationService + .createReplicationRule(this.getRuleByForm())) + .then(response=>{ + this.translateService.get('REPLICATION.CREATED_SUCCESS') + .subscribe(res=>this.errorHandler.info(res)); + this.createEditRuleOpened = false; + this.reload.emit(true); + }) + .catch(error=>{ + if (error.status === 409) { + this.inlineAlert.showInlineError('REPLICATION.POLICY_ALREADY_EXISTS'); + } else { + this.inlineAlert.showInlineError(error); + } + }); + } + + updateReplicationRule(): void { + toPromise(this.replicationService + .updateReplicationRule(this.getRuleByForm())) + .then(()=>{ + this.translateService.get('REPLICATION.UPDATED_SUCCESS') + .subscribe(res=>this.errorHandler.info(res)); + this.createEditRuleOpened = false; + this.reload.emit(true); + }) + .catch(error=>{ + if (error.status === 409) { + this.inlineAlert.showInlineError('REPLICATION.POLICY_ALREADY_EXISTS'); + } else { + this.inlineAlert.showInlineError(error); + } + } + ); + } + + createWithEndpoint(actionType: ActionType): void { + toPromise(this.endpointService + .createEndpoint(this.getEndpointByForm())) + .then(()=>{ + toPromise(this.endpointService + .getEndpoints(this.createEditRule.endpointName)) + .then(endpoints=>{ + if(endpoints && endpoints.length > 0) { + let addedEndpoint: Endpoint = endpoints[0]; + this.createEditRule.endpointId = addedEndpoint.id; + switch(actionType) { + case ActionType.ADD_NEW: + this.createReplicationRule(); + break; + case ActionType.EDIT: + this.updateReplicationRule(); + break; + } + } + }) + .catch(error=>{ + this.inlineAlert.showInlineError(error); + this.errorHandler.error(error); + }); + }) + .catch(error=>{ + this.inlineAlert.showInlineError(error); + this.errorHandler.error(error); + }); + } + + onSubmit() { + if(this.isCreateEndpoint) { + this.createWithEndpoint(this.actionType); + } else { + switch(this.actionType) { + case ActionType.ADD_NEW: + this.createReplicationRule(); + break; + case ActionType.EDIT: + this.updateReplicationRule(); + break; + } + } + } + + onCancel() { + if(this.hasChanged) { + this.inlineAlert.showInlineConfirmation({message: 'ALERT.FORM_CHANGE_CONFIRMATION'}); + } else { + this.createEditRuleOpened = false; + this.ruleForm.reset(); + } + } + + confirmCancel(confirmed: boolean) { + this.createEditRuleOpened = false; + this.inlineAlert.close(); + this.ruleForm.reset(); + } + + ngAfterViewChecked(): void { + this.ruleForm = this.currentForm; + if(this.ruleForm) { + let comparison: {[key: string]: any} = { + name: this.initVal.name, + description: this.initVal.description, + enable: this.initVal.enable, + endpointId: this.initVal.endpointId, + targetName: this.initVal.name, + endpointUrl: this.initVal.endpointUrl, + username: this.initVal.username, + password: this.initVal.password + }; + let self: CreateEditRuleComponent | any = this; + if(self) { + self.ruleForm.valueChanges.subscribe((data: any)=>{ + for(let key in data) { + let current = data[key]; + let origin: string = comparison[key]; + if(((self.actionType === ActionType.EDIT && !self.readonly && !current ) || current) && current !== origin) { + self.hasChanged = true; + break; + } else { + self.hasChanged = false; + self.inlineAlert.close(); + } + } + }); + } + } + } + + testConnection() { + this.pingStatus = true; + this.translateService.get('REPLICATION.TESTING_CONNECTION').subscribe(res=>this.pingTestMessage=res); + this.testOngoing = !this.testOngoing; + let pingTarget: Endpoint = this.initEndpoint; + if(this.isCreateEndpoint) { + pingTarget.endpoint = this.createEditRule.endpointUrl || ''; + pingTarget.username = this.createEditRule.username; + pingTarget.password = this.createEditRule.password; + } else { + pingTarget.id = this.createEditRule.endpointId; + } + toPromise(this.endpointService + .pingEndpoint(pingTarget)) + .then(()=>{ + this.testOngoing = !this.testOngoing; + this.translateService.get('REPLICATION.TEST_CONNECTION_SUCCESS').subscribe(res=>this.pingTestMessage=res); + this.pingStatus = true; + }) + .catch(error=>{ + this.testOngoing = !this.testOngoing; + this.translateService.get('REPLICATION.TEST_CONNECTION_FAILURE').subscribe(res=>this.pingTestMessage=res); + this.pingStatus = false; + }); + } +} \ No newline at end of file diff --git a/src/ui_ng/lib/src/create-edit-rule/index.ts b/src/ui_ng/lib/src/create-edit-rule/index.ts new file mode 100644 index 000000000..8703750fd --- /dev/null +++ b/src/ui_ng/lib/src/create-edit-rule/index.ts @@ -0,0 +1,7 @@ +import { Type } from '@angular/core'; + +import { CreateEditRuleComponent } from './create-edit-rule.component'; + +export const CREATE_EDIT_RULE_DIRECTIVES: Type[] = [ + CreateEditRuleComponent +]; \ No newline at end of file diff --git a/src/ui_ng/lib/src/datetime-picker/date-validator.directive.ts b/src/ui_ng/lib/src/datetime-picker/date-validator.directive.ts new file mode 100644 index 000000000..841c90180 --- /dev/null +++ b/src/ui_ng/lib/src/datetime-picker/date-validator.directive.ts @@ -0,0 +1,50 @@ +// Copyright (c) 2017 VMware, Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { Directive, OnChanges, Input, SimpleChanges } from '@angular/core'; +import { NG_VALIDATORS, Validator, Validators, ValidatorFn, AbstractControl } from '@angular/forms'; + +@Directive({ + selector: '[dateValidator]', + providers: [{provide: NG_VALIDATORS, useExisting: DateValidatorDirective, multi: true}] +}) +export class DateValidatorDirective implements Validator, OnChanges { + @Input() dateValidator: string; + private valFn = Validators.nullValidator; + + ngOnChanges(changes: SimpleChanges): void { + const change = changes['dateValidator']; + if (change) { + this.valFn = dateValidator(); + } else { + this.valFn = Validators.nullValidator; + } + } + validate(control: AbstractControl): {[key: string]: any} { + return this.valFn(control) || Validators.nullValidator; + } +} + +export function dateValidator(): ValidatorFn { + return (control: AbstractControl): {[key: string]: any} => { + let controlValue = control.value; + let valid = true; + if(controlValue) { + const regYMD=/^(19|20)\d\d([- /.])(0[1-9]|1[012])\2(0[1-9]|[12][0-9]|3[01])$/g; + const regDMY=/^(0[1-9]|[12][0-9]|3[01])[- /.](0[1-9]|1[012])[- /.](19|20)\d\d$/g; + valid = (regYMD.test(controlValue) || regDMY.test(controlValue)); + } + return valid ? Validators.nullValidator : {'dateValidator': { value: controlValue }}; + }; +} + diff --git a/src/ui_ng/lib/src/datetime-picker/datetime-picker.component.html.ts b/src/ui_ng/lib/src/datetime-picker/datetime-picker.component.html.ts new file mode 100644 index 000000000..90c35c590 --- /dev/null +++ b/src/ui_ng/lib/src/datetime-picker/datetime-picker.component.html.ts @@ -0,0 +1,9 @@ +export const DATETIME_PICKER_TEMPLATE: string = ` + + +`; \ No newline at end of file diff --git a/src/ui_ng/lib/src/datetime-picker/datetime-picker.component.ts b/src/ui_ng/lib/src/datetime-picker/datetime-picker.component.ts new file mode 100644 index 000000000..72270a4e9 --- /dev/null +++ b/src/ui_ng/lib/src/datetime-picker/datetime-picker.component.ts @@ -0,0 +1,43 @@ +import { Component, Input, Output, EventEmitter, ViewChild } from '@angular/core'; +import { NgModel } from '@angular/forms'; + +import { DATETIME_PICKER_TEMPLATE } from './datetime-picker.component.html'; + +@Component({ + selector: 'hbr-datetime', + template: DATETIME_PICKER_TEMPLATE +}) +export class DatePickerComponent { + + @Input() dateInput: string; + @Input() oneDayOffset: boolean; + + @ViewChild('searchTime') + searchTime: NgModel; + + @Output() search = new EventEmitter(); + + get dateInvalid(): boolean { + return (this.searchTime.errors && this.searchTime.errors.dateValidator && (this.searchTime.dirty || this.searchTime.touched)) || false; + } + + convertDate(strDate: string): string { + if(/^(0[1-9]|[12][0-9]|3[01])[- /.](0[1-9]|1[012])[- /.](19|20)\d\d$/.test(strDate)) { + let parts = strDate.split(/[-\/]/); + strDate = parts[2] /*Year*/ + '-' +parts[1] /*Month*/ + '-' + parts[0] /*Date*/; + } + return strDate; + } + + doSearch() { + let searchTerm: string = ''; + if(this.searchTime.valid && this.dateInput) { + let timestamp: number = new Date(this.convertDate(this.searchTime.value)).getTime() / 1000; + if(this.oneDayOffset) { + timestamp += 3600 * 24; + } + searchTerm = timestamp.toString(); + } + this.search.emit(searchTerm); + } +} \ No newline at end of file diff --git a/src/ui_ng/lib/src/datetime-picker/index.ts b/src/ui_ng/lib/src/datetime-picker/index.ts new file mode 100644 index 000000000..68a096a16 --- /dev/null +++ b/src/ui_ng/lib/src/datetime-picker/index.ts @@ -0,0 +1,8 @@ +import { Type } from '@angular/core'; + +import { DatePickerComponent } from './datetime-picker.component'; +import { DateValidatorDirective } from './date-validator.directive'; +export const DATETIME_PICKER_DIRECTIVES: Type[] = [ + DatePickerComponent, + DateValidatorDirective +]; \ No newline at end of file diff --git a/src/ui_ng/lib/src/endpoint/endpoint.component.ts b/src/ui_ng/lib/src/endpoint/endpoint.component.ts index f6dad4a11..cb5b4b282 100644 --- a/src/ui_ng/lib/src/endpoint/endpoint.component.ts +++ b/src/ui_ng/lib/src/endpoint/endpoint.component.ts @@ -163,13 +163,12 @@ export class EndpointComponent implements OnInit { } deleteTarget(target: Endpoint) { - console.log('Endpoint:' + JSON.stringify(target)); if (target) { let targetId = target.id; let deletionMessage = new ConfirmationMessage( 'REPLICATION.DELETION_TITLE_TARGET', 'REPLICATION.DELETION_SUMMARY_TARGET', - target.name, + target.name || '', target.id, ConfirmationTargets.TARGET, ConfirmationButtons.DELETE_CANCEL); diff --git a/src/ui_ng/lib/src/harbor-library.module.ts b/src/ui_ng/lib/src/harbor-library.module.ts index 9757acfdc..98d37ca11 100644 --- a/src/ui_ng/lib/src/harbor-library.module.ts +++ b/src/ui_ng/lib/src/harbor-library.module.ts @@ -7,12 +7,17 @@ import { REPOSITORY_DIRECTIVES } from './repository/index'; import { LIST_REPOSITORY_DIRECTIVES } from './list-repository/index'; import { TAG_DIRECTIVES } from './tag/index'; +import { REPLICATION_DIRECTIVES } from './replication/index'; +import { CREATE_EDIT_RULE_DIRECTIVES } from './create-edit-rule/index'; +import { LIST_REPLICATION_RULE_DIRECTIVES } from './list-replication-rule/index'; + import { CREATE_EDIT_ENDPOINT_DIRECTIVES } from './create-edit-endpoint/index'; import { SERVICE_CONFIG, IServiceConfig } from './service.config'; import { CONFIRMATION_DIALOG_DIRECTIVES } from './confirmation-dialog/index'; import { INLINE_ALERT_DIRECTIVES } from './inline-alert/index'; +import { DATETIME_PICKER_DIRECTIVES } from './datetime-picker/index'; import { AccessLogService, @@ -41,11 +46,11 @@ import { CookieService } from 'ngx-cookie'; */ export const DefaultServiceConfig: IServiceConfig = { systemInfoEndpoint: "/api/system", - repositoryBaseEndpoint: "", + repositoryBaseEndpoint: "/api/repositories", logBaseEndpoint: "/api/logs", - targetBaseEndpoint: "", - replicationRuleEndpoint: "", - replicationJobEndpoint: "", + targetBaseEndpoint: "/api/targets", + replicationRuleEndpoint: "/api/policies/replication", + replicationJobEndpoint: "/api/jobs/replication", langCookieKey: DEFAULT_LANG_COOKIE_KEY, supportedLangs: DEFAULT_SUPPORTING_LANGS, enablei18Support: false @@ -128,7 +133,11 @@ export function initConfig(translateService: TranslateService, config: IServiceC TAG_DIRECTIVES, CREATE_EDIT_ENDPOINT_DIRECTIVES, CONFIRMATION_DIALOG_DIRECTIVES, - INLINE_ALERT_DIRECTIVES + INLINE_ALERT_DIRECTIVES, + REPLICATION_DIRECTIVES, + LIST_REPLICATION_RULE_DIRECTIVES, + CREATE_EDIT_RULE_DIRECTIVES, + DATETIME_PICKER_DIRECTIVES ], exports: [ LOG_DIRECTIVES, @@ -139,7 +148,11 @@ export function initConfig(translateService: TranslateService, config: IServiceC TAG_DIRECTIVES, CREATE_EDIT_ENDPOINT_DIRECTIVES, CONFIRMATION_DIALOG_DIRECTIVES, - INLINE_ALERT_DIRECTIVES + INLINE_ALERT_DIRECTIVES, + REPLICATION_DIRECTIVES, + LIST_REPLICATION_RULE_DIRECTIVES, + CREATE_EDIT_RULE_DIRECTIVES, + DATETIME_PICKER_DIRECTIVES ], providers: [] }) diff --git a/src/ui_ng/lib/src/i18n/lang/en-us-lang.ts b/src/ui_ng/lib/src/i18n/lang/en-us-lang.ts index 1b4178daf..f214b3ec9 100644 --- a/src/ui_ng/lib/src/i18n/lang/en-us-lang.ts +++ b/src/ui_ng/lib/src/i18n/lang/en-us-lang.ts @@ -253,6 +253,7 @@ export const EN_US_LANG: any = { "CREATION_TIME": "Start Time", "END_TIME": "End Time", "LOGS": "Logs", + "OF": "of", "ITEMS": "item(s)", "TOGGLE_ENABLE_TITLE": "Enable Rule", "CONFIRM_TOGGLE_ENABLE_POLICY": "After enabling the replication rule, all repositories under the project will be replicated to the destination registry. \nPlease confirm to continue.", diff --git a/src/ui_ng/lib/src/i18n/lang/zh-cn-lang.ts b/src/ui_ng/lib/src/i18n/lang/zh-cn-lang.ts index f97d39931..8a3f57931 100644 --- a/src/ui_ng/lib/src/i18n/lang/zh-cn-lang.ts +++ b/src/ui_ng/lib/src/i18n/lang/zh-cn-lang.ts @@ -253,6 +253,7 @@ export const ZH_CN_LANG: any = { "CREATION_TIME": "创建时间", "END_TIME": "结束时间", "LOGS": "日志", + "OF": "共计", "ITEMS": "条记录", "TOGGLE_ENABLE_TITLE": "启用规则", "CONFIRM_TOGGLE_ENABLE_POLICY": "启用规则后,该项目下的所有镜像仓库将复制到目标实例。\n请确认继续。", diff --git a/src/ui_ng/lib/src/index.ts b/src/ui_ng/lib/src/index.ts index 5a0611ab2..38f33349a 100644 --- a/src/ui_ng/lib/src/index.ts +++ b/src/ui_ng/lib/src/index.ts @@ -5,4 +5,7 @@ export * from './error-handler/index'; //export * from './utils'; export * from './log/index'; export * from './filter/index'; -export * from './endpoint/index'; \ No newline at end of file +export * from './endpoint/index'; +export * from './repository/index'; +export * from './tag/index'; +export * from './replication/index'; \ No newline at end of file diff --git a/src/ui_ng/lib/src/list-replication-rule/index.ts b/src/ui_ng/lib/src/list-replication-rule/index.ts new file mode 100644 index 000000000..3287654d6 --- /dev/null +++ b/src/ui_ng/lib/src/list-replication-rule/index.ts @@ -0,0 +1,7 @@ +import { Type } from '@angular/core'; + +import { ListReplicationRuleComponent } from './list-replication-rule.component'; + +export const LIST_REPLICATION_RULE_DIRECTIVES: Type[] = [ + ListReplicationRuleComponent +]; \ No newline at end of file diff --git a/src/ui_ng/lib/src/list-replication-rule/list-replication-rule.component.html.ts b/src/ui_ng/lib/src/list-replication-rule/list-replication-rule.component.html.ts new file mode 100644 index 000000000..e92dc8ea6 --- /dev/null +++ b/src/ui_ng/lib/src/list-replication-rule/list-replication-rule.component.html.ts @@ -0,0 +1,40 @@ +export const LIST_REPLICATION_RULE_TEMPLATE: string = ` + + + + {{'REPLICATION.NAME' | translate}} + {{'REPLICATION.PROJECT' | translate}} + {{'REPLICATION.DESCRIPTION' | translate}} + {{'REPLICATION.DESTINATION_NAME' | translate}} + {{'REPLICATION.LAST_START_TIME' | translate}} + {{'REPLICATION.ACTIVATION' | translate}} + + + + + + + + + {{p.name}} + + + {{p.name}} + + + {{p.project_name}} + {{p.description ? p.description : '-'}} + {{p.target_name}} + + - + {{p.start_time}} + + + {{ (p.enabled === 1 ? 'REPLICATION.ENABLED' : 'REPLICATION.DISABLED') | translate}} + + + + {{pagination.firstItem + 1}} - {{pagination.lastItem +1 }} {{'REPLICATION.OF' | translate}} {{pagination.totalItems }} {{'REPLICATION.ITEMS' | translate}} + + +`; \ No newline at end of file diff --git a/src/ui_ng/lib/src/list-replication-rule/list-replication-rule.component.spec.ts b/src/ui_ng/lib/src/list-replication-rule/list-replication-rule.component.spec.ts new file mode 100644 index 000000000..512391a18 --- /dev/null +++ b/src/ui_ng/lib/src/list-replication-rule/list-replication-rule.component.spec.ts @@ -0,0 +1,127 @@ +import { ComponentFixture, TestBed, async } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; + +import { DebugElement } from '@angular/core'; + +import { SharedModule } from '../shared/shared.module'; +import { ConfirmationDialogComponent } from '../confirmation-dialog/confirmation-dialog.component'; + +import { ListReplicationRuleComponent } from '../list-replication-rule/list-replication-rule.component'; +import { ReplicationRule } from '../service/interface'; + +import { ErrorHandler } from '../error-handler/error-handler'; +import { SERVICE_CONFIG, IServiceConfig } from '../service.config'; +import { ReplicationService, ReplicationDefaultService } from '../service/replication.service'; + + +describe('ListReplicationRuleComponent (inline template)', ()=>{ + + let mockRules: ReplicationRule[] = [ + { + "id": 1, + "project_id": 1, + "project_name": "library", + "target_id": 1, + "target_name": "target_01", + "name": "sync_01", + "enabled": 0, + "description": "", + "cron_str": "", + "error_job_count": 2, + "deleted": 0 + }, + { + "id": 2, + "project_id": 1, + "project_name": "library", + "target_id": 3, + "target_name": "target_02", + "name": "sync_02", + "enabled": 1, + "description": "", + "cron_str": "", + "error_job_count": 1, + "deleted": 0 + }, + { + "id": 3, + "project_id": 1, + "project_name": "library", + "target_id": 2, + "target_name": "target_03", + "name": "sync_03", + "enabled": 0, + "description": "", + "cron_str": "", + "error_job_count": 0, + "deleted": 0 + } + ]; + + + let mockRule: ReplicationRule = { + "id": 1, + "project_id": 1, + "project_name": "library", + "target_id": 1, + "target_name": "target_01", + "name": "sync_01", + "enabled": 0, + "description": "", + "cron_str": "", + "error_job_count": 2, + "deleted": 0 + }; + + let fixture: ComponentFixture; + + let comp: ListReplicationRuleComponent; + + let replicationService: ReplicationService; + + let spyRules: jasmine.Spy; + + let config: IServiceConfig = { + replicationRuleEndpoint: '/api/policies/replication/testing' + }; + + beforeEach(async(()=>{ + TestBed.configureTestingModule({ + imports: [ + SharedModule, + NoopAnimationsModule + ], + declarations: [ + ListReplicationRuleComponent, + ConfirmationDialogComponent + ], + providers: [ + ErrorHandler, + { provide: SERVICE_CONFIG, useValue: config }, + { provide: ReplicationService, useClass: ReplicationDefaultService } + ] + }); + })); + + beforeEach(()=>{ + fixture = TestBed.createComponent(ListReplicationRuleComponent); + comp = fixture.componentInstance; + replicationService = fixture.debugElement.injector.get(ReplicationService); + spyRules = spyOn(replicationService, 'getReplicationRules').and.returnValues(Promise.resolve(mockRules)); + fixture.detectChanges(); + }); + + it('Should load and render data', async(()=>{ + fixture.detectChanges(); + fixture.whenStable().then(()=>{ + fixture.detectChanges(); + let de: DebugElement = fixture.debugElement.query(By.css('datagrid-cell')); + expect(de).toBeTruthy(); + fixture.detectChanges(); + let el: HTMLElement = de.nativeElement; + expect(el.textContent.trim()).toEqual('sync_01'); + }); + })); + +}); \ No newline at end of file diff --git a/src/ui_ng/lib/src/list-replication-rule/list-replication-rule.component.ts b/src/ui_ng/lib/src/list-replication-rule/list-replication-rule.component.ts new file mode 100644 index 000000000..b52ddade2 --- /dev/null +++ b/src/ui_ng/lib/src/list-replication-rule/list-replication-rule.component.ts @@ -0,0 +1,136 @@ +// Copyright (c) 2017 VMware, Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { Component, Input, Output, EventEmitter, ViewChild, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core'; + +import { ReplicationService } from '../service/replication.service'; +import { ReplicationRule } from '../service/interface'; + +import { ConfirmationDialogComponent } from '../confirmation-dialog/confirmation-dialog.component'; +import { ConfirmationMessage } from '../confirmation-dialog/confirmation-message'; +import { ConfirmationAcknowledgement } from '../confirmation-dialog/confirmation-state-message'; + +import { ConfirmationState, ConfirmationTargets, ConfirmationButtons } from '../shared/shared.const'; + +import { TranslateService } from '@ngx-translate/core'; + +import { ErrorHandler } from '../error-handler/error-handler'; +import { toPromise } from '../utils'; + +import { State } from 'clarity-angular'; + +import { LIST_REPLICATION_RULE_TEMPLATE } from './list-replication-rule.component.html'; + +@Component({ + selector: 'list-replication-rule', + template: LIST_REPLICATION_RULE_TEMPLATE, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ListReplicationRuleComponent { + + nullTime: string = '0001-01-01T00:00:00Z'; + + @Input() rules: ReplicationRule[]; + @Input() projectless: boolean; + @Input() selectedId: number | string; + + @Output() reload = new EventEmitter(); + @Output() selectOne = new EventEmitter(); + @Output() editOne = new EventEmitter(); + @Output() toggleOne = new EventEmitter(); + + @ViewChild('toggleConfirmDialog') + toggleConfirmDialog: ConfirmationDialogComponent; + + @ViewChild('deletionConfirmDialog') + deletionConfirmDialog: ConfirmationDialogComponent; + + constructor( + private replicationService: ReplicationService, + private translateService: TranslateService, + private errorHandler: ErrorHandler, + private ref: ChangeDetectorRef) { + setInterval(()=>ref.markForCheck(), 500); + } + + toggleConfirm(message: ConfirmationAcknowledgement) { + if(message && + message.source === ConfirmationTargets.TOGGLE_CONFIRM && + message.state === ConfirmationState.CONFIRMED) { + let rule: ReplicationRule = message.data; + if(rule) { + rule.enabled = rule.enabled === 0 ? 1 : 0; + toPromise(this.replicationService + .enableReplicationRule(rule.id || '', rule.enabled)) + .then(() => + this.translateService.get('REPLICATION.TOGGLED_SUCCESS') + .subscribe(res=>this.errorHandler.info(res))) + .catch(error => this.errorHandler.error(error)); + } + } + } + + deletionConfirm(message: ConfirmationAcknowledgement) { + if (message && + message.source === ConfirmationTargets.POLICY && + message.state === ConfirmationState.CONFIRMED) { + toPromise(this.replicationService + .deleteReplicationRule(message.data)) + .then(() => { + this.translateService.get('REPLICATION.DELETED_SUCCESS') + .subscribe(res=>this.errorHandler.info(res)); + this.reload.emit(true); + }) + .catch(error => { + if(error && error.status === 412) { + this.translateService.get('REPLICATION.FAILED_TO_DELETE_POLICY_ENABLED') + .subscribe(res=>this.errorHandler.error(res)); + } else { + this.errorHandler.error(error); + } + }); + } + } + + selectRule(rule: ReplicationRule): void { + this.selectedId = rule.id || ''; + this.selectOne.emit(rule); + } + + editRule(rule: ReplicationRule) { + this.editOne.emit(rule); + } + + toggleRule(rule: ReplicationRule) { + let toggleConfirmMessage: ConfirmationMessage = new ConfirmationMessage( + rule.enabled === 1 ? 'REPLICATION.TOGGLE_DISABLE_TITLE' : 'REPLICATION.TOGGLE_ENABLE_TITLE', + rule.enabled === 1 ? 'REPLICATION.CONFIRM_TOGGLE_DISABLE_POLICY': 'REPLICATION.CONFIRM_TOGGLE_ENABLE_POLICY', + rule.name || '', + rule, + ConfirmationTargets.TOGGLE_CONFIRM + ); + this.toggleConfirmDialog.open(toggleConfirmMessage); + } + + deleteRule(rule: ReplicationRule) { + let deletionMessage: ConfirmationMessage = new ConfirmationMessage( + 'REPLICATION.DELETION_TITLE', + 'REPLICATION.DELETION_SUMMARY', + rule.name || '', + rule.id, + ConfirmationTargets.POLICY, + ConfirmationButtons.DELETE_CANCEL); + this.deletionConfirmDialog.open(deletionMessage); + } + +} \ No newline at end of file diff --git a/src/ui_ng/lib/src/log/recent-log.component.spec.ts b/src/ui_ng/lib/src/log/recent-log.component.spec.ts index d296d348f..914a1214e 100644 --- a/src/ui_ng/lib/src/log/recent-log.component.spec.ts +++ b/src/ui_ng/lib/src/log/recent-log.component.spec.ts @@ -54,6 +54,9 @@ describe('RecentLogComponent', () => { ] }); + })); + + beforeEach(()=>{ fixture = TestBed.createComponent(RecentLogComponent); component = fixture.componentInstance; serviceConfig = TestBed.get(SERVICE_CONFIG); @@ -63,8 +66,7 @@ describe('RecentLogComponent', () => { .and.returnValue(Promise.resolve(mockData)); fixture.detectChanges(); - - })); + }); it('should be created', () => { expect(component).toBeTruthy(); @@ -122,7 +124,7 @@ describe('RecentLogComponent', () => { fixture.whenStable().then(() => { fixture.detectChanges(); - expect(component.recentLogs.length).toEqual(2); + expect(component.recentLogs.length).toEqual(1); }); })); diff --git a/src/ui_ng/lib/src/replication/index.ts b/src/ui_ng/lib/src/replication/index.ts new file mode 100644 index 000000000..715aedf76 --- /dev/null +++ b/src/ui_ng/lib/src/replication/index.ts @@ -0,0 +1,6 @@ +import { Type } from '@angular/core'; +import { ReplicationComponent } from './replication.component'; + +export const REPLICATION_DIRECTIVES: Type[] = [ + ReplicationComponent +]; \ No newline at end of file diff --git a/src/ui_ng/lib/src/replication/replication.component.css.ts b/src/ui_ng/lib/src/replication/replication.component.css.ts new file mode 100644 index 000000000..7e5276d15 --- /dev/null +++ b/src/ui_ng/lib/src/replication/replication.component.css.ts @@ -0,0 +1,18 @@ +export const REPLICATION_STYLE: string = ` +.option-left { + padding-left: 16px; + margin-top: 24px; +} +.option-right { + padding-right: 16px; + margin-top: 18px; +} + +.option-left-down { + margin-top: 36px; +} + +.option-right-down { + padding-right: 16px; + margin-top: 24px; +}`; \ No newline at end of file diff --git a/src/ui_ng/lib/src/replication/replication.component.html.ts b/src/ui_ng/lib/src/replication/replication.component.html.ts new file mode 100644 index 000000000..f3ef8748b --- /dev/null +++ b/src/ui_ng/lib/src/replication/replication.component.html.ts @@ -0,0 +1,75 @@ +export const REPLICATION_TEMPLATE: string = ` +
+
+
+
+ + +
+
+
+ +
+ + + + +
+
+
+
+ +
+
+
+
{{'REPLICATION.REPLICATION_JOBS' | translate}}
+
+ + + + + +
+
+
+
+ +
+
+ + +
+
+
+
+ + {{'REPLICATION.NAME' | translate}} + {{'REPLICATION.STATUS' | translate}} + {{'REPLICATION.OPERATION' | translate}} + {{'REPLICATION.CREATION_TIME' | translate}} + {{'REPLICATION.END_TIME' | translate}} + {{'REPLICATION.LOGS' | translate}} + + {{j.repository}} + {{j.status}} + {{j.operation}} + {{j.creation_time}} + {{j.update_time}} + + + + + + + + {{pagination.firstItem + 1}} - {{pagination.lastItem + 1}} {{'REPLICATION.OF' | translate}} + {{pagination.totalItems}} {{'REPLICATION.ITEMS' | translate}} + + + +
+
`; \ No newline at end of file diff --git a/src/ui_ng/lib/src/replication/replication.component.spec.ts b/src/ui_ng/lib/src/replication/replication.component.spec.ts new file mode 100644 index 000000000..ab4dd0692 --- /dev/null +++ b/src/ui_ng/lib/src/replication/replication.component.spec.ts @@ -0,0 +1,300 @@ +import { ComponentFixture, TestBed, async, inject } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { DebugElement } from '@angular/core'; +import { NoopAnimationsModule } from "@angular/platform-browser/animations"; + +import { SharedModule } from '../shared/shared.module'; +import { ConfirmationDialogComponent } from '../confirmation-dialog/confirmation-dialog.component'; +import { ReplicationComponent } from './replication.component'; +import { ListReplicationRuleComponent } from '../list-replication-rule/list-replication-rule.component'; +import { CreateEditRuleComponent } from '../create-edit-rule/create-edit-rule.component'; +import { DatePickerComponent } from '../datetime-picker/datetime-picker.component'; +import { DateValidatorDirective } from '../datetime-picker/date-validator.directive'; +import { FilterComponent } from '../filter/filter.component'; +import { InlineAlertComponent } from '../inline-alert/inline-alert.component'; +import { ReplicationRule, ReplicationJob, Endpoint } from '../service/interface'; + +import { ErrorHandler } from '../error-handler/error-handler'; +import { SERVICE_CONFIG, IServiceConfig } from '../service.config'; +import { ReplicationService, ReplicationDefaultService } from '../service/replication.service'; +import { EndpointService, EndpointDefaultService } from '../service/endpoint.service'; + +describe('Replication Component (inline template)', ()=>{ + + let mockRules: ReplicationRule[] = [ + { + "id": 1, + "project_id": 1, + "project_name": "library", + "target_id": 1, + "target_name": "target_01", + "name": "sync_01", + "enabled": 0, + "description": "", + "cron_str": "", + "error_job_count": 2, + "deleted": 0 + }, + { + "id": 2, + "project_id": 1, + "project_name": "library", + "target_id": 3, + "target_name": "target_02", + "name": "sync_02", + "enabled": 1, + "description": "", + "cron_str": "", + "error_job_count": 1, + "deleted": 0 + }, + { + "id": 3, + "project_id": 1, + "project_name": "library", + "target_id": 2, + "target_name": "target_03", + "name": "sync_03", + "enabled": 0, + "description": "", + "cron_str": "", + "error_job_count": 0, + "deleted": 0 + } + ]; + + let mockJobs: ReplicationJob[] = [ + { + "id": 1, + "status": "error", + "repository": "library/nginx", + "policy_id": 1, + "operation": "transfer", + "update_time": new Date("2017-05-23 12:20:33"), + "tags": null + }, + { + "id": 2, + "status": "finished", + "repository": "library/mysql", + "policy_id": 1, + "operation": "transfer", + "update_time": new Date("2017-05-27 12:20:33"), + "tags": null + }, + { + "id": 3, + "status": "stopped", + "repository": "library/busybox", + "policy_id": 2, + "operation": "transfer", + "update_time": new Date("2017-04-23 12:20:33"), + "tags": null + } + ]; + + let mockEndpoints: Endpoint[] = [ + { + "id": 1, + "endpoint": "https://10.117.4.151", + "name": "target_01", + "username": "admin", + "password": "", + "type": 0 + }, + { + "id": 2, + "endpoint": "https://10.117.5.142", + "name": "target_02", + "username": "AAA", + "password": "", + "type": 0 + }, + { + "id": 3, + "endpoint": "https://101.1.11.111", + "name": "target_03", + "username": "admin", + "password": "", + "type": 0 + }, + { + "id": 4, + "endpoint": "http://4.4.4.4", + "name": "target_04", + "username": "", + "password": "", + "type": 0 + } + ]; + + let mockRule: ReplicationRule = { + "id": 1, + "project_id": 1, + "project_name": "library", + "target_id": 1, + "target_name": "target_01", + "name": "sync_01", + "enabled": 0, + "description": "", + "cron_str": "", + "error_job_count": 2, + "deleted": 0 + }; + + let fixture: ComponentFixture; + let comp: ReplicationComponent; + + let replicationService: ReplicationService; + + let spyRules: jasmine.Spy; + let spyJobs: jasmine.Spy; + + let deGrids: DebugElement[]; + let deRules: DebugElement; + let deJobs: DebugElement; + + let elRule: HTMLElement; + let elJob: HTMLElement; + + let config: IServiceConfig = { + replicationRuleEndpoint: '/api/policies/replication/testing', + replicationJobEndpoint: '/api/jobs/replication/testing' + }; + + beforeEach(async(()=>{ + TestBed.configureTestingModule({ + imports: [ + SharedModule, + NoopAnimationsModule + ], + declarations: [ + ReplicationComponent, + ListReplicationRuleComponent, + CreateEditRuleComponent, + ConfirmationDialogComponent, + DatePickerComponent, + FilterComponent, + InlineAlertComponent + ], + providers: [ + ErrorHandler, + { provide: SERVICE_CONFIG, useValue: config }, + { provide: ReplicationService, useClass: ReplicationDefaultService }, + { provide: EndpointService, useClass: EndpointDefaultService } + ] + }); + })); + + beforeEach(()=>{ + fixture = TestBed.createComponent(ReplicationComponent); + + comp = fixture.componentInstance; + comp.projectId = 1; + comp.search.ruleId = 1; + + replicationService = fixture.debugElement.injector.get(ReplicationService); + + spyRules = spyOn(replicationService, 'getReplicationRules').and.returnValues(Promise.resolve(mockRules)); + spyJobs = spyOn(replicationService, 'getJobs').and.returnValues(Promise.resolve(mockJobs)); + + fixture.detectChanges(); + fixture.whenStable().then(()=>{ + fixture.detectChanges(); + deGrids = fixture.debugElement.queryAll(del=>del.classes['datagrid']); + fixture.detectChanges(); + expect(deGrids).toBeTruthy(); + expect(deGrids.length).toEqual(2); + }); + }); + + it('Should load replication rules', async(()=>{ + fixture.detectChanges(); + fixture.whenStable().then(()=>{ + fixture.detectChanges(); + deRules = deGrids[0].query(By.css('datagrid-cell')); + expect(deRules).toBeTruthy(); + fixture.detectChanges(); + elRule = deRules.nativeElement; + expect(elRule).toBeTruthy(); + expect(elRule.textContent).toEqual('sync_01'); + }); + })); + + it('Should load replication jobs', async(()=>{ + fixture.detectChanges(); + fixture.whenStable().then(()=>{ + fixture.detectChanges(); + deJobs = deGrids[1].query(By.css('datagrid-cell')); + expect(deJobs).toBeTruthy(); + fixture.detectChanges(); + elJob = deJobs.nativeElement; + fixture.detectChanges(); + expect(elJob).toBeTruthy(); + expect(elJob.textContent).toEqual('library/nginx'); + }); + })); + + it('Should filter replication rules by keywords', async(()=>{ + fixture.detectChanges(); + fixture.whenStable().then(()=>{ + fixture.detectChanges(); + comp.doSearchRules('sync_01'); + fixture.detectChanges(); + let el: HTMLElement = deRules.nativeElement; + fixture.detectChanges(); + expect(el.textContent.trim()).toEqual('sync_01'); + }); + })); + + it('Should filter replication rules by status', async(()=>{ + fixture.detectChanges(); + fixture.whenStable().then(()=>{ + fixture.detectChanges(); + comp.doFilterRuleStatus('1' /*Enabled*/); + fixture.detectChanges(); + let el: HTMLElement = deRules.nativeElement; + fixture.detectChanges(); + expect(el).toBeTruthy(); + expect(el.textContent.trim()).toEqual('sync_02'); + }); + })); + + it('Should filter replication jobs by keywords', async(()=>{ + fixture.detectChanges(); + fixture.whenStable().then(()=>{ + fixture.detectChanges(); + comp.doSearchJobs('nginx'); + fixture.detectChanges(); + let el: HTMLElement = deJobs.nativeElement; + fixture.detectChanges(); + expect(el).toBeTruthy(); + expect(el.textContent.trim()).toEqual('library/nginx'); + }); + })); + + it('Should filter replication jobs by status', async(()=>{ + fixture.detectChanges(); + fixture.whenStable().then(()=>{ + fixture.detectChanges(); + comp.doFilterJobStatus('finished'); + let el: HTMLElement = deJobs.nativeElement; + fixture.detectChanges(); + expect(el).toBeTruthy(); + expect(el.textContent.trim()).toEqual('library/mysql'); + }); + })); + + it('Should filter replication jobs by date range', async(()=>{ + fixture.detectChanges(); + fixture.whenStable().then(()=>{ + fixture.detectChanges(); + comp.doJobSearchByStartTime('2017-05-01'); + comp.doJobSearchByEndTime('2015-05-25'); + let el: HTMLElement = deJobs.nativeElement; + fixture.detectChanges(); + expect(el).toBeTruthy(); + expect(el.textContent.trim()).toEqual('library/nginx'); + }); + })) +}); \ No newline at end of file diff --git a/src/ui_ng/lib/src/replication/replication.component.ts b/src/ui_ng/lib/src/replication/replication.component.ts new file mode 100644 index 000000000..6a85e35a7 --- /dev/null +++ b/src/ui_ng/lib/src/replication/replication.component.ts @@ -0,0 +1,232 @@ +// Copyright (c) 2017 VMware, Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { Component, OnInit, ViewChild, Input } from '@angular/core'; +import { ResponseOptions, RequestOptions } from '@angular/http'; +import { NgModel } from '@angular/forms'; + +import { TranslateService } from '@ngx-translate/core'; + +import { CreateEditRuleComponent } from '../create-edit-rule/create-edit-rule.component'; +import { ErrorHandler } from '../error-handler/error-handler'; + +import { ReplicationService } from '../service/replication.service'; +import { RequestQueryParams } from '../service/RequestQueryParams'; +import { ReplicationRule, ReplicationJob, Endpoint } from '../service/interface'; + +import { toPromise } from '../utils'; + +import { REPLICATION_TEMPLATE } from './replication.component.html'; +import { REPLICATION_STYLE } from './replication.component.css'; + +const ruleStatus: {[key: string]: any} = [ + { 'key': 'all', 'description': 'REPLICATION.ALL_STATUS'}, + { 'key': '1', 'description': 'REPLICATION.ENABLED'}, + { 'key': '0', 'description': 'REPLICATION.DISABLED'} +]; + +const jobStatus: {[key: string]: any} = [ + { 'key': 'all', 'description': 'REPLICATION.ALL' }, + { 'key': 'pending', 'description': 'REPLICATION.PENDING' }, + { 'key': 'running', 'description': 'REPLICATION.RUNNING' }, + { 'key': 'error', 'description': 'REPLICATION.ERROR' }, + { 'key': 'retrying', 'description': 'REPLICATION.RETRYING' }, + { 'key': 'stopped' , 'description': 'REPLICATION.STOPPED' }, + { 'key': 'finished', 'description': 'REPLICATION.FINISHED' }, + { 'key': 'canceled', 'description': 'REPLICATION.CANCELED' } +]; + +const optionalSearch: {} = {0: 'REPLICATION.ADVANCED', 1: 'REPLICATION.SIMPLE'}; + +export class SearchOption { + ruleId: number | string; + ruleName: string = ''; + repoName: string = ''; + status: string = ''; + startTime: string = ''; + startTimestamp: string = ''; + endTime: string = ''; + endTimestamp: string = ''; + page: number = 1; + pageSize: number = 5; +} + +@Component({ + selector: 'hbr-replication', + template: REPLICATION_TEMPLATE, + styles: [ REPLICATION_STYLE ] +}) +export class ReplicationComponent implements OnInit { + + @Input() projectId: number | string; + + search: SearchOption = new SearchOption(); + + ruleStatus = ruleStatus; + currentRuleStatus: {key: string, description: string}; + + jobStatus = jobStatus; + currentJobStatus: {key: string, description: string}; + + changedRules: ReplicationRule[]; + initSelectedId: number | string; + + rules: ReplicationRule[]; + jobs: ReplicationJob[]; + + jobsTotalRecordCount: number; + jobsTotalPage: number; + + toggleJobSearchOption = optionalSearch; + currentJobSearchOption: number; + + @ViewChild(CreateEditRuleComponent) + createEditPolicyComponent: CreateEditRuleComponent; + + constructor( + private errorHandler: ErrorHandler, + private replicationService: ReplicationService, + private translateService: TranslateService) { + } + + ngOnInit(): void { + if(!this.projectId) { + this.errorHandler.warning('Project ID is unset.'); + } + this.currentRuleStatus = this.ruleStatus[0]; + this.currentJobStatus = this.jobStatus[0]; + this.currentJobSearchOption = 0; + this.retrieveRules(); + } + + retrieveRules(): void { + toPromise(this.replicationService + .getReplicationRules(this.projectId, this.search.ruleName)) + .then(response=>{ + this.changedRules = response || []; + if(this.changedRules && this.changedRules.length > 0) { + this.initSelectedId = this.changedRules[0].id || ''; + } + this.rules = this.changedRules; + if(this.changedRules && this.changedRules.length > 0) { + this.search.ruleId = this.changedRules[0].id || ''; + this.fetchReplicationJobs(); + } + } + ).catch(error=>this.errorHandler.error(error)); + } + + openModal(): void { + this.createEditPolicyComponent.openCreateEditRule(true); + } + + openEditRule(rule: ReplicationRule) { + if(rule) { + let editable = true; + if(rule.enabled === 1) { + editable = false; + } + this.createEditPolicyComponent.openCreateEditRule(editable, rule.id); + } + } + + fetchReplicationJobs() { + + let params: RequestQueryParams = new RequestQueryParams(); + params.set('status', this.search.status); + params.set('repository', this.search.repoName); + params.set('start_time', this.search.startTimestamp); + params.set('end_time', this.search.endTimestamp); + + toPromise(this.replicationService + .getJobs(this.search.ruleId, params)) + .then( + response=>{ + this.jobs = response; + }).catch(error=>this.errorHandler.error(error)); + } + + selectOneRule(rule: ReplicationRule) { + if (rule) { + this.search.ruleId = rule.id || ''; + this.search.repoName = ''; + this.search.status = ''; + this.currentJobSearchOption = 0; + this.currentJobStatus = { 'key': 'all', 'description': 'REPLICATION.ALL' }; + this.fetchReplicationJobs(); + } + } + + doSearchRules(ruleName: string) { + this.search.ruleName = ruleName; + this.retrieveRules(); + } + + doFilterRuleStatus($event: any) { + if ($event && $event.target && $event.target["value"]) { + let status = $event.target["value"]; + this.currentRuleStatus = this.ruleStatus.find((r: any)=>r.key === status); + if(this.currentRuleStatus.key === 'all') { + this.changedRules = this.rules; + } else { + this.changedRules = this.rules.filter(policy=>policy.enabled === +this.currentRuleStatus.key); + } + } + } + + doFilterJobStatus($event: any) { + if ($event && $event.target && $event.target["value"]) { + let status = $event.target["value"]; + this.currentJobStatus = this.jobStatus.find((r: any)=>r.key === status); + if(this.currentJobStatus.key === 'all') { + status = ''; + } + this.search.status = status; + this.doSearchJobs(this.search.repoName); + } + } + + doSearchJobs(repoName: string) { + this.search.repoName = repoName; + this.fetchReplicationJobs(); + } + + reloadRules(isReady: boolean) { + if(isReady) { + this.search.ruleName = ''; + this.retrieveRules(); + } + } + + refreshRules() { + this.retrieveRules(); + } + + refreshJobs() { + this.fetchReplicationJobs(); + } + + toggleSearchJobOptionalName(option: number) { + (option === 1) ? this.currentJobSearchOption = 0 : this.currentJobSearchOption = 1; + } + + doJobSearchByStartTime(fromTimestamp: string) { + this.search.startTimestamp = fromTimestamp; + this.fetchReplicationJobs(); + } + + doJobSearchByEndTime(toTimestamp: string) { + this.search.endTimestamp = toTimestamp; + this.fetchReplicationJobs(); + } +} \ No newline at end of file diff --git a/src/ui_ng/lib/src/service/interface.ts b/src/ui_ng/lib/src/service/interface.ts index ac76ccd2b..f814ed57c 100644 --- a/src/ui_ng/lib/src/service/interface.ts +++ b/src/ui_ng/lib/src/service/interface.ts @@ -75,8 +75,8 @@ export interface Tag extends Base { export interface Endpoint extends Base { endpoint: string; name: string; - username: string; - password: string; + username?: string; + password?: string; type: number; } @@ -87,9 +87,9 @@ export interface Endpoint extends Base { * @interface ReplicationRule */ export interface ReplicationRule extends Base { - project_id: number; + project_id: number | string; project_name: string; - target_id: number; + target_id: number | string; target_name: string; enabled: number; description?: string; diff --git a/src/ui_ng/lib/src/service/replication.service.ts b/src/ui_ng/lib/src/service/replication.service.ts index 6c2b52222..8d7bfb64d 100644 --- a/src/ui_ng/lib/src/service/replication.service.ts +++ b/src/ui_ng/lib/src/service/replication.service.ts @@ -84,7 +84,7 @@ export abstract class ReplicationService { * * @memberOf ReplicationService */ - abstract enableReplicationRule(ruleId: number | string): Observable | Promise | any; + abstract enableReplicationRule(ruleId: number | string, enablement: number): Observable | Promise | any; /** * Disable the specified replication rule. @@ -130,13 +130,13 @@ export class ReplicationDefaultService extends ReplicationService { constructor( private http: Http, - @Inject(SERVICE_CONFIG) private config: IServiceConfig + @Inject(SERVICE_CONFIG) config: IServiceConfig ) { super(); - this._ruleBaseUrl = this.config.replicationRuleEndpoint ? - this.config.replicationRuleEndpoint : '/api/policies/replication'; - this._jobBaseUrl = this.config.replicationJobEndpoint ? - this.config.replicationJobEndpoint : '/api/jobs/replication'; + this._ruleBaseUrl = config.replicationRuleEndpoint ? + config.replicationRuleEndpoint : '/api/policies/replication'; + this._jobBaseUrl = config.replicationJobEndpoint ? + config.replicationJobEndpoint : '/api/jobs/replication'; } //Private methods @@ -169,7 +169,7 @@ export class ReplicationDefaultService extends ReplicationService { } let url: string = `${this._ruleBaseUrl}/${ruleId}`; - return this.http.get(url, HTTP_JSON_OPTIONS).toPromise() + return this.http.get(url).toPromise() .then(response => response.json() as ReplicationRule) .catch(error => Promise.reject(error)); } @@ -206,13 +206,13 @@ export class ReplicationDefaultService extends ReplicationService { .catch(error => Promise.reject(error)); } - public enableReplicationRule(ruleId: number | string): Observable | Promise | any { + public enableReplicationRule(ruleId: number | string, enablement: number): Observable | Promise | any { if (!ruleId || ruleId <= 0) { return Promise.reject('Bad argument'); } let url: string = `${this._ruleBaseUrl}/${ruleId}/enablement`; - return this.http.put(url, { enabled: 1 }, HTTP_JSON_OPTIONS).toPromise() + return this.http.put(url, { enabled: enablement }, HTTP_JSON_OPTIONS).toPromise() .then(response => response) .catch(error => Promise.reject(error)); }