This is an automated email from the ASF dual-hosted git repository. dgnatyshyn pushed a commit to branch develop in repository https://gitbox.apache.org/repos/asf/incubator-dlab.git
The following commit(s) were added to refs/heads/develop by this push: new 729bef9 [DLAB-1861]: Added checkboxes to environment management (#785) 729bef9 is described below commit 729bef999c9c20d9802e40650ae65cbfb6e32940 Author: Dmytro Gnatyshyn <42860905+dg1...@users.noreply.github.com> AuthorDate: Mon Jun 15 10:04:01 2020 +0300 [DLAB-1861]: Added checkboxes to environment management (#785) [DLAB-1861]: Added checkboxes to environment management --- .../management-grid/management-grid.component.html | 37 +++++- .../management-grid/management-grid.component.scss | 11 +- .../management-grid/management-grid.component.ts | 147 +++++++++++++++------ .../management/management.component.html | 35 ++++- .../management/management.component.ts | 103 ++++++++++++++- .../resources/webapp/src/assets/styles/_theme.scss | 94 ++++++++++++- 6 files changed, 379 insertions(+), 48 deletions(-) diff --git a/services/self-service/src/main/resources/webapp/src/app/administration/management/management-grid/management-grid.component.html b/services/self-service/src/main/resources/webapp/src/app/administration/management/management-grid/management-grid.component.html index 8b7e459..88b005c 100644 --- a/services/self-service/src/main/resources/webapp/src/app/administration/management/management-grid/management-grid.component.html +++ b/services/self-service/src/main/resources/webapp/src/app/administration/management/management-grid/management-grid.component.html @@ -19,6 +19,25 @@ <div class="ani"> <table mat-table [dataSource]="allFilteredEnvironmentData" class="data-grid management mat-elevation-z6"> + <ng-container matColumnDef="checkbox"> + <th mat-header-cell *matHeaderCellDef class="checkbox label-header"> + <div class="empty-checkbox header-checkbox" [ngClass]="{'checked': selected?.length === allActiveNotebooks?.length}" (click)="toggleSelectionAll();$event.stopPropagation()" > + <span class="checked-checkbox" *ngIf="selected?.length === allActiveNotebooks?.length"></span> + </div> + <button mat-icon-button aria-label="More" class="ar checkbox-border" (click)="toggleFilterRow()"> + <i class="material-icons"> +<!-- <span *ngIf="filtering && filterForm.users.length > 0 && !collapsedFilterRow">filter_list</span>--> + <span>more_vert</span> + </i> + </button> + </th> + <td mat-cell *matCellDef="let element"> + <div *ngIf="element.type !== 'edge node' && (element.status==='running' || element.status==='stopped')" class="empty-checkbox" [ngClass]="{'checked': element.isSelected}" (click)="toggleActionForAll(element);$event.stopPropagation()" > + <span class="checked-checkbox" *ngIf="element.isSelected"></span> + </div> + </td> + </ng-container> + <ng-container matColumnDef="user"> <th mat-header-cell *matHeaderCellDef class="user label-header"> <span class="label">User</span> @@ -137,10 +156,14 @@ <span class="label"> Actions </span> </th> <td mat-cell *matCellDef="let element" class="settings actions-col"> - <span #settings class="actions" (click)="actions.toggle($event, settings)" *ngIf="element.type !== 'edge node'" - [ngClass]="{ - 'disabled' : isActiveResources(element), - 'disabled' : element.status !== 'running' && element.status !== 'stopped' && element.status !== 'stopping' && element.status !== 'failed' }"></span> + <span [ngClass]="{'not-allow' : selected?.length}"> + <span #settings class="actions" (click)="actions.toggle($event, settings)" *ngIf="element.type !== 'edge node'" + [ngClass]="{ + 'disabled' : isActiveResources(element), + 'disabled' : (element.status !== 'running' && element.status !== 'stopped' && element.status !== 'stopping' && element.status !== 'failed') || selected?.length}"> + + </span> + </span> <bubble-up #actions class="list-menu" position="bottom-left" alternative="top-left"> <ul class="list-unstyled"> <li @@ -176,6 +199,12 @@ <!-- FILTERING --> + <ng-container matColumnDef="checkbox-filter" sticky> + <th mat-header-cell *matHeaderCellDef class="filter-row-item"> + + </th> + </ng-container> + <ng-container matColumnDef="user-filter" sticky> <th mat-header-cell *matHeaderCellDef class="filter-row-item"> <multi-select-dropdown (selectionChange)="onUpdate($event)" [type]="'users'" [items]="filterConfiguration.users" diff --git a/services/self-service/src/main/resources/webapp/src/app/administration/management/management-grid/management-grid.component.scss b/services/self-service/src/main/resources/webapp/src/app/administration/management/management-grid/management-grid.component.scss index 5d40c3e..99fde37 100644 --- a/services/self-service/src/main/resources/webapp/src/app/administration/management/management-grid/management-grid.component.scss +++ b/services/self-service/src/main/resources/webapp/src/app/administration/management/management-grid/management-grid.component.scss @@ -19,7 +19,16 @@ .data-grid { &.management { + .mat-column-checkbox{ + padding-left: 10px; + padding-right: 10px; + &.label-header{ + width: 65px; + display: flex; + align-items: center; + } + } .user{ width: 15%; } @@ -45,7 +54,7 @@ } .resources { - width: 22%; + width: 21%; padding: 5px; } diff --git a/services/self-service/src/main/resources/webapp/src/app/administration/management/management-grid/management-grid.component.ts b/services/self-service/src/main/resources/webapp/src/app/administration/management/management-grid/management-grid.component.ts index 796859a..38e4590 100644 --- a/services/self-service/src/main/resources/webapp/src/app/administration/management/management-grid/management-grid.component.ts +++ b/services/self-service/src/main/resources/webapp/src/app/administration/management/management-grid/management-grid.component.ts @@ -60,9 +60,12 @@ export class ManagementGridComponent implements OnInit { @Input() currentUser: string = ''; @Output() refreshGrid: EventEmitter<{}> = new EventEmitter(); @Output() actionToggle: EventEmitter<ManageAction> = new EventEmitter(); + @Output() emitSelectedList: EventEmitter<ManageAction> = new EventEmitter(); - displayedColumns: string[] = ['user', 'type', 'project', 'shape', 'status', 'resources', 'actions']; - displayedFilterColumns: string[] = ['user-filter', 'type-filter', 'project-filter', 'shape-filter', 'status-filter', 'resource-filter', 'actions-filter']; + displayedColumns: string[] = [ 'checkbox', 'user', 'type', 'project', 'shape', 'status', 'resources', 'actions']; + displayedFilterColumns: string[] = ['checkbox-filter', 'user-filter', 'type-filter', 'project-filter', 'shape-filter', 'status-filter', 'resource-filter', 'actions-filter']; + private selected; + private allActiveNotebooks: any; constructor( private healthStatusService: HealthStatusService, @@ -113,7 +116,7 @@ export class ManagementGridComponent implements OnInit { let filteredData = this.getEnvironmentDataCopy(); const containsStatus = (list, selectedItems) => { - if (list){ + if (list) { return list.filter((item: any) => { if (selectedItems.indexOf(item.status) !== -1) return item; }); } }; @@ -123,8 +126,8 @@ export class ManagementGridComponent implements OnInit { filteredData = filteredData.filter(item => { const isUser = config.users.length > 0 ? (config.users.indexOf(item.user) !== -1) : true; - const isTypeName = item.name ? - item.name.toLowerCase().indexOf(config.type.toLowerCase()) !== -1 : item.type.toLowerCase().indexOf(config.type.toLowerCase()) !== -1; + const isTypeName = item.name ? item.name.toLowerCase() + .indexOf(config.type.toLowerCase()) !== -1 : item.type.toLowerCase().indexOf(config.type.toLowerCase()) !== -1; const isStatus = config.statuses.length > 0 ? (config.statuses.indexOf(item.status) !== -1) : (config.type !== 'active'); const isShape = config.shapes.length > 0 ? (config.shapes.indexOf(item.shape) !== -1) : true; const isProject = config.projects.length > 0 ? (config.projects.indexOf(item.project) !== -1) : true; @@ -144,6 +147,7 @@ export class ManagementGridComponent implements OnInit { }); } this.allFilteredEnvironmentData = filteredData; + this.allActiveNotebooks = this.allFilteredEnvironmentData.filter(v => v.name && (v.status === 'running' || v.status === 'stopped')); } getEnvironmentDataCopy() { @@ -151,38 +155,7 @@ export class ManagementGridComponent implements OnInit { } toggleResourceAction(environment: any, action: string, resource?): void { - if (resource) { - const resource_name = resource ? resource.computational_name : environment.name; - this.dialog.open(ReconfirmationDialogComponent, { - data: { action, resource_name, user: environment.user }, - width: '550px', panelClass: 'error-modalbox' - }).afterClosed().subscribe(result => { - result && this.actionToggle.emit({ action, environment, resource }); - }); - } else { - const type = (environment.type.toLowerCase() === 'edge node') - ? ConfirmationDialogType.StopEdgeNode : ConfirmationDialogType.StopExploratory; - - if (action === 'stop') { - this.dialog.open(ConfirmationDialogComponent, { - data: { notebook: environment, type: type, manageAction: true }, panelClass: 'modal-md' - }).afterClosed().subscribe(() => this.buildGrid()); - } else if (action === 'terminate') { - this.dialog.open(ConfirmationDialogComponent, { - data: { notebook: environment, type: ConfirmationDialogType.TerminateExploratory, manageAction: true }, panelClass: 'modal-md' - }).afterClosed().subscribe(() => this.buildGrid()); - } else if (action === 'run') { - this.healthStatusService.runEdgeNode().subscribe(() => { - this.buildGrid(); - this.toastr.success('Edge node is starting!', 'Processing!'); - }, () => this.toastr.error('Edge Node running failed!', 'Oops!')); - } else if (action === 'recreate') { - this.healthStatusService.recreateEdgeNode().subscribe(() => { - this.buildGrid(); - this.toastr.success('Edge Node recreation is processing!', 'Processing!'); - }, () => this.toastr.error('Edge Node recreation failed!', 'Oops!')); - } - } + this.actionToggle.emit({ environment, action, resource }); } isResourcesInProgress(notebook) { @@ -240,6 +213,22 @@ export class ManagementGridComponent implements OnInit { }) .afterClosed().subscribe(() => {}); } + + toggleActionForAll(element) { + element.isSelected = !element.isSelected; + this.selected = this.allFilteredEnvironmentData.filter(item => !!item.isSelected); + this.emitSelectedList.emit(this.selected); + } + + toggleSelectionAll() { + if (this.selected && this.selected.length === this.allActiveNotebooks.length) { + this.allActiveNotebooks.forEach(notebook => notebook.isSelected = false); + } else { + this.allActiveNotebooks.forEach(notebook => notebook.isSelected = true); + } + this.selected = this.allFilteredEnvironmentData.filter(item => !!item.isSelected); + this.emitSelectedList.emit(this.selected); + } } @@ -251,23 +240,101 @@ export class ManagementGridComponent implements OnInit { <button type="button" class="close" (click)="dialogRef.close()">×</button> </div> <div mat-dialog-content class="content"> + <div *ngIf="data.type === 'cluster'"> <p>Resource <span class="strong"> {{ data.resource_name }}</span> of user <span class="strong"> {{ data.user }} </span> will be <span *ngIf="data.action === 'terminate'"> decommissioned.</span> <span *ngIf="data.action === 'stop'">stopped.</span> </p> - <p class="m-top-20"><span class="strong">Do you want to proceed?</span></p> + </div> + <div class="resource-list" *ngIf="data.type === 'notebook'"> + <div class="resource-list-header"> + <div class="resource-name">Notebook</div> + <div class="clusters-list"> + <div class="clusters-list-item"> + <div class="cluster">Cluster</div> + <div class="status">Further status</div> + </div> + </div> + + </div> + <div class="scrolling-content resource-heigth"> + <div class="resource-list-row sans node" *ngFor="let notebook of notebooks"> + <div class="resource-name ellipsis"> + {{notebook.name}} + </div> + + <div class="clusters-list"> + <div class="clusters-list-item"> + <div class="cluster"></div> + <div class="status" + [ngClass]="{ + 'stopped': data.action==='stop', 'terminated': data.action === 'terminate' + }" + > + {{data.action === 'stop' ? 'Stopped' : 'Terminated'}} + </div> + </div> + <div class="clusters-list-item" *ngFor="let cluster of notebook?.resources"> + <div class="cluster">{{cluster.computational_name}}</div> + <div class="status" [ngClass]="{ + 'stopped': (data.action==='stop' && cluster.image==='docker.dlab-dataengine'), 'terminated': data.action === 'terminate' || (data.action==='stop' && cluster.image!=='docker.dlab-dataengine') + }">{{data.action === 'stop' && cluster.image === "docker.dlab-dataengine" ? 'Stopped' : 'Terminated'}}</div> + </div> + </div> + </div> + </div> + </div> </div> - <div class="text-center"> + <div class="text-center "> + <p class="strong">Do you want to proceed?</p> + </div> + <div class="text-center m-top-20"> <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; margin: 0; } + .info { color: #35afd5; } + .info .confirm-dialog { color: #607D8B; } + header { display: flex; justify-content: space-between; color: #607D8B; } + header h4 i { vertical-align: bottom; } + header a i { font-size: 20px; } + header a:hover i { color: #35afd5; cursor: pointer; } + .plur { font-style: normal; } + .scrolling-content{overflow-y: auto; max-height: 200px; } + .cluster { width: 50%; text-align: left; color: #577289;} + .status { width: 50%;text-align: left;} + .label { font-size: 15px; font-weight: 500; font-family: "Open Sans",sans-serif;} + .node { font-weight: 300;} + .resource-name { width: 40%;text-align: left; padding: 10px 0;line-height: 26px;} + .clusters-list { width: 60%;text-align: left; padding: 10px 0;line-height: 26px;} + .clusters-list-item { width: 100%;text-align: left;display: flex} + .resource-list{max-width: 100%; margin: 0 auto;margin-top: 20px; } + .resource-list-header{display: flex; font-weight: 600; font-size: 16px;height: 48px; border-top: 1px solid #edf1f5; border-bottom: 1px solid #edf1f5; padding: 0 20px;} + .resource-list-row{display: flex; border-bottom: 1px solid #edf1f5;padding: 0 20px;} + .confirm-resource-terminating{text-align: left; padding: 10px 20px;} + .confirm-message{color: #ef5c4b;font-size: 13px;min-height: 18px; text-align: center; padding-top: 20px} + .checkbox{margin-right: 5px;vertical-align: middle; margin-bottom: 3px;} + label{cursor: pointer} + .bottom-message{padding-top: 15px;} + .table-header{padding-bottom: 10px;}` ] }) + export class ReconfirmationDialogComponent { + private notebooks; constructor( public dialogRef: MatDialogRef<ReconfirmationDialogComponent>, @Inject(MAT_DIALOG_DATA) public data: any - ) { } + ) { + if (data.notebooks && data.notebooks.length) { + this.notebooks = JSON.parse(JSON.stringify(data.notebooks)); + this.notebooks = this.notebooks.map(notebook => { + notebook.resources = notebook.resources.filter(res => res.status !== 'terminated' && res.status.slice(0, 4) !== data.action); + return notebook; + }); + } + } } diff --git a/services/self-service/src/main/resources/webapp/src/app/administration/management/management.component.html b/services/self-service/src/main/resources/webapp/src/app/administration/management/management.component.html index 4c4bdae..eeb2d9b 100644 --- a/services/self-service/src/main/resources/webapp/src/app/administration/management/management.component.html +++ b/services/self-service/src/main/resources/webapp/src/app/administration/management/management.component.html @@ -20,6 +20,39 @@ <div class="base-retreat"> <div class="sub-nav"> <div *ngIf="healthStatus?.admin" class="admin-group"> + <div class="action-select-wrapper admin-group" > + <span class="action-button-wrapper"> + <button + type="button" class="butt actions-btn" + mat-raised-button + [disabled]="!selected.length" + (click)="toogleActions();$event.stopPropagation()" + > + Actions + <i class="material-icons" >{{ !isActionsOpen ? 'expand_more' : 'expand_less' }}</i> + </button> + </span> + <div class="action-menu" *ngIf="isActionsOpen"> + <span> + <button + type="button" class="butt action-menu-item" + [ngClass]="{'disabled': selectedRunning.length === 0 || selectedStopped.length !== 0 }" + mat-raised-button + [disabled]="selectedRunning.length === 0 || selectedStopped.length !== 0" + (click)="resourseAction('stop');$event.stopPropagation()" + > + Stop + </button> + </span> + <button + type="button" class="butt action-menu-item" + mat-raised-button + (click)="resourseAction('terminate');$event.stopPropagation()" + > + Terminate + </button> + </div> + </div> <button mat-raised-button class="butt ssn" (click)="showEndpointsDialog()"> <i class="material-icons"></i>Endpoints </button> @@ -40,6 +73,6 @@ <mat-divider></mat-divider> <management-grid [currentUser]="user.toLowerCase()" [isAdmin]="healthStatus?.admin" [environmentsHealthStatuses]="healthStatus?.list_resources" (refreshGrid)="buildGrid()" - (actionToggle)="manageEnvironmentAction($event)"> + (actionToggle)="toggleResourceAction($event)" (emitSelectedList)="selectedList($event)"> </management-grid> </div> diff --git a/services/self-service/src/main/resources/webapp/src/app/administration/management/management.component.ts b/services/self-service/src/main/resources/webapp/src/app/administration/management/management.component.ts index 87e554d..3062a07 100644 --- a/services/self-service/src/main/resources/webapp/src/app/administration/management/management.component.ts +++ b/services/self-service/src/main/resources/webapp/src/app/administration/management/management.component.ts @@ -17,7 +17,7 @@ * under the License. */ -import { Component, OnInit } from '@angular/core'; +import {Component, OnInit, ViewChild} from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { ToastrService } from 'ngx-toastr'; @@ -39,6 +39,9 @@ import { ExploratoryModel } from '../../resources/resources-grid/resources-grid. import { EnvironmentsDataService } from './management-data.service'; import { ProjectService } from '../../core/services'; +import {ConfirmationDialogComponent, ConfirmationDialogType} from '../../shared/modal-dialog/confirmation-dialog'; +import {ManagementGridComponent, ReconfirmationDialogComponent} from './management-grid/management-grid.component'; +import {FolderTreeComponent} from '../../resources/bucket-browser/folder-tree/folder-tree.component'; @Component({ selector: 'environments-management', @@ -50,6 +53,13 @@ export class ManagementComponent implements OnInit { public healthStatus: GeneralEnvironmentStatus; // public anyEnvInProgress: boolean = false; public dialogRef: any; + private selected: any[] = []; + public isActionsOpen: boolean = false; + public selectedRunning: any[]; + public selectedStopped: any[]; + + @ViewChild(ManagementGridComponent, {static: true}) managementGrid; + constructor( public toastr: ToastrService, @@ -171,4 +181,95 @@ export class ManagementComponent implements OnInit { private getTotalBudgetData() { return this.healthStatusService.getTotalBudgetData(); } + + public selectedList($event) { + this.selected = $event; + if (this.selected.length === 0) { + this.isActionsOpen = false; + } + + this.selectedRunning = this.selected.filter(item => item.status === 'running'); + this.selectedStopped = this.selected.filter(item => item.status === 'stopped'); + } + + public toogleActions() { + this.isActionsOpen = !this.isActionsOpen; + } + + toggleResourceAction($event): void { + const {environment, action, resource} = $event; + if (resource) { + const resource_name = resource ? resource.computational_name : environment.name; + this.dialog.open(ReconfirmationDialogComponent, { + data: { action, resource_name, user: environment.user, type: 'cluster'}, + width: '550px', panelClass: 'error-modalbox' + }).afterClosed().subscribe(result => { + result && this.manageEnvironmentAction({ action, environment, resource }); + }); + } else { + const notebooks = this.selected.length ? this.selected : [environment]; + if (action === 'stop') { + this.dialog.open(ReconfirmationDialogComponent, { + data: { notebooks: notebooks, type: 'notebook', action }, + width: '550px', panelClass: 'error-modalbox' + }).afterClosed().subscribe((res) => { + if (res) { + notebooks.forEach((env) => { + this.manageEnvironmentsService.environmentManagement(env.user, 'stop', env.project, env.name) + .subscribe( + response => { + console.log(response); + this.buildGrid(); + }, + error => console.log(error) + ); + }); + } + this.clearSelection(); + }); + } else if (action === 'terminate') { + this.dialog.open(ReconfirmationDialogComponent, { + data: { notebooks: notebooks, type: 'notebook', action }, width: '550px', panelClass: 'error-modalbox' + }).afterClosed().subscribe((res) => { + if (res) { + notebooks.forEach((env) => { + this.manageEnvironmentsService.environmentManagement(env.user, 'terminate', env.project, env.name) + .subscribe( + response => { + this.buildGrid(); + + }, + error => console.log(error) + ); + }); + } + this.clearSelection(); + }); + // } else if (action === 'run') { + // this.healthStatusService.runEdgeNode().subscribe(() => { + // this.buildGrid(); + // this.toastr.success('Edge node is starting!', 'Processing!'); + // }, () => this.toastr.error('Edge Node running failed!', 'Oops!')); + // } else if (action === 'recreate') { + // this.healthStatusService.recreateEdgeNode().subscribe(() => { + // this.buildGrid(); + // this.toastr.success('Edge Node recreation is processing!', 'Processing!'); + // }, () => this.toastr.error('Edge Node recreation failed!', 'Oops!')); + } + } + } + + private clearSelection() { + this.selected = []; + this.isActionsOpen = false; + if (this.managementGrid.selected && this.managementGrid.selected.length !== 0) { + this.managementGrid.selected.forEach(item => item.isSelected = false); + this.managementGrid.selected = []; + } + } + + + public resourseAction(action) { + this.toggleResourceAction({environment: this.selected, action: action}); + } } diff --git a/services/self-service/src/main/resources/webapp/src/assets/styles/_theme.scss b/services/self-service/src/main/resources/webapp/src/assets/styles/_theme.scss index 43e9c50..bf3529d 100644 --- a/services/self-service/src/main/resources/webapp/src/assets/styles/_theme.scss +++ b/services/self-service/src/main/resources/webapp/src/assets/styles/_theme.scss @@ -433,6 +433,98 @@ span.mat-slide-toggle-content { } } +.empty-checkbox { + min-width: 16px; + width: 16px; + height: 16px; + border-radius: 2px; + border: 2px solid lightgrey; + margin-top: 2px; + position: relative; + cursor: pointer; + &.checked { + border-color: #35afd5; + background-color: #35afd5; + } + .checked-checkbox { + top: 0; + left: 4px; + width: 5px; + height: 10px; + border-bottom: 2px solid white; + border-right: 2px solid white; + position: absolute; + transform: rotate(45deg); + } +} + +.action-select-wrapper{ + position: relative; + display: block !important; + + .action-button-wrapper{ + position: relative; + width: 160px; + } + + .mat-raised-button.butt{ + margin-bottom: 0; + padding-left: 45px; + text-align: left; + + + &.actions-btn{ + padding-right: 38px; + + + .material-icons{ + transition: ease-in-out 1s; + font-size: 25px; + position: absolute; + top: 7px; + right: 30px; + } + } + } + + .action-menu{ + position: absolute; + text-align: center; + display: block; + background-color: #fff; + + &-item.mat-raised-button.butt{ + z-index: 101; + margin: 0; + box-shadow: 0 2px 1px -1px rgba(0,0,0,.2), 0 0px 0px 0 rgba(0,0,0,.14), 0 1px 5px 0 rgba(0,0,0,.12); + width: 160px; + padding: 0 20px; + border-radius: 0; + font-style: normal; + font-weight: 600; + font-size: 15px; + font-family: 'Open Sans', sans-serif; + color: #577289; + position: relative; + overflow: hidden; + line-height: 36px; + padding-left: 45px; + text-align: left; + &.action-menu-item{ + &:hover{ + color: #00bcd4; + background-color: #fafafa; + } + &.disabled{ + &:hover{ + color: #577289; + } + } + } + } + } +} + mat-horizontal-stepper { .mat-step-header { .mat-step-icon { @@ -561,7 +653,7 @@ mat-horizontal-stepper { .content { color: #718ba6; - padding: 20px 0; + padding: 20px 40px; font-size: 14px; font-weight: 400; text-align: center; --------------------------------------------------------------------- To unsubscribe, e-mail: commits-unsubscr...@dlab.apache.org For additional commands, e-mail: commits-h...@dlab.apache.org