From 88a225f84700a9b78738b4a973a63fd1f0cbab84 Mon Sep 17 00:00:00 2001 From: Yuge Zhang Date: Wed, 14 Oct 2020 15:01:48 +0800 Subject: [PATCH] Chevron icon before table row and TableList refactoring (#2900) --- src/webui/.eslintrc | 1 + src/webui/src/components/TrialsDetail.tsx | 129 +- .../modals/ChangeColumnComponent.tsx | 129 +- src/webui/src/components/modals/Compare.tsx | 295 ++--- .../public-child/ExpandableDetails.tsx | 22 + .../public-child/PaginationTable.tsx | 120 ++ .../src/components/trial-detail/Para.tsx | 57 +- .../src/components/trial-detail/TableList.tsx | 1163 +++++++---------- src/webui/src/static/interface.ts | 1 + src/webui/src/static/model/trial.ts | 15 +- src/webui/src/static/style/table.scss | 2 +- 11 files changed, 870 insertions(+), 1064 deletions(-) create mode 100644 src/webui/src/components/public-child/ExpandableDetails.tsx create mode 100644 src/webui/src/components/public-child/PaginationTable.tsx diff --git a/src/webui/.eslintrc b/src/webui/.eslintrc index 9fb5393e3e..e220b1f541 100644 --- a/src/webui/.eslintrc +++ b/src/webui/.eslintrc @@ -28,6 +28,7 @@ "@typescript-eslint/no-inferrable-types": 0, "@typescript-eslint/no-use-before-define": [2, "nofunc"], "@typescript-eslint/no-var-requires": 0, + "@typescript-eslint/no-unused-vars": [2, { "argsIgnorePattern": "^_" }], "arrow-parens": [2, "as-needed"], "no-inner-declarations": 0, "no-empty": 2, diff --git a/src/webui/src/components/TrialsDetail.tsx b/src/webui/src/components/TrialsDetail.tsx index b7d1b358cd..d54a75a3c6 100644 --- a/src/webui/src/components/TrialsDetail.tsx +++ b/src/webui/src/components/TrialsDetail.tsx @@ -1,10 +1,7 @@ import * as React from 'react'; -import { Stack, StackItem, Pivot, PivotItem, Dropdown, IDropdownOption, DefaultButton } from '@fluentui/react'; +import { Stack, Pivot, PivotItem } from '@fluentui/react'; import { EXPERIMENT, TRIALS } from '../static/datamodel'; -import { Trial } from '../static/model/trial'; import { AppContext } from '../App'; -import { Title } from './overview/Title'; -import { TitleContext } from './overview/TitleContext'; import DefaultPoint from './trial-detail/DefaultMetricPoint'; import Duration from './trial-detail/Duration'; import Para from './trial-detail/Para'; @@ -13,18 +10,8 @@ import TableList from './trial-detail/TableList'; import '../static/style/trialsDetail.scss'; import '../static/style/search.scss'; -const searchOptions = [ - { key: 'id', text: 'Id' }, - { key: 'Trial No.', text: 'Trial No.' }, - { key: 'status', text: 'Status' }, - { key: 'parameters', text: 'Parameters' } -]; - interface TrialDetailState { - tablePageSize: number; // table components val whichChart: string; - searchType: string; - searchFilter: (trial: Trial) => boolean; } class TrialsDetail extends React.Component<{}, TrialDetailState> { @@ -39,71 +26,22 @@ class TrialsDetail extends React.Component<{}, TrialDetailState> { constructor(props) { super(props); this.state = { - tablePageSize: 20, - whichChart: 'Default metric', - searchType: 'id', - // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/explicit-function-return-type - searchFilter: trial => true + whichChart: 'Default metric' }; } - // search a trial by trial No. | trial id | Parameters | Status - searchTrial = (event: React.ChangeEvent): void => { - const targetValue = event.target.value; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - let filter = (trial: Trial): boolean => true; - if (!targetValue.trim()) { - this.setState({ searchFilter: filter }); - return; - } - switch (this.state.searchType) { - case 'id': - filter = (trial): boolean => trial.info.id.toUpperCase().includes(targetValue.toUpperCase()); - break; - case 'Trial No.': - filter = (trial): boolean => trial.info.sequenceId.toString() === targetValue; - break; - case 'status': - filter = (trial): boolean => trial.info.status.toUpperCase().includes(targetValue.toUpperCase()); - break; - case 'parameters': - // TODO: support filters like `x: 2` (instead of `"x": 2`) - filter = (trial): boolean => JSON.stringify(trial.info.hyperParameters, null, 4).includes(targetValue); - break; - default: - alert(`Unexpected search filter ${this.state.searchType}`); - } - this.setState({ searchFilter: filter }); - }; - - handleTablePageSizeSelect = (event: React.FormEvent, item: IDropdownOption | undefined): void => { - if (item !== undefined) { - this.setState({ tablePageSize: item.text === 'all' ? -1 : parseInt(item.text, 10) }); - } - }; - handleWhichTabs = (item: any): void => { this.setState({ whichChart: item.props.headerText }); }; - updateSearchFilterType = (event: React.FormEvent, item: IDropdownOption | undefined): void => { - // clear input value and re-render table - if (item !== undefined) { - if (this.searchInput !== null) { - this.searchInput.value = ''; - } - this.setState(() => ({ searchType: item.key.toString() })); - } - }; - render(): React.ReactNode { - const { tablePageSize, whichChart, searchType } = this.state; - const source = TRIALS.filter(this.state.searchFilter); - const trialIds = TRIALS.filter(this.state.searchFilter).map(trial => trial.id); + const { whichChart } = this.state; + const source = TRIALS.toArray(); + const trialIds = TRIALS.toArray().map(trial => trial.id); return ( - {(value): React.ReactNode => ( + {(_value): React.ReactNode => (
{
{/* trial table list */} -
- - - - </TitleContext.Provider> - </Stack> - <Stack horizontal className='allList'> - <StackItem grow={50}> - <DefaultButton - text='Compare' - className='allList-compare' - // use child-component tableList's function, the function is in child-component. - onClick={(): void => { - if (this.tableList) { - this.tableList.compareBtn(); - } - }} - /> - </StackItem> - <StackItem grow={50}> - <Stack horizontal horizontalAlign='end' className='allList'> - <DefaultButton - className='allList-button-gap' - text='Add column' - onClick={(): void => { - if (this.tableList) { - this.tableList.addColumn(); - } - }} - /> - <Dropdown - selectedKey={searchType} - options={searchOptions} - onChange={this.updateSearchFilterType} - styles={{ root: { width: 150 } }} - /> - <input - type='text' - className='allList-search-input' - placeholder={`Search by ${this.state.searchType}`} - onChange={this.searchTrial} - style={{ width: 230 }} - ref={(text): any => (this.searchInput = text)} - /> - </Stack> - </StackItem> - </Stack> + <div style={{ backgroundColor: '#fff' }}> <TableList - pageSize={tablePageSize} - tableSource={source.map(trial => trial.tableRecord)} - columnList={value.columnList} - changeColumn={value.changeColumn} + tableSource={source} trialsUpdateBroadcast={this.context.trialsUpdateBroadcast} - // TODO: change any to specific type - ref={(tabList): any => (this.tableList = tabList)} /> </div> </React.Fragment> diff --git a/src/webui/src/components/modals/ChangeColumnComponent.tsx b/src/webui/src/components/modals/ChangeColumnComponent.tsx index a4691b8d40..8fbc10980c 100644 --- a/src/webui/src/components/modals/ChangeColumnComponent.tsx +++ b/src/webui/src/components/modals/ChangeColumnComponent.tsx @@ -1,18 +1,22 @@ import * as React from 'react'; import { Dialog, DialogType, DialogFooter, Checkbox, PrimaryButton, DefaultButton } from '@fluentui/react'; -import { OPERATION } from '../../static/const'; interface ChangeColumnState { - userSelectColumnList: string[]; - originSelectColumnList: string[]; + // buffer, not saved yet + currentSelected: string[]; } interface ChangeColumnProps { - isHideDialog: boolean; - showColumn: string[]; // all column List - selectedColumn: string[]; // user selected column list - changeColumn: (val: string[]) => void; - hideShowColumnDialog: () => void; + allColumns: SimpleColumn[]; // all column List + selectedColumns: string[]; // user selected column list + onSelectedChange: (val: string[]) => void; + onHideDialog: () => void; + minSelected?: number; +} + +interface SimpleColumn { + key: string; // key for management + name: string; // name to display } interface CheckBoxItems { @@ -20,12 +24,12 @@ interface CheckBoxItems { checked: boolean; onChange: () => void; } + class ChangeColumnComponent extends React.Component<ChangeColumnProps, ChangeColumnState> { constructor(props: ChangeColumnProps) { super(props); this.state = { - userSelectColumnList: this.props.selectedColumn, - originSelectColumnList: this.props.selectedColumn + currentSelected: this.props.selectedColumns }; } @@ -38,97 +42,50 @@ class ChangeColumnComponent extends React.Component<ChangeColumnProps, ChangeCol label: string, val?: boolean ): void => { - const source: string[] = JSON.parse(JSON.stringify(this.state.userSelectColumnList)); + const source: string[] = [...this.state.currentSelected]; if (val === true) { if (!source.includes(label)) { source.push(label); - this.setState(() => ({ userSelectColumnList: source })); + this.setState({ currentSelected: source }); } } else { - if (source.includes(label)) { - // remove from source - const result = source.filter(item => item !== label); - this.setState(() => ({ userSelectColumnList: result })); - } + // remove from source + const result = source.filter(item => item !== label); + this.setState({ currentSelected: result }); } }; saveUserSelectColumn = (): void => { - const { userSelectColumnList } = this.state; - const { showColumn } = this.props; - // sort by Trial No. | ID | Duration | Start Time | End Time | ... - const sortColumn: string[] = []; - /** - * - * TODO: use this function to refactor sort column - * search space might orderless - showColumn.map(item => { - userSelectColumnList.map(key => { - if (item === key || key.includes('search space')) { - if (!sortColumn.includes(key)) { - sortColumn.push(key); - } - } - }); - }); - */ - // push ![Operation] ![search space] column - showColumn.map(item => { - userSelectColumnList.map(key => { - if (item === key && item !== OPERATION) { - sortColumn.push(key); - } - }); - }); - // push search space key - userSelectColumnList.map(index => { - if (index.includes('search space')) { - if (!sortColumn.includes(index)) { - sortColumn.push(index); - } - } - }); - // push Operation - if (userSelectColumnList.includes(OPERATION)) { - sortColumn.push(OPERATION); - } - this.props.changeColumn(sortColumn); - this.hideDialog(); // hide dialog - }; - - hideDialog = (): void => { - this.props.hideShowColumnDialog(); + const { currentSelected } = this.state; + const { allColumns, onSelectedChange } = this.props; + const selectedColumns = allColumns.map(column => column.key).filter(key => currentSelected.includes(key)); + onSelectedChange(selectedColumns); + this.hideDialog(); }; // user exit dialog cancelOption = (): void => { // reset select column - const { originSelectColumnList } = this.state; - this.setState({ userSelectColumnList: originSelectColumnList }, () => { + this.setState({ currentSelected: this.props.selectedColumns }, () => { this.hideDialog(); }); }; + private hideDialog = (): void => { + this.props.onHideDialog(); + }; + render(): React.ReactNode { - const { showColumn, isHideDialog } = this.props; - const { userSelectColumnList } = this.state; - const renderOptions: Array<CheckBoxItems> = []; - showColumn.map(item => { - if (userSelectColumnList.includes(item)) { - // selected column name - renderOptions.push({ label: item, checked: true, onChange: this.makeChangeHandler(item) }); - } else { - renderOptions.push({ label: item, checked: false, onChange: this.makeChangeHandler(item) }); - } - }); + const { allColumns, minSelected } = this.props; + const { currentSelected } = this.state; return ( <div> <Dialog - hidden={isHideDialog} // required field! + hidden={false} dialogContentProps={{ type: DialogType.largeHeader, - title: 'Change table column', - subText: 'You can chose which columns you want to see in the table.' + title: 'Customize columns', + subText: 'You can choose which columns you wish to see.' }} modalProps={{ isBlocking: false, @@ -136,12 +93,22 @@ class ChangeColumnComponent extends React.Component<ChangeColumnProps, ChangeCol }} > <div className='columns-height'> - {renderOptions.map(item => { - return <Checkbox key={item.label} {...item} styles={{ root: { marginBottom: 8 } }} />; - })} + {allColumns.map(item => ( + <Checkbox + key={item.key} + label={item.name} + checked={currentSelected.includes(item.key)} + onChange={this.makeChangeHandler(item.key)} + styles={{ root: { marginBottom: 8 } }} + /> + ))} </div> <DialogFooter> - <PrimaryButton text='Save' onClick={this.saveUserSelectColumn} /> + <PrimaryButton + text='Save' + onClick={this.saveUserSelectColumn} + disabled={currentSelected.length < (minSelected === undefined ? 1 : minSelected)} + /> <DefaultButton text='Cancel' onClick={this.cancelOption} /> </DialogFooter> </Dialog> diff --git a/src/webui/src/components/modals/Compare.tsx b/src/webui/src/components/modals/Compare.tsx index daed24526c..936c2714d3 100644 --- a/src/webui/src/components/modals/Compare.tsx +++ b/src/webui/src/components/modals/Compare.tsx @@ -1,11 +1,16 @@ import * as React from 'react'; +import { renderToString } from 'react-dom/server'; import { Stack, Modal, IconButton, IDragOptions, ContextualMenu } from '@fluentui/react'; import ReactEcharts from 'echarts-for-react'; -import IntermediateVal from '../public-child/IntermediateVal'; -import { TRIALS } from '../../static/datamodel'; -import { TableRecord, Intermedia, TooltipForIntermediate } from '../../static/interface'; +import { TooltipForIntermediate, TableObj, SingleAxis } from '../../static/interface'; import { contentStyles, iconButtonStyles } from '../buttons/ModalTheme'; import '../../static/style/compare.scss'; +import { convertDuration, parseMetrics } from '../../static/function'; +import { EXPERIMENT, TRIALS } from '../../static/datamodel'; + +function _getWebUIWidth(): number { + return window.innerWidth; +} const dragOptions: IDragOptions = { moveMenuItemText: 'Move', @@ -13,79 +18,81 @@ const dragOptions: IDragOptions = { menu: ContextualMenu }; -// the modal of trial compare +// TODO: this should be refactored to the common modules +// copied from trial.ts +function _parseIntermediates(trial: TableObj): number[] { + const intermediates: number[] = []; + for (const metric of trial.intermediates) { + if (metric === undefined) { + break; + } + const parsedMetric = parseMetrics(metric.data); + if (typeof parsedMetric === 'object') { + // TODO: should handle more types of metric keys + intermediates.push(parsedMetric.default); + } else { + intermediates.push(parsedMetric); + } + } + return intermediates; +} + +interface Item { + id: string; + sequenceId: number; + duration: string; + parameters: Map<string, any>; + metrics: Map<string, any>; + intermediates: number[]; +} + interface CompareProps { - compareStacks: Array<TableRecord>; - cancelFunc: () => void; + trials: TableObj[]; + title: string; + showDetails: boolean; + onHideDialog: () => void; } class Compare extends React.Component<CompareProps, {}> { - public _isCompareMount!: boolean; constructor(props: CompareProps) { super(props); } - intermediate = (): React.ReactNode => { - const { compareStacks } = this.props; - const trialIntermediate: Array<Intermedia> = []; - const idsList: string[] = []; - compareStacks.forEach(element => { - const trial = TRIALS.getTrial(element.id); - trialIntermediate.push({ - name: element.id, - data: trial.description.intermediate, - type: 'line', - hyperPara: trial.description.parameters - }); - idsList.push(element.id); - }); - // find max intermediate number - trialIntermediate.sort((a, b) => { - return b.data.length - a.data.length; - }); - const legend: string[] = []; - // max length - const length = trialIntermediate[0] !== undefined ? trialIntermediate[0].data.length : 0; - const xAxis: number[] = []; - trialIntermediate.forEach(element => { - legend.push(element.name); - }); - for (let i = 1; i <= length; i++) { - xAxis.push(i); - } + private _generateTooltipSummary(row: Item, metricKey: string): string { + return renderToString( + <div className='tooldetailAccuracy'> + <div>Trial ID: {row.id}</div> + <div>Default metric: {row.metrics.get(metricKey) || 'N/A'}</div> + </div> + ); + } + + private _intermediates(items: Item[], metricKey: string): React.ReactNode { + // Precondition: make sure `items` is not empty + const xAxisMax = Math.max(...items.map(item => item.intermediates.length)); + const xAxis = Array(xAxisMax) + .fill(0) + .map((_, i) => i + 1); // [1, 2, 3, ..., xAxisMax] + const dataForEchart = items.map(item => ({ + name: item.id, + data: item.intermediates, + type: 'line' + })); + const legend = dataForEchart.map(item => item.name); const option = { tooltip: { trigger: 'item', enterable: true, - position: function(point: number[], data: TooltipForIntermediate): number[] { + position: (point: number[], data: TooltipForIntermediate): [number, number] => { if (data.dataIndex < length / 2) { return [point[0], 80]; } else { return [point[0] - 300, 80]; } }, - formatter: function(data: TooltipForIntermediate): React.ReactNode { - const trialId = data.seriesName; - let obj = {}; - const temp = trialIntermediate.find(key => key.name === trialId); - if (temp !== undefined) { - obj = temp.hyperPara; - } - return ( - '<div class="tooldetailAccuracy">' + - '<div>Trial ID: ' + - trialId + - '</div>' + - '<div>Intermediate: ' + - data.data + - '</div>' + - '<div>Parameters: ' + - '<pre>' + - JSON.stringify(obj, null, 4) + - '</pre>' + - '</div>' + - '</div>' - ); + formatter: (data: TooltipForIntermediate): string => { + const item = items.find(k => k.id === data.seriesName) as Item; + return this._generateTooltipSummary(item, metricKey); } }, grid: { @@ -96,12 +103,11 @@ class Compare extends React.Component<CompareProps, {}> { legend: { type: 'scroll', right: 40, - left: idsList.length > 6 ? 80 : null, - data: idsList + left: legend.length > 6 ? 80 : null, + data: legend }, xAxis: { type: 'category', - // name: '# Intermediate', boundaryGap: false, data: xAxis }, @@ -110,7 +116,7 @@ class Compare extends React.Component<CompareProps, {}> { name: 'Metric', scale: true }, - series: trialIntermediate + series: dataForEchart }; return ( <ReactEcharts @@ -119,108 +125,92 @@ class Compare extends React.Component<CompareProps, {}> { notMerge={true} // update now /> ); - }; - - // render table column --- - initColumn = (): React.ReactNode => { - const idList: string[] = []; - const sequenceIdList: number[] = []; - const durationList: number[] = []; + } - const compareStacks = this.props.compareStacks.map(tableRecord => TRIALS.getTrial(tableRecord.id)); + private _renderRow( + key: string, + rowName: string, + className: string, + items: Item[], + formatter: (item: Item) => string + ): React.ReactNode { + return ( + <tr key={key}> + <td className='column'>{rowName}</td> + {items.map(item => ( + <td className={className} key={item.id}> + {formatter(item)} + </td> + ))} + </tr> + ); + } - const parameterList: Array<object> = []; - let parameterKeys: string[] = []; - if (compareStacks.length !== 0) { - parameterKeys = Object.keys(compareStacks[0].description.parameters); - } - compareStacks.forEach(temp => { - idList.push(temp.id); - sequenceIdList.push(temp.sequenceId); - durationList.push(temp.duration); - parameterList.push(temp.description.parameters); - }); - let isComplexSearchSpace; - if (parameterList.length > 0) { - isComplexSearchSpace = typeof parameterList[0][parameterKeys[0]] === 'object' ? true : false; + private _overlapKeys(s: Map<string, any>[]): string[] { + // Calculate the overlapped keys for multiple + const intersection: string[] = []; + for (const i of s[0].keys()) { + let inAll = true; + for (const t of s) { + if (!Array.from(t.keys()).includes(i)) { + inAll = false; + break; + } + } + if (inAll) { + intersection.push(i); + } } - const width = this.getWebUIWidth(); - let scrollClass; + return intersection; + } + + // render table column --- + private _columns(items: Item[]): React.ReactNode { + // Precondition: make sure `items` is not empty + const width = _getWebUIWidth(); + let scrollClass: string = ''; if (width > 1200) { - scrollClass = idList.length > 3 ? 'flex' : ''; + scrollClass = items.length > 3 ? 'flex' : ''; } else if (width < 700) { - scrollClass = idList.length > 1 ? 'flex' : ''; + scrollClass = items.length > 1 ? 'flex' : ''; } else { - scrollClass = idList.length > 2 ? 'flex' : ''; + scrollClass = items.length > 2 ? 'flex' : ''; } + const parameterKeys = this._overlapKeys(items.map(item => item.parameters)); + const metricKeys = this._overlapKeys(items.map(item => item.metrics)); return ( <table className={`compare-modal-table ${scrollClass}`}> <tbody> - <tr> - <td className='column'>Id</td> - {Object.keys(idList).map(key => ( - <td className='value idList' key={key}> - {idList[key]} - </td> - ))} - </tr> - <tr> - <td className='column'>Trial No.</td> - {Object.keys(sequenceIdList).map(key => ( - <td className='value idList' key={key}> - {sequenceIdList[key]} - </td> - ))} - </tr> - <tr> - <td className='column'>Default metric</td> - {Object.keys(compareStacks).map(index => ( - <td className='value' key={index}> - <IntermediateVal trialId={compareStacks[index].id} /> - </td> - ))} - </tr> - <tr> - <td className='column'>duration</td> - {Object.keys(durationList).map(index => ( - <td className='value' key={index}> - {durationList[index]} - </td> - ))} - </tr> - {isComplexSearchSpace - ? null - : Object.keys(parameterKeys).map(index => ( - <tr key={index}> - <td className='column' key={index}> - {parameterKeys[index]} - </td> - {Object.keys(parameterList).map(key => ( - <td key={key} className='value'> - {parameterList[key][parameterKeys[index]]} - </td> - ))} - </tr> - ))} + {this._renderRow('id', 'ID', 'value idList', items, item => item.id)} + {this._renderRow('trialnum', 'Trial No.', 'value', items, item => item.sequenceId.toString())} + {this._renderRow('duration', 'Duration', 'value', items, item => item.duration)} + {parameterKeys.map(k => + this._renderRow(`space_${k}`, k, 'value', items, item => item.parameters.get(k)) + )} + {metricKeys.map(k => + this._renderRow(`metrics_${k}`, `Metric: ${k}`, 'value', items, item => item.metrics.get(k)) + )} </tbody> </table> ); - }; - - getWebUIWidth = (): number => { - return window.innerWidth; - }; - - componentDidMount(): void { - this._isCompareMount = true; - } - - componentWillUnmount(): void { - this._isCompareMount = false; } render(): React.ReactNode { - const { cancelFunc } = this.props; + const { onHideDialog, trials, title, showDetails } = this.props; + const flatten = (m: Map<SingleAxis, any>): Map<string, any> => { + return new Map(Array.from(m).map(([key, value]) => [key.baseName, value])); + }; + const inferredSearchSpace = TRIALS.inferredSearchSpace(EXPERIMENT.searchSpaceNew); + const items: Item[] = trials.map(trial => ({ + id: trial.id, + sequenceId: trial.sequenceId, + duration: convertDuration(trial.duration), + parameters: flatten(trial.parameters(inferredSearchSpace)), + metrics: flatten(trial.metrics(TRIALS.inferredMetricSpace())), + intermediates: _parseIntermediates(trial) + })); + const metricKeys = this._overlapKeys(items.map(item => item.metrics)); + const defaultMetricKey = !metricKeys || metricKeys.includes('default') ? 'default' : metricKeys[0]; return ( <Modal @@ -229,22 +219,23 @@ class Compare extends React.Component<CompareProps, {}> { className='compare-modal' allowTouchBodyScroll={true} dragOptions={dragOptions} + onDismiss={onHideDialog} > <div> <div className={contentStyles.header}> - <span>Compare trials</span> + <span>{title}</span> <IconButton styles={iconButtonStyles} iconProps={{ iconName: 'Cancel' }} ariaLabel='Close popup modal' - onClick={cancelFunc} + onClick={onHideDialog} /> </div> <Stack className='compare-modal-intermediate'> - {this.intermediate()} + {this._intermediates(items, defaultMetricKey)} <Stack className='compare-yAxis'># Intermediate result</Stack> </Stack> - <Stack>{this.initColumn()}</Stack> + {showDetails && <Stack>{this._columns(items)}</Stack>} </div> </Modal> ); diff --git a/src/webui/src/components/public-child/ExpandableDetails.tsx b/src/webui/src/components/public-child/ExpandableDetails.tsx new file mode 100644 index 0000000000..34f010f215 --- /dev/null +++ b/src/webui/src/components/public-child/ExpandableDetails.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import { DetailsRow, IDetailsRowBaseProps } from '@fluentui/react'; +import OpenRow from '../public-child/OpenRow'; + +interface ExpandableDetailsProps { + detailsProps: IDetailsRowBaseProps; + isExpand: boolean; +} + +class ExpandableDetails extends React.Component<ExpandableDetailsProps, {}> { + render(): React.ReactNode { + const { detailsProps, isExpand } = this.props; + return ( + <div> + <DetailsRow {...detailsProps} /> + {isExpand && <OpenRow trialId={detailsProps.item.id} />} + </div> + ); + } +} + +export default ExpandableDetails; diff --git a/src/webui/src/components/public-child/PaginationTable.tsx b/src/webui/src/components/public-child/PaginationTable.tsx new file mode 100644 index 0000000000..9379968e95 --- /dev/null +++ b/src/webui/src/components/public-child/PaginationTable.tsx @@ -0,0 +1,120 @@ +import * as React from 'react'; +import { DetailsList, Dropdown, Icon, IDetailsListProps, IDropdownOption, IStackTokens, Stack } from '@fluentui/react'; +import ReactPaginate from 'react-paginate'; + +interface PaginationTableState { + itemsPerPage: number; + currentPage: number; + itemsOnPage: any[]; // this needs to be stored in state to prevent re-rendering +} + +const horizontalGapStackTokens: IStackTokens = { + childrenGap: 20, + padding: 10 +}; + +function _currentTableOffset(perPage: number, currentPage: number, source: any[]): number { + return perPage === -1 ? 0 : Math.min(currentPage, Math.floor((source.length - 1) / perPage)) * perPage; +} + +function _obtainPaginationSlice(perPage: number, currentPage: number, source: any[]): any[] { + if (perPage === -1) { + return source; + } else { + const offset = _currentTableOffset(perPage, currentPage, source); + return source.slice(offset, offset + perPage); + } +} + +class PaginationTable extends React.PureComponent<IDetailsListProps, PaginationTableState> { + constructor(props: IDetailsListProps) { + super(props); + this.state = { + itemsPerPage: 20, + currentPage: 0, + itemsOnPage: [] + }; + } + + private _onItemsPerPageSelect(event: React.FormEvent<HTMLDivElement>, item: IDropdownOption | undefined): void { + if (item !== undefined) { + const { items } = this.props; + // use current offset to calculate the next `current_page` + const currentOffset = _currentTableOffset(this.state.itemsPerPage, this.state.currentPage, items); + const itemsPerPage = item.key as number; + const currentPage = Math.floor(currentOffset / itemsPerPage); + this.setState({ + itemsPerPage: itemsPerPage, + currentPage: currentPage, + itemsOnPage: _obtainPaginationSlice(itemsPerPage, currentPage, this.props.items) + }); + } + } + + private _onPageSelect(event: any): void { + const currentPage = event.selected; + this.setState({ + currentPage: currentPage, + itemsOnPage: _obtainPaginationSlice(this.state.itemsPerPage, currentPage, this.props.items) + }); + } + + componentDidUpdate(prevProps: IDetailsListProps): void { + if (prevProps.items !== this.props.items) { + this.setState({ + itemsOnPage: _obtainPaginationSlice(this.state.itemsPerPage, this.state.currentPage, this.props.items) + }); + } + } + + render(): React.ReactNode { + const { itemsPerPage, itemsOnPage } = this.state; + const detailListProps = { + ...this.props, + items: itemsOnPage + }; + const itemsCount = this.props.items.length; + const pageCount = itemsPerPage === -1 ? 1 : Math.ceil(itemsCount / itemsPerPage); + const perPageOptions = [ + { key: 10, text: '10 items per page' }, + { key: 20, text: '20 items per page' }, + { key: 50, text: '50 items per page' }, + { key: -1, text: 'All items' } + ]; + return ( + <div> + <DetailsList {...detailListProps} /> + <Stack + horizontal + horizontalAlign='end' + verticalAlign='baseline' + styles={{ root: { padding: 10 } }} + tokens={horizontalGapStackTokens} + > + <Dropdown + selectedKey={itemsPerPage} + options={perPageOptions} + onChange={this._onItemsPerPageSelect.bind(this)} + styles={{ dropdown: { width: 150 } }} + /> + <ReactPaginate + previousLabel={<Icon aria-hidden={true} iconName='ChevronLeft' />} + nextLabel={<Icon aria-hidden={true} iconName='ChevronRight' />} + breakLabel={'...'} + breakClassName={'break'} + pageCount={pageCount} + marginPagesDisplayed={2} + pageRangeDisplayed={2} + onPageChange={this._onPageSelect.bind(this)} + containerClassName={itemsCount === 0 ? 'pagination hidden' : 'pagination'} + subContainerClassName={'pages pagination'} + disableInitialCallback={false} + activeClassName={'active'} + /> + </Stack> + </div> + ); + } +} + +export default PaginationTable; diff --git a/src/webui/src/components/trial-detail/Para.tsx b/src/webui/src/components/trial-detail/Para.tsx index 31f33207b7..cc3d3a8e9b 100644 --- a/src/webui/src/components/trial-detail/Para.tsx +++ b/src/webui/src/components/trial-detail/Para.tsx @@ -1,5 +1,5 @@ import * as d3 from 'd3'; -import { Dropdown, IDropdownOption, Stack } from '@fluentui/react'; +import { Dropdown, IDropdownOption, Stack, DefaultButton } from '@fluentui/react'; import ParCoords from 'parcoord-es'; import 'parcoord-es/dist/parcoords.css'; import * as React from 'react'; @@ -9,12 +9,16 @@ import { filterByStatus } from '../../static/function'; import { TableObj, SingleAxis, MultipleAxes } from '../../static/interface'; import '../../static/style/button.scss'; import '../../static/style/para.scss'; +import ChangeColumnComponent from '../modals/ChangeColumnComponent'; interface ParaState { dimName: string[]; selectedPercent: string; primaryMetricKey: string; noChart: boolean; + customizeColumnsDialogVisible: boolean; + availableDimensions: string[]; + chosenDimensions: string[]; } interface ParaProps { @@ -45,7 +49,10 @@ class Para extends React.Component<ParaProps, ParaState> { dimName: [], primaryMetricKey: 'default', selectedPercent: '1', - noChart: true + noChart: true, + customizeColumnsDialogVisible: false, + availableDimensions: [], + chosenDimensions: [] }; } @@ -82,11 +89,24 @@ class Para extends React.Component<ParaProps, ParaState> { } render(): React.ReactNode { - const { selectedPercent, noChart } = this.state; + const { + selectedPercent, + noChart, + customizeColumnsDialogVisible, + availableDimensions, + chosenDimensions + } = this.state; return ( <div className='parameter'> <Stack horizontal className='para-filter' horizontalAlign='end'> + <DefaultButton + text='Add/Remove axes' + onClick={(): void => { + this.setState({ customizeColumnsDialogVisible: true }); + }} + styles={{ root: { marginRight: 10 } }} + /> <Dropdown selectedKey={selectedPercent} onChange={this.percentNum} @@ -101,6 +121,21 @@ class Para extends React.Component<ParaProps, ParaState> { /> {this.finalKeysDropdown()} </Stack> + {customizeColumnsDialogVisible && availableDimensions.length > 0 && ( + <ChangeColumnComponent + selectedColumns={chosenDimensions} + allColumns={availableDimensions.map(dim => ({ key: dim, name: dim }))} + onSelectedChange={(selected: string[]): void => { + this.setState({ chosenDimensions: selected }, () => { + this.renderParallelCoordinates(); + }); + }} + onHideDialog={(): void => { + this.setState({ customizeColumnsDialogVisible: false }); + }} + minSelected={2} + /> + )} <div className='parcoords' style={this.chartMulineStyle} ref={this.paraRef} /> {noChart && <div className='nodata'>No data</div>} </div> @@ -143,13 +178,13 @@ class Para extends React.Component<ParaProps, ParaState> { private renderParallelCoordinates(): void { const { searchSpace } = this.props; const percent = parseFloat(this.state.selectedPercent); - const { primaryMetricKey } = this.state; + const { primaryMetricKey, chosenDimensions } = this.state; const inferredSearchSpace = TRIALS.inferredSearchSpace(searchSpace); const inferredMetricSpace = TRIALS.inferredMetricSpace(); let convertedTrials = this.getTrialsAsObjectList(inferredSearchSpace, inferredMetricSpace); - const dimensions: [any, any][] = []; + const dimensions: [string, any][] = []; let colorDim: string | undefined = undefined, colorScale: any = undefined; // treat every axis as numeric to fit for brush @@ -213,7 +248,11 @@ class Para extends React.Component<ParaProps, ParaState> { } this.pcs .data(convertedTrials) - .dimensions(dimensions.reduce((obj, entry) => ({ ...obj, [entry[0]]: entry[1] }), {})); + .dimensions( + dimensions + .filter(([d, _]) => chosenDimensions.length === 0 || chosenDimensions.includes(d)) + .reduce((obj, entry) => ({ ...obj, [entry[0]]: entry[1] }), {}) + ); if (firstRun) { this.pcs .margin(this.innerChartMargins) @@ -230,6 +269,12 @@ class Para extends React.Component<ParaProps, ParaState> { if (firstRun) { this.setState({ noChart: false }); } + + // set new available dims + this.setState({ + availableDimensions: dimensions.map(e => e[0]), + chosenDimensions: chosenDimensions.length === 0 ? dimensions.map(e => e[0]) : chosenDimensions + }); } private getTrialsAsObjectList(inferredSearchSpace: MultipleAxes, inferredMetricSpace: MultipleAxes): {}[] { diff --git a/src/webui/src/components/trial-detail/TableList.tsx b/src/webui/src/components/trial-detail/TableList.tsx index a5200d81a6..13805d6d79 100644 --- a/src/webui/src/components/trial-detail/TableList.tsx +++ b/src/webui/src/components/trial-detail/TableList.tsx @@ -1,41 +1,39 @@ -import React, { lazy } from 'react'; -import axios from 'axios'; -import ReactEcharts from 'echarts-for-react'; import { - Stack, + DefaultButton, Dropdown, - DetailsList, - IDetailsListProps, - DetailsListLayoutMode, - PrimaryButton, - Modal, - IDropdownOption, IColumn, + Icon, + IDropdownOption, + PrimaryButton, Selection, SelectionMode, - IconButton, - TooltipHost, - IStackTokens + Stack, + StackItem, + TooltipHost } from '@fluentui/react'; -import ReactPaginate from 'react-paginate'; -import { LineChart, blocked, copy } from '../buttons/Icon'; -import { MANAGER_IP, COLUMNPro } from '../../static/const'; -import { convertDuration, formatTimestamp, intermediateGraphOption, parseMetrics } from '../../static/function'; +import React from 'react'; import { EXPERIMENT, TRIALS } from '../../static/datamodel'; -import { TableRecord, TrialJobInfo } from '../../static/interface'; -const Details = lazy(() => import('../overview/table/Details')); -const ChangeColumnComponent = lazy(() => import('../modals/ChangeColumnComponent')); -const Compare = lazy(() => import('../modals/Compare')); -const KillJob = lazy(() => import('../modals/Killjob')); -const Customize = lazy(() => import('../modals/CustomizedTrial')); -import { contentStyles, iconButtonStyles } from '../buttons/ModalTheme'; +import { convertDuration, formatTimestamp } from '../../static/function'; +import { TableObj } from '../../static/interface'; import '../../static/style/search.scss'; import '../../static/style/tableStatus.css'; import '../../static/style/logPath.scss'; import '../../static/style/table.scss'; 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 { blocked, copy, LineChart, tableListIcon } from '../buttons/Icon'; +import ChangeColumnComponent from '../modals/ChangeColumnComponent'; +import Compare from '../modals/Compare'; +import Customize from '../modals/CustomizedTrial'; +import KillJob from '../modals/Killjob'; +import ExpandableDetails from '../public-child/ExpandableDetails'; +import PaginationTable from '../public-child/PaginationTable'; +import { Trial } from '../../static/model/trial'; const echarts = require('echarts/lib/echarts'); require('echarts/lib/chart/line'); @@ -45,749 +43,518 @@ echarts.registerTheme('my_theme', { color: '#3c8dbc' }); -const horizontalGapStackTokens: IStackTokens = { - childrenGap: 20, - padding: 10 +type SearchOptionType = 'id' | 'trialnum' | 'status' | 'parameters'; +const searchOptionLiterals = { + id: 'ID', + trialnum: 'Trial No.', + status: 'Status', + parameters: 'Parameters' }; -interface TableListProps { - pageSize: number; - tableSource: Array<TableRecord>; - columnList: string[]; // user select columnKeys - changeColumn: (val: string[]) => void; - trialsUpdateBroadcast: number; -} +const defaultDisplayedColumns = ['sequenceId', 'id', 'duration', 'status', 'latestAccuracy']; interface SortInfo { field: string; isDescend?: boolean; } +function _copyAndSort<T>(items: T[], columnKey: string, isSortedDescending?: boolean): any { + const key = columnKey as keyof T; + return items.slice(0).sort(function(a: T, b: T): any { + if ( + a[key] === undefined || + Object.is(a[key], NaN) || + Object.is(a[key], Infinity) || + Object.is(a[key], -Infinity) || + typeof a[key] === 'object' + ) { + return 1; + } + if ( + b[key] === undefined || + Object.is(b[key], NaN) || + Object.is(b[key], Infinity) || + Object.is(b[key], -Infinity) || + typeof b[key] === 'object' + ) { + return -1; + } + return (isSortedDescending ? a[key] < b[key] : a[key] > b[key]) ? 1 : -1; + }); +} + +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.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[]; + trialsUpdateBroadcast: number; +} + interface TableListState { - intermediateOption: object; - modalVisible: boolean; - isObjFinal: boolean; - isShowColumn: boolean; - selectRows: Array<any>; - isShowCompareModal: boolean; - selectedRowKeys: string[] | number[]; - intermediateData: Array<object>; // a trial's intermediate results (include dict) - intermediateId: string; - intermediateOtherKeys: string[]; - isShowCustomizedModal: boolean; - copyTrialId: string; // user copy trial to submit a new customized trial - isCalloutVisible: boolean; // kill job button callout [kill or not kill job window] - intermediateKey: string; // intermeidate modal: which key is choosed. - isExpand: boolean; - modalIntermediateWidth: number; - modalIntermediateHeight: number; - tableColumns: IColumn[]; - allColumnList: string[]; - tableSourceForSort: Array<TableRecord>; - sortMessage: SortInfo; - offset: number; - tablePerPage: Array<TableRecord>; - perPage: number; - currentPage: number; - pageCount: number; + displayedItems: any[]; + displayedColumns: string[]; + columns: IColumn[]; + searchType: SearchOptionType; + searchText: string; + selectedRowIds: string[]; + customizeColumnsDialogVisible: boolean; + compareDialogVisible: boolean; + intermediateDialogTrial: TableObj | undefined; + copiedTrialId: string | undefined; + sortInfo: SortInfo; } class TableList extends React.Component<TableListProps, TableListState> { - public intervalTrialLog = 10; - public trialId!: string; + private _selection: Selection; + private _expandedTrialIds: Set<string>; constructor(props: TableListProps) { super(props); this.state = { - intermediateOption: {}, - modalVisible: false, - isObjFinal: false, - isShowColumn: false, - isShowCompareModal: false, - selectRows: [], - selectedRowKeys: [], // close selected trial message after modal closed - intermediateData: [], - intermediateId: '', - intermediateOtherKeys: [], - isShowCustomizedModal: false, - isCalloutVisible: false, - copyTrialId: '', - intermediateKey: 'default', - isExpand: false, - modalIntermediateWidth: window.innerWidth, - modalIntermediateHeight: window.innerHeight, - tableColumns: this.initTableColumnList(this.props.columnList), - allColumnList: this.getAllColumnKeys(), - sortMessage: { field: '', isDescend: false }, - offset: 0, - tablePerPage: [], - perPage: 20, - currentPage: 0, - pageCount: 0, - tableSourceForSort: this.props.tableSource + displayedItems: [], + displayedColumns: defaultDisplayedColumns, + columns: [], + searchType: 'id', + searchText: '', + customizeColumnsDialogVisible: false, + compareDialogVisible: false, + selectedRowIds: [], + intermediateDialogTrial: undefined, + copiedTrialId: undefined, + sortInfo: { field: '', isDescend: true } }; - } - // sort for table column - onColumnClick = (ev: React.MouseEvent<HTMLElement>, getColumn: IColumn): void => { - const { tableColumns } = this.state; - const newColumns: IColumn[] = tableColumns.slice(); - const currColumn: IColumn = newColumns.filter(item => getColumn.key === item.key)[0]; - newColumns.forEach((newCol: IColumn) => { - if (newCol === currColumn) { - currColumn.isSortedDescending = !currColumn.isSortedDescending; - currColumn.isSorted = true; - } else { - newCol.isSorted = false; - newCol.isSortedDescending = true; + this._selection = new Selection({ + onSelectionChanged: (): void => { + this.setState({ + selectedRowIds: this._selection.getSelection().map(s => (s as any).id) + }); } }); - this.setState( - { - tableColumns: newColumns, - sortMessage: { field: getColumn.key, isDescend: currColumn.isSortedDescending } - }, - () => { - this.updateData(); - } - ); - }; - - AccuracyColumnConfig: any = { - name: 'Default metric', - className: 'leftTitle', - key: 'latestAccuracy', - fieldName: 'latestAccuracy', - minWidth: 200, - maxWidth: 300, - isResizable: true, - data: 'number', - onColumnClick: this.onColumnClick, - onRender: (item): React.ReactNode => ( - <TooltipHost content={item.formattedLatestAccuracy}> - <div className='ellipsis'>{item.formattedLatestAccuracy}</div> - </TooltipHost> - ) - }; - - SequenceIdColumnConfig: any = { - name: 'Trial No.', - key: 'sequenceId', - fieldName: 'sequenceId', - minWidth: 80, - maxWidth: 240, - className: 'tableHead', - data: 'number', - onColumnClick: this.onColumnClick - }; - - IdColumnConfig: any = { - name: 'ID', - key: 'id', - fieldName: 'id', - minWidth: 150, - maxWidth: 200, - isResizable: true, - data: 'string', - onColumnClick: this.onColumnClick, - className: 'tableHead leftTitle' - }; - - StartTimeColumnConfig: any = { - name: 'Start time', - key: 'startTime', - fieldName: 'startTime', - minWidth: 150, - maxWidth: 400, - isResizable: true, - data: 'number', - onColumnClick: this.onColumnClick, - onRender: (record): React.ReactNode => <span>{formatTimestamp(record.startTime)}</span> - }; - - EndTimeColumnConfig: any = { - name: 'End time', - key: 'endTime', - fieldName: 'endTime', - minWidth: 200, - maxWidth: 400, - isResizable: true, - data: 'number', - onColumnClick: this.onColumnClick, - onRender: (record): React.ReactNode => <span>{formatTimestamp(record.endTime, '--')}</span> - }; - - DurationColumnConfig: any = { - name: 'Duration', - key: 'duration', - fieldName: 'duration', - minWidth: 150, - maxWidth: 300, - isResizable: true, - data: 'number', - onColumnClick: this.onColumnClick, - onRender: (record): React.ReactNode => <span className='durationsty'>{convertDuration(record.duration)}</span> - }; - - StatusColumnConfig: any = { - name: 'Status', - key: 'status', - fieldName: 'status', - className: 'tableStatus', - minWidth: 150, - maxWidth: 250, - isResizable: true, - data: 'string', - onColumnClick: this.onColumnClick, - onRender: (record): React.ReactNode => <span className={`${record.status} commonStyle`}>{record.status}</span> - }; - - IntermediateCountColumnConfig: any = { - name: 'Intermediate result', - dataIndex: 'intermediateCount', - fieldName: 'intermediateCount', - minWidth: 150, - maxWidth: 200, - isResizable: true, - data: 'number', - onColumnClick: this.onColumnClick, - onRender: (record): React.ReactNode => <span>{`#${record.intermediateCount}`}</span> - }; + this._expandedTrialIds = new Set<string>(); + } - showIntermediateModal = async (record: TrialJobInfo, event: React.SyntheticEvent<EventTarget>): Promise<void> => { - event.preventDefault(); - event.stopPropagation(); - const res = await axios.get(`${MANAGER_IP}/metric-data/${record.id}`); - if (res.status === 200) { - const intermediateArr: number[] = []; - // support intermediate result is dict because the last intermediate result is - // final result in a succeed trial, it may be a dict. - // get intermediate result dict keys array - const { intermediateKey } = this.state; - const otherkeys: string[] = []; - const metricDatas = res.data; - if (metricDatas.length !== 0) { - // just add type=number keys - const intermediateMetrics = parseMetrics(metricDatas[0].data); - for (const key in intermediateMetrics) { - if (typeof intermediateMetrics[key] === 'number') { - otherkeys.push(key); - } - } + /* Search related methods */ + + // This functions as the filter for the final trials displayed in the current table + private _filterTrials(trials: TableObj[]): TableObj[] { + const { searchText, searchType } = this.state; + // search a trial by Trial No. | Trial ID | Parameters | Status + let searchFilter = (_: TableObj): boolean => true; // eslint-disable-line no-unused-vars + if (searchText.trim()) { + if (searchType === 'id') { + searchFilter = (trial): boolean => trial.id.toUpperCase().includes(searchText.toUpperCase()); + } else if (searchType === 'trialnum') { + searchFilter = (trial): boolean => trial.sequenceId.toString() === searchText; + } else if (searchType === 'status') { + searchFilter = (trial): boolean => trial.status.toUpperCase().includes(searchText.toUpperCase()); + } else if (searchType === 'parameters') { + // TODO: support filters like `x: 2` (instead of `'x': 2`) + searchFilter = (trial): boolean => JSON.stringify(trial.description.parameters).includes(searchText); } - // intermediateArr just store default val - metricDatas.map(item => { - if (item.type === 'PERIODICAL') { - const temp = parseMetrics(item.data); - if (typeof temp === 'object') { - intermediateArr.push(temp[intermediateKey]); - } else { - intermediateArr.push(temp); - } - } - }); - const intermediate = intermediateGraphOption(intermediateArr, record.id); - this.setState({ - intermediateData: res.data, // store origin intermediate data for a trial - intermediateOption: intermediate, - intermediateOtherKeys: otherkeys, - intermediateId: record.id - }); } - this.setState({ modalVisible: true }); - }; + return trials.filter(searchFilter); + } - // intermediate button click -> intermediate graph for each trial - // support intermediate is dict - selectOtherKeys = (event: React.FormEvent<HTMLDivElement>, item?: IDropdownOption): void => { + private _updateSearchFilterType(_event: React.FormEvent<HTMLDivElement>, item: IDropdownOption | undefined): void { if (item !== undefined) { - const value = item.text; - const isShowDefault: boolean = value === 'default' ? true : false; - const { intermediateData, intermediateId } = this.state; - const intermediateArr: number[] = []; - // just watch default key-val - if (isShowDefault === true) { - Object.keys(intermediateData).map(item => { - if (intermediateData[item].type === 'PERIODICAL') { - const temp = parseMetrics(intermediateData[item].data); - if (typeof temp === 'object') { - intermediateArr.push(temp[value]); - } else { - intermediateArr.push(temp); - } - } - }); - } else { - Object.keys(intermediateData).map(item => { - const temp = parseMetrics(intermediateData[item].data); - if (typeof temp === 'object') { - intermediateArr.push(temp[value]); - } - }); + const value = item.key.toString(); + if (searchOptionLiterals.hasOwnProperty(value)) { + this.setState({ searchType: value as SearchOptionType }, this._updateTableSource); } - const intermediate = intermediateGraphOption(intermediateArr, intermediateId); - // re-render - this.setState({ - intermediateKey: value, - intermediateOption: intermediate - }); } - }; - - hideIntermediateModal = (): void => { - this.setState({ - modalVisible: false - }); - }; - - hideShowColumnModal = (): void => { - this.setState(() => ({ isShowColumn: false })); - }; - - // click add column btn, just show the modal of addcolumn - addColumn = (): void => { - // show user select check button - this.setState(() => ({ isShowColumn: true })); - }; - - fillSelectedRowsTostate = (selected: number[] | string[], selectedRows: Array<TableRecord>): void => { - this.setState({ selectRows: selectedRows, selectedRowKeys: selected }); - }; + } - // open Compare-modal - compareBtn = (): void => { - const { selectRows } = this.state; - if (selectRows.length === 0) { - alert('Please select datas you want to compare!'); - } else { - this.setState({ isShowCompareModal: true }); - } - }; + private _updateSearchText(ev: React.ChangeEvent<HTMLInputElement>): void { + this.setState({ searchText: ev.target.value }, this._updateTableSource); + } - // close Compare-modal - hideCompareModal = (): void => { - // close modal. clear select rows data, clear selected track - this.setState({ isShowCompareModal: false, selectedRowKeys: [], selectRows: [] }); - }; + /* Table basic function related methods */ - // open customized trial modal - private setCustomizedTrial = (trialId: string, event: React.SyntheticEvent<EventTarget>): void => { - event.preventDefault(); - event.stopPropagation(); - this.setState({ - isShowCustomizedModal: true, - copyTrialId: trialId - }); - }; + private _onColumnClick(ev: React.MouseEvent<HTMLElement>, 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 closeCustomizedTrial = (): void => { - this.setState({ - isShowCustomizedModal: false, - copyTrialId: '' + 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 items = trials.map(trial => { + const ret = { + sequenceId: trial.sequenceId, + id: trial.id, + 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, + 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; }); - }; - - private onWindowResize = (): void => { - this.setState(() => ({ - modalIntermediateHeight: window.innerHeight, - modalIntermediateWidth: window.innerWidth - })); - }; - - private onRenderRow: IDetailsListProps['onRenderRow'] = props => { - if (props) { - return <Details detailsProps={props} />; - } - return null; - }; - private getSelectedRows = new Selection({ - onSelectionChanged: (): void => { - this.setState(() => ({ selectRows: this.getSelectedRows.getSelection() })); - } - }); - - // trial parameters & dict final keys & Trial No. Id ... - private getAllColumnKeys = (): string[] => { - const tableSource: Array<TableRecord> = JSON.parse(JSON.stringify(this.props.tableSource)); - // parameter as table column - const parameterStr: string[] = []; - if (!EXPERIMENT.isNestedExp()) { - if (tableSource.length > 0) { - const trialMess = TRIALS.getTrial(tableSource[0].id); - const trial = trialMess.description.parameters; - const parameterColumn: string[] = Object.keys(trial); - parameterColumn.forEach(value => { - parameterStr.push(`${value} (search space)`); - }); - } + const { sortInfo } = this.state; + if (sortInfo.field !== '') { + return _copyAndSort(items, sortInfo.field, sortInfo.isDescend); + } else { + return items; } - // concat trial all final keys and remove dup "default" val, return list - const finalKeysList = TRIALS.finalKeys().filter(item => item !== 'default'); - return COLUMNPro.concat(parameterStr).concat(finalKeysList); - }; + } - // get IColumn[] - // when user click [Add Column] need to use the function - private initTableColumnList = (columnList: string[]): IColumn[] => { - // const { columnList } = this.props; - const disabledAddCustomizedTrial = ['DONE', 'ERROR', 'STOPPED'].includes(EXPERIMENT.status); - const showColumn: IColumn[] = []; - for (const item of columnList) { - const paraColumn = item.match(/ \(search space\)$/); - let result; - if (paraColumn !== null) { - result = paraColumn.input; + private _buildColumnsFromTableItems(tableItems: any[]): IColumn[] { + // extra column, for a icon to expand the trial details panel + const columns: IColumn[] = [ + { + key: '_expand', + name: '', + onRender: (item, index): any => { + return ( + <Icon + aria-hidden={true} + iconName='ChevronRight' + styles={{ + root: { + transition: 'all 0.2s', + transform: `rotate(${item._expandDetails ? 90 : 0}deg)` + } + }} + onClick={(event): void => { + 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]; + newItems[index as number] = newItem; + this.setState({ + displayedItems: newItems + }); + }} + onMouseDown={(e): void => { + e.stopPropagation(); + }} + onMouseUp={(e): void => { + e.stopPropagation(); + }} + /> + ); + }, + fieldName: 'expand', + isResizable: false, + minWidth: 20, + maxWidth: 20 } - switch (item) { - case 'Trial No.': - showColumn.push(this.SequenceIdColumnConfig); - break; - case 'ID': - showColumn.push(this.IdColumnConfig); - break; - case 'Start time': - showColumn.push(this.StartTimeColumnConfig); - break; - case 'End time': - showColumn.push(this.EndTimeColumnConfig); - break; - case 'Duration': - showColumn.push(this.DurationColumnConfig); - break; - case 'Status': - showColumn.push(this.StatusColumnConfig); - break; - case 'Intermediate result': - showColumn.push(this.IntermediateCountColumnConfig); - break; - case 'Default': - showColumn.push(this.AccuracyColumnConfig); - break; - case 'Operation': - showColumn.push({ - name: 'Operation', - key: 'operation', - fieldName: 'operation', - minWidth: 160, - maxWidth: 200, - isResizable: true, - className: 'detail-table', - onRender: (record: any) => { - const trialStatus = record.status; - const flag: boolean = trialStatus === 'RUNNING' || trialStatus === 'UNKNOWN' ? false : true; - return ( - <Stack className='detail-button' horizontal> - {/* see intermediate result graph */} - <PrimaryButton - className='detail-button-operation' - title='Intermediate' - onClick={this.showIntermediateModal.bind(this, record)} - > - {LineChart} - </PrimaryButton> - {/* kill job */} - {flag ? ( - <PrimaryButton className='detail-button-operation' disabled={true} title='kill'> - {blocked} - </PrimaryButton> - ) : ( - <KillJob trial={record} /> - )} - {/* Add a new trial-customized trial */} - <PrimaryButton - className='detail-button-operation' - title='Customized trial' - onClick={this.setCustomizedTrial.bind(this, record.id)} - disabled={disabledAddCustomizedTrial} - > - {copy} - </PrimaryButton> - </Stack> - ); - } - }); - break; - case result: - // remove SEARCH_SPACE title - // const realItem = item.replace(' (search space)', ''); - showColumn.push({ - name: item.replace(' (search space)', ''), - key: item, - fieldName: item, - minWidth: 150, - onRender: (record: TableRecord) => { - const eachTrial = TRIALS.getTrial(record.id); - return <span>{eachTrial.description.parameters[item.replace(' (search space)', '')]}</span>; - } - }); - break; - default: - showColumn.push({ - name: item, - key: item, - fieldName: item, - minWidth: 100, - onRender: (record: TableRecord) => { - const accDictionary = record.accDictionary; - let other = ''; - if (accDictionary !== undefined) { - other = accDictionary[item].toString(); - } - return ( - <TooltipHost content={other}> - <div className='ellipsis'>{other}</div> - </TooltipHost> - ); - } - }); + ]; + // 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 lengths = tableItems.map(item => `${item[k]}`.length); + const avgLengths = lengths.reduce((a, b) => a + b) / lengths.length; + const columnTitle = _inferColumnTitle(k); + const columnWidth = Math.max(columnTitle.length, avgLengths); + // TODO: add blacklist + columns.push({ + name: columnTitle, + key: k, + fieldName: k, + minWidth: columnWidth * 13, + maxWidth: columnWidth * 18, + isResizable: true, + onColumnClick: this._onColumnClick.bind(this), + ...(k === 'status' && { + // color status + onRender: (record): React.ReactNode => ( + <span className={`${record.status} commonStyle`}>{record.status}</span> + ) + }), + ...((k.startsWith('metric/') || k.startsWith('space/')) && { + // show tooltip + onRender: (record): React.ReactNode => ( + <TooltipHost content={record[k]}> + <div className='ellipsis'>{record[k]}</div> + </TooltipHost> + ) + }), + ...(k === 'latestAccuracy' && { + // FIXME: this is ad-hoc + onRender: (record): React.ReactNode => ( + <TooltipHost content={record._formattedLatestAccuracy}> + <div className='ellipsis'>{record._formattedLatestAccuracy}</div> + </TooltipHost> + ) + }), + ...(['startTime', 'endTime'].includes(k) && { + onRender: (record): React.ReactNode => <span>{formatTimestamp(record[k], '--')}</span> + }), + ...(k === 'duration' && { + onRender: (record): React.ReactNode => ( + <span className='durationsty'>{convertDuration(record[k])}</span> + ) + }) + }); } - return showColumn; - }; - - componentDidMount(): void { - window.addEventListener('resize', this.onWindowResize); - this.updateData(); - } + // operations column + columns.push({ + name: 'Operation', + key: '_operation', + fieldName: 'operation', + minWidth: 160, + maxWidth: 200, + isResizable: true, + className: 'detail-table', + onRender: this._renderOperationColumn.bind(this) + }); - componentDidUpdate(prevProps: TableListProps): void { - if ( - this.props.columnList !== prevProps.columnList || - this.props.tableSource !== prevProps.tableSource || - prevProps.trialsUpdateBroadcast !== this.props.trialsUpdateBroadcast - ) { - const { columnList } = this.props; - this.setState( - { - tableColumns: this.initTableColumnList(columnList), - allColumnList: this.getAllColumnKeys() - }, - () => { - this.updateData(); - } - ); + 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; } - // slice all table data into current page data - updateData(): void { - const tableSource: Array<TableRecord> = this.props.tableSource; - const { offset, perPage, sortMessage } = this.state; - - if (sortMessage.field !== '') { - tableSource.sort(function(a, b): any { - if ( - a[sortMessage.field] === undefined || - Object.is(a[sortMessage.field], NaN) || - Object.is(a[sortMessage.field], Infinity) || - Object.is(a[sortMessage.field], -Infinity) || - typeof a[sortMessage.field] === 'object' - ) { - return 1; - } - if ( - b[sortMessage.field] === undefined || - Object.is(b[sortMessage.field], NaN) || - Object.is(b[sortMessage.field], Infinity) || - Object.is(b[sortMessage.field], -Infinity) || - typeof b[sortMessage.field] === 'object' - ) { - return -1; - } - return (sortMessage.isDescend - ? a[sortMessage.field] < b[sortMessage.field] - : a[sortMessage.field] > b[sortMessage.field]) - ? 1 - : -1; + private _updateTableSource(): void { + // call this method when trials or the computation of trial filter has changed + const items = this._trialsToTableItems(this._filterTrials(this.props.tableSource)); + if (items.length > 0) { + const columns = this._buildColumnsFromTableItems(items); + this.setState({ + displayedItems: items, + columns: columns + }); + } else { + this.setState({ + displayedItems: [], + columns: [] }); } + } - const tableSlice = tableSource.slice(offset, offset + perPage); - const curPageCount = Math.ceil(tableSource.length / perPage); + private _updateDisplayedColumns(displayedColumns: string[]): void { this.setState({ - tablePerPage: tableSlice, - pageCount: curPageCount + displayedColumns: displayedColumns }); } - // update data when click the page index of pagination - handlePageClick = (evt: any): void => { - const selectedPage = evt.selected; - const offset = selectedPage * this.state.perPage; - - this.setState( - { - currentPage: selectedPage, - offset: offset - }, - () => { - this.updateData(); - } + private _renderOperationColumn(record: any): React.ReactNode { + const runningTrial: boolean = ['RUNNING', 'UNKNOWN'].includes(record.status) ? false : true; + const disabledAddCustomizedTrial = ['DONE', 'ERROR', 'STOPPED'].includes(EXPERIMENT.status); + return ( + <Stack className='detail-button' horizontal> + <PrimaryButton + className='detail-button-operation' + title='Intermediate' + onClick={(): void => { + const { tableSource } = this.props; + const trial = tableSource.find(trial => trial.id === record.id) as TableObj; + this.setState({ intermediateDialogTrial: trial }); + }} + > + {LineChart} + </PrimaryButton> + {runningTrial ? ( + <PrimaryButton className='detail-button-operation' disabled={true} title='kill'> + {blocked} + </PrimaryButton> + ) : ( + <KillJob trial={record} /> + )} + <PrimaryButton + className='detail-button-operation' + title='Customized trial' + onClick={(): void => { + this.setState({ copiedTrialId: record.id }); + }} + disabled={disabledAddCustomizedTrial} + > + {copy} + </PrimaryButton> + </Stack> ); - }; - - // update per page items when click the dropdown of pagination - updatePerPage = (event: React.FormEvent<HTMLDivElement>, item: IDropdownOption | undefined): void => { - const { pageCount } = this.state; - - if (item !== undefined) { - const currentPerPage = item.key === 'all' ? this.props.tableSource.length : Number(item.key); - const currentPageCount = this.props.tableSource.length <= currentPerPage ? 1 : pageCount; + } - this.setState( - { - perPage: currentPerPage, - offset: 0, - currentPage: 0, - pageCount: currentPageCount - }, - () => { - this.updateData(); - } - ); + componentDidUpdate(prevProps: TableListProps): void { + if (this.props.tableSource !== prevProps.tableSource) { + this._updateTableSource(); } - }; + } + + componentDidMount(): void { + this._updateTableSource(); + } render(): React.ReactNode { const { - intermediateKey, - modalIntermediateWidth, - modalIntermediateHeight, - tableColumns, - allColumnList, - isShowColumn, - modalVisible, - selectRows, - isShowCompareModal, - intermediateOtherKeys, - isShowCustomizedModal, - copyTrialId, - intermediateOption, - tablePerPage + displayedItems, + columns, + searchType, + customizeColumnsDialogVisible, + compareDialogVisible, + displayedColumns, + selectedRowIds, + intermediateDialogTrial, + copiedTrialId } = this.state; - const { columnList } = this.props; - const perPageOptions = [ - { key: '10', text: '10 items per page' }, - { key: '20', text: '20 items per page' }, - { key: '50', text: '50 items per page' }, - { key: 'all', text: 'All items' } - ]; return ( - <Stack> - <div id='tableList'> - <DetailsList - columns={tableColumns} - items={tablePerPage} - setKey='set' - compact={true} - onRenderRow={this.onRenderRow} - layoutMode={DetailsListLayoutMode.justified} - selectionMode={SelectionMode.multiple} - selection={this.getSelectedRows} - /> - - <Stack - horizontal - horizontalAlign='end' - verticalAlign='baseline' - styles={{ root: { padding: 10 } }} - tokens={horizontalGapStackTokens} - > - <Dropdown - selectedKey={ - this.state.perPage === this.props.tableSource.length - ? 'all' - : String(this.state.perPage) - } - options={perPageOptions} - onChange={this.updatePerPage} - styles={{ dropdown: { width: 150 } }} - /> - - <ReactPaginate - previousLabel={'<'} - nextLabel={'>'} - breakLabel={'...'} - breakClassName={'break'} - pageCount={this.state.pageCount} - marginPagesDisplayed={2} - pageRangeDisplayed={2} - onPageChange={this.handlePageClick} - containerClassName={this.props.tableSource.length == 0 ? 'pagination hidden' : 'pagination'} - subContainerClassName={'pages pagination'} - disableInitialCallback={false} - activeClassName={'active'} - forcePage={this.state.currentPage} - /> - </Stack> - </div> - {/* Intermediate Result Modal */} - <Modal - isOpen={modalVisible} - onDismiss={this.hideIntermediateModal} - containerClassName={contentStyles.container} - > - <div className={contentStyles.header}> - <span>Intermediate result</span> - <IconButton - styles={iconButtonStyles} - iconProps={{ iconName: 'Cancel' }} - ariaLabel='Close popup modal' - onClick={this.hideIntermediateModal as any} + <div id='tableList'> + <Stack horizontal className='panelTitle' style={{ marginTop: 10 }}> + <span style={{ marginRight: 12 }}>{tableListIcon}</span> + <span>Trial jobs</span> + </Stack> + <Stack horizontal className='allList'> + <StackItem grow={50}> + <DefaultButton + text='Compare' + className='allList-compare' + onClick={(): void => { + this.setState({ compareDialogVisible: true }); + }} + disabled={selectedRowIds.length === 0} /> - </div> - {intermediateOtherKeys.length > 1 ? ( - <Stack horizontalAlign='end' className='selectKeys'> + </StackItem> + <StackItem grow={50}> + <Stack horizontal horizontalAlign='end' className='allList'> + <DefaultButton + className='allList-button-gap' + text='Add/Remove columns' + onClick={(): void => { + this.setState({ customizeColumnsDialogVisible: true }); + }} + /> <Dropdown - className='select' - selectedKey={intermediateKey} - options={intermediateOtherKeys.map((key, item) => { - return { - key: key, - text: intermediateOtherKeys[item] - }; - })} - onChange={this.selectOtherKeys} + selectedKey={searchType} + options={Object.entries(searchOptionLiterals).map(([k, v]) => ({ + key: k, + text: v + }))} + onChange={this._updateSearchFilterType.bind(this)} + styles={{ root: { width: 150 } }} + /> + <input + type='text' + className='allList-search-input' + placeholder={`Search by ${ + ['id', 'trialnum'].includes(searchType) + ? searchOptionLiterals[searchType] + : searchType + }`} + onChange={this._updateSearchText.bind(this)} + style={{ width: 230 }} /> </Stack> - ) : null} - <div className='intermediate-graph'> - <ReactEcharts - option={intermediateOption} - style={{ - width: 0.5 * modalIntermediateWidth, - height: 0.7 * modalIntermediateHeight, - maxHeight: 534, - padding: 20 - }} - theme='my_theme' - /> - <div className='xAxis'>#Intermediate result</div> - </div> - </Modal> - {/* Add Column Modal */} - {isShowColumn && ( + </StackItem> + </Stack> + {columns && displayedItems && ( + <PaginationTable + columns={columns.filter( + column => + displayedColumns.includes(column.key) || ['_expand', '_operation'].includes(column.key) + )} + items={displayedItems} + compact={true} + selection={this._selection} + selectionMode={SelectionMode.multiple} + selectionPreservedOnEmptyClick={true} + onRenderRow={(props): any => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return <ExpandableDetails detailsProps={props!} isExpand={props!.item._expandDetails} />; + }} + /> + )} + {compareDialogVisible && ( + <Compare + title='Compare trials' + showDetails={true} + trials={this.props.tableSource.filter(trial => selectedRowIds.includes(trial.id))} + onHideDialog={(): void => { + this.setState({ compareDialogVisible: false }); + }} + /> + )} + {intermediateDialogTrial !== undefined && ( + <Compare + title='Intermediate results' + showDetails={false} + trials={[intermediateDialogTrial]} + onHideDialog={(): void => { + this.setState({ intermediateDialogTrial: undefined }); + }} + /> + )} + {customizeColumnsDialogVisible && ( <ChangeColumnComponent - hideShowColumnDialog={this.hideShowColumnModal} - isHideDialog={!isShowColumn} - showColumn={allColumnList} - selectedColumn={columnList} - changeColumn={this.props.changeColumn} + selectedColumns={displayedColumns} + allColumns={columns + .filter(column => !column.key.startsWith('_')) + .map(column => ({ key: column.key, name: column.name }))} + onSelectedChange={this._updateDisplayedColumns.bind(this)} + onHideDialog={(): void => { + this.setState({ customizeColumnsDialogVisible: false }); + }} /> )} - {/* compare trials based message */} - {isShowCompareModal && <Compare compareStacks={selectRows} cancelFunc={this.hideCompareModal} />} - {/* clone trial parameters and could submit a customized trial */} + {/* Clone a trial and customize a set of new parameters */} + {/* visible is done inside because prompt is needed even when the dialog is closed */} <Customize - visible={isShowCustomizedModal} - copyTrialId={copyTrialId} - closeCustomizeModal={this.closeCustomizedTrial} + visible={copiedTrialId !== undefined} + copyTrialId={copiedTrialId || ''} + closeCustomizeModal={(): void => { + this.setState({ copiedTrialId: undefined }); + }} /> - </Stack> + </div> ); } } diff --git a/src/webui/src/static/interface.ts b/src/webui/src/static/interface.ts index 400d33eee2..e950eec94f 100644 --- a/src/webui/src/static/interface.ts +++ b/src/webui/src/static/interface.ts @@ -33,6 +33,7 @@ interface TableObj { color?: string; startTime?: number; endTime?: number; + intermediates: (MetricDataRecord | undefined)[]; parameters(axes: MultipleAxes): Map<SingleAxis, any>; metrics(axes: MultipleAxes): Map<SingleAxis, any>; } diff --git a/src/webui/src/static/model/trial.ts b/src/webui/src/static/model/trial.ts index 1578a6c4b2..c33888144d 100644 --- a/src/webui/src/static/model/trial.ts +++ b/src/webui/src/static/model/trial.ts @@ -60,7 +60,7 @@ function inferTrialParameters( class Trial implements TableObj { private metricsInitialized: boolean = false; private infoField: TrialJobInfo | undefined; - private intermediates: (MetricDataRecord | undefined)[] = []; + public intermediates: (MetricDataRecord | undefined)[] = []; public final: MetricDataRecord | undefined; private finalAcc: number | undefined; @@ -224,24 +224,29 @@ class Trial implements TableObj { } public parameters(axes: MultipleAxes): Map<SingleAxis, any> { + const ret = new Map<SingleAxis, any>(Array.from(axes.axes.values()).map(k => [k, null])); if (this.info === undefined || this.info.hyperParameters === undefined) { - throw new Map(); + throw ret; } else { const tempHyper = this.info.hyperParameters; let params = JSON.parse(tempHyper[tempHyper.length - 1]).parameters; if (typeof params === 'string') { params = JSON.parse(params); } - const [result, unexpectedEntries] = inferTrialParameters(params, axes); + const [updated, unexpectedEntries] = inferTrialParameters(params, axes); if (unexpectedEntries.size) { throw unexpectedEntries; } - return result; + for (const [k, v] of updated) { + ret.set(k, v); + } + return ret; } } public metrics(space: MultipleAxes): Map<SingleAxis, any> { - const ret = new Map<SingleAxis, any>(); + // set default value: null + const ret = new Map<SingleAxis, any>(Array.from(space.axes.values()).map(k => [k, null])); const unexpectedEntries = new Map<string, any>(); if (this.acc === undefined) { return ret; diff --git a/src/webui/src/static/style/table.scss b/src/webui/src/static/style/table.scss index 14d6712f23..054c7a63e9 100644 --- a/src/webui/src/static/style/table.scss +++ b/src/webui/src/static/style/table.scss @@ -58,7 +58,7 @@ } .detail-table { - padding: 5px 0 0 0; + padding-top: 5px; } .columns-height {