TableList.tsx 25.8 KB
Newer Older
Lijiaoa's avatar
Lijiaoa committed
1
import React from 'react';
2
import {
3
    DefaultButton,
4
    IColumn,
5
6
7
8
    Icon,
    PrimaryButton,
    Stack,
    StackItem,
9
    TooltipHost,
10
11
    DirectionalHint,
    Checkbox
12
} from '@fluentui/react';
13
import { EXPERIMENT, TRIALS } from '../../static/datamodel';
14
import { TOOLTIP_BACKGROUND_COLOR } from '../../static/const';
15
import { convertDuration, formatTimestamp, copyAndSort, parametersType, parseMetrics } from '../../static/function';
16
17
import { TableObj, SortInfo, SearchItems } from '../../static/interface';
import { getTrialsBySearchFilters } from './search/searchFunction';
18
19
20
21
import { blocked, copy, LineChart, tableListIcon } from '../buttons/Icon';
import ChangeColumnComponent from '../modals/ChangeColumnComponent';
import Compare from '../modals/Compare';
import Customize from '../modals/CustomizedTrial';
Lijiaoa's avatar
Lijiaoa committed
22
import TensorboardUI from '../modals/tensorboard/TensorboardUI';
23
import Search from './search/Search';
24
25
26
import KillJob from '../modals/Killjob';
import ExpandableDetails from '../public-child/ExpandableDetails';
import PaginationTable from '../public-child/PaginationTable';
27
import CopyButton from '../public-child/CopyButton';
28
import { Trial } from '../../static/model/trial';
Lijiaoa's avatar
Lijiaoa committed
29
30
31
32
33
34
35
36
37
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';
38

Lijiao's avatar
Lijiao committed
39
40
41
42
require('echarts/lib/chart/line');
require('echarts/lib/component/tooltip');
require('echarts/lib/component/title');

43
type SearchOptionType = 'id' | 'trialnum' | 'status' | 'parameters';
44

45
const defaultDisplayedColumns = ['sequenceId', 'id', 'duration', 'status', 'latestAccuracy'];
Lijiao's avatar
Lijiao committed
46

47
48
49
50
51
52
53
function _inferColumnTitle(columnKey: string): string {
    if (columnKey === 'sequenceId') {
        return 'Trial No.';
    } else if (columnKey === 'id') {
        return 'ID';
    } else if (columnKey === 'intermediateCount') {
        return 'Intermediate results (#)';
54
55
    } else if (columnKey === 'message') {
        return 'Message';
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
    } 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[];
73
    updateDetailPage: () => void;
74
75
}

Lijiao's avatar
Lijiao committed
76
interface TableListState {
77
78
79
80
81
82
83
84
85
86
87
    displayedItems: any[];
    displayedColumns: string[];
    columns: IColumn[];
    searchType: SearchOptionType;
    searchText: string;
    selectedRowIds: string[];
    customizeColumnsDialogVisible: boolean;
    compareDialogVisible: boolean;
    intermediateDialogTrial: TableObj | undefined;
    copiedTrialId: string | undefined;
    sortInfo: SortInfo;
88
89
    searchItems: Array<SearchItems>;
    relation: Map<string, string>;
90
    intermediateKeyList: string[];
91
92
}

Lijiao's avatar
Lijiao committed
93
class TableList extends React.Component<TableListProps, TableListState> {
94
    private _expandedTrialIds: Set<string>;
95

Lijiao's avatar
Lijiao committed
96
97
98
99
    constructor(props: TableListProps) {
        super(props);

        this.state = {
100
            displayedItems: [],
101
102
103
104
105
            displayedColumns:
                localStorage.getItem('columns') !== null
                    ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
                      JSON.parse(localStorage.getItem('columns')!)
                    : defaultDisplayedColumns,
106
107
108
109
110
111
112
113
            columns: [],
            searchType: 'id',
            searchText: '',
            customizeColumnsDialogVisible: false,
            compareDialogVisible: false,
            selectedRowIds: [],
            intermediateDialogTrial: undefined,
            copiedTrialId: undefined,
114
115
            sortInfo: { field: '', isDescend: true },
            searchItems: [],
116
117
            relation: parametersType(),
            intermediateKeyList: []
Lijiao's avatar
Lijiao committed
118
119
        };

120
121
        this._expandedTrialIds = new Set<string>();
    }
122

123
    /* Table basic function related methods */
Lijiao's avatar
Lijiao committed
124

125
126
127
128
129
130
131
132
133
134
135
136
137
    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
        );
    }
