This is an automated email from the ASF dual-hosted git repository. ankovalyshyn pushed a commit to branch DLAB-375 in repository https://gitbox.apache.org/repos/asf/incubator-dlab.git
commit 6e29cd251509ad495c2d0ce81200dab7f3ede50b Author: Andriana Kovalyshyn <[email protected]> AuthorDate: Fri Mar 1 13:09:41 2019 +0200 [DLAB-375]: moved modal dialogs; admin preferences --- .../backup-dilog/backup-dilog.component.html | 81 ++++++ .../backup-dilog/backup-dilog.component.scss | 25 ++ .../backup-dilog/backup-dilog.component.ts | 74 ++++++ .../manage-environment-dilog.component.html | 72 ++++++ .../manage-environment-dilog.component.scss | 86 +++++++ .../manage-environment-dilog.component.ts | 126 +++++++++ .../group-name-validarion.directive.ts | 36 +++ .../manage-roles-groups.component.html | 154 +++++++++++ .../manage-roles-groups.component.scss | 282 +++++++++++++++++++++ .../manage-roles-groups.component.ts | 189 ++++++++++++++ .../ssn-monitor/ssn-monitor.component.html | 116 +++++++++ .../ssn-monitor/ssn-monitor.component.scss | 49 ++++ .../ssn-monitor/ssn-monitor.component.ts | 58 +++++ 13 files changed, 1348 insertions(+) diff --git a/services/self-service/src/main/resources/webapp/src/app/management/backup-dilog/backup-dilog.component.html b/services/self-service/src/main/resources/webapp/src/app/management/backup-dilog/backup-dilog.component.html new file mode 100644 index 0000000..862b8d0 --- /dev/null +++ b/services/self-service/src/main/resources/webapp/src/app/management/backup-dilog/backup-dilog.component.html @@ -0,0 +1,81 @@ +<!-- + +Copyright (c) 2016, EPAM SYSTEMS INC + +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. + +--> + +<modal-dialog #bindDialog modalClass="backup-dialog modal-sm"> + <modal-header> + <h4 class="modal-title">Backup options</h4> + </modal-header> + <modal-content> + <div class="content-box" *ngIf="backupOptions"> + <div class="hold-block"> + <mat-slide-toggle + labelPosition="before" + [checked]="backupOptions.configFiles[0] === 'all'" + (change)="onHoldChanged($event, 'configFiles')"> + <span class="hold-label">Configuration files</span> + </mat-slide-toggle> + </div> + <div class="hold-block"> + <mat-slide-toggle + labelPosition="before" + [checked]="backupOptions.keys[0] === 'all'" + (change)="onHoldChanged($event, 'keys')"> + <span class="hold-label">User keys</span> + </mat-slide-toggle> + </div> + <div class="hold-block"> + <mat-slide-toggle + labelPosition="before" + [checked]="backupOptions.databaseBackup" + (change)="onHoldChanged($event, 'databaseBackup')"> + <span class="hold-label">Database</span> + </mat-slide-toggle> + </div> + <div class="hold-block"> + <mat-slide-toggle + labelPosition="before" + [checked]="backupOptions.certificates[0] === 'all'" + (change)="onHoldChanged($event, 'certificates')"> + <span class="hold-label">SSL Certificates</span> + </mat-slide-toggle> + </div> + <div class="hold-block"> + <mat-slide-toggle + labelPosition="before" + [checked]="backupOptions.jars[0] === 'all'" + (change)="onHoldChanged($event, 'jars')"> + <span class="hold-label">JAR files</span> + </mat-slide-toggle> + </div> + <div class="hold-block"> + <mat-slide-toggle + labelPosition="before" + [checked]="backupOptions.logsBackup" + (change)="onHoldChanged($event, 'logsBackup')"> + <span class="hold-label">Log files</span> + </mat-slide-toggle> + </div> + <div class="text-center m-top-30 m-bott-10"> + <button mat-raised-button type="button" class="butt" (click)="bindDialog.close(); backupOptions.setDegault()"> + Cancel + </button> + <button mat-raised-button type="button" (click)="applyOptions()" class="butt butt-success" [disabled]="!valid">Apply</button> + </div> + </div> + </modal-content> +</modal-dialog> diff --git a/services/self-service/src/main/resources/webapp/src/app/management/backup-dilog/backup-dilog.component.scss b/services/self-service/src/main/resources/webapp/src/app/management/backup-dilog/backup-dilog.component.scss new file mode 100644 index 0000000..6d36f82 --- /dev/null +++ b/services/self-service/src/main/resources/webapp/src/app/management/backup-dilog/backup-dilog.component.scss @@ -0,0 +1,25 @@ +/*************************************************************************** + +Copyright (c) 2016, EPAM SYSTEMS INC + +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. + +****************************************************************************/ + +.hold-block { + padding: 5px 10px; + transition: all 0.4s ease-out; + &:hover { + color: #35afd5; + } +} diff --git a/services/self-service/src/main/resources/webapp/src/app/management/backup-dilog/backup-dilog.component.ts b/services/self-service/src/main/resources/webapp/src/app/management/backup-dilog/backup-dilog.component.ts new file mode 100644 index 0000000..4d0e9e9 --- /dev/null +++ b/services/self-service/src/main/resources/webapp/src/app/management/backup-dilog/backup-dilog.component.ts @@ -0,0 +1,74 @@ +/*************************************************************************** + +Copyright (c) 2016, EPAM SYSTEMS INC + +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, Output, EventEmitter } from '@angular/core'; +import { DICTIONARY } from '../../../dictionary/global.dictionary'; + +import { BackupOptionsModel } from '../management.model'; + +@Component({ + selector: 'dlab-backup-dilog', + templateUrl: './backup-dilog.component.html', + styleUrls: ['./backup-dilog.component.scss'] +}) +export class BackupDilogComponent implements OnInit { + readonly DICTIONARY = DICTIONARY; + public backupOptions: BackupOptionsModel = new BackupOptionsModel([], [], [], [], false, false); + public valid: boolean = true; + + @ViewChild('bindDialog') bindDialog; + @Output() backupOpts: EventEmitter<{}> = new EventEmitter(); + + ngOnInit() { + this.backupOptions.setDegault(); + this.bindDialog.onClosing = () => this.backupOptions.setDegault(); + } + + public open(param): void { + this.valid = true; + this.bindDialog.open(param); + } + + public onHoldChanged($event, key): void { + this.backupOptions[key] instanceof Array + ? (this.backupOptions[key][0] = $event.checked ? 'all' : 'skip') + : (this.backupOptions[key] = !this.backupOptions[key]); + + this.checkValidity(); + } + + public applyOptions(): void { + this.backupOpts.emit(this.backupOptions); + this.backupOptions.setDegault(); + this.bindDialog.close(); + } + + private checkValidity(): void { + const items = []; + + Object.keys(this.backupOptions).forEach(el => { + if (this.backupOptions[el] instanceof Array) { + if (this.backupOptions[el][0] && this.backupOptions[el][0] !== 'skip') items.push(this.backupOptions[el][0]); + } else { + if (this.backupOptions[el]) items.push(this.backupOptions[el]) ; + } + }); + + this.valid = items.length > 0; + } +} diff --git a/services/self-service/src/main/resources/webapp/src/app/management/manage-environment/manage-environment-dilog.component.html b/services/self-service/src/main/resources/webapp/src/app/management/manage-environment/manage-environment-dilog.component.html new file mode 100644 index 0000000..200dd61 --- /dev/null +++ b/services/self-service/src/main/resources/webapp/src/app/management/manage-environment/manage-environment-dilog.component.html @@ -0,0 +1,72 @@ +<!-- + +Copyright (c) 2016, EPAM SYSTEMS INC + +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. + +--> + +<modal-dialog #bindDialog modalClass="manage-env-dialog modal-xl-s"> + <modal-header> + <h4 class="modal-title">Manage environment</h4> + </modal-header> + <modal-content> + <div class="content-box"> + <div *ngIf="usersList.length"> + <form [formGroup]="manageUsersForm" (submit)="setBudgetLimits(manageUsersForm.value)" novalidate> + <mat-list> + <mat-list-item class="list-header"> + <div class="username">User</div> + <div class="quotes" *ngIf="DICTIONARY.cloud_provider !== 'gcp'">Limit</div> + <div class="action">Actions</div> + </mat-list-item> + <div class="scrolling-content" id="scrolling" formArrayName="users"> + <mat-list-item *ngFor="let item of usersEnvironments.controls; let i=index" [formGroupName]="i" class="list-item"> + <div class="username ellipsis">{{ manageUsersForm.controls['users'].controls[i].value['name'] }}</div> + <div class="quotes" *ngIf="DICTIONARY.cloud_provider !== 'gcp'"> + <input type="number" min="0" placeholder="Enter limit, in USD" formControlName="budget"> + <span class="danger_color" *ngIf="!manageUsersForm?.controls['users'].controls[i].controls['budget'].valid && !manageUsersForm?.controls['users'].controls[i].controls['budget'].hasError('overrun')">Only positive integers are allowed</span> + <span class="danger_color" *ngIf="manageUsersForm?.controls['users'].controls[i].controls['budget'].hasError('overrun')">Per-user quotes cannot be greater than total budget</span> + </div> + <div class="action"> + <span *ngIf=" manageUsersForm.controls['users'].controls[i].value['status'] === 'ACTIVE'; else not_active" matTooltip="Stop" matTooltipPosition="above" (click)="applyAction('stop', item)"><i class="material-icons">pause_circle_outline</i></span> + + <ng-template #not_active> + <span class="disabled" matTooltip="User's environment is not active" matTooltipPosition="above"> + <i class="material-icons">pause_circle_outline</i> + </span> + </ng-template> + + <span matTooltip="Terminate" matTooltipPosition="above" (click)="applyAction('terminate', item)"><i class="material-icons">phonelink_off</i></span> + </div> + </mat-list-item> + </div> + <div class="control-group total-budget" *ngIf="DICTIONARY.cloud_provider !== 'gcp'"> + <label class="label">Total budget</label> + <div class="control"> + <input type="number" formControlName="total" placeholder="Enter total budget, in USD"> + <span class="danger_color" *ngIf="manageUsersForm?.controls['total'].hasError('overrun')">Total budget cannot be lower than a sum of users quotes</span> + </div> + </div> + <div class="text-center m-top-30" *ngIf="DICTIONARY.cloud_provider !== 'gcp'"> + <button mat-raised-button type="button" (click)="bindDialog.close()" class="butt action">Cancel</button> + <button mat-raised-button type="submit" [disabled]="!manageUsersForm.valid" + class="butt butt-success" [ngClass]="{'not-allowed': !manageUsersForm.valid}">Apply</button> + </div> + </mat-list> + </form> + </div> + <div class="info message" *ngIf="!usersList.length">No active users environments</div> + </div> + </modal-content> +</modal-dialog> diff --git a/services/self-service/src/main/resources/webapp/src/app/management/manage-environment/manage-environment-dilog.component.scss b/services/self-service/src/main/resources/webapp/src/app/management/manage-environment/manage-environment-dilog.component.scss new file mode 100644 index 0000000..c10c2e8 --- /dev/null +++ b/services/self-service/src/main/resources/webapp/src/app/management/manage-environment/manage-environment-dilog.component.scss @@ -0,0 +1,86 @@ +/*************************************************************************** + +Copyright (c) 2016, EPAM SYSTEMS INC + +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. + +****************************************************************************/ + +.manage-env-dialog { + .mat-list { + .mat-list-item { + height: 60px; + position: relative; + .mat-list-item-content { + padding: 0 !important; + justify-content: space-between; + color: #577289; + } + } + } + .scrolling-content { + max-height: 300px; + min-height: 85px; + overflow-y: auto; + } + .username { + width: 45%; + } + .quotes { + width: 40%; + margin-right: 10px; + position: relative; + .danger_color { + position: absolute; + left: 0; + top: 36px; + } + } + .action { + width: 15%; + span { + padding: 3px; + cursor: pointer; + &:hover { + color: #35afd5; + } + i { + font-size: 20px; + } + } + .disabled { + cursor: not-allowed !important; + pointer-events: all; + opacity: .6; + &:hover { + color: #6b8299; + } + } + } + .total-budget { + border-top: 1px solid #edf1f5; + padding-top: 15px; + .control { + position: relative; + width: 56%; + .danger_color { + position: absolute; + left: 0; + bottom: -20px; + } + } + .label { + width: 44%; + } + } +} \ No newline at end of file diff --git a/services/self-service/src/main/resources/webapp/src/app/management/manage-environment/manage-environment-dilog.component.ts b/services/self-service/src/main/resources/webapp/src/app/management/manage-environment/manage-environment-dilog.component.ts new file mode 100644 index 0000000..212a3b6 --- /dev/null +++ b/services/self-service/src/main/resources/webapp/src/app/management/manage-environment/manage-environment-dilog.component.ts @@ -0,0 +1,126 @@ +/*************************************************************************** + +Copyright (c) 2016, EPAM SYSTEMS INC + +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, ViewChild, Output, EventEmitter, ViewEncapsulation, Inject } from '@angular/core'; +import { Validators, FormBuilder, FormGroup, FormArray } from '@angular/forms'; +import { MatDialog, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material'; +import { DICTIONARY } from '../../../dictionary/global.dictionary'; + +@Component({ + selector: 'dlab-manage-env-dilog', + templateUrl: './manage-environment-dilog.component.html', + styleUrls: ['./manage-environment-dilog.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class ManageEnvironmentComponent { + readonly DICTIONARY = DICTIONARY; + public usersList: Array<string> = []; + public manageUsersForm: FormGroup; + public manageTotalsForm: FormGroup; + + @ViewChild('bindDialog') bindDialog; + @Output() manageEnv: EventEmitter<{}> = new EventEmitter(); + @Output() setBudget: EventEmitter<{}> = new EventEmitter(); + + constructor( + private _fb: FormBuilder, + public dialog: MatDialog + ) { } + + get usersEnvironments(): FormArray{ + return <FormArray>this.manageUsersForm.get('users'); + } + + public open(param, data, settings): void { + this.usersList = data; + !this.manageUsersForm && this.initForm(); + + this.manageUsersForm.setControl('users', + this._fb.array((this.usersList || []).map((x: any) => this._fb.group({ + name: x.name, budget: [x.budget, [Validators.min(0), this.userValidityCheck.bind(this)]], status: x.status + })))); + this.manageUsersForm.controls['total'].setValue(settings.conf_max_budget || null); + this.bindDialog.open(param); + } + + public setBudgetLimits(value) { + this.setBudget.emit(value); + this.bindDialog.close(); + } + + public applyAction(action, user) { + const dialogRef: MatDialogRef<ConfirmActionDialogComponent> = this.dialog.open( + ConfirmActionDialogComponent, { data: {action, user: user.value.name}, width: '550px' }); + dialogRef.afterClosed().subscribe(result => { + if (result) this.manageEnv.emit({action, user: user.value.name}); + }); + } + + private initForm(): void { + this.manageUsersForm = this._fb.group({ + total: [null, [Validators.min(0), this.totalValidityCheck.bind(this)]], + users: this._fb.array([this._fb.group({ name: '', budget: null, status: ''})]) + }); + } + + private getCurrentUsersTotal(): number { + return this.manageUsersForm.value.users.reduce((memo, el) => memo += el.budget, 0); + } + + private getCurrentTotalValue(): number { + return this.manageUsersForm.value.total; + } + + private totalValidityCheck(control) { + return (control && control.value) + ? (control.value >= this.getCurrentUsersTotal() ? null : { overrun: true }) + : null; + } + + private userValidityCheck(control) { + if (control && control.value) { + return (this.getCurrentTotalValue() && this.getCurrentTotalValue() < this.getCurrentUsersTotal()) ? { overrun: true } : null; + } + } +} + +@Component({ + selector: 'dialog-result-example-dialog', + template: ` + <div mat-dialog-content class="content"> + <p>Environment of <b>{{ data.user }}</b> will be + <span *ngIf="data.action === 'terminate'"> terminated.</span> + <span *ngIf="data.action === 'stop'">stopped.</span> + </p> + <p class="m-top-20"><strong>Do you want to proceed?</strong></p> + </div> + <div class="text-center"> + <button type="button" class="butt" mat-raised-button (click)="dialogRef.close()">No</button> + <button type="button" class="butt butt-success" mat-raised-button (click)="dialogRef.close(true)">Yes</button> + </div> + `, + styles: [` + .content { color: #718ba6; padding: 20px 50px; font-size: 14px; font-weight: 400 } + `] +}) +export class ConfirmActionDialogComponent { + constructor( + public dialogRef: MatDialogRef<ConfirmActionDialogComponent>, + @Inject(MAT_DIALOG_DATA) public data: any + ) { } +} diff --git a/services/self-service/src/main/resources/webapp/src/app/management/manage-roles-groups/group-name-validarion.directive.ts b/services/self-service/src/main/resources/webapp/src/app/management/manage-roles-groups/group-name-validarion.directive.ts new file mode 100644 index 0000000..2b35c8d --- /dev/null +++ b/services/self-service/src/main/resources/webapp/src/app/management/manage-roles-groups/group-name-validarion.directive.ts @@ -0,0 +1,36 @@ +/*************************************************************************** + +Copyright (c) 2016, EPAM SYSTEMS INC + +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, forwardRef, Input } from '@angular/core'; +import { AbstractControl, NG_VALIDATORS, Validator } from '@angular/forms'; + +@Directive({ + selector: '[validator][ngModel],[group-dir][ngFormControl]', + providers: [{ + multi: true, + provide: NG_VALIDATORS, + useExisting: forwardRef(() => GroupNameValidationDirective) + }] +}) +export class GroupNameValidationDirective implements Validator { + @Input() validator: Function; + + validate(control: AbstractControl): { [key: string]: any; } { + return this.validator(control); + } +} diff --git a/services/self-service/src/main/resources/webapp/src/app/management/manage-roles-groups/manage-roles-groups.component.html b/services/self-service/src/main/resources/webapp/src/app/management/manage-roles-groups/manage-roles-groups.component.html new file mode 100644 index 0000000..0803011 --- /dev/null +++ b/services/self-service/src/main/resources/webapp/src/app/management/manage-roles-groups/manage-roles-groups.component.html @@ -0,0 +1,154 @@ +<!-- + +Copyright (c) 2016, EPAM SYSTEMS INC + +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. + +--> + +<modal-dialog #bindDialog modalClass="manage-roles-dialog modal-xxl"> + <modal-header> + <h4 class="modal-title">Manage roles</h4> + </modal-header> + <modal-content> + <div class="content-box"> + <button mat-raised-button class="butt add-group" (click)="stepperView = !stepperView"> + <i class="material-icons">people_outline</i>Add group + </button> + <mat-horizontal-stepper #stepper *ngIf="stepperView" class="stepper ani"> + <mat-step> + <ng-template matStepLabel>Groups</ng-template> + <div class="inner-step mat-reset"> + <input [validator]="groupValidarion()" type="text" placeholder="Enter group name" [(ngModel)]="setupGroup" #setupGroupName="ngModel"> + <div class="danger_color" *ngIf="setupGroupName.errors?.patterns && setupGroupName.dirty">Group name can only contain letters, numbers, hyphens and '_'</div> + <div class="danger_color" *ngIf="setupGroupName.errors?.duplicate && setupGroupName.dirty">Group name already exists</div> + </div> + <div class="text-center m-bott-10"> + <button mat-raised-button (click)="resetDialog()" class="butt">Cancel</button> + <button mat-raised-button matStepperNext class="butt">Next<i class="material-icons">keyboard_arrow_right</i></button> + </div> + </mat-step> + <mat-step> + <ng-template matStepLabel>Roles</ng-template> + <div class="inner-step mat-reset"> + <div class="selector-wrapper"> + <!-- <multi-select-dropdown (selectionChange)="onUpdate($event)" [type]="'role'" [items]="rolesList" [model]="setupRoles"></multi-select-dropdown> --> + <mat-form-field> + <mat-select multiple [compareWith]="compareObjects" name="roles" [(value)]="setupRoles" placeholder="Select roles"> + <mat-option class="multiple-select" disabled> + <a class="select ani" (click)="selectAllOptions(setupRoles, rolesList)"> + <i class="material-icons">playlist_add_check</i> All + </a> + <a class="deselect ani" (click)="selectAllOptions(setupRoles)"> + <i class="material-icons">clear</i> None + </a> + </mat-option> + <mat-option *ngFor="let role of rolesList" [value]="role"> + {{ role }} + </mat-option> + </mat-select> + <button class="caret"> + <i class="material-icons">keyboard_arrow_down</i> + </button> + </mat-form-field> + </div> + </div> + <div class="text-center m-bott-10"> + <button mat-raised-button matStepperPrevious class="butt"><i class="material-icons">keyboard_arrow_left</i>Back</button> + <button mat-raised-button (click)="resetDialog()" class="butt">Cancel</button> + <button mat-raised-button matStepperNext class="butt">Next<i class="material-icons">keyboard_arrow_right</i></button> + </div> + </mat-step> + <mat-step> + <ng-template matStepLabel>Users</ng-template> + <div class="inner-step mat-reset"> + <input type="text" placeholder="Enter user login" [(ngModel)]="setupUser"> + </div> + <div class="text-center m-bott-10"> + <button mat-raised-button matStepperPrevious class="butt"><i class="material-icons">keyboard_arrow_left</i>Back</button> + <button mat-raised-button (click)="resetDialog()" class="butt">Cancel</button> + <button mat-raised-button (click)="manageAction('create', 'group')" class="butt butt-success" + [disabled]="!setupGroup || setupGroupName.errors?.pattern || !setupRoles.length > 0">Create</button> + </div> + </mat-step> + </mat-horizontal-stepper> + <mat-divider></mat-divider> + <div *ngIf="groupsData.length" class="ani"> + <table class="dashboard_table"> + <tr> + <th class="th_name groups">Name</th> + <th class="roles">Roles</th> + <th class="users">Users</th> + <th class="th_actions">Action</th> + </tr> + <tr *ngFor="let item of groupsData" class="dashboard_table_body filter-row"> + <td>{{ item.group }}</td> + <td class="roles mat-reset"> + <div class="selector-wrapper-edit"> + <mat-form-field> + <mat-select multiple [compareWith]="compareObjects" name="selected_roles" [(value)]="item.selected_roles" placeholder="Select roles"> + <mat-option class="multiple-select" disabled> + <a class="select ani" (click)="selectAllOptions(item, rolesList, 'selected_roles')"> + <i class="material-icons">playlist_add_check</i> All + </a> + <a class="deselect ani" (click)="selectAllOptions(item, null, 'selected_roles')"> + <i class="material-icons">clear</i> None + </a> + </mat-option> + <mat-option *ngFor="let role of rolesList" [value]="role"> + {{ role }} + </mat-option> + </mat-select> + <button class="caret ani"> + <i class="material-icons">keyboard_arrow_down</i> + </button> + </mat-form-field> + </div> + </td> + <td class="users-list ani"> + <mat-form-field class="chip-list"> + <input #user matInput placeholder="Enter user login" pattern="[@.-_0-9a-zA-Z]" (keydown.enter)="addUser(user.value, item); user.value = ''"> + <button mat-icon-button matSuffix (click)="addUser(user.value, item); user.value = ''"> + <mat-icon>person_add</mat-icon> + </button> + </mat-form-field> + <div class="list-selected list-container ani"> + <mat-chip-list> + <mat-chip *ngFor="let user of item.users"> + {{ user }} + <a class="material-icons" (click)="removeUser(item.users, user)">clear</a> + </mat-chip> + </mat-chip-list> + </div> + </td> + <td class="actions"> + <button mat-icon-button class="reset ani" (click)="manageAction('delete', 'group', item)"> + <i class="material-icons">close</i> + </button> + + <button mat-icon-button class="apply ani" matTooltip="Group cannot be updated without any selected role" + matTooltipPosition="above" + [matTooltipDisabled]="item.selected_roles.length > 0"> + <span class="mid" [ngClass]="{ 'not-allowed' : !item.selected_roles.length }" + (click)="manageAction('update', 'group', item)"> + <i class="material-icons">done</i> + </span> + </button> + </td> + </tr> + </table> + </div> + </div> + </modal-content> +</modal-dialog> + diff --git a/services/self-service/src/main/resources/webapp/src/app/management/manage-roles-groups/manage-roles-groups.component.scss b/services/self-service/src/main/resources/webapp/src/app/management/manage-roles-groups/manage-roles-groups.component.scss new file mode 100644 index 0000000..205fd83 --- /dev/null +++ b/services/self-service/src/main/resources/webapp/src/app/management/manage-roles-groups/manage-roles-groups.component.scss @@ -0,0 +1,282 @@ +/*************************************************************************** + +Copyright (c) 2016, EPAM SYSTEMS INC + +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. + +****************************************************************************/ + +::ng-deep .mat-option:first-child .mat-pseudo-checkbox { display: none; } + +.caret { + width: 40px; + color: #4ab8dc; + border: none; + border-left: 1px solid #ececec; + background-color: #fff; + position: absolute; + right: 0; + top: 0px; + height: 36px; + cursor: pointer; + &.not-allowed { + background-color: #dcdcdc; + } +} + + +.content-box { + padding: 20px 30px 35px; + height: 85vh; + overflow-y: auto; +} +.no-details { + color: #d8d8d8; +} +.mat-divider { + margin: 10px 0; +} +.stepper { + height: 190px; + margin-top: 10px; + .inner-step { + height: 70px; + padding: 5px; + display: flex; + justify-content: center; + flex-direction: column; + text-align: center; + input { + width: 490px; + align-self: center; + } + .caret { + i { + margin-top: 3px; + } + } + } +} +.selector-wrapper { + display: flex; + align-self: center; + width: 490px; + height: 36px; + padding-left: 10px; + font-family: 'Open Sans', sans-serif; + font-size: 15px; + font-weight: 300; + box-shadow: 0 3px 1px -2px rgba(0, 0, 0, 0.2), 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 1px 5px 0 rgba(0, 0, 0, 0.12); + mat-form-field { + width: 100%; + .mat-form-field-wrapper { + padding-bottom: 0; + } + .mat-icon { + font-size: 20px; + } + } + .dropdown-multiselect { + width: 100% !important; + > button { + padding: 6px 22px; + } + } +} +.list-header { + padding: 0 15px; +} +.scrolling-content { + max-height: 450px; + overflow-x: hidden; + overflow-y: auto; + display: block; + padding: 15px 5px; + &.stepper-opened { + height: 250px; + } +} +.roles { + width: 30%; + padding: 0 10px; + .selector-wrapper-edit { + position: relative; + display: flex; + justify-content: space-between; + height: 36px; + padding-left: 10px; + background: #fff; + box-shadow: 0 3px 1px -2px rgba(0, 0, 0, 0.2), 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 1px 5px 0 rgba(0, 0, 0, 0.12); + multi-select-dropdown { + width: 100%; + .dropdown-multiselect { + > button { + padding: 8px 22px 5px; + } + } + } + .caret { + &:hover { + box-shadow: 0 3px 1px -10px rgba(0, 0, 0, 0.2), 0 2px 1px 0 rgba(0, 0, 0, 0.17), 0 1px 5px 0 rgba(0, 0, 0, 0.12) + } + } + } +} +.groups { + width: 20%; +} +.users { + width: 30%; + padding: 0 10px; +} +.users-list { + padding: 5px 10px; + font-family: 'Open Sans', sans-serif; + font-size: 15px; + font-weight: 300; + color: #577289; + position: relative; + i { + color: #FF5722; + font-size: 18px; + cursor: pointer; + } + .list-selected { + width: 100%; + margin-top: 50px; + height: inherit; + } +} +.expanded-panel { + display: flex; + align-items: flex-end; + .add-input-block { + display: flex; + height: 36px; + padding-right: 0; + outline: none; + box-shadow: 0 3px 1px -2px rgba(0, 0, 0, 0.2), 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 1px 5px 0 rgba(0, 0, 0, 0.12); + input { + height: 36px; + padding: 0; + padding-left: 10px; + width: 170px; + } + .caret { + width: 50px; + } + } +} + + +mat-expansion-panel-header { + &.mat-expansion-panel-header { + font-family: 'Open Sans', sans-serif; + font-size: 15px; + font-weight: 300; + color: #577289; + } +} + +.mat-step-header { + .mat-step-icon { + background-color: #36afd5 !important; + } + .mat-step-label { + font-family: 'Open Sans', sans-serif; + font-size: 16px; + font-weight: 300; + } +} + +.dashboard_table { + .dashboard_table_body { + .actions { + padding: 10px 0; + } + &.filter-row { + .actions { + button { + background: none; + .mid { + vertical-align: sub; + } + } + } + } + } + .th_actions { + width: 10%; + } +} + +.mat-chip:not(.mat-basic-chip) { + transition: box-shadow 280ms cubic-bezier(.4,0,.2,1); + padding: 7px 0 7px 10px; + border-radius: 24px; + cursor: default; + display: inline-block; + position: relative; + padding-right: 25px; + white-space: nowrap; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + margin: 2px !important; +} +mat-chip.mat-chip a { + position: absolute; + right: 15px; +} +mat-form-field.chip-list { + &.mat-form-field { + position: absolute; + top: 2px; + width: 94%; + font-size: 16px; + font-weight: 300; + } +} + +.mat-raised-button { + &.butt { + &.butt-success { + margin-left: 0; + } + } +} +.multiple-select { + border-bottom: 1px solid #dedbdb; + padding: 0; + a { + display: inline-block; + width: 50%; + padding: 0 15px; + vertical-align: middle; + color: #575757; + cursor: pointer; + i { + vertical-align: sub; + font-size: 20px; + } + &:hover { + color: #4eaf3e; + background: #f9fafb; + } + &.deselect { + &:hover { + color: #f1696e; + } + } + } +} diff --git a/services/self-service/src/main/resources/webapp/src/app/management/manage-roles-groups/manage-roles-groups.component.ts b/services/self-service/src/main/resources/webapp/src/app/management/manage-roles-groups/manage-roles-groups.component.ts new file mode 100644 index 0000000..25124ca --- /dev/null +++ b/services/self-service/src/main/resources/webapp/src/app/management/manage-roles-groups/manage-roles-groups.component.ts @@ -0,0 +1,189 @@ +/*************************************************************************** + +Copyright (c) 2016, EPAM SYSTEMS INC + +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, Output, EventEmitter, Inject } from '@angular/core'; +import { ValidatorFn, FormControl, NgModel } from '@angular/forms'; +import { MatDialog, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material'; +import { DICTIONARY } from '../../../dictionary/global.dictionary'; + +@Component({ + selector: 'dlab-manage-roles-groups', + templateUrl: './manage-roles-groups.component.html', + styleUrls: ['../../resources/resources-grid/resources-grid.component.css', './manage-roles-groups.component.scss'] +}) +export class ManageRolesGroupsComponent implements OnInit { + readonly DICTIONARY = DICTIONARY; + + public groupsData: Array<any> = []; + public roles: Array<any> = []; + public rolesList: Array<string> = []; + public setupGroup: string = ''; + public setupUser: string = ''; + public manageUser: string = ''; + public setupRoles: Array<string> = []; + public updatedRoles: Array<string> = []; + public delimitersRegex = /[-_]?/g; + public groupnamePattern = new RegExp(/^[a-zA-Z0-9_\-]+$/); + + @ViewChild('bindDialog') bindDialog; + @Output() manageRolesGroupAction: EventEmitter<{}> = new EventEmitter(); + stepperView: boolean = false; + + constructor(public dialog: MatDialog) { } + + ngOnInit() { + this.bindDialog.onClosing = () => this.resetDialog(); + } + + public open(param, groups, roles): void { + this.roles = roles; + this.rolesList = roles.map(role => role.description); + this.updateGroupData(groups); + + this.stepperView = false; + this.bindDialog.open(param); + } + + public onUpdate($event) { + if ($event.type === 'role') { + this.setupRoles = $event.model; + } else { + this.updatedRoles = $event.model; + } + $event.$event.preventDefault(); + } + + public selectAllOptions(item, values, byKey?) { + byKey ? (item[byKey] = values ? values : []) : this.setupRoles = values ? values : []; + } + + public manageAction(action: string, type: string, item?: any, value?) { + if (action === 'create') { + this.manageRolesGroupAction.emit( + { action, type, value: { + name: this.setupGroup, + users: this.setupUser ? this.setupUser.split(',').map(elem => elem.trim()) : [], + roleIds: this.extractIds(this.roles, this.setupRoles) + } + }); + this.stepperView = false; + } else if (action === 'delete') { + const data = (type === 'users') ? {group: item.group, user: value} : {group: item.group, id: item}; + const dialogRef: MatDialogRef<ConfirmDeleteUserAccountDialogComponent> = this.dialog.open( + ConfirmDeleteUserAccountDialogComponent, + { data: data, width: '550px' } + ); + + dialogRef.afterClosed().subscribe(result => { + if (result) { + const emitValue = (type === 'users') + ? {action, type, id: item.name, value: { user: value, group: item.group }} + : {action, type, id: item.name, value: item.group} ; + this.manageRolesGroupAction.emit(emitValue); + } + }); + } else if (action === 'update') { + this.manageRolesGroupAction.emit({action, type, value: { + name: item.group, + roleIds: this.extractIds(this.roles, item.selected_roles), + users: item.users || [] }}); + } + this.resetDialog(); + } + + public extractIds(sourceList, target) { + return sourceList.reduce((acc, item) => { + target.includes(item.description) && acc.push(item._id); + return acc; + }, []); + } + + public updateGroupData(groups) { + this.groupsData = groups; + + this.groupsData.forEach(item => { + item.selected_roles = item.roles.map(role => role.description); + }); + } + + public groupValidarion(): ValidatorFn { + + const duplicateList: any = this.groupsData.map(item => item.group); + return <ValidatorFn>((control: FormControl) => { + if (control.value && duplicateList.includes(this.delimitersFiltering(control.value))) + return { duplicate: true }; + + if (control.value && !this.groupnamePattern.test(control.value)) + return { patterns: true }; + + return null; + }); + } + + public compareObjects(o1: any, o2: any): boolean { + return o1.toLowerCase() === o2.toLowerCase(); + } + + public delimitersFiltering(resource): string { + return resource.replace(this.delimitersRegex, '').toString().toLowerCase(); + } + + public resetDialog() { + this.stepperView = false; + this.setupGroup = ''; + this.setupUser = ''; + this.manageUser = ''; + this.setupRoles = []; + this.updatedRoles = []; + } + + public removeUser(list, item): void { + list.splice(list.indexOf(item), 1); + } + + public addUser(value: string, item): void { + if (value && value.trim()) { + item.users instanceof Array ? item.users.push(value.trim()) : item.users = [value.trim()]; + } + } +} + + +@Component({ + selector: 'dialog-result-example-dialog', + template: ` + <div mat-dialog-content class="content"> + <p *ngIf="data.user">User <strong>{{ data.user }}</strong> will be deleted from <strong>{{ data.group }}</strong> group.</p> + <p *ngIf="data.id">Group <strong>{{ data.group }}</strong> will be decommissioned.</p> + <p class="m-top-20"><strong>Do you want to proceed?</strong></p> + </div> + <div class="text-center"> + <button type="button" class="butt" mat-raised-button (click)="dialogRef.close()">No</button> + <button type="button" class="butt butt-success" mat-raised-button (click)="dialogRef.close(true)">Yes</button> + </div> + `, + styles: [` + .content { color: #718ba6; padding: 20px 50px; font-size: 14px; font-weight: 400 } + `] +}) +export class ConfirmDeleteUserAccountDialogComponent { + constructor( + public dialogRef: MatDialogRef<ConfirmDeleteUserAccountDialogComponent>, + @Inject(MAT_DIALOG_DATA) public data: any + ) { } +} diff --git a/services/self-service/src/main/resources/webapp/src/app/management/ssn-monitor/ssn-monitor.component.html b/services/self-service/src/main/resources/webapp/src/app/management/ssn-monitor/ssn-monitor.component.html new file mode 100644 index 0000000..94f7564 --- /dev/null +++ b/services/self-service/src/main/resources/webapp/src/app/management/ssn-monitor/ssn-monitor.component.html @@ -0,0 +1,116 @@ +<!-- + +Copyright (c) 2016, EPAM SYSTEMS INC + +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. + +--> + +<modal-dialog #bindDialog modalClass="ssn-monitor-dialog modal-lg"> + <modal-header> + <h4 class="modal-title">SSN Monitor</h4> + </modal-header> + <modal-content> + <div class="content-box" *ngIf="monitorData"> + <div class="ssn-info"> + <mat-tab-group *ngIf="monitorData?.processorInfo" [dynamicHeight]="true"> + <mat-tab label="CPU"> + <div class="scrolling-content" id="scrolling"> + <mat-list-item class="list-header"> + <div class="col">Name</div> + <div class="col">{{ monitorData.processorInfo.name }}</div> + </mat-list-item> + <mat-list-item class="list-header"> + <div class="col">Vendor</div> + <div class="col">{{ monitorData.processorInfo.vendor }}</div> + </mat-list-item> + <mat-list-item class="list-header"> + <div class="col">Logical Core Count</div> + <div class="col">{{ monitorData.processorInfo.logicalCoreCount }}</div> + </mat-list-item> + <mat-list-item class="list-header"> + <div class="col">Physical Core Count</div> + <div class="col">{{ monitorData.processorInfo.physicalCoreCount }}</div> + </mat-list-item> + <mat-list-item class="list-header"> + <div class="col">Current System Load</div> + <div class="col">{{ monitorData.processorInfo.currentSystemLoad /100 | percent:'1.0-2' }}</div> + </mat-list-item> + <mat-list-item class="list-header"> + <div class="col">System Load Average</div> + <div class="col">{{ monitorData.processorInfo.systemLoadAverage /100 | percent:'1.0-2' }}</div> + </mat-list-item> + <mat-list-item class="list-header"> + <div class="col">CPU 64 Bit</div> + <div class="col">{{ monitorData.processorInfo.cpu64Bit }}</div> + </mat-list-item> + </div> + </mat-tab> + + <mat-tab label="Memory"> + <div class="scrolling-content" id="scrolling"> + <mat-list-item class="list-header"> + <div class="col">Available Memory</div> + <div class="col">{{ convertSize(monitorData.memoryInfo.availableMemory) }}</div> + </mat-list-item> + <mat-list-item class="list-header"> + <div class="col">Total Memory</div> + <div class="col">{{ convertSize(monitorData.memoryInfo.totalMemory) }}</div> + </mat-list-item> + <mat-list-item class="list-header"> + <div class="col">Swap Total</div> + <div class="col">{{ convertSize(monitorData.memoryInfo.swapTotal) }}</div> + </mat-list-item> + <mat-list-item class="list-header"> + <div class="col">Swap Used</div> + <div class="col">{{ convertSize(monitorData.memoryInfo.swapUsed) }}</div> + </mat-list-item> + <mat-list-item class="list-header"> + <div class="col">Pages Page In</div> + <div class="col">{{ convertSize(monitorData.memoryInfo.pagesPageIn) }}</div> + </mat-list-item> + <mat-list-item class="list-header"> + <div class="col">Pages Page Out</div> + <div class="col">{{ convertSize(monitorData.memoryInfo.pagesPageOut) }}</div> + </mat-list-item> + </div> + + </mat-tab> + <mat-tab label="HDD"> + <div class="scrolling-content" id="scrolling"> + <div *ngFor="let disk of monitorData.disksInfo; let i = index"> + <mat-list-item> + <div class="col"><strong>Disk {{i +1}}</strong></div> + </mat-list-item> + <mat-list-item> + <div class="col">Used Space</div> + <div class="col">{{ convertSize(disk.usedByteSpace) }}</div> + </mat-list-item> + <mat-list-item> + <div class="col">Total Space</div> + <div class="col">{{ convertSize(disk.totalByteSpace) }}</div> + </mat-list-item> + </div> + </div> + </mat-tab> + </mat-tab-group> + <div class="text-center"> + <button type="button" class="butt" mat-raised-button (click)="close()">Close</button> + </div> + </div> + <div class="info message" *ngIf="isEmpty(monitorData)"> + No ssn monitor data available + </div> + </div> + </modal-content> +</modal-dialog> diff --git a/services/self-service/src/main/resources/webapp/src/app/management/ssn-monitor/ssn-monitor.component.scss b/services/self-service/src/main/resources/webapp/src/app/management/ssn-monitor/ssn-monitor.component.scss new file mode 100644 index 0000000..a4d6f9d --- /dev/null +++ b/services/self-service/src/main/resources/webapp/src/app/management/ssn-monitor/ssn-monitor.component.scss @@ -0,0 +1,49 @@ +/*************************************************************************** + +Copyright (c) 2016, EPAM SYSTEMS INC + +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. + +****************************************************************************/ + +.ssn-monitor-dialog { + .content-box { + padding-top: 10px !important; + } + .ssn-info { + min-height: 400px; + max-height: 500px; + .scrolling-content { + max-height: 310px; + overflow-y: auto; + } + .text-center { + position: absolute; + bottom: 20px; + left: 0; + right: 0; + } + } + .mat-list-item-content { + display: flex; + justify-content: initial; + color: #577289; + padding: 15px 5px; + border-bottom: 1px solid #f3f2f2; + font-size: 15px; + .col { + width: 50%; + font-weight: 300; + } + } +} \ No newline at end of file diff --git a/services/self-service/src/main/resources/webapp/src/app/management/ssn-monitor/ssn-monitor.component.ts b/services/self-service/src/main/resources/webapp/src/app/management/ssn-monitor/ssn-monitor.component.ts new file mode 100644 index 0000000..b7d5e0c --- /dev/null +++ b/services/self-service/src/main/resources/webapp/src/app/management/ssn-monitor/ssn-monitor.component.ts @@ -0,0 +1,58 @@ +/*************************************************************************** + +Copyright (c) 2016, EPAM SYSTEMS INC + +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, Output, EventEmitter, ViewEncapsulation } from '@angular/core'; +import { DICTIONARY } from './../../../dictionary/global.dictionary'; + +@Component({ + selector: 'dlab-ssn-monitor', + templateUrl: './ssn-monitor.component.html', + styleUrls: ['./ssn-monitor.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class SsnMonitorComponent implements OnInit { + readonly DICTIONARY = DICTIONARY; + + public errorMessage: string = ''; + public monitorData = {}; + + @ViewChild('bindDialog') bindDialog; + @Output() manageEnv: EventEmitter<{}> = new EventEmitter(); + + ngOnInit() {} + + public open(param, data): void { + this.monitorData = data || {}; + this.bindDialog.open(param); + } + public close(param, data): void { + this.bindDialog.close(); + } + + public isEmpty(obj) { + if (obj) return Object.keys(obj).length === 0; + } + + public convertSize(bytes) { + if (Number(bytes) === 0) return '0 Byte'; + + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return parseFloat((bytes / Math.pow(1024, i)).toFixed(3)) + ' ' + sizes[i]; + } +} --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
