mirror of
https://github.com/goharbor/harbor.git
synced 2024-11-05 01:59:44 +01:00
fix issue #2435
This commit is contained in:
parent
9582637651
commit
5f548ea49a
@ -3,7 +3,12 @@ import { Component, OnInit, EventEmitter, Output, ViewChild, Input } from '@angu
|
||||
import { Configuration, ComplexValueItem } from './config';
|
||||
import { REGISTRY_CONFIG_HTML } from './registry-config.component.html';
|
||||
import { ConfigurationService, SystemInfoService, SystemInfo, ClairDBStatus } from '../service/index';
|
||||
import { toPromise } from '../utils';
|
||||
import {
|
||||
toPromise,
|
||||
compareValue,
|
||||
isEmptyObject,
|
||||
clone
|
||||
} from '../utils';
|
||||
import { ErrorHandler } from '../error-handler';
|
||||
import {
|
||||
ReplicationConfigComponent,
|
||||
@ -70,7 +75,7 @@ export class RegistryConfigComponent implements OnInit {
|
||||
}
|
||||
|
||||
hasChanges(): boolean {
|
||||
return !this._isEmptyObject(this.getChanges());
|
||||
return !isEmptyObject(this.getChanges());
|
||||
}
|
||||
|
||||
//Get system info
|
||||
@ -85,7 +90,7 @@ export class RegistryConfigComponent implements OnInit {
|
||||
this.onGoing = true;
|
||||
toPromise<Configuration>(this.configService.getConfigurations())
|
||||
.then((config: Configuration) => {
|
||||
this.configCopy = this._clone(config);
|
||||
this.configCopy = clone(config);
|
||||
this.config = config;
|
||||
this.onGoing = false;
|
||||
})
|
||||
@ -99,7 +104,7 @@ export class RegistryConfigComponent implements OnInit {
|
||||
save(): void {
|
||||
let changes: { [key: string]: any | any[] } = this.getChanges();
|
||||
|
||||
if (this._isEmptyObject(changes)) {
|
||||
if (isEmptyObject(changes)) {
|
||||
//Guard code, do nothing
|
||||
return;
|
||||
}
|
||||
@ -147,7 +152,7 @@ export class RegistryConfigComponent implements OnInit {
|
||||
//Reset to the values of copy
|
||||
let changes: { [key: string]: any | any[] } = this.getChanges();
|
||||
for (let prop in changes) {
|
||||
this.config[prop] = this._clone(this.configCopy[prop]);
|
||||
this.config[prop] = clone(this.configCopy[prop]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -160,7 +165,7 @@ export class RegistryConfigComponent implements OnInit {
|
||||
for (let prop in this.config) {
|
||||
let field = this.configCopy[prop];
|
||||
if (field && field.editable) {
|
||||
if (!this._compareValue(field.value, this.config[prop].value)) {
|
||||
if (!compareValue(field.value, this.config[prop].value)) {
|
||||
changes[prop] = this.config[prop].value;
|
||||
//Number
|
||||
if (typeof field.value === "number") {
|
||||
@ -177,23 +182,4 @@ export class RegistryConfigComponent implements OnInit {
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
//private
|
||||
_compareValue(a: any, b: any): boolean {
|
||||
if ((a && !b) || (!a && b)) return false;
|
||||
if (!a && !b) return true;
|
||||
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
//private
|
||||
_isEmptyObject(obj: any): boolean {
|
||||
return !obj || JSON.stringify(obj) === "{}";
|
||||
}
|
||||
|
||||
//Deeper clone all
|
||||
_clone(srcObj: any): any {
|
||||
if (!srcObj) return null;
|
||||
return JSON.parse(JSON.stringify(srcObj));
|
||||
}
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
export const CREATE_EDIT_ENDPOINT_TEMPLATE: string = `<clr-modal [(clrModalOpen)]="createEditDestinationOpened" [clrModalStaticBackdrop]="staticBackdrop" [clrModalClosable]="closable">
|
||||
export const CREATE_EDIT_ENDPOINT_TEMPLATE: string = `
|
||||
<clr-modal [(clrModalOpen)]="createEditDestinationOpened" [clrModalStaticBackdrop]="staticBackdrop" [clrModalClosable]="closable">
|
||||
<h3 class="modal-title">{{modalTitle}}</h3>
|
||||
<hbr-inline-alert class="modal-title" (confirmEvt)="confirmCancel($event)"></hbr-inline-alert>
|
||||
<div class="modal-body">
|
||||
@ -12,7 +13,7 @@ export const CREATE_EDIT_ENDPOINT_TEMPLATE: string = `<clr-modal [(clrModalOpen)
|
||||
<form #targetForm="ngForm">
|
||||
<section class="form-block">
|
||||
<div class="form-group">
|
||||
<label for="destination_name" class="col-md-4 form-group-label-override">{{ 'DESTINATION.NAME' | translate }}<span style="color: red">*</span></label>
|
||||
<label for="destination_name" class="col-md-4 form-group-label-override required">{{ 'DESTINATION.NAME' | translate }}</label>
|
||||
<label class="col-md-8" for="destination_name" aria-haspopup="true" role="tooltip" [class.invalid]="targetName.errors && (targetName.dirty || targetName.touched)" [class.valid]="targetName.valid" class="tooltip tooltip-validation tooltip-sm tooltip-bottom-left">
|
||||
<input type="text" id="destination_name" [disabled]="testOngoing" [readonly]="!editable" [(ngModel)]="target.name" name="targetName" size="20" #targetName="ngModel" required (keyup)="changedTargetName($event)">
|
||||
<span class="tooltip-content" *ngIf="targetName.errors && targetName.errors.required && (targetName.dirty || targetName.touched)">
|
||||
@ -21,9 +22,9 @@ export const CREATE_EDIT_ENDPOINT_TEMPLATE: string = `<clr-modal [(clrModalOpen)
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="destination_url" class="col-md-4 form-group-label-override">{{ 'DESTINATION.URL' | translate }}<span style="color: red">*</span></label>
|
||||
<label for="destination_url" class="col-md-4 form-group-label-override required">{{ 'DESTINATION.URL' | translate }}</label>
|
||||
<label class="col-md-8" for="destination_url" aria-haspopup="true" role="tooltip" [class.invalid]="targetEndpoint.errors && (targetEndpoint.dirty || targetEndpoint.touched)" [class.valid]="targetEndpoint.valid" class="tooltip tooltip-validation tooltip-sm tooltip-bottom-left">
|
||||
<input type="text" id="destination_url" [disabled]="testOngoing" [readonly]="!editable" [(ngModel)]="target.endpoint" size="20" name="endpointUrl" #targetEndpoint="ngModel" required (keyup)="clearPassword($event)">
|
||||
<input type="text" id="destination_url" [disabled]="testOngoing" [readonly]="!editable" [(ngModel)]="target.endpoint" size="20" name="endpointUrl" #targetEndpoint="ngModel" required (keyup)="clearPassword($event)" placeholder="http(s)://192.168.1.1">
|
||||
<span class="tooltip-content" *ngIf="targetEndpoint.errors && targetEndpoint.errors.required && (targetEndpoint.dirty || targetEndpoint.touched)">
|
||||
{{ 'DESTINATION.URL_IS_REQUIRED' | translate }}
|
||||
</span>
|
||||
@ -31,7 +32,7 @@ export const CREATE_EDIT_ENDPOINT_TEMPLATE: string = `<clr-modal [(clrModalOpen)
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="destination_username" class="col-md-4 form-group-label-override">{{ 'DESTINATION.USERNAME' | translate }}</label>
|
||||
<input type="text" class="col-md-8" id="destination_username" [disabled]="testOngoing" [readonly]="!editable" [(ngModel)]="target.username" size="20" name="username" #username="ngModel" (keyup)="clearPassword($event)">
|
||||
<input type="text" class="col-md-8" id="destination_username" [disabled]="testOngoing" [readonly]="!editable" [(ngModel)]="target.username" size="20" name="username" #username="ngModel" (keyup)="clearPassword($event)">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="destination_password" class="col-md-4 form-group-label-override">{{ 'DESTINATION.PASSWORD' | translate }}</label>
|
||||
@ -39,14 +40,14 @@ export const CREATE_EDIT_ENDPOINT_TEMPLATE: string = `<clr-modal [(clrModalOpen)
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="spin" class="col-md-4"></label>
|
||||
<span class="col-md-8 spinner spinner-inline" [hidden]="!testOngoing"></span>
|
||||
<span class="col-md-8 spinner spinner-inline" [hidden]="!inProgress"></span>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline" (click)="testConnection()" [disabled]="testOngoing || targetEndpoint.errors">{{ 'DESTINATION.TEST_CONNECTION' | translate }}</button>
|
||||
<button type="button" class="btn btn-outline" (click)="onCancel()" [disabled]="testOngoing">{{ 'BUTTON.CANCEL' | translate }}</button>
|
||||
<button type="submit" class="btn btn-primary" (click)="onSubmit()" [disabled]="testOngoing || targetForm.form.invalid || !editable">{{ 'BUTTON.OK' | translate }}</button>
|
||||
<button type="button" class="btn btn-outline" (click)="testConnection()" [disabled]="testOngoing || onGoing || targetEndpoint.errors">{{ 'DESTINATION.TEST_CONNECTION' | translate }}</button>
|
||||
<button type="button" class="btn btn-outline" (click)="onCancel()" [disabled]="testOngoing || onGoing">{{ 'BUTTON.CANCEL' | translate }}</button>
|
||||
<button type="submit" class="btn btn-primary" (click)="onSubmit()" [disabled]="!isValid">{{ 'BUTTON.OK' | translate }}</button>
|
||||
</div>
|
||||
</clr-modal>`;
|
@ -12,11 +12,13 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
import {
|
||||
Component,
|
||||
Output,
|
||||
EventEmitter,
|
||||
ViewChild,
|
||||
AfterViewChecked
|
||||
Component,
|
||||
Output,
|
||||
EventEmitter,
|
||||
ViewChild,
|
||||
AfterViewChecked,
|
||||
ChangeDetectorRef,
|
||||
OnDestroy
|
||||
} from '@angular/core';
|
||||
import { NgForm } from '@angular/forms';
|
||||
|
||||
@ -33,255 +35,338 @@ import { TranslateService } from '@ngx-translate/core';
|
||||
import { CREATE_EDIT_ENDPOINT_STYLE } from './create-edit-endpoint.component.css';
|
||||
import { CREATE_EDIT_ENDPOINT_TEMPLATE } from './create-edit-endpoint.component.html';
|
||||
|
||||
import { toPromise, clone, compareValue } from '../utils';
|
||||
|
||||
import { toPromise } from '../utils';
|
||||
import { Subscription } from 'rxjs/Subscription';
|
||||
|
||||
const FAKE_PASSWORD = 'rjGcfuRu';
|
||||
|
||||
@Component({
|
||||
selector: 'create-edit-endpoint',
|
||||
template: CREATE_EDIT_ENDPOINT_TEMPLATE,
|
||||
styles: [CREATE_EDIT_ENDPOINT_STYLE]
|
||||
selector: 'create-edit-endpoint',
|
||||
template: CREATE_EDIT_ENDPOINT_TEMPLATE,
|
||||
styles: [CREATE_EDIT_ENDPOINT_STYLE]
|
||||
})
|
||||
export class CreateEditEndpointComponent implements AfterViewChecked {
|
||||
export class CreateEditEndpointComponent implements AfterViewChecked, OnDestroy {
|
||||
modalTitle: string;
|
||||
createEditDestinationOpened: boolean;
|
||||
staticBackdrop: boolean = true;
|
||||
closable: boolean = false;
|
||||
|
||||
modalTitle: string;
|
||||
createEditDestinationOpened: boolean;
|
||||
editable: boolean;
|
||||
testOngoing: boolean;
|
||||
actionType: ActionType;
|
||||
editable: boolean;
|
||||
|
||||
actionType: ActionType;
|
||||
target: Endpoint = this.initEndpoint();
|
||||
initVal: Endpoint;
|
||||
|
||||
target: Endpoint = this.initEndpoint;
|
||||
initVal: Endpoint = this.initEndpoint;
|
||||
targetForm: NgForm;
|
||||
@ViewChild('targetForm')
|
||||
currentForm: NgForm;
|
||||
|
||||
targetForm: NgForm;
|
||||
endpointHasChanged: boolean;
|
||||
targetNameHasChanged: boolean;
|
||||
|
||||
staticBackdrop: boolean = true;
|
||||
closable: boolean = false;
|
||||
testOngoing: boolean;
|
||||
onGoing: boolean;
|
||||
|
||||
@ViewChild('targetForm')
|
||||
currentForm: NgForm;
|
||||
@ViewChild(InlineAlertComponent)
|
||||
inlineAlert: InlineAlertComponent;
|
||||
|
||||
hasChanged: boolean;
|
||||
endpointHasChanged: boolean;
|
||||
targetNameHasChanged: boolean;
|
||||
@Output() reload = new EventEmitter<boolean>();
|
||||
|
||||
@ViewChild(InlineAlertComponent)
|
||||
inlineAlert: InlineAlertComponent;
|
||||
timerHandler: any;
|
||||
valueChangesSub: Subscription;
|
||||
formValues: { [key: string]: string } | any;
|
||||
|
||||
@Output() reload = new EventEmitter<boolean>();
|
||||
constructor(
|
||||
private endpointService: EndpointService,
|
||||
private errorHandler: ErrorHandler,
|
||||
private translateService: TranslateService,
|
||||
private ref: ChangeDetectorRef
|
||||
) { }
|
||||
|
||||
|
||||
get initEndpoint(): Endpoint {
|
||||
return {
|
||||
endpoint: "",
|
||||
name: "",
|
||||
username: "",
|
||||
password: "",
|
||||
type: 0
|
||||
};
|
||||
}
|
||||
|
||||
constructor(
|
||||
private endpointService: EndpointService,
|
||||
private errorHandler: ErrorHandler,
|
||||
private translateService: TranslateService) { }
|
||||
|
||||
openCreateEditTarget(editable: boolean, targetId?: number | string) {
|
||||
|
||||
this.target = this.initEndpoint;
|
||||
this.editable = editable;
|
||||
this.createEditDestinationOpened = true;
|
||||
this.hasChanged = false;
|
||||
this.endpointHasChanged = false;
|
||||
this.targetNameHasChanged = false;
|
||||
|
||||
this.testOngoing = false;
|
||||
|
||||
if (targetId) {
|
||||
this.actionType = ActionType.EDIT;
|
||||
this.translateService.get('DESTINATION.TITLE_EDIT').subscribe(res => this.modalTitle = res);
|
||||
toPromise<Endpoint>(this.endpointService
|
||||
.getEndpoint(targetId))
|
||||
.then(
|
||||
target => {
|
||||
this.target = target;
|
||||
this.initVal.name = this.target.name;
|
||||
this.initVal.endpoint = this.target.endpoint;
|
||||
this.initVal.username = this.target.username;
|
||||
this.initVal.password = FAKE_PASSWORD;
|
||||
this.target.password = this.initVal.password;
|
||||
})
|
||||
.catch(error => this.errorHandler.error(error));
|
||||
} else {
|
||||
this.actionType = ActionType.ADD_NEW;
|
||||
this.translateService.get('DESTINATION.TITLE_ADD').subscribe(res => this.modalTitle = res);
|
||||
}
|
||||
}
|
||||
|
||||
testConnection() {
|
||||
let payload: Endpoint = this.initEndpoint;
|
||||
if (this.endpointHasChanged) {
|
||||
payload.endpoint = this.target.endpoint;
|
||||
payload.username = this.target.username;
|
||||
payload.password = this.target.password;
|
||||
} else {
|
||||
payload.id = this.target.id;
|
||||
public get hasChanged(): boolean {
|
||||
if (this.actionType === ActionType.ADD_NEW) {
|
||||
//Create new
|
||||
return this.target && (
|
||||
(this.target.endpoint && this.target.endpoint.trim() !== "") ||
|
||||
(this.target.name && this.target.name.trim() !== "") ||
|
||||
(this.target.username && this.target.username.trim() !== "") ||
|
||||
(this.target.password && this.target.password.trim() !== ""));
|
||||
} else {
|
||||
//Edit
|
||||
return !compareValue(this.target, this.initVal);
|
||||
}
|
||||
}
|
||||
|
||||
this.testOngoing = true;
|
||||
toPromise<Endpoint>(this.endpointService
|
||||
.pingEndpoint(payload))
|
||||
.then(
|
||||
response => {
|
||||
this.testOngoing = false;
|
||||
this.inlineAlert.showInlineSuccess({ message: "DESTINATION.TEST_CONNECTION_SUCCESS" });
|
||||
}).catch(
|
||||
error => {
|
||||
this.testOngoing = false;
|
||||
this.inlineAlert.showInlineError('DESTINATION.TEST_CONNECTION_FAILURE');
|
||||
});
|
||||
}
|
||||
|
||||
changedTargetName($event: any) {
|
||||
if (this.editable) {
|
||||
this.targetNameHasChanged = true;
|
||||
public get isValid(): boolean {
|
||||
return !this.testOngoing &&
|
||||
!this.onGoing &&
|
||||
this.targetForm &&
|
||||
this.targetForm.valid &&
|
||||
this.editable;
|
||||
}
|
||||
}
|
||||
|
||||
clearPassword($event: any) {
|
||||
if (this.editable) {
|
||||
this.target.password = '';
|
||||
this.endpointHasChanged = true;
|
||||
public get inProgress(): boolean {
|
||||
return this.onGoing || this.testOngoing;
|
||||
}
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
switch (this.actionType) {
|
||||
case ActionType.ADD_NEW:
|
||||
this.addEndpoint();
|
||||
break;
|
||||
case ActionType.EDIT:
|
||||
this.updateEndpoint();
|
||||
break;
|
||||
ngOnDestroy(): void {
|
||||
if (this.valueChangesSub) {
|
||||
this.valueChangesSub.unsubscribe();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addEndpoint() {
|
||||
toPromise<number>(this.endpointService
|
||||
.createEndpoint(this.target))
|
||||
.then(
|
||||
response => {
|
||||
this.translateService.get('DESTINATION.CREATED_SUCCESS')
|
||||
.subscribe(res => this.errorHandler.info(res));
|
||||
|
||||
initEndpoint(): Endpoint {
|
||||
return {
|
||||
endpoint: "",
|
||||
name: "",
|
||||
username: "",
|
||||
password: "",
|
||||
type: 0
|
||||
};
|
||||
}
|
||||
|
||||
open(): void {
|
||||
this.createEditDestinationOpened = true;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.createEditDestinationOpened = false;
|
||||
this.reload.emit(true);
|
||||
})
|
||||
.catch(
|
||||
error => {
|
||||
let errorMessageKey = this.handleErrorMessageKey(error.status);
|
||||
this.translateService
|
||||
.get(errorMessageKey)
|
||||
.subscribe(res => {
|
||||
this.inlineAlert.showInlineError(res);
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
updateEndpoint() {
|
||||
if (!(this.targetNameHasChanged || this.endpointHasChanged)) {
|
||||
this.createEditDestinationOpened = false;
|
||||
return;
|
||||
}
|
||||
let payload: Endpoint = this.initEndpoint;
|
||||
if (this.targetNameHasChanged) {
|
||||
payload.name = this.target.name;
|
||||
delete payload.endpoint;
|
||||
}
|
||||
if (this.endpointHasChanged) {
|
||||
payload.endpoint = this.target.endpoint;
|
||||
payload.username = this.target.username;
|
||||
payload.password = this.target.password;
|
||||
delete payload.name;
|
||||
}
|
||||
|
||||
if (!this.target.id) { return; }
|
||||
toPromise<number>(this.endpointService
|
||||
.updateEndpoint(this.target.id, payload))
|
||||
.then(
|
||||
response => {
|
||||
this.translateService.get('DESTINATION.UPDATED_SUCCESS')
|
||||
.subscribe(res => this.errorHandler.info(res));
|
||||
this.createEditDestinationOpened = false;
|
||||
this.reload.emit(true);
|
||||
})
|
||||
.catch(
|
||||
error => {
|
||||
let errorMessageKey = this.handleErrorMessageKey(error.status);
|
||||
this.translateService
|
||||
.get(errorMessageKey)
|
||||
.subscribe(res => {
|
||||
this.inlineAlert.showInlineError(res);
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
reset(): void {
|
||||
//Reset status variables
|
||||
this.endpointHasChanged = false;
|
||||
this.targetNameHasChanged = false;
|
||||
this.testOngoing = false;
|
||||
this.onGoing = false;
|
||||
|
||||
handleErrorMessageKey(status: number): string {
|
||||
switch (status) {
|
||||
case 409: this
|
||||
return 'DESTINATION.CONFLICT_NAME';
|
||||
case 400:
|
||||
return 'DESTINATION.INVALID_NAME';
|
||||
default:
|
||||
return 'UNKNOWN_ERROR';
|
||||
//Reset data
|
||||
this.target = this.initEndpoint();
|
||||
this.initVal = this.initEndpoint();
|
||||
this.formValues = null;
|
||||
}
|
||||
}
|
||||
|
||||
onCancel() {
|
||||
if (this.hasChanged) {
|
||||
this.inlineAlert.showInlineConfirmation({ message: 'ALERT.FORM_CHANGE_CONFIRMATION' });
|
||||
} else {
|
||||
this.createEditDestinationOpened = false;
|
||||
if (this.targetForm)
|
||||
this.targetForm.reset();
|
||||
}
|
||||
}
|
||||
|
||||
confirmCancel(confirmed: boolean) {
|
||||
this.createEditDestinationOpened = false;
|
||||
this.inlineAlert.close();
|
||||
}
|
||||
|
||||
ngAfterViewChecked(): void {
|
||||
this.targetForm = this.currentForm;
|
||||
if (this.targetForm) {
|
||||
let comparison: { [key: string]: any } = {
|
||||
targetName: this.initVal.name,
|
||||
endpointUrl: this.initVal.endpoint,
|
||||
username: this.initVal.username,
|
||||
password: this.initVal.password
|
||||
};
|
||||
let self: CreateEditEndpointComponent | any = this;
|
||||
if (self) {
|
||||
self.targetForm.valueChanges.subscribe((data: any) => {
|
||||
for (let key in data) {
|
||||
let current = data[key];
|
||||
let origin: string = comparison[key];
|
||||
if (((this.actionType === ActionType.EDIT && this.editable && !current) || current) &&
|
||||
current !== origin) {
|
||||
this.hasChanged = true;
|
||||
break;
|
||||
} else {
|
||||
this.hasChanged = false;
|
||||
this.inlineAlert.close();
|
||||
//Forcely refresh the view
|
||||
forceRefreshView(duration: number): void {
|
||||
//Reset timer
|
||||
if (this.timerHandler) {
|
||||
clearInterval(this.timerHandler);
|
||||
}
|
||||
this.timerHandler = setInterval(() => this.ref.markForCheck(), 100);
|
||||
setTimeout(() => {
|
||||
if (this.timerHandler) {
|
||||
clearInterval(this.timerHandler);
|
||||
this.timerHandler = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}, duration);
|
||||
}
|
||||
|
||||
openCreateEditTarget(editable: boolean, targetId?: number | string) {
|
||||
this.editable = editable;
|
||||
//reset
|
||||
this.reset();
|
||||
if (targetId) {
|
||||
this.actionType = ActionType.EDIT;
|
||||
this.translateService.get('DESTINATION.TITLE_EDIT').subscribe(res => this.modalTitle = res);
|
||||
toPromise<Endpoint>(this.endpointService
|
||||
.getEndpoint(targetId))
|
||||
.then(
|
||||
target => {
|
||||
this.target = target;
|
||||
//Keep data cache
|
||||
this.initVal = clone(target);
|
||||
this.initVal.password = FAKE_PASSWORD;
|
||||
this.target.password = FAKE_PASSWORD;
|
||||
|
||||
//Open the modal now
|
||||
this.open();
|
||||
this.forceRefreshView(1000);
|
||||
})
|
||||
.catch(error => this.errorHandler.error(error));
|
||||
} else {
|
||||
this.actionType = ActionType.ADD_NEW;
|
||||
this.translateService.get('DESTINATION.TITLE_ADD').subscribe(res => this.modalTitle = res);
|
||||
//Directly open the modal
|
||||
this.open();
|
||||
}
|
||||
}
|
||||
|
||||
testConnection() {
|
||||
let payload: Endpoint = this.initEndpoint();
|
||||
|
||||
if (this.endpointHasChanged) {
|
||||
payload.endpoint = this.target.endpoint;
|
||||
payload.username = this.target.username;
|
||||
payload.password = this.target.password;
|
||||
} else {
|
||||
payload.id = this.target.id;
|
||||
}
|
||||
|
||||
this.testOngoing = true;
|
||||
toPromise<Endpoint>(this.endpointService
|
||||
.pingEndpoint(payload))
|
||||
.then(
|
||||
response => {
|
||||
this.inlineAlert.showInlineSuccess({ message: "DESTINATION.TEST_CONNECTION_SUCCESS" });
|
||||
this.forceRefreshView(1000);
|
||||
this.testOngoing = false;
|
||||
}).catch(
|
||||
error => {
|
||||
this.inlineAlert.showInlineError('DESTINATION.TEST_CONNECTION_FAILURE');
|
||||
this.forceRefreshView(1000);
|
||||
this.testOngoing = false;
|
||||
});
|
||||
}
|
||||
|
||||
changedTargetName($event: any) {
|
||||
if (this.editable) {
|
||||
this.targetNameHasChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
clearPassword($event: any) {
|
||||
if (this.editable) {
|
||||
this.target.password = '';
|
||||
this.endpointHasChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
onSubmit() {
|
||||
switch (this.actionType) {
|
||||
case ActionType.ADD_NEW:
|
||||
this.addEndpoint();
|
||||
break;
|
||||
case ActionType.EDIT:
|
||||
this.updateEndpoint();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
addEndpoint() {
|
||||
if (this.onGoing) {
|
||||
return;//Avoid duplicated submitting
|
||||
}
|
||||
|
||||
this.onGoing = true;
|
||||
toPromise<number>(this.endpointService
|
||||
.createEndpoint(this.target))
|
||||
.then(response => {
|
||||
this.translateService.get('DESTINATION.CREATED_SUCCESS')
|
||||
.subscribe(res => this.errorHandler.info(res));
|
||||
this.reload.emit(true);
|
||||
this.onGoing = false;
|
||||
this.close();
|
||||
}).catch(error => {
|
||||
let errorMessageKey = this.handleErrorMessageKey(error.status);
|
||||
this.translateService
|
||||
.get(errorMessageKey)
|
||||
.subscribe(res => {
|
||||
this.inlineAlert.showInlineError(res);
|
||||
this.onGoing = false;
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
updateEndpoint() {
|
||||
if (this.onGoing) {
|
||||
return;//Avoid duplicated submitting
|
||||
}
|
||||
if (!(this.targetNameHasChanged || this.endpointHasChanged)) {
|
||||
return;//Avoid invalid submitting
|
||||
}
|
||||
let payload: Endpoint = this.initEndpoint();
|
||||
if (this.targetNameHasChanged) {
|
||||
payload.name = this.target.name;
|
||||
delete payload.endpoint;
|
||||
}
|
||||
if (this.endpointHasChanged) {
|
||||
payload.endpoint = this.target.endpoint;
|
||||
payload.username = this.target.username;
|
||||
payload.password = this.target.password;
|
||||
delete payload.name;
|
||||
}
|
||||
if (!this.target.id) { return; }
|
||||
|
||||
this.onGoing = true;
|
||||
toPromise<number>(this.endpointService
|
||||
.updateEndpoint(this.target.id, payload))
|
||||
.then(
|
||||
response => {
|
||||
this.translateService.get('DESTINATION.UPDATED_SUCCESS')
|
||||
.subscribe(res => this.errorHandler.info(res));
|
||||
this.reload.emit(true);
|
||||
this.close();
|
||||
this.onGoing = false;
|
||||
})
|
||||
.catch(
|
||||
error => {
|
||||
let errorMessageKey = this.handleErrorMessageKey(error.status);
|
||||
this.translateService
|
||||
.get(errorMessageKey)
|
||||
.subscribe(res => {
|
||||
this.inlineAlert.showInlineError(res);
|
||||
});
|
||||
this.onGoing = false;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
handleErrorMessageKey(status: number): string {
|
||||
switch (status) {
|
||||
case 409: this
|
||||
return 'DESTINATION.CONFLICT_NAME';
|
||||
case 400:
|
||||
return 'DESTINATION.INVALID_NAME';
|
||||
default:
|
||||
return 'UNKNOWN_ERROR';
|
||||
}
|
||||
}
|
||||
|
||||
onCancel() {
|
||||
if (this.hasChanged) {
|
||||
this.inlineAlert.showInlineConfirmation({ message: 'ALERT.FORM_CHANGE_CONFIRMATION' });
|
||||
} else {
|
||||
this.close();
|
||||
if (this.targetForm) {
|
||||
this.targetForm.reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
confirmCancel(confirmed: boolean) {
|
||||
this.inlineAlert.close();
|
||||
this.close();
|
||||
}
|
||||
|
||||
ngAfterViewChecked(): void {
|
||||
if (this.targetForm != this.currentForm) {
|
||||
this.targetForm = this.currentForm;
|
||||
if (this.targetForm) {
|
||||
this.valueChangesSub = this.targetForm.valueChanges.subscribe((data: { [key: string]: string } | any) => {
|
||||
if (data) {
|
||||
//To avoid invalid change publish events
|
||||
let keyNumber: number = 0;
|
||||
for (let key in data) {
|
||||
//Empty string "" is accepted
|
||||
if (data[key] !== null) {
|
||||
keyNumber++;
|
||||
}
|
||||
}
|
||||
if (keyNumber !== 4) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!compareValue(this.formValues, data)) {
|
||||
this.formValues = data;
|
||||
this.inlineAlert.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -37,153 +37,167 @@ import { toPromise, CustomComparator } from '../utils';
|
||||
import { State, Comparator } from 'clarity-angular';
|
||||
|
||||
@Component({
|
||||
selector: 'hbr-endpoint',
|
||||
template: ENDPOINT_TEMPLATE,
|
||||
styles: [ ENDPOINT_STYLE ],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
selector: 'hbr-endpoint',
|
||||
template: ENDPOINT_TEMPLATE,
|
||||
styles: [ENDPOINT_STYLE],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class EndpointComponent implements OnInit {
|
||||
|
||||
@ViewChild(CreateEditEndpointComponent)
|
||||
createEditEndpointComponent: CreateEditEndpointComponent;
|
||||
@ViewChild(CreateEditEndpointComponent)
|
||||
createEditEndpointComponent: CreateEditEndpointComponent;
|
||||
|
||||
|
||||
@ViewChild('confirmationDialog')
|
||||
confirmationDialogComponent: ConfirmationDialogComponent;
|
||||
@ViewChild('confirmationDialog')
|
||||
confirmationDialogComponent: ConfirmationDialogComponent;
|
||||
|
||||
targets: Endpoint[];
|
||||
target: Endpoint;
|
||||
targets: Endpoint[];
|
||||
target: Endpoint;
|
||||
|
||||
targetName: string;
|
||||
subscription: Subscription;
|
||||
targetName: string;
|
||||
subscription: Subscription;
|
||||
|
||||
loading: boolean = false;
|
||||
loading: boolean = false;
|
||||
|
||||
creationTimeComparator: Comparator<Endpoint> = new CustomComparator<Endpoint>('creation_time', 'date');
|
||||
creationTimeComparator: Comparator<Endpoint> = new CustomComparator<Endpoint>('creation_time', 'date');
|
||||
|
||||
get initEndpoint(): Endpoint {
|
||||
return {
|
||||
endpoint: "",
|
||||
name: "",
|
||||
username: "",
|
||||
password: "",
|
||||
type: 0
|
||||
};
|
||||
}
|
||||
timerHandler: any;
|
||||
|
||||
constructor(
|
||||
private endpointService: EndpointService,
|
||||
private errorHandler: ErrorHandler,
|
||||
private translateService: TranslateService,
|
||||
private ref: ChangeDetectorRef) {
|
||||
let hnd = setInterval(()=>ref.markForCheck(), 100);
|
||||
setTimeout(()=>clearInterval(hnd), 1000);
|
||||
}
|
||||
get initEndpoint(): Endpoint {
|
||||
return {
|
||||
endpoint: "",
|
||||
name: "",
|
||||
username: "",
|
||||
password: "",
|
||||
type: 0
|
||||
};
|
||||
}
|
||||
|
||||
confirmDeletion(message: ConfirmationAcknowledgement) {
|
||||
if (message &&
|
||||
message.source === ConfirmationTargets.TARGET &&
|
||||
message.state === ConfirmationState.CONFIRMED) {
|
||||
|
||||
let targetId = message.data;
|
||||
toPromise<number>(this.endpointService
|
||||
.deleteEndpoint(targetId))
|
||||
.then(
|
||||
response => {
|
||||
this.translateService.get('DESTINATION.DELETED_SUCCESS')
|
||||
.subscribe(res=>this.errorHandler.info(res));
|
||||
this.reload(true);
|
||||
}).catch(
|
||||
error => {
|
||||
if(error && error.status === 412) {
|
||||
this.translateService.get('DESTINATION.FAILED_TO_DELETE_TARGET_IN_USED')
|
||||
.subscribe(res=>this.errorHandler.error(res));
|
||||
} else {
|
||||
this.errorHandler.error(error);
|
||||
constructor(
|
||||
private endpointService: EndpointService,
|
||||
private errorHandler: ErrorHandler,
|
||||
private translateService: TranslateService,
|
||||
private ref: ChangeDetectorRef) {
|
||||
this.forceRefreshView(1000);
|
||||
}
|
||||
|
||||
confirmDeletion(message: ConfirmationAcknowledgement) {
|
||||
if (message &&
|
||||
message.source === ConfirmationTargets.TARGET &&
|
||||
message.state === ConfirmationState.CONFIRMED) {
|
||||
|
||||
let targetId = message.data;
|
||||
toPromise<number>(this.endpointService
|
||||
.deleteEndpoint(targetId))
|
||||
.then(
|
||||
response => {
|
||||
this.translateService.get('DESTINATION.DELETED_SUCCESS')
|
||||
.subscribe(res => this.errorHandler.info(res));
|
||||
this.reload(true);
|
||||
}).catch(
|
||||
error => {
|
||||
if (error && error.status === 412) {
|
||||
this.translateService.get('DESTINATION.FAILED_TO_DELETE_TARGET_IN_USED')
|
||||
.subscribe(res => this.errorHandler.error(res));
|
||||
} else {
|
||||
this.errorHandler.error(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.targetName = '';
|
||||
this.retrieve();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.subscription) {
|
||||
this.subscription.unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
retrieve(): void {
|
||||
this.loading = true;
|
||||
toPromise<Endpoint[]>(this.endpointService
|
||||
.getEndpoints(this.targetName))
|
||||
.then(
|
||||
targets => {
|
||||
this.targets = targets || [];
|
||||
this.forceRefreshView(1000);
|
||||
this.loading = false;
|
||||
}).catch(error => {
|
||||
this.errorHandler.error(error);
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
doSearchTargets(targetName: string) {
|
||||
this.targetName = targetName;
|
||||
this.retrieve();
|
||||
}
|
||||
|
||||
refreshTargets() {
|
||||
this.retrieve();
|
||||
}
|
||||
|
||||
reload($event: any) {
|
||||
this.targetName = '';
|
||||
this.retrieve();
|
||||
}
|
||||
|
||||
openModal() {
|
||||
this.createEditEndpointComponent.openCreateEditTarget(true);
|
||||
this.target = this.initEndpoint;
|
||||
}
|
||||
|
||||
editTarget(target: Endpoint) {
|
||||
if (target) {
|
||||
let editable = true;
|
||||
if (!target.id) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
let id: number | string = target.id;
|
||||
toPromise<ReplicationRule[]>(this.endpointService
|
||||
.getEndpointWithReplicationRules(id))
|
||||
.then(
|
||||
rules => {
|
||||
if (rules && rules.length > 0) {
|
||||
rules.forEach((rule) => editable = (rule && rule.enabled !== 1));
|
||||
}
|
||||
this.createEditEndpointComponent.openCreateEditTarget(editable, id);
|
||||
this.forceRefreshView(1000);
|
||||
})
|
||||
.catch(error => this.errorHandler.error(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.targetName = '';
|
||||
this.retrieve();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.subscription) {
|
||||
this.subscription.unsubscribe();
|
||||
deleteTarget(target: Endpoint) {
|
||||
if (target) {
|
||||
let targetId = target.id;
|
||||
let deletionMessage = new ConfirmationMessage(
|
||||
'REPLICATION.DELETION_TITLE_TARGET',
|
||||
'REPLICATION.DELETION_SUMMARY_TARGET',
|
||||
target.name || '',
|
||||
target.id,
|
||||
ConfirmationTargets.TARGET,
|
||||
ConfirmationButtons.DELETE_CANCEL);
|
||||
this.confirmationDialogComponent.open(deletionMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
retrieve(): void {
|
||||
this.loading = true;
|
||||
toPromise<Endpoint[]>(this.endpointService
|
||||
.getEndpoints(this.targetName))
|
||||
.then(
|
||||
targets => {
|
||||
this.targets = targets || [];
|
||||
let hnd = setInterval(()=>this.ref.markForCheck(), 100);
|
||||
setTimeout(()=>clearInterval(hnd), 1000);
|
||||
this.loading = false;
|
||||
}).catch(error => {
|
||||
this.errorHandler.error(error);
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
doSearchTargets(targetName: string) {
|
||||
this.targetName = targetName;
|
||||
this.retrieve();
|
||||
}
|
||||
|
||||
refreshTargets() {
|
||||
this.retrieve();
|
||||
}
|
||||
|
||||
reload($event: any) {
|
||||
this.targetName = '';
|
||||
this.retrieve();
|
||||
}
|
||||
|
||||
openModal() {
|
||||
this.createEditEndpointComponent.openCreateEditTarget(true);
|
||||
this.target = this.initEndpoint;
|
||||
}
|
||||
|
||||
editTarget(target: Endpoint) {
|
||||
if(target) {
|
||||
let editable = true;
|
||||
if (!target.id) {
|
||||
return;
|
||||
}
|
||||
let id: number | string = target.id;
|
||||
toPromise<ReplicationRule[]>(this.endpointService
|
||||
.getEndpointWithReplicationRules(id))
|
||||
.then(
|
||||
rules=>{
|
||||
if(rules && rules.length > 0) {
|
||||
rules.forEach((rule)=>editable = (rule && rule.enabled !== 1));
|
||||
}
|
||||
this.createEditEndpointComponent.openCreateEditTarget(editable, id);
|
||||
let hnd = setInterval(()=>this.ref.markForCheck(), 100);
|
||||
setTimeout(()=>clearInterval(hnd), 1000);
|
||||
})
|
||||
.catch(error=>this.errorHandler.error(error));
|
||||
//Forcely refresh the view
|
||||
forceRefreshView(duration: number): void {
|
||||
//Reset timer
|
||||
if (this.timerHandler) {
|
||||
clearInterval(this.timerHandler);
|
||||
}
|
||||
this.timerHandler = setInterval(() => this.ref.markForCheck(), 100);
|
||||
setTimeout(() => {
|
||||
if (this.timerHandler) {
|
||||
clearInterval(this.timerHandler);
|
||||
this.timerHandler = null;
|
||||
}
|
||||
}, duration);
|
||||
}
|
||||
}
|
||||
|
||||
deleteTarget(target: Endpoint) {
|
||||
if (target) {
|
||||
let targetId = target.id;
|
||||
let deletionMessage = new ConfirmationMessage(
|
||||
'REPLICATION.DELETION_TITLE_TARGET',
|
||||
'REPLICATION.DELETION_SUMMARY_TARGET',
|
||||
target.name || '',
|
||||
target.id,
|
||||
ConfirmationTargets.TARGET,
|
||||
ConfirmationButtons.DELETE_CANCEL);
|
||||
this.confirmationDialogComponent.open(deletionMessage);
|
||||
}
|
||||
}
|
||||
}
|
@ -48,8 +48,8 @@ export const TAG_DETAIL_HTML: string = `
|
||||
</div>
|
||||
</div>
|
||||
<div class="second-column">
|
||||
<div>{{highCount}} {{getPackageText(highCount) | translate}} {{'VULNERABILITY.SEVERITY.HIGH' | translate }} {{suffixForHigh | translate }}</div>
|
||||
<div class="second-row">{{mediumCount}} {{getPackageText(mediumCount) | translate}} {{'VULNERABILITY.SEVERITY.MEDIUM' | translate }} {{suffixForMedium | translate }}</div>
|
||||
<div>{{highCount}} {{'VULNERABILITY.SEVERITY.HIGH' | translate }}</div>
|
||||
<div class="second-row">{{mediumCount}} {{'VULNERABILITY.SEVERITY.MEDIUM' | translate }}</div>
|
||||
</div>
|
||||
<div class="third-column">
|
||||
<div>
|
||||
@ -60,8 +60,8 @@ export const TAG_DETAIL_HTML: string = `
|
||||
</div>
|
||||
</div>
|
||||
<div class="fourth-column">
|
||||
<div>{{lowCount}} {{getPackageText(lowCount) | translate}} {{'VULNERABILITY.SEVERITY.LOW' | translate }} {{suffixForLow | translate }}</div>
|
||||
<div class="second-row">{{unknownCount}} {{getPackageText(unknownCount) | translate}} {{'VULNERABILITY.SEVERITY.UNKNOWN' | translate }} {{suffixForUnknown | translate }}</div>
|
||||
<div>{{lowCount}} {{'VULNERABILITY.SEVERITY.LOW' | translate }}</div>
|
||||
<div class="second-row">{{unknownCount}} {{'VULNERABILITY.SEVERITY.UNKNOWN' | translate }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -144,7 +144,7 @@ describe('TagDetailComponent (inline template)', () => {
|
||||
expect(el).toBeTruthy();
|
||||
let el2: HTMLElement = el.querySelector('div');
|
||||
expect(el2).toBeTruthy();
|
||||
expect(el2.textContent.trim()).toEqual("13 VULNERABILITY.PACKAGES VULNERABILITY.SEVERITY.HIGH VULNERABILITY.PLURAL");
|
||||
expect(el2.textContent.trim()).toEqual("13 VULNERABILITY.SEVERITY.HIGH");
|
||||
});
|
||||
}));
|
||||
|
||||
|
@ -155,7 +155,7 @@ export function calculatePage(state: State): number {
|
||||
* @param {State} state
|
||||
* @returns {void}
|
||||
*/
|
||||
export function doFiltering<T extends {[key:string]: any | any[]}>(items: T[], state: State): T[] {
|
||||
export function doFiltering<T extends { [key: string]: any | any[] }>(items: T[], state: State): T[] {
|
||||
if (!items || items.length === 0) {
|
||||
return items;
|
||||
}
|
||||
@ -187,7 +187,16 @@ export function regexpFilter(terms: string, testedValue: any): boolean {
|
||||
return reg.test(testedValue);
|
||||
}
|
||||
|
||||
export function doSorting<T extends {[key:string]: any | any[]}>(items: T[], state: State): T[] {
|
||||
/**
|
||||
* Sorting the data by column
|
||||
*
|
||||
* @export
|
||||
* @template T
|
||||
* @param {T[]} items
|
||||
* @param {State} state
|
||||
* @returns {T[]}
|
||||
*/
|
||||
export function doSorting<T extends { [key: string]: any | any[] }>(items: T[], state: State): T[] {
|
||||
if (!items || items.length === 0) {
|
||||
return items;
|
||||
}
|
||||
@ -218,4 +227,42 @@ export function doSorting<T extends {[key:string]: any | any[]}>(items: T[], sta
|
||||
|
||||
return comp;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare the two objects to adjust if they're equal
|
||||
*
|
||||
* @export
|
||||
* @param {*} a
|
||||
* @param {*} b
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function compareValue(a: any, b: any): boolean {
|
||||
if ((a && !b) || (!a && b)) return false;
|
||||
if (!a && !b) return true;
|
||||
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the object is null or empty '{}'
|
||||
*
|
||||
* @export
|
||||
* @param {*} obj
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isEmptyObject(obj: any): boolean {
|
||||
return !obj || JSON.stringify(obj) === "{}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Deeper clone all
|
||||
*
|
||||
* @export
|
||||
* @param {*} srcObj
|
||||
* @returns {*}
|
||||
*/
|
||||
export function clone(srcObj: any): any {
|
||||
if (!srcObj) return null;
|
||||
return JSON.parse(JSON.stringify(srcObj));
|
||||
}
|
@ -31,7 +31,7 @@
|
||||
"clarity-icons": "^0.9.8",
|
||||
"clarity-ui": "^0.9.8",
|
||||
"core-js": "^2.4.1",
|
||||
"harbor-ui": "0.3.54",
|
||||
"harbor-ui": "0.3.64",
|
||||
"intl": "^1.2.5",
|
||||
"mutationobserver-shim": "^0.3.2",
|
||||
"ngx-cookie": "^1.0.0",
|
||||
|
@ -497,8 +497,8 @@
|
||||
"UNKNOWN": "unknown",
|
||||
"NONE": "none"
|
||||
},
|
||||
"SINGULAR": "Vulnerability",
|
||||
"PLURAL": "Vulnerabilities",
|
||||
"SINGULAR": "vulnerability",
|
||||
"PLURAL": "vulnerabilities",
|
||||
"PLACEHOLDER": "Filter Vulnerabilities",
|
||||
"PACKAGE": "package",
|
||||
"PACKAGES": "packages",
|
||||
|
@ -496,8 +496,8 @@
|
||||
"UNKNOWN": "unknown",
|
||||
"NONE": "none"
|
||||
},
|
||||
"SINGULAR": "Vulnerability",
|
||||
"PLURAL": "Vulnerabilities",
|
||||
"SINGULAR": "vulnerability",
|
||||
"PLURAL": "vulnerabilities",
|
||||
"PLACEHOLDER": "Filter Vulnerabilities",
|
||||
"PACKAGE": "package",
|
||||
"PACKAGES": "packages",
|
||||
|
Loading…
Reference in New Issue
Block a user