138

139
140
141
142
    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();
143
        const { selectedRowIds } = this.state;
144
145
146
147
        const items = trials.map(trial => {
            const ret = {
                sequenceId: trial.sequenceId,
                id: trial.id,
148
                _checked: selectedRowIds.includes(trial.id) ? true : false,
149
150
151
152
                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,
153
                message: (trial as Trial).info.message || '--',
154
155
156
157
158
159
160
161
162
163
164
165
                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;
166
        });
167

168
169
        const { sortInfo } = this.state;
        if (sortInfo.field !== '') {
170
            return copyAndSort(items, sortInfo.field, sortInfo.isDescend);
171
172
        } else {
            return items;
173
        }
174
    }
175

176
177
178
179
180
181
182
183
184
185
186
187
188
    private selectedTrialOnChangeEvent = (
        id: string,
        _ev?: React.FormEvent<HTMLElement | HTMLInputElement>,
        checked?: boolean
    ): void => {
        const { displayedItems, selectedRowIds } = this.state;
        const items = JSON.parse(JSON.stringify(displayedItems));
        const temp = selectedRowIds;
        if (checked === true) {
            temp.push(id);
        }
        items.forEach(item => {
            if (item.id === id) {
189
                item._checked = !!checked;
190
191
192
193
194
195
196
197
198
            }
        });
        this.setState(() => ({ displayedItems: items, selectedRowIds: temp }));
    };

    private changeSelectTrialIds = (): void => {
        const { displayedItems } = this.state;
        const newDisplayedItems = displayedItems;
        newDisplayedItems.forEach(item => {
199
            item._checked = false;
200
201
202
203
204
205
206
        });
        this.setState(() => ({
            selectedRowIds: [],
            displayedItems: newDisplayedItems
        }));
    };

207
208
    private _buildColumnsFromTableItems(tableItems: any[]): IColumn[] {
        const columns: IColumn[] = [
209
210
211
212
213
214
215
216
217
218
219
220
            // select trial function
            {
                name: '',
                key: '_selected',
                fieldName: 'selected',
                minWidth: 20,
                maxWidth: 20,
                isResizable: true,
                className: 'detail-table',
                onRender: (record): React.ReactNode => (
                    <Checkbox
                        label={undefined}
221
                        checked={record._checked}
222
223
224
225
226
227
                        className='detail-check'
                        onChange={this.selectedTrialOnChangeEvent.bind(this, record.id)}
                    />
                )
            },
            // extra column, for a icon to expand the trial details panel
228
229
230
            {
                key: '_expand',
                name: '',
231
                onRender: (item): any => {
232
233
234
235
                    return (
                        <Icon
                            aria-hidden={true}
                            iconName='ChevronRight'
236
                            className='cursor'
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
                            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);
                                }
252
253
254
                                const newItems = this.state.displayedItems.map(item =>
                                    item.id === newItem.id ? newItem : item
                                );
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
                                this.setState({
                                    displayedItems: newItems
                                });
                            }}
                            onMouseDown={(e): void => {
                                e.stopPropagation();
                            }}
                            onMouseUp={(e): void => {
                                e.stopPropagation();
                            }}
                        />
                    );
                },
                fieldName: 'expand',
                isResizable: false,
                minWidth: 20,
                maxWidth: 20
272
            }
273
        ];
274

275
276
277
278
279
        // 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;
Lijiao's avatar
Lijiao committed
280
            }
281
282
            const columnTitle = _inferColumnTitle(k);
            // TODO: add blacklist
Lijiaoa's avatar
Lijiaoa committed
283
284
            // 0.85: tableWidth / screen
            const widths = window.innerWidth * 0.85;
