import React from 'react'; import { DefaultButton, IColumn, Icon, PrimaryButton, Stack, StackItem, TooltipHost, DirectionalHint, Checkbox } from '@fluentui/react'; import { EXPERIMENT, TRIALS } from '../../static/datamodel'; import { TOOLTIP_BACKGROUND_COLOR } from '../../static/const'; import { convertDuration, formatTimestamp, copyAndSort, parametersType, parseMetrics } from '../../static/function'; import { TableObj, SortInfo, SearchItems } from '../../static/interface'; import { getTrialsBySearchFilters } from './search/searchFunction'; import { blocked, copy, LineChart, tableListIcon } from '../buttons/Icon'; import ChangeColumnComponent from '../modals/ChangeColumnComponent'; import Compare from '../modals/Compare'; import Customize from '../modals/CustomizedTrial'; import TensorboardUI from '../modals/tensorboard/TensorboardUI'; import Search from './search/Search'; import KillJob from '../modals/Killjob'; import ExpandableDetails from '../public-child/ExpandableDetails'; import PaginationTable from '../public-child/PaginationTable'; import CopyButton from '../public-child/CopyButton'; import { Trial } from '../../static/model/trial'; import '../../static/style/button.scss'; import '../../static/style/logPath.scss'; import '../../static/style/openRow.scss'; import '../../static/style/pagination.scss'; import '../../static/style/search.scss'; import '../../static/style/table.scss'; import '../../static/style/tableStatus.css'; import '../../static/style/tensorboard.scss'; import '../../static/style/overview/overviewTitle.scss'; require('echarts/lib/chart/line'); require('echarts/lib/component/tooltip'); require('echarts/lib/component/title'); type SearchOptionType = 'id' | 'trialnum' | 'status' | 'parameters'; const defaultDisplayedColumns = ['sequenceId', 'id', 'duration', 'status', 'latestAccuracy']; function _inferColumnTitle(columnKey: string): string { if (columnKey === 'sequenceId') { return 'Trial No.'; } else if (columnKey === 'id') { return 'ID'; } else if (columnKey === 'intermediateCount') { return 'Intermediate results (#)'; } else if (columnKey === 'message') { return 'Message'; } else if (columnKey.startsWith('space/')) { return columnKey.split('/', 2)[1] + ' (space)'; } else if (columnKey === 'latestAccuracy') { return 'Default metric'; // to align with the original design } else if (columnKey.startsWith('metric/')) { return columnKey.split('/', 2)[1] + ' (metric)'; } else if (columnKey.startsWith('_')) { return columnKey; } else { // camel case to verbose form const withSpace = columnKey.replace(/[A-Z]/g, letter => ` ${letter.toLowerCase()}`); return withSpace.charAt(0).toUpperCase() + withSpace.slice(1); } } interface TableListProps { tableSource: TableObj[]; updateDetailPage: () => void; } interface TableListState { displayedItems: any[]; displayedColumns: string[]; columns: IColumn[]; searchType: SearchOptionType; searchText: string; selectedRowIds: string[]; customizeColumnsDialogVisible: boolean; compareDialogVisible: boolean; intermediateDialogTrial: TableObj | undefined; copiedTrialId: string | undefined; sortInfo: SortInfo; searchItems: Array; relation: Map; intermediateKeyList: string[]; } class TableList extends React.Component { private _expandedTrialIds: Set; constructor(props: TableListProps) { super(props); this.state = { displayedItems: [], displayedColumns: localStorage.getItem('columns') !== null ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion JSON.parse(localStorage.getItem('columns')!) : defaultDisplayedColumns, columns: [], searchType: 'id', searchText: '', customizeColumnsDialogVisible: false, compareDialogVisible: false, selectedRowIds: [], intermediateDialogTrial: undefined, copiedTrialId: undefined, sortInfo: { field: '', isDescend: true }, searchItems: [], relation: parametersType(), intermediateKeyList: [] }; this._expandedTrialIds = new Set(); } /* Table basic function related methods */ private _onColumnClick(ev: React.MouseEvent, column: IColumn): void { // handle the click events on table header (do sorting) const { columns } = this.state; const newColumns: IColumn[] = columns.slice(); const currColumn: IColumn = newColumns.filter(currCol => column.key === currCol.key)[0]; const isSortedDescending = !currColumn.isSortedDescending; this.setState( { sortInfo: { field: column.key, isDescend: isSortedDescending } }, this._updateTableSource ); } private _trialsToTableItems(trials: TableObj[]): any[] { // TODO: use search space and metrics space from TRIALS will cause update issues. const searchSpace = TRIALS.inferredSearchSpace(EXPERIMENT.searchSpaceNew); const metricSpace = TRIALS.inferredMetricSpace(); const { selectedRowIds } = this.state; const items = trials.map(trial => { const ret = { sequenceId: trial.sequenceId, id: trial.id, _checked: selectedRowIds.includes(trial.id) ? true : false, startTime: (trial as Trial).info.startTime, // FIXME: why do we need info here? endTime: (trial as Trial).info.endTime, duration: trial.duration, status: trial.status, message: (trial as Trial).info.message || '--', intermediateCount: trial.intermediates.length, _expandDetails: this._expandedTrialIds.has(trial.id) // hidden field names should start with `_` }; for (const [k, v] of trial.parameters(searchSpace)) { ret[`space/${k.baseName}`] = v; } for (const [k, v] of trial.metrics(metricSpace)) { ret[`metric/${k.baseName}`] = v; } ret['latestAccuracy'] = (trial as Trial).latestAccuracy; ret['_formattedLatestAccuracy'] = (trial as Trial).formatLatestAccuracy(); return ret; }); const { sortInfo } = this.state; if (sortInfo.field !== '') { return copyAndSort(items, sortInfo.field, sortInfo.isDescend); } else { return items; } } private selectedTrialOnChangeEvent = ( id: string, _ev?: React.FormEvent, checked?: boolean ): void => { const { displayedItems, selectedRowIds } = this.state; const latestDisplayedItems = JSON.parse(JSON.stringify(displayedItems)); let latestSelectedRowIds = selectedRowIds; if (checked === false) { latestSelectedRowIds = latestSelectedRowIds.filter(item => item !== id); } else { latestSelectedRowIds.push(id); } latestDisplayedItems.forEach(item => { if (item.id === id) { item._checked = !!checked; } }); this.setState(() => ({ displayedItems: latestDisplayedItems, selectedRowIds: latestSelectedRowIds })); }; private changeSelectTrialIds = (): void => { const { displayedItems } = this.state; const newDisplayedItems = displayedItems; newDisplayedItems.forEach(item => { item._checked = false; }); this.setState(() => ({ selectedRowIds: [], displayedItems: newDisplayedItems })); }; private _buildColumnsFromTableItems(tableItems: any[]): IColumn[] { const columns: IColumn[] = [ // select trial function { name: '', key: '_selected', fieldName: 'selected', minWidth: 20, maxWidth: 20, isResizable: true, className: 'detail-table', onRender: (record): React.ReactNode => ( ) }, // extra column, for a icon to expand the trial details panel { key: '_expand', name: '', onRender: (item): any => { return ( { event.stopPropagation(); const newItem: any = { ...item, _expandDetails: !item._expandDetails }; if (newItem._expandDetails) { // preserve to be restored when refreshed this._expandedTrialIds.add(newItem.id); } else { this._expandedTrialIds.delete(newItem.id); } const newItems = this.state.displayedItems.map(item => item.id === newItem.id ? newItem : item ); this.setState({ displayedItems: newItems }); }} onMouseDown={(e): void => { e.stopPropagation(); }} onMouseUp={(e): void => { e.stopPropagation(); }} /> ); }, fieldName: 'expand', isResizable: false, minWidth: 20, maxWidth: 20 } ]; // looking at the first row only for now for (const k of Object.keys(tableItems[0])) { if (k === 'metric/default') { // FIXME: default metric is hacked as latestAccuracy currently continue; } const columnTitle = _inferColumnTitle(k); // TODO: add blacklist // 0.85: tableWidth / screen const widths = window.innerWidth * 0.85; columns.push({ name: columnTitle, key: k, fieldName: k, minWidth: widths * 0.12, maxWidth: widths * 0.19, isResizable: true, onColumnClick: this._onColumnClick.bind(this), ...(k === 'status' && { // color status onRender: (record): React.ReactNode => ( {record.status} ) }), ...(k === 'message' && { onRender: (record): React.ReactNode => record.message.length > 15 ? (
{record.message}
) : (
{record.message}
) }), ...((k.startsWith('metric/') || k.startsWith('space/')) && { // show tooltip onRender: (record): React.ReactNode => (
{record[k]}
) }), ...(k === 'latestAccuracy' && { // FIXME: this is ad-hoc onRender: (record): React.ReactNode => (
{record._formattedLatestAccuracy}
) }), ...(['startTime', 'endTime'].includes(k) && { onRender: (record): React.ReactNode => {formatTimestamp(record[k], '--')} }), ...(k === 'duration' && { onRender: (record): React.ReactNode => ( {convertDuration(record[k])} ) }), ...(k === 'id' && { onRender: (record): React.ReactNode => (
{record.id}
) }) }); } // operations column columns.push({ name: 'Operation', key: '_operation', fieldName: 'operation', minWidth: 150, maxWidth: 160, isResizable: true, className: 'detail-table', onRender: this._renderOperationColumn.bind(this) }); const { sortInfo } = this.state; for (const column of columns) { if (column.key === sortInfo.field) { column.isSorted = true; column.isSortedDescending = sortInfo.isDescend; } else { column.isSorted = false; column.isSortedDescending = true; } } return columns; } private _updateTableSource(): void { // call this method when trials or the computation of trial filter has changed const { searchItems, relation } = this.state; let items = this._trialsToTableItems(this.props.tableSource); if (searchItems.length > 0) { items = getTrialsBySearchFilters(items, searchItems, relation); // use search filter to filter data } if (items.length > 0) { const columns = this._buildColumnsFromTableItems(items); this.setState({ displayedItems: items, columns: columns }); } else { this.setState({ displayedItems: [], columns: [] }); } } private _updateDisplayedColumns(displayedColumns: string[]): void { this.setState({ displayedColumns: displayedColumns }); } private _renderOperationColumn(record: any): React.ReactNode { const runningTrial: boolean = ['RUNNING', 'UNKNOWN'].includes(record.status) ? false : true; const disabledAddCustomizedTrial = ['DONE', 'ERROR', 'STOPPED', 'VIEWED'].includes(EXPERIMENT.status); return ( { const { tableSource } = this.props; const trial = tableSource.find(trial => trial.id === record.id) as TableObj; const intermediateKeyListResult = this.getIntermediateAllKeys(trial); this.setState({ intermediateDialogTrial: trial, intermediateKeyList: intermediateKeyListResult }); }} > {LineChart} {runningTrial ? ( {blocked} ) : ( )} { this.setState({ copiedTrialId: record.id }); }} disabled={disabledAddCustomizedTrial} > {copy} ); } private changeSearchFilterList = (arr: Array): void => { this.setState(() => ({ searchItems: arr })); }; private getIntermediateAllKeys = (intermediateDialogTrial: any): string[] => { let intermediateAllKeysList: string[] = []; if ( intermediateDialogTrial!.intermediateMetrics !== undefined && intermediateDialogTrial!.intermediateMetrics[0] ) { const parsedMetric = parseMetrics(intermediateDialogTrial!.intermediateMetrics[0].data); if (parsedMetric !== undefined && typeof parsedMetric === 'object') { const allIntermediateKeys: string[] = []; // just add type=number keys for (const key in parsedMetric) { if (typeof parsedMetric[key] === 'number') { allIntermediateKeys.push(key); } } intermediateAllKeysList = allIntermediateKeys; } } if (intermediateAllKeysList.includes('default') && intermediateAllKeysList[0] !== 'default') { intermediateAllKeysList = intermediateAllKeysList.filter(item => item !== 'default'); intermediateAllKeysList.unshift('default'); } return intermediateAllKeysList; }; componentDidUpdate(prevProps: TableListProps): void { if (this.props.tableSource !== prevProps.tableSource) { this._updateTableSource(); } } componentDidMount(): void { this._updateTableSource(); } render(): React.ReactNode { const { displayedItems, columns, customizeColumnsDialogVisible, compareDialogVisible, displayedColumns, selectedRowIds, intermediateDialogTrial, copiedTrialId, searchItems, intermediateKeyList } = this.state; return (
{tableListIcon} Trial jobs { this.setState({ customizeColumnsDialogVisible: true }); }} /> { this.setState({ compareDialogVisible: true }); }} disabled={selectedRowIds.length === 0} /> {columns && displayedItems && ( displayedColumns.includes(column.key) || ['_expand', '_operation', '_selected'].includes(column.key) )} items={displayedItems} compact={true} selectionMode={0} selectionPreservedOnEmptyClick={true} onRenderRow={(props): any => { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion return ; }} /> )} {compareDialogVisible && ( selectedRowIds.includes(trial.id))} onHideDialog={(): void => { this.setState({ compareDialogVisible: false }); }} changeSelectTrialIds={this.changeSelectTrialIds} /> )} {intermediateDialogTrial !== undefined && ( { this.setState({ intermediateDialogTrial: undefined }); }} /> )} {customizeColumnsDialogVisible && ( !column.key.startsWith('_')) .map(column => ({ key: column.key, name: column.name }))} onSelectedChange={this._updateDisplayedColumns.bind(this)} onHideDialog={(): void => { this.setState({ customizeColumnsDialogVisible: false }); }} whichComponent='table' /> )} {/* Clone a trial and customize a set of new parameters */} {/* visible is done inside because prompt is needed even when the dialog is closed */} { this.setState({ copiedTrialId: undefined }); }} />
); } } export default TableList;