blob: d6e147e1c9e74ee8c03d6f6f2ee5083987a0300a [file] [log] [blame]
/**
* Copyright 2020 Google LLC
*
* 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 {LiveAnnouncer} from '@angular/cdk/a11y';
import {Component, ElementRef, EventEmitter, HostListener, Inject, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, ViewChild} from '@angular/core';
import {MatSort, Sort} from '@angular/material/sort';
import {MatTable, MatTableDataSource} from '@angular/material/table';
import {Router} from '@angular/router';
import {TableColumn} from 'google3/third_party/py/multitest_transport/ui2/app/services/mtt_models';
import {Notifier} from 'google3/third_party/py/multitest_transport/ui2/app/services/notifier';
import {TableRowsSelectManager} from 'google3/third_party/py/multitest_transport/ui2/app/shared/table_rows_select';
import {assertRequiredInput} from 'google3/third_party/py/multitest_transport/ui2/app/shared/util';
import {of as observableOf, ReplaySubject, throwError} from 'rxjs';
import {catchError, filter, switchMap, takeUntil} from 'rxjs/operators';
import {APP_DATA, AppData} from '../services';
import {DEVICE_SERIAL, getDeviceSerialForDisplay, HOSTNAME, LabDeviceInfo, REMOVE_DEVICE_MESSAGE} from '../services/mtt_lab_models';
import {DeviceSerialWithDisplay, StorageService} from '../services/storage_service';
import {TfcClient} from '../services/tfc_client';
import {DeviceRecoveryStateRequest, RecoveryState, TestHarness} from '../services/tfc_models';
import {UserService} from '../services/user_service';
/**
* A component for displaying a list of device.
*/
@Component({
selector: 'device-list-table',
styleUrls: ['device_list_table.css'],
templateUrl: './device_list_table.ng.html',
})
export class DeviceListTable implements OnDestroy, OnInit, OnChanges {
/** The device list data that is provided for the table. */
@Input()
set dataSource(value: LabDeviceInfo[]) {
this.tableDataSource.data = value;
this.tableDataSource.sort = this.matSort;
}
get dataSource() {
return this.tableDataSource.data;
}
@Input()
displayedColumns: string[] = [
'device_serial',
'run_target',
'state',
'last_checkin',
'notesUpdateTime',
'offline_reason',
'recovery_action',
'note',
'sponge',
'build_alias',
'sdk_version',
'battery_level',
'actions',
];
@Input() initialSelection: string[] = [];
@Input() isLoading = false;
@Input() isModalMode = false;
@Input() headerRowTop = '0';
/** When true, shows a column of checkboxes. */
@Input() selectable = true;
@Output() readonly selectionChange = new EventEmitter<string[]>();
@Output() readonly deviceListChangeSort = new EventEmitter<Sort>();
@ViewChild(MatTable, {static: true}) matTable!: MatTable<{}>;
@ViewChild('table', {static: false, read: ElementRef}) table!: ElementRef;
@ViewChild(TableRowsSelectManager, {static: true})
tableRowsSelectManager!: TableRowsSelectManager;
@ViewChild(MatSort, {static: true}) matSort!: MatSort;
private readonly destroy = new ReplaySubject<void>();
tableDataSource = new MatTableDataSource<LabDeviceInfo>([]);
isTableScrolled = false;
readonly recoveryState = RecoveryState;
readonly testHarness = TestHarness;
readonly getDeviceSerialForDisplay = getDeviceSerialForDisplay;
logUrl = '';
readonly COLUMN_DISPLAY_STORAGE_KEY = 'DEVICE_COLUMN_DISPLAY';
columns: TableColumn[] = [
{
fieldName: 'device_serial',
displayName: 'Device Serial',
removable: false,
show: true
},
{
fieldName: 'run_target',
displayName: 'Run Targets',
removable: true,
show: true
},
{
fieldName: 'last_checkin',
displayName: 'Last Check-in',
removable: true,
show: true
},
{fieldName: 'state', displayName: 'State', removable: true, show: true},
{
fieldName: 'notesUpdateTime',
displayName: 'Notes Update Time',
removable: true,
show: true
},
{
fieldName: 'notesUpdateTimestamp',
displayName: 'Notes Update Timestamp',
removable: true,
show: false
},
{
fieldName: 'offline_reason',
displayName: 'Offline Reason',
removable: true,
show: true
},
{
fieldName: 'recovery_action',
displayName: 'Recovery Action',
removable: true,
show: true
},
{fieldName: 'note', displayName: 'Note', removable: true, show: true},
{fieldName: 'sponge', displayName: 'Sponge', removable: true, show: true},
{
fieldName: 'build_alias',
displayName: 'Build Alias',
removable: true,
show: true
},
{fieldName: 'sdk_version', displayName: 'SDK', removable: true, show: true},
{
fieldName: 'battery_level',
displayName: 'Battery',
removable: true,
show: true
},
{fieldName: 'actions', displayName: 'Actions', removable: true, show: true},
];
get deviceSerials() {
return this.tableDataSource.data.map(info => info.device_serial);
}
get deviceSerialsWithDisplay(): DeviceSerialWithDisplay[] {
return this.dataSource.map(
info => ({
serial: info.device_serial,
serialForDisplay: this.getDeviceSerialForDisplay(info),
}));
}
/** Clicks header to sort. */
changeSort(sortInfo: Sort) {
this.matSort.active = sortInfo.active;
this.matSort.direction = sortInfo.direction;
this.deviceListChangeSort.emit(sortInfo);
}
/** Check if table is scrolled to the right to update sticky styling. */
checkTableScrolled() {
const el = this.table.nativeElement;
this.isTableScrolled = el.scrollLeft === el.scrollWidth - el.clientWidth;
}
constructor(
private readonly liveAnnouncer: LiveAnnouncer,
private readonly notifier: Notifier,
private readonly router: Router,
private readonly storageService: StorageService,
private readonly tfcClient: TfcClient,
readonly userService: UserService,
@Inject(APP_DATA) private readonly appData: AppData,
) {
if (!this.appData.isGoogle) {
this.columns = this.columns.filter(x => x.fieldName !== 'sponge');
} else {
this.logUrl = appData.logUrl || '';
}
}
/** Gets customized field values for sorting data on TableDataSource. */
getSortingData(device: LabDeviceInfo, sortHeaderId: string): string|number {
switch (sortHeaderId) {
case 'device_serial':
return device.device_serial || '';
case 'run_target':
return device.run_target || '';
case 'build_alias':
return device.extraInfo.build_id || '';
case 'sdk_version':
return device.extraInfo.sdk_version || '';
case 'battery_level':
return device.extraInfo.battery_level || '';
case 'state':
return device.state || '';
case 'offline_reason':
return device.note?.offline_reason || '';
case 'recovery_action':
return device.note?.recovery_action || '';
case 'note':
return device.note?.message || '';
case 'notesUpdateTime':
return Date.parse(device.note?.timestamp || '');
case 'last_checkin':
return Date.parse(device.timestamp);
default:
return '';
}
}
/**
* Initializes the visible columns. The previous setting stored in local
* storage has the higher priority than the displayedColumns value specified
* by parent component.
*/
initColumns() {
if (!this.loadDisplayedColumnFromLocalStorage()) {
for (const c of this.columns) {
c.show = this.displayedColumns.includes(c.fieldName);
}
}
}
private loadDisplayedColumnFromLocalStorage(): boolean {
const storedData =
window.localStorage.getItem(this.COLUMN_DISPLAY_STORAGE_KEY);
if (!storedData) {
return false;
}
const storedTableColumns = JSON.parse(storedData) as TableColumn[];
for (const c of this.columns) {
c.show =
storedTableColumns.find((s) => s.fieldName === c.fieldName)?.show ??
c.show;
}
this.setDisplayedColumns();
return true;
}
/** Naviagte to device details page. */
openDeviceDetails(deviceSerial: string) {
this.storageService.deviceList = this.deviceSerialsWithDisplay;
const url = this.getDeviceDetailsUrl(deviceSerial);
this.router.navigateByUrl(url);
}
getDeviceDetailsUrl(deviceSerial: string): string {
const url = this.router.serializeUrl(
this.router.createUrlTree(['/devices', deviceSerial]));
return url;
}
ngOnChanges(changes: SimpleChanges) {
if (changes['dataSource']) {
this.tableRowsSelectManager.rowIdFieldAllValues = this.deviceSerials;
this.tableRowsSelectManager.selection.clear();
this.tableRowsSelectManager.resetPrevClickedRowIndex();
}
}
ngOnDestroy() {
this.destroy.next();
}
ngOnInit() {
assertRequiredInput(this.matTable, 'matTable', 'deviceListTable');
assertRequiredInput(
this.tableRowsSelectManager, 'tableRowsSelectManager',
'deviceListTable');
assertRequiredInput(this.matSort, 'matSort', 'deviceListTable');
if (this.selectable) {
this.displayedColumns.unshift('select');
this.columns.unshift(
{fieldName: 'select', displayName: '', removable: false, show: true});
}
this.tableRowsSelectManager.selectSelection(this.initialSelection);
this.overrideDatsSourceSortRules();
this.initColumns();
}
@HostListener('window:resize')
onWindowResize() {
this.checkTableScrolled();
}
overrideDatsSourceSortRules() {
this.tableDataSource.sortingDataAccessor = this.getSortingData;
// TODO: Make it a shared function
// Overrides the sortData. We sort the data of tableDataSource directly to
// get the same order as the rows on the table. It helps the rangeSelect
// method to get correct index of the row on the table.
this.tableDataSource.sortData =
(data: LabDeviceInfo[], sort: MatSort): LabDeviceInfo[] => {
const {active, direction} = sort;
if (!active || direction === '') {
return data;
}
this.tableDataSource.data.sort((rowA, rowB) => {
const valueA =
this.tableDataSource.sortingDataAccessor(rowA, active);
const valueB =
this.tableDataSource.sortingDataAccessor(rowB, active);
let comparatorResult = 0;
if (valueA != null && valueB != null) {
if (valueA > valueB) {
comparatorResult = 1;
} else if (valueA < valueB) {
comparatorResult = -1;
}
} else if (valueA != null) {
comparatorResult = 1;
} else if (valueB != null) {
comparatorResult = -1;
}
return comparatorResult * (direction === 'asc' ? 1 : -1);
});
this.tableRowsSelectManager.resetPrevClickedRowIndex();
this.tableRowsSelectManager.rowIdFieldAllValues = this.deviceSerials;
return this.tableDataSource.data;
};
}
/** Remove the device from the host. */
removeDevice(event: Event, serial: string) {
event.stopPropagation();
this.notifier.confirm('', REMOVE_DEVICE_MESSAGE, 'Remove device', 'Cancel')
.pipe(
switchMap((result) => {
if (!result) return observableOf(false);
const hostname = this.tableDataSource.data
.find(info => info.device_serial === serial)
?.hostname ??
'';
return this.tfcClient.removeDevice(serial, hostname)
.pipe(
catchError((err) => throwError(err)),
);
}),
filter(
isConfirmed =>
isConfirmed !== false), // Remove canceled confirmation.
takeUntil(this.destroy),
)
.subscribe(
() => {
this.tableDataSource.data = this.tableDataSource.data.filter(
x => x.device_serial !== serial);
this.matTable.renderRows();
this.tableRowsSelectManager.rowIdFieldAllValues =
this.deviceSerials;
this.tableRowsSelectManager.resetSelection();
this.notifier.showMessage('Device removed');
},
() => {
this.notifier.showError(`Failed to remove device ${serial}`);
});
}
toggleDisplayedColumn(columnIndex: number) {
this.columns[columnIndex].show = !this.columns[columnIndex].show;
this.setDisplayedColumns();
this.updateDisplayedColumnToLocalStorage();
setTimeout(() => {
this.checkTableScrolled();
});
}
setDisplayedColumns() {
this.displayedColumns =
this.columns.filter(c => c.show).map(c => c.fieldName);
}
updateDisplayedColumnToLocalStorage() {
window.localStorage.setItem(
this.COLUMN_DISPLAY_STORAGE_KEY, JSON.stringify(this.columns));
}
toggleDeviceFixedState(device: LabDeviceInfo, event: MouseEvent) {
event.stopPropagation();
if (!this.appData.userNickname) {
this.notifier.showError('Please sign in');
return;
}
const currentState = device.recovery_state;
const fixedMsg = `The device's recovery state has been marked as FIXED`;
const undoMsg = `The device's recovery state marked back as UNKNOWN.`;
const errMsg = `Failed to mark the device's recovery state as FIXED`;
const msg = currentState === RecoveryState.FIXED ? undoMsg : fixedMsg;
const nextState = currentState === RecoveryState.FIXED ?
RecoveryState.UNKNOWN :
RecoveryState.FIXED;
const deviceRecoveryStateRequests = [{
hostname: device.hostname,
device_serial: device.device_serial,
recovery_state: nextState,
} as DeviceRecoveryStateRequest];
this.tfcClient.batchSetDevicesRecoveryStates(deviceRecoveryStateRequests)
.pipe(takeUntil(this.destroy))
.subscribe(
() => {
device.recovery_state = nextState;
this.notifier.showMessage(msg);
this.liveAnnouncer.announce(msg, 'assertive');
},
() => {
this.notifier.showError(errMsg);
});
}
getLogUrl(device: LabDeviceInfo): string {
return this.logUrl.replace(HOSTNAME, device.hostname || '')
.replace(DEVICE_SERIAL, device.device_serial);
}
storeDeviceSerialsInLocalStorage() {
this.storageService.saveDeviceListInLocalStorage(
this.deviceSerialsWithDisplay);
}
}