285
286
287
288
            columns.push({
                name: columnTitle,
                key: k,
                fieldName: k,
Lijiaoa's avatar
Lijiaoa committed
289
290
                minWidth: widths * 0.12,
                maxWidth: widths * 0.19,
291
292
293
294
295
296
297
298
                isResizable: true,
                onColumnClick: this._onColumnClick.bind(this),
                ...(k === 'status' && {
                    // color status
                    onRender: (record): React.ReactNode => (
                        <span className={`${record.status} commonStyle`}>{record.status}</span>
                    )
                }),
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
                ...(k === 'message' && {
                    onRender: (record): React.ReactNode =>
                        record.message.length > 15 ? (
                            <TooltipHost
                                content={record.message}
                                directionalHint={DirectionalHint.bottomCenter}
                                tooltipProps={{
                                    calloutProps: {
                                        styles: {
                                            beak: { background: TOOLTIP_BACKGROUND_COLOR },
                                            beakCurtain: { background: TOOLTIP_BACKGROUND_COLOR },
                                            calloutMain: { background: TOOLTIP_BACKGROUND_COLOR }
                                        }
                                    }
                                }}
                            >
                                <div>{record.message}</div>
                            </TooltipHost>
                        ) : (
                            <div>{record.message}</div>
                        )
                }),
321
322
323
                ...((k.startsWith('metric/') || k.startsWith('space/')) && {
                    // show tooltip
                    onRender: (record): React.ReactNode => (
324
325
326
327
328
329
330
331
332
333
334
335
336
                        <TooltipHost
                            content={record[k]}
                            directionalHint={DirectionalHint.bottomCenter}
                            tooltipProps={{
                                calloutProps: {
                                    styles: {
                                        beak: { background: TOOLTIP_BACKGROUND_COLOR },
                                        beakCurtain: { background: TOOLTIP_BACKGROUND_COLOR },
                                        calloutMain: { background: TOOLTIP_BACKGROUND_COLOR }
                                    }
                                }
                            }}
                        >
337
338
339
340
341
342
343
                            <div className='ellipsis'>{record[k]}</div>
                        </TooltipHost>
                    )
                }),
                ...(k === 'latestAccuracy' && {
                    // FIXME: this is ad-hoc
                    onRender: (record): React.ReactNode => (
344
345
346
347
348
349
350
351
352
353
354
355
356
                        <TooltipHost
                            content={record._formattedLatestAccuracy}
                            directionalHint={DirectionalHint.bottomCenter}
                            tooltipProps={{
                                calloutProps: {
                                    styles: {
                                        beak: { background: TOOLTIP_BACKGROUND_COLOR },
                                        beakCurtain: { background: TOOLTIP_BACKGROUND_COLOR },
                                        calloutMain: { background: TOOLTIP_BACKGROUND_COLOR }
                                    }
                                }
                            }}
                        >
357
358
359
360
361
362
363
364
365
366
367
                            <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>
                    )
368
369
370
371
372
373
374
375
                }),
                ...(k === 'id' && {
                    onRender: (record): React.ReactNode => (
                        <Stack horizontal className='idCopy'>
                            <div>{record.id}</div>
                            <CopyButton value={record.id} />
                        </Stack>
                    )
376
377
                })
            });
378
        }
379
380
381
382
383
        // operations column
        columns.push({
            name: 'Operation',
            key: '_operation',
            fieldName: 'operation',
Lijiaoa's avatar
Lijiaoa committed
384
385
            minWidth: 150,
            maxWidth: 160,
386
387
388
389
            isResizable: true,
            className: 'detail-table',
            onRender: this._renderOperationColumn.bind(this)
        });
390

391
392
393
394
395
396
397
398
399
        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;
            }
400
        }
401
        return columns;
402
    }
403

404
405
    private _updateTableSource(): void {
        // call this method when trials or the computation of trial filter has changed
406
407
408
409
410
        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
        }
411
412
413
414
415
416
417
418
419
420
        if (items.length > 0) {
            const columns = this._buildColumnsFromTableItems(items);
            this.setState({
                displayedItems: items,
                columns: columns
            });
        } else {
            this.setState({
                displayedItems: [],
                columns: []
421
            });
422
        }
423
    }
424

425
    private _updateDisplayedColumns(displayedColumns: string[]): void {
426
        this.setState({
427
            displayedColumns: displayedColumns
428
429
        });
    }
430

431
432
    private _renderOperationColumn(record: any): React.ReactNode {
        const runningTrial: boolean = ['RUNNING', 'UNKNOWN'].includes(record.status) ? false : true;
433
        const disabledAddCustomizedTrial = ['DONE', 'ERROR', 'STOPPED', 'VIEWED'].includes(EXPERIMENT.status);
434
435
436
437
438
439
440
441
        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;
442
443
444
445
446
                        const intermediateKeyListResult = this.getIntermediateAllKeys(trial);
                        this.setState({
                            intermediateDialogTrial: trial,
                            intermediateKeyList: intermediateKeyListResult
                        });
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
                    }}
                >
                    {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>
469
        );
470
    }
471

472
473
474
475
476
477
    private changeSearchFilterList = (arr: Array<SearchItems>): void => {
        this.setState(() => ({
            searchItems: arr
        }));
    };

478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
    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;
    };

505
506
507
    componentDidUpdate(prevProps: TableListProps): void {
        if (this.props.tableSource !== prevProps.tableSource) {
            this._updateTableSource();
508
        }
509
510
511
512
513
    }

    componentDidMount(): void {
        this._updateTableSource();
    }
514

515
    render(): React.ReactNode {
516
        const {
517
518
519
520
521
522
523
            displayedItems,
            columns,
            customizeColumnsDialogVisible,
            compareDialogVisible,
            displayedColumns,
            selectedRowIds,
            intermediateDialogTrial,
524
            copiedTrialId,
525
526
            searchItems,
            intermediateKeyList
527
        } = this.state;
528

Lijiao's avatar
Lijiao committed
529
        return (
530
531
532
533
534
535
            <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'>
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
                    <StackItem>
                        <Stack horizontal horizontalAlign='end' className='allList'>
                            <Search
                                searchFilter={searchItems} // search filter list
                                changeSearchFilterList={this.changeSearchFilterList}
                                updatePage={this.props.updateDetailPage}
                            />
                        </Stack>
                    </StackItem>

                    <StackItem styles={{ root: { position: 'absolute', right: '0' } }}>
                        <DefaultButton
                            className='allList-button-gap'
                            text='Add/Remove columns'
                            onClick={(): void => {
                                this.setState({ customizeColumnsDialogVisible: true });
                            }}
                        />
554
555
556
557
558
559
560
                        <DefaultButton
                            text='Compare'
                            className='allList-compare'
                            onClick={(): void => {
                                this.setState({ compareDialogVisible: true });
                            }}
                            disabled={selectedRowIds.length === 0}
561
                        />
562
563
564
565
                        <TensorboardUI
                            selectedRowIds={selectedRowIds}
                            changeSelectTrialIds={this.changeSelectTrialIds}
                        />
566
567
568
569
570
571
                    </StackItem>
                </Stack>
                {columns && displayedItems && (
                    <PaginationTable
                        columns={columns.filter(
                            column =>
572
573
                                displayedColumns.includes(column.key) ||
                                ['_expand', '_operation', '_selected'].includes(column.key)
574
575
576
                        )}
                        items={displayedItems}
                        compact={true}
577
                        selectionMode={0}
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
                        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 });
                        }}
593
                        changeSelectTrialIds={this.changeSelectTrialIds}
594
595
596
597
598
599
600
                    />
                )}
                {intermediateDialogTrial !== undefined && (
                    <Compare
                        title='Intermediate results'
                        showDetails={false}
                        trials={[intermediateDialogTrial]}
601
                        intermediateKeyList={intermediateKeyList}
602
603
604
605
606
607
                        onHideDialog={(): void => {
                            this.setState({ intermediateDialogTrial: undefined });
                        }}
                    />
                )}
                {customizeColumnsDialogVisible && (
608
                    <ChangeColumnComponent
609
610
611
612
613
614
615
616
                        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 });
                        }}
617
                        whichComponent='table'
618
                    />
619
                )}
620
621
                {/* Clone a trial and customize a set of new parameters */}
                {/* visible is done inside because prompt is needed even when the dialog is closed */}
622
                <Customize
623
624
625
626
627
                    visible={copiedTrialId !== undefined}
                    copyTrialId={copiedTrialId || ''}
                    closeCustomizeModal={(): void => {
                        this.setState({ copiedTrialId: undefined });
                    }}
628
                />
629
            </div>
Lijiao's avatar
Lijiao committed
630
631
632
633
        );
    }
}

634
export default TableList;